Files
davide 41e4a8141f tests: add BitcoinPurple test suite
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
2026-04-29 10:08:33 +02:00

486 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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))