diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 000000000..02416b50b --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,29 @@ +module.exports = { + apps: [{ + name: 'electrumx-doriancoin', + script: './electrumx_server', + cwd: '/home/electrumx/electrumx', + interpreter: '/home/electrumx/electrumx/venv/bin/python3', + env: { + COIN: 'Doriancoin', + NET: 'mainnet', + DB_DIRECTORY: '/var/lib/electrumx/doriancoin', + DAEMON_URL: 'http://user:password@127.0.0.1:1948/', + SERVICES: 'tcp://:51001,ssl://:51002', + SSL_CERTFILE: '/home/electrumx/.electrumx/certs/server.crt', + SSL_KEYFILE: '/home/electrumx/.electrumx/certs/server.key', + CACHE_MB: '2000', + BANDWIDTH_UNIT_COST: '100000', + REQUEST_TIMEOUT: '60', + MAX_RECV: '50000000', + MAX_SEND: '20000000', + DB_BATCH_SIZE: '10000', + COST_SOFT_LIMIT: '2000', + COST_HARD_LIMIT: '100000', + REQUEST_SLEEP: '5000', + }, + autorestart: true, + max_restarts: 10, + restart_delay: 5000, + }] +}; diff --git a/src/electrumx/lib/coins.py b/src/electrumx/lib/coins.py index baa32d84f..a1d174b53 100644 --- a/src/electrumx/lib/coins.py +++ b/src/electrumx/lib/coins.py @@ -38,7 +38,7 @@ from typing import Sequence, Tuple, Optional import electrumx.lib.util as util -from electrumx.lib.hash import Base58, double_sha256, hash_to_hex_str +from electrumx.lib.hash import Base58, Bech32, Bech32Error, double_sha256, hash_to_hex_str from electrumx.lib.hash import HASHX_LEN, hex_str_to_hash from electrumx.lib.script import (_match_ops, Script, ScriptError, ScriptPubKey, OpCodes) @@ -88,6 +88,9 @@ class Coin: WIF_BYTE = bytes.fromhex("80") ENCODE_CHECK = Base58.encode_check DECODE_CHECK = Base58.decode_check + # Bech32/Bech32m HRP (Human Readable Part) for SegWit/Taproot addresses + # Set to None for coins that don't support bech32 + BECH32_HRP = None GENESIS_HASH = ('000000000019d6689c085ae165831e93' '4ff763ae46a2a6c172b3f1b60a8ce26f') GENESIS_ACTIVATION = 100_000_000 @@ -206,11 +209,28 @@ def hash160_to_P2PKH_hashX(cls, hash160): @classmethod def pay_to_address_script(cls, address): - '''Return a pubkey script that pays to a pubkey hash. + '''Return a pubkey script that pays to an address. - Pass the address (either P2PKH or P2SH) in base58 form. + Supports: + - P2PKH and P2SH addresses in base58 form + - P2WPKH and P2WSH addresses in bech32 form (SegWit v0) + - P2TR addresses in bech32m form (Taproot, SegWit v1) ''' - raw = cls.DECODE_CHECK(address) + # Try bech32/bech32m first if the coin supports it + if cls.BECH32_HRP is not None: + try: + hrp, witness_version, witness_program = Bech32.decode(address) + if hrp != cls.BECH32_HRP: + raise CoinError(f'invalid address HRP: expected {cls.BECH32_HRP}, got {hrp}') + return ScriptPubKey.witness_program_script(witness_version, witness_program) + except Bech32Error: + pass # Not a valid bech32 address, try base58 + + # Try base58 + try: + raw = cls.DECODE_CHECK(address) + except Exception: + raise CoinError(f'invalid address: {address}') # Require version byte(s) plus hash160. verbyte = -1 @@ -297,6 +317,7 @@ class BitcoinMixin: XPUB_VERBYTES = bytes.fromhex("0488b21e") XPRV_VERBYTES = bytes.fromhex("0488ade4") RPC_PORT = 8332 + BECH32_HRP = "bc" class Bitcoin(BitcoinMixin, Coin): @@ -373,6 +394,7 @@ class BitcoinTestnetMixin: GENESIS_HASH = ('000000000933ea01ad0ee984209779ba' 'aec3ced90fa3f408719526f8d77f4943') REORG_LIMIT = 8000 + BECH32_HRP = "tb" TX_COUNT = 12242438 TX_COUNT_HEIGHT = 1035428 TX_PER_BLOCK = 21 @@ -1038,6 +1060,7 @@ class Litecoin(Coin): TX_COUNT_HEIGHT = 1105256 TX_PER_BLOCK = 10 RPC_PORT = 9332 + BECH32_HRP = "ltc" REORG_LIMIT = 800 PEERS = [ 'ex.lug.gs s444', @@ -1065,6 +1088,7 @@ class LitecoinTestnet(Litecoin): RPC_PORT = 19332 REORG_LIMIT = 4000 PEER_DEFAULT_PORTS = {'t': '51001', 's': '51002'} + BECH32_HRP = "tltc" PEERS = [ 'electrum-ltc.bysh.me s t', 'electrum.ltc.xurious.com s t', @@ -1081,6 +1105,57 @@ class LitecoinRegtest(LitecoinTestnet): TX_COUNT_HEIGHT = 1 +class Doriancoin(Coin): + NAME = "Doriancoin" + SHORTNAME = "DSV" + NET = "mainnet" + XPUB_VERBYTES = bytes.fromhex("0488b21e") + XPRV_VERBYTES = bytes.fromhex("0488ade4") + P2PKH_VERBYTE = bytes.fromhex("1e") + P2SH_VERBYTES = (bytes.fromhex("05"), bytes.fromhex("1c")) + WIF_BYTE = bytes.fromhex("b0") + GENESIS_HASH = ('d21da25e277bd20b7456087d69c5fee2' + 'ebc6091b410271b5cb0623c7d1e7d1b9') + DESERIALIZER = lib_tx.DeserializerLitecoin + TX_COUNT = 1608098 + TX_COUNT_HEIGHT = 1243844 + TX_PER_BLOCK = 2 + RPC_PORT = 1948 + REORG_LIMIT = 800 + PEERS = [] + + +class DoriancoinTestnet(Doriancoin): + SHORTNAME = "tDSV" + NET = "testnet" + XPUB_VERBYTES = bytes.fromhex("043587cf") + XPRV_VERBYTES = bytes.fromhex("04358394") + P2PKH_VERBYTE = bytes.fromhex("1e") + P2SH_VERBYTES = (bytes.fromhex("16"), bytes.fromhex("3a")) + WIF_BYTE = bytes.fromhex("ef") + GENESIS_HASH = ('707769464eb59fdd7b75cdbc5f0e7222' + '6345281852325c965b8ee1fd592fbf51') + TX_COUNT = 1 + TX_COUNT_HEIGHT = 1 + TX_PER_BLOCK = 1 + RPC_PORT = 11948 + REORG_LIMIT = 4000 + PEER_DEFAULT_PORTS = {'t': '51001', 's': '51002'} + PEERS = [] + + +class DoriancoinRegtest(DoriancoinTestnet): + NET = "regtest" + P2PKH_VERBYTE = bytes.fromhex("6f") + P2SH_VERBYTES = (bytes.fromhex("c4"), bytes.fromhex("3a")) + GENESIS_HASH = ('707769464eb59fdd7b75cdbc5f0e7222' + '6345281852325c965b8ee1fd592fbf51') + RPC_PORT = 19443 + PEERS = [] + TX_COUNT = 1 + TX_COUNT_HEIGHT = 1 + + class BitcoinCashRegtest(BitcoinTestnetMixin, Coin): NAME = "BitcoinCash" NET = "regtest" diff --git a/src/electrumx/lib/hash.py b/src/electrumx/lib/hash.py index fe11d9137..662cd7e0b 100644 --- a/src/electrumx/lib/hash.py +++ b/src/electrumx/lib/hash.py @@ -137,3 +137,150 @@ def encode_check(payload, *, hash_fn=double_sha256): into a Base58Check string.""" be_bytes = payload + hash_fn(payload)[:4] return Base58.encode(be_bytes) + + +class Bech32Error(Exception): + '''Exception used for Bech32 errors.''' + + +class Bech32: + '''Class providing bech32 and bech32m functionality (BIP173/BIP350).''' + + CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' + BECH32_CONST = 1 + BECH32M_CONST = 0x2bc830a3 + + @staticmethod + def _polymod(values): + '''Internal function that computes the bech32 checksum.''' + GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + chk = 1 + for v in values: + b = chk >> 25 + chk = ((chk & 0x1ffffff) << 5) ^ v + for i in range(5): + chk ^= GEN[i] if ((b >> i) & 1) else 0 + return chk + + @staticmethod + def _hrp_expand(hrp): + '''Expand the HRP into values for checksum computation.''' + return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + + @classmethod + def _verify_checksum(cls, hrp, data): + '''Verify a checksum given HRP and converted data characters. + Returns the encoding constant (BECH32_CONST or BECH32M_CONST) if valid.''' + const = cls._polymod(cls._hrp_expand(hrp) + data) + if const == cls.BECH32_CONST: + return cls.BECH32_CONST + if const == cls.BECH32M_CONST: + return cls.BECH32M_CONST + return None + + @classmethod + def _create_checksum(cls, hrp, data, spec): + '''Compute the checksum values given HRP, data and spec (BECH32 or BECH32M).''' + const = cls.BECH32M_CONST if spec == 'bech32m' else cls.BECH32_CONST + polymod = cls._polymod(cls._hrp_expand(hrp) + data + [0, 0, 0, 0, 0, 0]) ^ const + return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + + @classmethod + def _convertbits(cls, data, frombits, tobits, pad=True): + '''General power-of-2 base conversion.''' + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + for value in data: + if value < 0 or (value >> frombits): + raise Bech32Error('invalid data value') + acc = (acc << frombits) | value + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits or ((acc << (tobits - bits)) & maxv): + raise Bech32Error('invalid padding') + return ret + + @classmethod + def decode(cls, addr): + '''Decode a bech32 or bech32m string. + + Returns (hrp, witness_version, witness_program) or raises Bech32Error. + ''' + if any(ord(x) < 33 or ord(x) > 126 for x in addr): + raise Bech32Error('invalid character') + if addr.lower() != addr and addr.upper() != addr: + raise Bech32Error('mixed case') + addr = addr.lower() + pos = addr.rfind('1') + if pos < 1 or pos + 7 > len(addr) or len(addr) > 90: + raise Bech32Error('invalid separator position') + hrp = addr[:pos] + data_part = addr[pos + 1:] + + # Decode data part + try: + data = [cls.CHARSET.index(c) for c in data_part] + except ValueError: + raise Bech32Error('invalid character in data part') + + # Verify checksum and get encoding type + encoding = cls._verify_checksum(hrp, data) + if encoding is None: + raise Bech32Error('invalid checksum') + + # Extract witness version and program + if len(data) < 7: + raise Bech32Error('data too short') + + witness_version = data[0] + if witness_version > 16: + raise Bech32Error(f'invalid witness version: {witness_version}') + + # Convert from 5-bit to 8-bit + try: + witness_program = bytes(cls._convertbits(data[1:-6], 5, 8, pad=False)) + except Bech32Error: + raise Bech32Error('invalid witness program padding') + + # Validate witness program length + if len(witness_program) < 2 or len(witness_program) > 40: + raise Bech32Error(f'invalid witness program length: {len(witness_program)}') + + # Witness version 0 must use bech32, version 1+ must use bech32m + if witness_version == 0 and encoding != cls.BECH32_CONST: + raise Bech32Error('witness v0 must use bech32 encoding') + if witness_version != 0 and encoding != cls.BECH32M_CONST: + raise Bech32Error('witness v1+ must use bech32m encoding') + + # Version-specific length requirements + if witness_version == 0: + if len(witness_program) != 20 and len(witness_program) != 32: + raise Bech32Error('witness v0 program must be 20 or 32 bytes') + elif witness_version == 1: + if len(witness_program) != 32: + raise Bech32Error('witness v1 (taproot) program must be 32 bytes') + + return hrp, witness_version, witness_program + + @classmethod + def encode(cls, hrp, witness_version, witness_program): + '''Encode a witness program to bech32 or bech32m. + + Uses bech32 for witness version 0, bech32m for version 1+. + ''' + if witness_version < 0 or witness_version > 16: + raise Bech32Error(f'invalid witness version: {witness_version}') + if len(witness_program) < 2 or len(witness_program) > 40: + raise Bech32Error(f'invalid witness program length: {len(witness_program)}') + + spec = 'bech32' if witness_version == 0 else 'bech32m' + data = [witness_version] + cls._convertbits(witness_program, 8, 5) + checksum = cls._create_checksum(hrp, data, spec) + return hrp + '1' + ''.join(cls.CHARSET[d] for d in data + checksum) diff --git a/src/electrumx/lib/script.py b/src/electrumx/lib/script.py index 7a1e3a431..e1ff71a16 100644 --- a/src/electrumx/lib/script.py +++ b/src/electrumx/lib/script.py @@ -117,6 +117,40 @@ def P2PKH_script(cls, hash160): + Script.push_data(hash160) + bytes((OpCodes.OP_EQUALVERIFY, OpCodes.OP_CHECKSIG))) + @classmethod + def P2WPKH_script(cls, hash160): + '''Create a P2WPKH (SegWit v0 pubkey hash) script.''' + # OP_0 <20-byte-hash> + return bytes((OpCodes.OP_0, 20)) + hash160 + + @classmethod + def P2WSH_script(cls, hash256): + '''Create a P2WSH (SegWit v0 script hash) script.''' + # OP_0 <32-byte-hash> + return bytes((OpCodes.OP_0, 32)) + hash256 + + @classmethod + def P2TR_script(cls, output_key): + '''Create a P2TR (Taproot, SegWit v1) script.''' + # OP_1 <32-byte-key> + return bytes((OpCodes.OP_1, 32)) + output_key + + @classmethod + def witness_program_script(cls, version, program): + '''Create a witness program script for any version. + + version: 0-16 + program: witness program bytes (length varies by version) + ''' + if version < 0 or version > 16: + raise ScriptError(f'invalid witness version: {version}') + # OP_0 is 0x00, OP_1 through OP_16 are 0x51 through 0x60 + if version == 0: + version_op = OpCodes.OP_0 + else: + version_op = OpCodes.OP_1 + (version - 1) + return bytes((version_op, len(program))) + program + class Script: diff --git a/tests/blocks/doriancoin_mainnet_1000.json b/tests/blocks/doriancoin_mainnet_1000.json new file mode 100644 index 000000000..a5318336a --- /dev/null +++ b/tests/blocks/doriancoin_mainnet_1000.json @@ -0,0 +1,14 @@ +{ + "hash": "ac0d6def1ef1aa96464fbeccf0b767f48a138d2fe7d80d8a949a76e54bf39f70", + "size": 188, + "height": 1000, + "merkleroot": "ee72c5be92df4bc817209224aac62dcc835dd51c2e80dfbf58be91949834cc80", + "tx": [ + "ee72c5be92df4bc817209224aac62dcc835dd51c2e80dfbf58be91949834cc80" + ], + "time": 1394418275, + "nonce": 3633, + "bits": "1e0ffff0", + "previousblockhash": "bfe9d4b4231401ba515493300fbfd09b21fedeb9465362aa68b4c6aaebfa9afd", + "block": "02000000fd9afaebaac6b468aa625346b9defe219bd0bf0f30935451ba011423b4d4e9bf80cc34989491be58bfdf802e1cd55d83cc2dc6aa24922017c84bdf92bec572ee63221d53f0ff0f1e310e00000101000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0c02e8030106062f503253482fffffffff0100f2052a0100000023210364e9edba6808aeb21b9475423240d9c8b5be9abd0ee129aede6bfbbcf9c37884ac00000000" +} diff --git a/tests/lib/test_addresses.py b/tests/lib/test_addresses.py index ea792998e..500d94fc2 100644 --- a/tests/lib/test_addresses.py +++ b/tests/lib/test_addresses.py @@ -86,3 +86,59 @@ def address(request): def test_address_to_hashX(address): coin, addr, _, hashX = address assert coin.address_to_hashX(addr).hex() == hashX + + +# SegWit and Taproot address tests +segwit_addresses = [ + # Bitcoin P2WPKH (SegWit v0, 20-byte) + (coins.Bitcoin, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + "0014751e76e8199196d454941c45d1b3a323f1433bd6"), + # Bitcoin P2WSH (SegWit v0, 32-byte) + (coins.Bitcoin, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3", + "00201863143c14c5166804bd19203356da136c985678cd4d27a1b8c6329604903262"), + # Bitcoin P2TR (Taproot, SegWit v1) + (coins.Bitcoin, "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", + "512079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"), + # Litecoin P2WPKH + (coins.Litecoin, "ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9", + "0014751e76e8199196d454941c45d1b3a323f1433bd6"), +] + + +@pytest.fixture(params=segwit_addresses) +def segwit_address(request): + return request.param + + +def test_segwit_address_to_script(segwit_address): + coin, addr, expected_script_hex = segwit_address + script = coin.pay_to_address_script(addr) + assert script.hex() == expected_script_hex + + +def test_segwit_address_to_hashX(segwit_address): + coin, addr, expected_script_hex = segwit_address + # hashX is SHA256 of the script, truncated to 11 bytes + import hashlib + expected_script = bytes.fromhex(expected_script_hex) + expected_hashX = hashlib.sha256(expected_script).digest()[:11] + assert coin.address_to_hashX(addr) == expected_hashX + + +def test_bitcoin_p2tr_address(): + # Test a specific Taproot address + addr = "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0" + script = coins.Bitcoin.pay_to_address_script(addr) + # Should be OP_1 (0x51) + push 32 bytes (0x20) + 32-byte key + assert script[0] == 0x51 # OP_1 + assert script[1] == 0x20 # 32 bytes + assert len(script) == 34 + + +def test_invalid_bech32_hrp(): + # Bitcoin address with wrong HRP should fail + import pytest + from electrumx.lib.coins import CoinError + with pytest.raises(CoinError): + # This is a valid bech32m but with HRP "tb" (testnet) on Bitcoin mainnet + coins.Bitcoin.pay_to_address_script("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c") diff --git a/tests/lib/test_hash.py b/tests/lib/test_hash.py index 82750a88e..ae01dfb8d 100644 --- a/tests/lib/test_hash.py +++ b/tests/lib/test_hash.py @@ -72,3 +72,87 @@ def test_Base58_encode_check_custom(): with pytest.raises(TypeError): encode_check_sha256('foo') assert encode_check_sha256(b'foo') == '4t9WFhKfWr' + + +# Bech32/Bech32m tests (BIP173/BIP350) + +def test_Bech32_decode_p2wpkh(): + # P2WPKH address (witness version 0, 20-byte program) + hrp, version, program = lib_hash.Bech32.decode( + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4' + ) + assert hrp == 'bc' + assert version == 0 + assert program == bytes.fromhex('751e76e8199196d454941c45d1b3a323f1433bd6') + +def test_Bech32_decode_p2wsh(): + # P2WSH address (witness version 0, 32-byte program) + hrp, version, program = lib_hash.Bech32.decode( + 'bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3' + ) + assert hrp == 'bc' + assert version == 0 + assert len(program) == 32 + +def test_Bech32_decode_p2tr(): + # P2TR address (witness version 1, 32-byte program) - uses bech32m + hrp, version, program = lib_hash.Bech32.decode( + 'bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0' + ) + assert hrp == 'bc' + assert version == 1 + assert len(program) == 32 + +def test_Bech32_decode_testnet_p2tr(): + # Testnet P2TR address + hrp, version, program = lib_hash.Bech32.decode( + 'tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c' + ) + assert hrp == 'tb' + assert version == 1 + assert len(program) == 32 + +def test_Bech32_encode_p2wpkh(): + # Encode P2WPKH + addr = lib_hash.Bech32.encode( + 'bc', 0, bytes.fromhex('751e76e8199196d454941c45d1b3a323f1433bd6') + ) + assert addr == 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4' + +def test_Bech32_encode_p2tr(): + # Encode P2TR (should use bech32m) + program = bytes.fromhex('79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798') + addr = lib_hash.Bech32.encode('bc', 1, program) + # Decode it back to verify roundtrip + hrp, version, decoded_program = lib_hash.Bech32.decode(addr) + assert hrp == 'bc' + assert version == 1 + assert decoded_program == program + +def test_Bech32_invalid_checksum(): + with pytest.raises(lib_hash.Bech32Error): + lib_hash.Bech32.decode('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5') # wrong checksum + +def test_Bech32_mixed_case(): + with pytest.raises(lib_hash.Bech32Error): + lib_hash.Bech32.decode('bc1qW508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4') # mixed case + +def test_Bech32_wrong_encoding_v0(): + # Witness v0 with bech32m encoding should fail + with pytest.raises(lib_hash.Bech32Error): + # This is a v0 program encoded with bech32m (wrong) + lib_hash.Bech32.decode('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh') + +def test_Bech32_wrong_encoding_v1(): + # Witness v1 with bech32 encoding should fail + with pytest.raises(lib_hash.Bech32Error): + # This is a v1 program encoded with bech32 (wrong) + lib_hash.Bech32.decode('bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7k7grplx') + +def test_Bech32_invalid_program_length(): + with pytest.raises(lib_hash.Bech32Error): + # Too short + lib_hash.Bech32.encode('bc', 0, b'\x00') + with pytest.raises(lib_hash.Bech32Error): + # v0 must be 20 or 32 bytes - 25 is invalid + lib_hash.Bech32.decode('bc1q0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3cchs7ex')