From 41e4a8141f59270a8f9b7e8002a2cbaca059c3c1 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 29 Apr 2026 10:08:29 +0200 Subject: [PATCH] tests: add BitcoinPurple test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 46 tests across three classes: TestBitcoinPurpleConstants — validates all BTCP network constants against the technical specification: NET_NAME, TESTNET flags, CLI flags, address prefixes (P2PKH 56, P2SH 55, WIF 0xb7), bech32/BOLT11 HRP, genesis hashes and wire-order chain_hash, default ports, PoW parameters (adj_interval 120, target_timespan 7200, MAX_TARGET, POW_GENESIS_BITS), SLIP-0132 HD key headers, LN parameters, NETS_LIST uniqueness, and inheritance independence. TestBitcoinPurpleDifficultyAdjustment — tests the 120-block retarget logic using BitcoinPurple mainnet (testnet was wrong because get_target always returns 0 on testnet). Covers: genesis index returns genesis target from POW_GENESIS_BITS, on-time/fast/slow adjustments, lower and upper clamp, the 120-block window (not 2016), and can_connect() calling get_target with the correct period index. TestBitcoinPurpleAddress — tests address encoding under BitcoinPurple: P2PKH starts with 'P', bech32 with HRP 'btcp', address_to_script produces correct P2WPKH scriptPubKey, WIF round-trip with prefix 0xb7, Bitcoin addresses rejected on BTCP network --- tests/test_bitcoinpurple.py | 485 ++++++++++++++++++++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 tests/test_bitcoinpurple.py diff --git a/tests/test_bitcoinpurple.py b/tests/test_bitcoinpurple.py new file mode 100644 index 000000000..3f2529103 --- /dev/null +++ b/tests/test_bitcoinpurple.py @@ -0,0 +1,485 @@ +""" +Tests for BitcoinPurple (BTCP) network support. + +Covers: + - Network constants against the technical specification + - 120-block difficulty adjustment logic in blockchain.py + - Address encoding under the BTCP network parameters +""" +import os +from unittest.mock import patch + +from electrum import bitcoin, blockchain, constants, segwit_addr +from electrum.bitcoin import ( + address_to_script, + is_address, + is_b58_address, + is_segwit_address, + public_key_to_p2pkh, + serialize_privkey, + deserialize_privkey, +) +from electrum.blockchain import Blockchain, InvalidHeader +from electrum.constants import BitcoinPurple, BitcoinPurpleTestnet +from electrum.simple_config import SimpleConfig +from electrum.util import make_dir + +from . import ElectrumTestCase + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +class TestBitcoinPurpleConstants(ElectrumTestCase): + """Verify all BTCP network constants against the technical specification.""" + + # --- identity --- + + def test_net_names(self): + self.assertEqual("bitcoinpurple", BitcoinPurple.NET_NAME) + self.assertEqual("bitcoinpurple_testnet", BitcoinPurpleTestnet.NET_NAME) + + def test_testnet_flags(self): + self.assertFalse(BitcoinPurple.TESTNET) + self.assertTrue(BitcoinPurpleTestnet.TESTNET) + + def test_cli_flags_and_datadir(self): + self.assertEqual("bitcoinpurple", BitcoinPurple.cli_flag()) + self.assertEqual("bitcoinpurple", BitcoinPurple.datadir_subdir()) + self.assertEqual("bitcoinpurple_testnet", BitcoinPurpleTestnet.cli_flag()) + self.assertEqual("bitcoinpurple_testnet", BitcoinPurpleTestnet.datadir_subdir()) + + # --- address encoding --- + + def test_address_prefixes_mainnet(self): + self.assertEqual(56, BitcoinPurple.ADDRTYPE_P2PKH) # 0x38 + self.assertEqual(55, BitcoinPurple.ADDRTYPE_P2SH) # 0x37 + self.assertEqual(0xb7, BitcoinPurple.WIF_PREFIX) # 183 + + def test_address_prefixes_testnet(self): + # BTCP testnet keeps the same prefixes as mainnet + self.assertEqual(56, BitcoinPurpleTestnet.ADDRTYPE_P2PKH) + self.assertEqual(55, BitcoinPurpleTestnet.ADDRTYPE_P2SH) + self.assertEqual(0xb7, BitcoinPurpleTestnet.WIF_PREFIX) + + def test_segwit_hrp(self): + self.assertEqual("btcp", BitcoinPurple.SEGWIT_HRP) + self.assertEqual("tbtcp", BitcoinPurpleTestnet.SEGWIT_HRP) + + def test_bolt11_hrp(self): + self.assertEqual("btcp", BitcoinPurple.BOLT11_HRP) + self.assertEqual("tbtcp", BitcoinPurpleTestnet.BOLT11_HRP) + + # --- genesis --- + + def test_genesis_mainnet(self): + self.assertEqual( + "000003823fbf82ea4906cbe214617ce7a70a5da29c19ecb1d65618bcf04ec015", + BitcoinPurple.GENESIS, + ) + + def test_genesis_testnet(self): + self.assertEqual( + "000002fdc3921c1ad368816fcc587f499698d42b42ab5a5d94ee67882ef9d998", + BitcoinPurpleTestnet.GENESIS, + ) + + def test_rev_genesis_bytes_mainnet(self): + # Wire-order (reversed) bytes used in LN chain_hash + expected_wire = "15c04ef0bc1856d6b1ec199ca25d0aa7e77c6114e2cb0649ea82bf3f82030000" + self.assertEqual(expected_wire, BitcoinPurple.rev_genesis_bytes().hex()) + + def test_rev_genesis_bytes_testnet(self): + expected_wire = "98d9f92e8867ee945d5aab422bd49896497f58cc6f8168d31a1c92c3fd020000" + self.assertEqual(expected_wire, BitcoinPurpleTestnet.rev_genesis_bytes().hex()) + + # --- ports --- + + def test_default_ports_mainnet(self): + self.assertEqual({'t': '50001', 's': '50002'}, BitcoinPurple.DEFAULT_PORTS) + + def test_default_ports_testnet(self): + self.assertEqual({'t': '60001', 's': '60002'}, BitcoinPurpleTestnet.DEFAULT_PORTS) + + # --- PoW constants --- + + def test_pow_adjustment_interval(self): + self.assertEqual(120, BitcoinPurple.DIFFICULTY_ADJUSTMENT_INTERVAL) + # testnet inherits the same value + self.assertEqual(120, BitcoinPurpleTestnet.DIFFICULTY_ADJUSTMENT_INTERVAL) + + def test_pow_target_timespan(self): + self.assertEqual(7200, BitcoinPurple.POW_TARGET_TIMESPAN) # 120 * 60 s + + def test_pow_max_adjustment_factor(self): + self.assertEqual(4, BitcoinPurple.MAX_ADJUSTMENT_FACTOR) + + def test_pow_max_target_exceeds_bitcoin(self): + # BTCP starts with easier proof-of-work than Bitcoin + self.assertGreater(BitcoinPurple.MAX_TARGET, constants.BitcoinMainnet.MAX_TARGET) + + def test_pow_max_target_value(self): + # compact 0x1e0ffff0 maps to a target just under BTCP MAX_TARGET + genesis_bits = 0x1e0ffff0 + genesis_target = Blockchain.bits_to_target(genesis_bits) + self.assertLessEqual(genesis_target, BitcoinPurple.MAX_TARGET) + + # --- HD key headers --- + + def test_xpub_headers_mainnet_match_bitcoin(self): + # BTCP mainnet reuses Bitcoin's BIP32 root bytes (0488B21E / 0488ADE4) + for script_type in ('standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh'): + with self.subTest(script_type=script_type): + self.assertEqual( + constants.BitcoinMainnet.XPUB_HEADERS[script_type], + BitcoinPurple.XPUB_HEADERS[script_type], + ) + self.assertEqual( + constants.BitcoinMainnet.XPRV_HEADERS[script_type], + BitcoinPurple.XPRV_HEADERS[script_type], + ) + + def test_xpub_headers_testnet_match_bitcoin_testnet(self): + for script_type in ('standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh'): + with self.subTest(script_type=script_type): + self.assertEqual( + constants.BitcoinTestnet.XPUB_HEADERS[script_type], + BitcoinPurpleTestnet.XPUB_HEADERS[script_type], + ) + self.assertEqual( + constants.BitcoinTestnet.XPRV_HEADERS[script_type], + BitcoinPurpleTestnet.XPRV_HEADERS[script_type], + ) + + def test_xpub_inv_headers_are_inverses(self): + for hdr, hdr_inv in ( + (BitcoinPurple.XPUB_HEADERS, BitcoinPurple.XPUB_HEADERS_INV), + (BitcoinPurple.XPRV_HEADERS, BitcoinPurple.XPRV_HEADERS_INV), + ): + for k, v in hdr.items(): + self.assertEqual(k, hdr_inv[v]) + + # --- Lightning --- + + def test_ln_realm_byte(self): + self.assertEqual(0, BitcoinPurple.LN_REALM_BYTE) + self.assertEqual(1, BitcoinPurpleTestnet.LN_REALM_BYTE) + + def test_ln_dns_seeds_empty(self): + self.assertEqual([], BitcoinPurple.LN_DNS_SEEDS) + self.assertEqual([], BitcoinPurpleTestnet.LN_DNS_SEEDS) + + def test_bip44_coin_type(self): + self.assertEqual(13496, BitcoinPurple.BIP44_COIN_TYPE) + self.assertEqual(1, BitcoinPurpleTestnet.BIP44_COIN_TYPE) + + # --- NETS_LIST integrity --- + + def test_nets_list_contains_btcp(self): + net_names = [c.NET_NAME for c in constants.NETS_LIST] + self.assertIn("bitcoinpurple", net_names) + self.assertIn("bitcoinpurple_testnet", net_names) + + def test_nets_list_unique(self): + net_names = [c.NET_NAME for c in constants.NETS_LIST] + self.assertEqual(len(net_names), len(set(net_names))) + + # --- inheritance: BitcoinPurple must not inherit from any Bitcoin class --- + + def test_btcp_independent_from_bitcoin_mainnet(self): + self.assertFalse(issubclass(BitcoinPurple, constants.BitcoinMainnet)) + + def test_btcp_independent_from_bitcoin_testnet(self): + self.assertFalse(issubclass(BitcoinPurple, constants.BitcoinTestnet)) + + def test_btcp_testnet_inherits_from_btcp(self): + self.assertTrue(issubclass(BitcoinPurpleTestnet, BitcoinPurple)) + + +# --------------------------------------------------------------------------- +# Difficulty adjustment (120-block retarget) +# --------------------------------------------------------------------------- + +class TestBitcoinPurpleDifficultyAdjustment(ElectrumTestCase): + """ + Test the 120-block BTCP difficulty retarget logic in blockchain.get_target(). + + Uses BitcoinPurple (mainnet) so that get_target() executes the real retarget + formula. (Testnet always returns 0, which prevents testing the formula.) + For can_connect() tests we either rely on the ordering of checks inside + can_connect() or patch the parts that are not under test. + """ + + GENESIS_BITS = 0x1e0ffff0 # nBits from BTCP genesis block + + @classmethod + def setUpClass(cls): + super().setUpClass() + constants.BitcoinPurple.set_as_network() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + constants.BitcoinMainnet.set_as_network() + + def setUp(self): + super().setUp() + make_dir(os.path.join(self.electrum_path, 'forks')) + self.config = SimpleConfig({'electrum_path': self.electrum_path}) + blockchain.blockchains = {} + + def _make_chain(self) -> Blockchain: + chain = Blockchain( + config=self.config, forkpoint=0, parent=None, + forkpoint_hash=constants.net.GENESIS, prev_hash=None) + open(chain.path(), 'w+').close() + blockchain.blockchains[constants.net.GENESIS] = chain + return chain + + def _fake_headers(self, first_ts: int, last_ts: int, bits: int = GENESIS_BITS): + """Return a read_header side_effect that yields controlled timestamps/bits.""" + adj = constants.net.DIFFICULTY_ADJUSTMENT_INTERVAL + + def read_header(height: int): + if height % adj == 0: + return {'bits': bits, 'timestamp': first_ts} + if height % adj == adj - 1: + return {'bits': bits, 'timestamp': last_ts} + return {'bits': bits, 'timestamp': first_ts + (last_ts - first_ts) * (height % adj) // (adj - 1)} + + return read_header + + # --- index -1 --- + + def test_get_target_genesis_index_returns_genesis_target(self): + # Index -1 must return the genesis-era target (derived from POW_GENESIS_BITS + # 0x1e0ffff0), which is slightly harder than MAX_TARGET (0x1e0fffff). + chain = self._make_chain() + got = chain.get_target(-1) + expected = Blockchain.bits_to_target(BitcoinPurple.POW_GENESIS_BITS) + self.assertEqual(expected, got) + # Genesis target must be at or below powLimit + self.assertLessEqual(got, BitcoinPurple.MAX_TARGET) + # Differs from Bitcoin mainnet's MAX_TARGET + self.assertNotEqual(constants.BitcoinMainnet.MAX_TARGET, got) + + # --- retarget formula --- + + def test_get_target_on_time(self): + """actual == target_timespan → target unchanged.""" + ts = constants.net.POW_TARGET_TIMESPAN # 7200 + initial_target = Blockchain.bits_to_target(self.GENESIS_BITS) + chain = self._make_chain() + with patch.object(chain, 'read_header', side_effect=self._fake_headers(0, ts)): + new_target = chain.get_target(0) + expected = Blockchain.bits_to_target(Blockchain.target_to_bits(initial_target)) + self.assertEqual(expected, new_target) + + def test_get_target_fast_blocks_increases_difficulty(self): + """Blocks mined twice as fast → target halves (difficulty doubles).""" + ts = constants.net.POW_TARGET_TIMESPAN # 7200 + actual = ts // 2 # 3600, above the 1800-floor clamp + + initial_target = Blockchain.bits_to_target(self.GENESIS_BITS) + expected_target = Blockchain.bits_to_target( + Blockchain.target_to_bits(initial_target * actual // ts) + ) + chain = self._make_chain() + with patch.object(chain, 'read_header', side_effect=self._fake_headers(0, actual)): + new_target = chain.get_target(0) + + self.assertEqual(expected_target, new_target) + self.assertLess(new_target, initial_target) # harder + + def test_get_target_slow_blocks_decreases_difficulty(self): + """Blocks mined twice as slow → target doubles (difficulty halves).""" + ts = constants.net.POW_TARGET_TIMESPAN # 7200 + actual = ts * 2 # 14400, below the 28800-ceiling clamp + + initial_target = Blockchain.bits_to_target(self.GENESIS_BITS) + expected_target = Blockchain.bits_to_target( + Blockchain.target_to_bits(min(BitcoinPurple.MAX_TARGET, initial_target * actual // ts)) + ) + chain = self._make_chain() + with patch.object(chain, 'read_header', side_effect=self._fake_headers(0, actual)): + new_target = chain.get_target(0) + + self.assertEqual(expected_target, new_target) + self.assertGreater(new_target, initial_target) # easier + + def test_get_target_clamp_lower(self): + """actual < target/4 → clamped to target/4 (max 4× harder per retarget).""" + ts = constants.net.POW_TARGET_TIMESPAN # 7200 + floor = ts // constants.net.MAX_ADJUSTMENT_FACTOR # 1800 + # Use actual timespan far below the floor + actual = 100 + + initial_target = Blockchain.bits_to_target(self.GENESIS_BITS) + expected_target = Blockchain.bits_to_target( + Blockchain.target_to_bits(initial_target * floor // ts) + ) + chain = self._make_chain() + with patch.object(chain, 'read_header', side_effect=self._fake_headers(0, actual)): + new_target = chain.get_target(0) + + self.assertEqual(expected_target, new_target) + + def test_get_target_clamp_upper(self): + """actual > target*4 → clamped to target*4 (max 4× easier per retarget).""" + ts = constants.net.POW_TARGET_TIMESPAN # 7200 + ceiling = ts * constants.net.MAX_ADJUSTMENT_FACTOR # 28800 + # Use actual timespan far above the ceiling + actual = 999_999 + + initial_target = Blockchain.bits_to_target(self.GENESIS_BITS) + raw = initial_target * ceiling // ts + expected_target = Blockchain.bits_to_target( + Blockchain.target_to_bits(min(BitcoinPurple.MAX_TARGET, raw)) + ) + chain = self._make_chain() + with patch.object(chain, 'read_header', side_effect=self._fake_headers(0, actual)): + new_target = chain.get_target(0) + + self.assertEqual(expected_target, new_target) + + # --- period index computation --- + + def test_adj_interval_is_120_not_2016(self): + """adj_interval from constants must equal 120 when BTCP network is active.""" + self.assertEqual(120, constants.net.DIFFICULTY_ADJUSTMENT_INTERVAL) + + def test_get_target_uses_120_block_window(self): + """get_target(1) reads headers at heights 120 and 239, not 2016 and 4031.""" + ts = constants.net.POW_TARGET_TIMESPAN + read_calls = [] + + def tracking_read_header(height): + read_calls.append(height) + return {'bits': self.GENESIS_BITS, 'timestamp': height * 60} + + chain = self._make_chain() + with patch.object(chain, 'read_header', side_effect=tracking_read_header): + chain.get_target(1) + + # Must have read exactly headers 120 and 239 (period 1 = [120, 239]) + self.assertIn(120, read_calls) + self.assertIn(239, read_calls) + # Must NOT have touched 2016 or 4031 (Bitcoin-style chunk boundary) + self.assertNotIn(2016, read_calls) + self.assertNotIn(4031, read_calls) + + def test_can_connect_uses_120_block_period(self): + """ + can_connect should look up target at height // 120 - 1, not height // 2016 - 1. + Verify by checking that get_target is called with the correct period index. + """ + chain = self._make_chain() + get_target_calls = [] + original_get_target = chain.get_target + + def tracking_get_target(index): + get_target_calls.append(index) + return original_get_target(index) + + # Height 1 (period 0): can_connect calls get_target(1 // 120 - 1) = get_target(-1) + # before verify_header. Even though verify_header rejects the PoW (nonce=0), + # get_target is called first so the index is recorded. + dummy_header = { + 'block_height': 1, + 'prev_block_hash': constants.net.GENESIS, + 'version': 1, + 'merkle_root': '00' * 32, + 'timestamp': 1691126892, + 'bits': self.GENESIS_BITS, + 'nonce': 0, + } + with patch.object(chain, 'get_target', side_effect=tracking_get_target): + chain.can_connect(dummy_header, check_height=False) + self.assertIn(-1, get_target_calls) + + # Height 121 (first block of period 1): get_target(121 // 120 - 1) = get_target(0). + # get_hash(120) would raise MissingHeader (header not on disk) before get_target + # is reached, so we patch get_hash to return a fake prev hash. + get_target_calls.clear() + fake_prev = 'ab' * 32 + dummy_header['block_height'] = 121 + dummy_header['prev_block_hash'] = fake_prev + with patch.object(chain, 'get_hash', return_value=fake_prev): + with patch.object(chain, 'get_target', side_effect=tracking_get_target): + chain.can_connect(dummy_header, check_height=False) + self.assertIn(0, get_target_calls) + + +# --------------------------------------------------------------------------- +# Address encoding under BTCP network parameters +# --------------------------------------------------------------------------- + +class TestBitcoinPurpleAddress(ElectrumTestCase): + """P2PKH, P2SH, Bech32, and WIF encoding under BitcoinPurple network.""" + + # compressed pubkey for a known private key (k=1, secp256k1) + PUBKEY_HEX = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + + @classmethod + def setUpClass(cls): + super().setUpClass() + constants.BitcoinPurple.set_as_network() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + constants.BitcoinMainnet.set_as_network() + + def test_p2pkh_address_starts_with_P(self): + # P2PKH prefix 56 (0x38) encodes to Base58 addresses starting with 'P' + pubkey = bytes.fromhex(self.PUBKEY_HEX) + addr = public_key_to_p2pkh(pubkey) + self.assertTrue(addr.startswith('P'), f"Expected 'P...' address, got: {addr}") + + def test_p2pkh_address_is_valid(self): + pubkey = bytes.fromhex(self.PUBKEY_HEX) + addr = public_key_to_p2pkh(pubkey) + self.assertTrue(is_address(addr)) + self.assertTrue(is_b58_address(addr)) + + def test_p2pkh_not_valid_on_bitcoin_mainnet(self): + # BTCP address must be rejected on Bitcoin mainnet (different prefix) + pubkey = bytes.fromhex(self.PUBKEY_HEX) + addr = public_key_to_p2pkh(pubkey) + self.assertFalse(is_address(addr, net=constants.BitcoinMainnet)) + + def test_bech32_hrp_is_btcp(self): + # A native SegWit witness-v0 address encoded with HRP 'btcp' must be + # accepted as valid by the BTCP network and rejected by Bitcoin mainnet. + witness_program = bytes(20) # all-zero 20-byte hash (P2WPKH) + addr = segwit_addr.encode_segwit_address(BitcoinPurple.SEGWIT_HRP, 0, witness_program) + self.assertIsNotNone(addr) + self.assertTrue(is_segwit_address(addr)) + self.assertFalse(is_segwit_address(addr, net=constants.BitcoinMainnet)) + + def test_bech32_address_to_script(self): + # address_to_script must produce a valid P2WPKH scriptPubKey for a BTCP bech32 addr + witness_program = bytes(20) + addr = segwit_addr.encode_segwit_address(BitcoinPurple.SEGWIT_HRP, 0, witness_program) + script = address_to_script(addr) + # P2WPKH scriptPubKey: OP_0 <20-byte-hash> = 0x0014 + 20 zero bytes + self.assertEqual('0014' + '00' * 20, script.hex()) + + def test_wif_roundtrip(self): + # serialize_privkey / deserialize_privkey must round-trip under BTCP prefix 0xb7 + raw_key = bytes(31) + bytes([1]) # 32-byte privkey with value 1 + wif = serialize_privkey(raw_key, True, 'p2pkh') + txin_type, got_key, compressed = deserialize_privkey(wif) + self.assertEqual(raw_key, got_key) + self.assertTrue(compressed) + + def test_bitcoin_address_not_valid_on_btcp(self): + # A Bitcoin mainnet P2PKH address ('1...') must not pass is_address on BTCP + btc_addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf" # Bitcoin genesis coinbase + self.assertFalse(is_address(btc_addr)) + + def test_bitcoin_bech32_not_valid_on_btcp(self): + # A Bitcoin mainnet bech32 address (HRP 'bc') must be rejected on BTCP + btc_bech32 = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" + self.assertFalse(is_segwit_address(btc_bech32))