From b7ce0efad1fe909f4f837a4a5343319565a305cc Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 23 Oct 2026 15:32:17 +0200 Subject: [PATCH] Support bech32 and bech32m (taproot) addresses in the query command Coin.BECH32_HRP (plm, tplm, rplm) and a BIP173/BIP350 segwit decoder let address_to_hashX read segwit addresses. The witness transaction fixtures are no longer skipped. Co-Authored-By: Claude Sonnet 5.5 --- src/electrumx/lib/coins.py | 17 +++++--- src/electrumx/lib/hash.py | 75 ++++++++++++++++++++++++++++++++ src/electrumx/lib/script.py | 5 +++ src/electrumx/server/session.py | 4 +- tests/lib/test_palladium.py | 77 ++++++++++++++++++++++++++++++++- tests/test_transactions.py | 2 - 6 files changed, 170 insertions(+), 10 deletions(-) diff --git a/src/electrumx/lib/coins.py b/src/electrumx/lib/coins.py index 18ae936..724c0ca 100644 --- a/src/electrumx/lib/coins.py +++ b/src/electrumx/lib/coins.py @@ -36,7 +36,7 @@ from hashlib import sha256 from typing import Sequence, Optional, Type import electrumx.lib.util as util -from electrumx.lib.hash import Base58, double_sha256, hash_to_hex_str, HASHX_LEN +from electrumx.lib.hash import Base58, Bech32, double_sha256, hash_to_hex_str, HASHX_LEN from electrumx.lib.script import ScriptPubKey import electrumx.lib.tx as lib_tx from electrumx.lib.tx import Tx @@ -72,6 +72,7 @@ class Coin: P2PKH_VERBYTE = bytes.fromhex("00") P2SH_VERBYTES = (bytes.fromhex("05"),) DECODE_CHECK = Base58.decode_check + BECH32_HRP: Optional[str] = None # human-readable part of the segwit addresses GENESIS_HASH = ('000000000019d6689c085ae165831e93' '4ff763ae46a2a6c172b3f1b60a8ce26f') # 'hum' byte-order @@ -184,9 +185,11 @@ class Coin: def pay_to_address_script(cls, address): '''Return a pubkey script that pays to a pubkey hash. - Pass the address (either P2PKH or P2SH) in base58 form. - Only used for the local RPC 'query' command (bech32 addresses are not supported). + Pass the address in base58 form (P2PKH or P2SH) or in bech32 form (segwit). + Only used for the local RPC 'query' command. ''' + if cls.BECH32_HRP and address.lower().startswith(cls.BECH32_HRP + '1'): + return ScriptPubKey.segwit_script(*Bech32.decode_segwit(cls.BECH32_HRP, address)) raw = cls.DECODE_CHECK(address) # Require version byte(s) plus hash160. @@ -261,15 +264,17 @@ class Coin: class Palladium(Coin): - """Palladium (PLM), a Bitcoin Core 0.19-based chain. Standard 80-byte headers, sha256d PoW, segwit. + """Palladium (PLM). Standard 80-byte headers, sha256d PoW, segwit and taproot (scripts are indexed by hash, + so no special handling is needed for either). - Its daemon predates 'txospenderindex', 'getindexinfo' and 'submitpackage', so + Its daemon has no 'txospenderindex', 'getindexinfo' or 'submitpackage', so 'blockchain.outpoint.subscribe' and 'blockchain.transaction.broadcast_package' will return daemon errors to clients. The daemon needs 'txindex=1'. """ NAME = "Palladium" SHORTNAME = "PLM" NET = "mainnet" + BECH32_HRP = "plm" P2PKH_VERBYTE = bytes.fromhex("37") P2SH_VERBYTES = (bytes.fromhex("05"),) # same genesis block as Bitcoin mainnet @@ -303,6 +308,7 @@ class Palladium(Coin): class PalladiumTestnet(Palladium): SHORTNAME = "TPLM" NET = "testnet" + BECH32_HRP = "tplm" P2PKH_VERBYTE = bytes.fromhex("7f") P2SH_VERBYTES = (bytes.fromhex("73"),) # same genesis block as Bitcoin testnet3 @@ -318,6 +324,7 @@ class PalladiumTestnet(Palladium): class PalladiumRegtest(PalladiumTestnet): NET = "regtest" + BECH32_HRP = "rplm" # same genesis block as Bitcoin regtest GENESIS_HASH = ('0f9188f13cb7b2c71f2a335e3a4fc328' 'bf5beb436012afca590b1a11466e2206') diff --git a/src/electrumx/lib/hash.py b/src/electrumx/lib/hash.py index 66d1db3..c81df07 100644 --- a/src/electrumx/lib/hash.py +++ b/src/electrumx/lib/hash.py @@ -137,3 +137,78 @@ class Base58: into a Base58Check string.""" be_bytes = payload + hash_fn(payload)[:4] return Base58.encode(be_bytes) + + +class Bech32Error(Exception): + '''Exception used for Bech32 and Bech32m errors.''' + + +class Bech32: + '''Decoding of segwit addresses, see BIP 173 (bech32) and BIP 350 (bech32m).''' + + CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' + BECH32_CONST = 1 + BECH32M_CONST = 0x2bc830a3 + + @staticmethod + def _polymod(values): + gen = (0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + chk = 1 + for value in values: + top = chk >> 25 + chk = (chk & 0x1ffffff) << 5 ^ value + for i in range(5): + chk ^= gen[i] if (top >> i) & 1 else 0 + return chk + + @staticmethod + def _convert_bits(data, from_bits, to_bits): + '''General power-of-2 base conversion without padding. Returns None if the input is invalid.''' + acc = 0 + bits = 0 + ret = bytearray() + maxv = (1 << to_bits) - 1 + for value in data: + acc = (acc << from_bits) | value + bits += from_bits + while bits >= to_bits: + bits -= to_bits + ret.append((acc >> bits) & maxv) + if bits >= from_bits or (acc << (to_bits - bits)) & maxv: + return None + return bytes(ret) + + @staticmethod + def decode_segwit(hrp, address): + '''Decode a segwit address of the given human-readable part. Returns (witness version, + witness program) or raises Bech32Error.''' + if not isinstance(address, str): + raise TypeError('a string is required') + if any(ord(c) < 33 or ord(c) > 126 for c in address): + raise Bech32Error('invalid character in address') + if address.lower() != address and address.upper() != address: + raise Bech32Error('mixed case address') + address = address.lower() + pos = address.rfind('1') + if pos < 1 or pos + 7 > len(address) or len(address) > 90: + raise Bech32Error('invalid address length or separator position') + if address[:pos] != hrp: + raise Bech32Error(f'unexpected human-readable part "{address[:pos]}"') + data = [Bech32.CHARSET.find(c) for c in address[pos + 1:]] + if -1 in data: + raise Bech32Error('invalid bech32 character') + const = Bech32._polymod([ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + data) + if const not in (Bech32.BECH32_CONST, Bech32.BECH32M_CONST): + raise Bech32Error('invalid bech32 checksum') + witver = data[0] + if witver > 16: + raise Bech32Error('invalid witness version') + # version 0 uses bech32, later versions use bech32m + if const != (Bech32.BECH32_CONST if witver == 0 else Bech32.BECH32M_CONST): + raise Bech32Error('checksum does not match the witness version') + program = Bech32._convert_bits(data[1:-6], 5, 8) + if program is None or not 2 <= len(program) <= 40: + raise Bech32Error('invalid witness program') + if witver == 0 and len(program) not in (20, 32): + raise Bech32Error('invalid witness program length for version 0') + return witver, program diff --git a/src/electrumx/lib/script.py b/src/electrumx/lib/script.py index 3749b15..e2d717e 100644 --- a/src/electrumx/lib/script.py +++ b/src/electrumx/lib/script.py @@ -94,6 +94,11 @@ class ScriptPubKey: + Script.push_data(hash160) + bytes((OpCodes.OP_EQUALVERIFY, OpCodes.OP_CHECKSIG))) + @classmethod + def segwit_script(cls, witver, program): + '''The script of a segwit output: the version (OP_0, OP_1 ... OP_16) and the witness program.''' + return bytes((OpCodes.OP_0 if witver == 0 else OpCodes.OP_1 + witver - 1,)) + Script.push_data(program) + class Script: diff --git a/src/electrumx/server/session.py b/src/electrumx/server/session.py index b6afd13..588622b 100644 --- a/src/electrumx/server/session.py +++ b/src/electrumx/server/session.py @@ -38,7 +38,7 @@ import electrumx import electrumx.lib.util as util from electrumx.lib.lrucache import LRUCache from electrumx.lib.util import OldTaskGroup, is_hex_str, get_running_loop -from electrumx.lib.hash import (HASHX_LEN, Base58Error, hash_to_hex_str, +from electrumx.lib.hash import (HASHX_LEN, Base58Error, Bech32Error, hash_to_hex_str, hex_str_to_hash, sha256, double_sha256) from electrumx.lib.merkle import MerkleCache from electrumx.lib.text import sessions_lines @@ -714,7 +714,7 @@ class SessionManager: hashX = coin.address_to_hashX(arg) lines.append(f'Address: {arg}') return hashX - except Base58Error: + except (Base58Error, Bech32Error): pass return None diff --git a/tests/lib/test_palladium.py b/tests/lib/test_palladium.py index dad6d96..84c6db7 100644 --- a/tests/lib/test_palladium.py +++ b/tests/lib/test_palladium.py @@ -4,6 +4,8 @@ import pytest from electrumx.lib import tx as lib_tx from electrumx.lib.coins import Coin, Palladium, PalladiumTestnet, PalladiumRegtest +from electrumx.lib.hash import Base58Error, Bech32, Bech32Error +from electrumx.lib.script import ScriptPubKey # (class, net, rpc port, P2PKH verbyte, P2SH verbyte, genesis hash) PARAMS = [ @@ -35,7 +37,80 @@ def test_lookup_coin_class(coin, net): def test_daemon_requirements_disabled(): - # Palladium Core (Bitcoin Core 0.19 based) has no getindexinfo/txospenderindex + # Palladium Core has no getindexinfo/txospenderindex for coin, *_ in PARAMS: assert coin.MIN_REQUIRED_DAEMON_VERSION is None assert not coin.REQUIRED_DAEMON_INDEXES + + +def bech32_encode_v0(hrp, program): + """Minimal bech32 encoder of a witness v0 address, only to build test addresses""" + charset = Bech32.CHARSET + acc = bits = 0 + data = [0] + for byte in program: + acc = (acc << 8) | byte + bits += 8 + while bits >= 5: + bits -= 5 + data.append((acc >> bits) & 31) + if bits: + data.append((acc << (5 - bits)) & 31) + values = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + data + polymod = Bech32._polymod(values + [0] * 6) ^ Bech32.BECH32_CONST + checksum = [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + return hrp + "1" + "".join(charset[d] for d in data + checksum) + + +# bech32 / bech32m: the valid and invalid segwit addresses of BIP 173 and BIP 350 +BIP_VALID = [ + ("bc", "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4", 0, "751e76e8199196d454941c45d1b3a323f1433bd6"), + ("tb", "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7", 0, + "1863143c14c5166804bd19203356da136c985678cd4d27a1b8c6329604903262"), + ("bc", "bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y", 1, + "751e76e8199196d454941c45d1b3a323f1433bd6751e76e8199196d454941c45d1b3a323f1433bd6"), + ("bc", "BC1SW50QGDZ25J", 16, "751e"), +] +BIP_INVALID = [ + ("bc", "tc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq5zuyut"), # wrong hrp + ("bc", "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqh2y7hd"), # bech32 for version 1 + ("bc", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh"), # bech32m for version 0 + ("bc", "BC1S0XLXVLHEMJA6C4DQV22UAPCTQUPFHLXM9H8Z3K2E72Q4K9HCZ7VQ54WELL"), # bad checksum + ("bc", "Bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), # mixed case + ("bc", "bc1gmk9yu"), # empty program + ("bc", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5"), # bad checksum +] + + +@pytest.mark.parametrize("hrp,address,witver,program", BIP_VALID) +def test_bech32_decode_segwit(hrp, address, witver, program): + assert Bech32.decode_segwit(hrp, address) == (witver, bytes.fromhex(program)) + + +@pytest.mark.parametrize("hrp,address", BIP_INVALID) +def test_bech32_decode_segwit_invalid(hrp, address): + with pytest.raises(Bech32Error): + Bech32.decode_segwit(hrp, address) + + +def test_segwit_script(): + assert ScriptPubKey.segwit_script(0, bytes(20)) == b"\x00\x14" + bytes(20) + assert ScriptPubKey.segwit_script(1, bytes(32)) == b"\x51\x20" + bytes(32) + assert ScriptPubKey.segwit_script(16, b"\x75\x1e") == b"\x60\x02\x75\x1e" + + +@pytest.mark.parametrize("coin,hrp", [(Palladium, "plm"), (PalladiumTestnet, "tplm"), (PalladiumRegtest, "rplm")]) +def test_bech32_address_to_script(coin, hrp): + assert coin.BECH32_HRP == hrp + # a v0 witness keyhash address built from a known hash160 (the checksum is computed with a constant) + h160 = bytes.fromhex("751e76e8199196d454941c45d1b3a323f1433bd6") + address = bech32_encode_v0(hrp, h160) + assert coin.pay_to_address_script(address) == b"\x00\x14" + h160 + assert coin.pay_to_address_script(address.upper()) == b"\x00\x14" + h160 + assert coin.address_to_hashX(address) == coin.hashX_from_script(b"\x00\x14" + h160) + + +def test_bech32_address_of_another_network_is_rejected(): + address = bech32_encode_v0("tplm", bytes(20)) + with pytest.raises((Base58Error, Bech32Error)): # not a mainnet bech32 address, so it is read as base58 + Palladium.pay_to_address_script(address) diff --git a/tests/test_transactions.py b/tests/test_transactions.py index 83e954d..6aca5f0 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -61,7 +61,5 @@ def test_transaction(transaction_details): spk = vout[i]['scriptPubKey'] tx_pks = tx.outputs[i].pk_script assert spk['hex'] == tx_pks.hex() - if spk['type'].startswith("witness_"): - continue # bech32 addresses are not supported by Coin.address_to_hashX address, = spk["addresses"] assert coin.address_to_hashX(address) == coin.hashX_from_script(tx_pks)