Skip to content

Commit 82ca8c0

Browse files
authored
Merge pull request #116 from alecalve/taproot
Add Taproot support
2 parents cd20851 + f519fa0 commit 82ca8c0

File tree

9 files changed

+214
-13
lines changed

9 files changed

+214
-13
lines changed

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This Python 3 library provides a parser for the raw data stored by bitcoind.
55
- Detects outputs types
66
- Detects addresses in outputs
77
- Interprets scripts
8-
- Supports SegWit
8+
- Supports SegWit and Taproot
99
- Supports ordered block parsing
1010

1111
## Installing
@@ -51,7 +51,7 @@ pip install -r requirements.txt
5151

5252
Run the test suite by lauching
5353
```
54-
./tests.sh
54+
pytest
5555
```
5656

5757
## Examples

blockchain_parser/address.py

+14-3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from bitcoin import base58
1313
from bitcoin.bech32 import CBech32Data
1414
from .utils import btc_ripemd160, double_sha256
15+
from .utils_taproot import from_taproot
16+
from binascii import b2a_hex
1517

1618

1719
class Address(object):
@@ -44,21 +46,30 @@ def from_bech32(cls, hash, segwit_version):
4446
"""Constructs an Address object from a bech32 hash."""
4547
return cls(hash, None, None, "bech32", segwit_version)
4648

49+
@classmethod
50+
def from_bech32m(cls, hash, segwit_version):
51+
"""Constructs an Address object from a bech32m script."""
52+
return cls(hash, None, None, "bech32m", segwit_version)
53+
4754
@property
4855
def hash(self):
4956
"""Returns the RIPEMD-160 hash corresponding to this address"""
5057
if self.public_key is not None and self._hash is None:
5158
self._hash = btc_ripemd160(self.public_key)
52-
5359
return self._hash
5460

5561
@property
5662
def address(self):
5763
"""Returns the encoded representation of this address.
58-
If SegWit, it's encoded using bech32, otherwise using base58
64+
If Taproot, it's encoded using bech32m,
65+
if SegWit, it's encoded using bech32,
66+
otherwise using base58
5967
"""
6068
if self._address is None:
61-
if self.type != "bech32":
69+
if self.type == "bech32m":
70+
tweaked_pubkey = b2a_hex(self.hash).decode("ascii")
71+
self._address = from_taproot(tweaked_pubkey)
72+
elif self.type != "bech32":
6273
version = b'\x00' if self.type == "normal" else b'\x05'
6374
checksum = double_sha256(version + self.hash)
6475

blockchain_parser/output.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ def addresses(self):
7777
elif self.type == "p2wsh":
7878
address = Address.from_bech32(self.script.operations[1], 0)
7979
self._addresses.append(address)
80-
80+
elif self.type == "p2tr":
81+
address = Address.from_bech32m(self.script.operations[1], 1)
82+
self._addresses.append(address)
8183
return self._addresses
8284

8385
def is_return(self):
@@ -104,6 +106,9 @@ def is_p2wpkh(self):
104106
def is_p2wsh(self):
105107
return self.script.is_p2wsh()
106108

109+
def is_p2tr(self):
110+
return self.script.is_p2tr()
111+
107112
@property
108113
def type(self):
109114
"""Returns the output's script type as a string"""
@@ -132,4 +137,7 @@ def type(self):
132137
if self.is_p2wsh():
133138
return "p2wsh"
134139

140+
if self.is_p2tr():
141+
return "p2tr"
142+
135143
return "unknown"

blockchain_parser/script.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from bitcoin.core.script import *
1313
from binascii import b2a_hex
14+
from .utils_taproot import from_taproot
1415

1516

1617
def is_public_key(hex_data):
@@ -107,6 +108,13 @@ def is_p2wsh(self):
107108
def is_p2wpkh(self):
108109
return self.script.is_witness_v0_keyhash()
109110

111+
def is_p2tr(self):
112+
if len(self.operations) > 1 and type(self.operations[1]) == bytes:
113+
taproot = from_taproot(b2a_hex(self.operations[1]).decode("ascii"))
114+
return self.operations[0] == 1 \
115+
and isinstance(taproot, str) \
116+
and taproot.startswith("bc1p")
117+
110118
def is_pubkey(self):
111119
return len(self.operations) == 2 \
112120
and self.operations[-1] == OP_CHECKSIG \
@@ -142,4 +150,4 @@ def is_unknown(self):
142150
return not self.is_pubkeyhash() and not self.is_pubkey() \
143151
and not self.is_p2sh() and not self.is_multisig() \
144152
and not self.is_return() and not self.is_p2wpkh() \
145-
and not self.is_p2wsh()
153+
and not self.is_p2wsh() and not self.is_p2tr()

blockchain_parser/tests/test_address.py

+13
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,16 @@ def test_from_ripemd160(self):
3030
ripemd160 = "010966776006953D5567439E5E39F86A0D273BEE"
3131
address = Address.from_ripemd160(a2b_hex(ripemd160))
3232
self.assertEqual(address.address, "16UwLL9Risc3QfPqBUvKofHmBQ7wMtjvM")
33+
34+
def test_from_bech32(self):
35+
# Example sourced from https://en.bitcoin.it/wiki/Bech32
36+
bech32 = "751e76e8199196d454941c45d1b3a323f1433bd6"
37+
address = Address.from_bech32(a2b_hex(bech32), segwit_version=0)
38+
self.assertEqual(address.address, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
39+
40+
def test_from_bech32m(self):
41+
# https://blockstream.info/tx/33e794d097969002ee05d336686fc03c9e15a597c1b9827669460fac98799036?expand
42+
# Second output
43+
bech32m = "a37c3903c8d0db6512e2b40b0dffa05e5a3ab73603ce8c9c4b7771e5412328f9"
44+
address = Address.from_bech32m(a2b_hex(bech32m), segwit_version=1)
45+
self.assertEqual(address.address, "bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297")

blockchain_parser/tests/test_script.py

+23
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def test_op_return_script(self):
2727
self.assertFalse(script.is_pubkeyhash())
2828
self.assertFalse(script.is_unknown())
2929
self.assertTrue(script.is_return())
30+
self.assertFalse(script.is_p2tr())
3031

3132
def test_unknown_script(self):
3233
case = "40"
@@ -40,6 +41,7 @@ def test_unknown_script(self):
4041
self.assertFalse(script.is_pubkeyhash())
4142
self.assertTrue(script.is_unknown())
4243
self.assertFalse(script.is_return())
44+
self.assertFalse(script.is_p2tr())
4345

4446
case = ""
4547
script = Script.from_hex(a2b_hex(case))
@@ -52,6 +54,7 @@ def test_unknown_script(self):
5254
self.assertFalse(script.is_pubkeyhash())
5355
self.assertTrue(script.is_unknown())
5456
self.assertFalse(script.is_return())
57+
self.assertFalse(script.is_p2tr())
5558

5659
def test_multisig_script(self):
5760
case = "514104cc71eb30d653c0c3163990c47b976f3fb3f37cccdcbedb169a1dfef58bbfbfaff7d8a473e7e2e6d317b87bafe8bde97e3cf8f065dec022b51d11fcdd0d348ac4410461cbdcc5409fb4b4d42b51d33381354d80e550078cb532a34bfa2fcfdeb7d76519aecc62770f5b0e4ef8551946d8a540911abe3e7854a26f39f58b25c15342af52ae"
@@ -64,6 +67,7 @@ def test_multisig_script(self):
6467
self.assertFalse(script.is_pubkeyhash())
6568
self.assertFalse(script.is_unknown())
6669
self.assertFalse(script.is_return())
70+
self.assertFalse(script.is_p2tr())
6771

6872
def test_p2sh_script(self):
6973
case = "a91428ad3e63dcae36e5010527578e2eef0e9eeaf3e487"
@@ -76,6 +80,7 @@ def test_p2sh_script(self):
7680
self.assertFalse(script.is_pubkeyhash())
7781
self.assertFalse(script.is_unknown())
7882
self.assertFalse(script.is_return())
83+
self.assertFalse(script.is_p2tr())
7984

8085
def test_p2wpkh_script(self):
8186
case = "0014c958269b5b6469b6e4b87de1062028ad3bb83cc2"
@@ -88,6 +93,7 @@ def test_p2wpkh_script(self):
8893
self.assertFalse(script.is_pubkeyhash())
8994
self.assertFalse(script.is_unknown())
9095
self.assertFalse(script.is_return())
96+
self.assertFalse(script.is_p2tr())
9197

9298
def test_p2wsh_script(self):
9399
case = "0020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d"
@@ -100,6 +106,7 @@ def test_p2wsh_script(self):
100106
self.assertFalse(script.is_pubkeyhash())
101107
self.assertFalse(script.is_unknown())
102108
self.assertFalse(script.is_return())
109+
self.assertFalse(script.is_p2tr())
103110

104111
def test_pubkeyhash_script(self):
105112
case = "76a914e9629ef6f5b82564a9b2ecae6c288c56fb33710888ac"
@@ -112,6 +119,7 @@ def test_pubkeyhash_script(self):
112119
self.assertTrue(script.is_pubkeyhash())
113120
self.assertFalse(script.is_unknown())
114121
self.assertFalse(script.is_return())
122+
self.assertFalse(script.is_p2tr())
115123

116124
def test_pubkey_script(self):
117125
script = Script.from_hex(a2b_hex("4104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac"))
@@ -123,3 +131,18 @@ def test_pubkey_script(self):
123131
self.assertFalse(script.is_pubkeyhash())
124132
self.assertFalse(script.is_unknown())
125133
self.assertFalse(script.is_return())
134+
self.assertFalse(script.is_p2tr())
135+
136+
def test_taproot_script(self):
137+
# https://blockstream.info/tx/33e794d097969002ee05d336686fc03c9e15a597c1b9827669460fac98799036?expand
138+
# Second output
139+
script = Script.from_hex(a2b_hex("5120a37c3903c8d0db6512e2b40b0dffa05e5a3ab73603ce8c9c4b7771e5412328f9"))
140+
self.assertFalse(script.is_pubkey())
141+
self.assertFalse(script.is_multisig())
142+
self.assertFalse(script.is_p2sh())
143+
self.assertFalse(script.is_p2wpkh())
144+
self.assertFalse(script.is_p2wsh())
145+
self.assertFalse(script.is_pubkeyhash())
146+
self.assertFalse(script.is_unknown())
147+
self.assertFalse(script.is_return())
148+
self.assertTrue(script.is_p2tr())

blockchain_parser/utils_taproot.py

+143
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright (C) 2015-2016 The bitcoin-blockchain-parser developers
2+
#
3+
# This file is part of bitcoin-blockchain-parser.
4+
#
5+
# It is subject to the license terms in the LICENSE file found in the top-level
6+
# directory of this distribution.
7+
#
8+
# No part of bitcoin-blockchain-parser, including this file, may be copied,
9+
# modified, propagated, or distributed except according to the terms contained
10+
# in the LICENSE file.
11+
#
12+
# Encoding/Decoding written by Pieter Wuille (2017)
13+
# and adapted by Anton Wahrstätter (2022)
14+
# https://github.com/Bytom/python-bytomlib/blob/master/pybtmsdk/segwit_addr.py
15+
16+
from enum import Enum
17+
18+
19+
class Encoding(Enum):
20+
"""Enumeration type to list the various supported encodings."""
21+
BECH32 = 1
22+
BECH32M = 2
23+
24+
25+
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
26+
BECH32M_CONST = 0x2bc830a3
27+
28+
29+
def bech32_polymod(values):
30+
"""Internal function that computes the Bech32 checksum."""
31+
generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
32+
chk = 1
33+
for value in values:
34+
top = chk >> 25
35+
chk = (chk & 0x1ffffff) << 5 ^ value
36+
for i in range(5):
37+
chk ^= generator[i] if ((top >> i) & 1) else 0
38+
return chk
39+
40+
41+
def bech32_hrp_expand(hrp):
42+
"""Expand the HRP into values for checksum computation."""
43+
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
44+
45+
46+
def bech32_verify_checksum(hrp, data):
47+
"""Verify a checksum given HRP and converted data characters."""
48+
const = bech32_polymod(bech32_hrp_expand(hrp) + data)
49+
if const == 1:
50+
return Encoding.BECH32
51+
if const == BECH32M_CONST:
52+
return Encoding.BECH32M
53+
return None
54+
55+
56+
def bech32_create_checksum(hrp, data, spec):
57+
"""Compute the checksum values given HRP and data."""
58+
values = bech32_hrp_expand(hrp) + data
59+
const = BECH32M_CONST if spec == Encoding.BECH32M else 1
60+
polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const
61+
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
62+
63+
64+
def bech32_encode(hrp, data, spec):
65+
"""Compute a Bech32 string given HRP and data values."""
66+
combined = data + bech32_create_checksum(hrp, data, spec)
67+
return hrp + '1' + ''.join([CHARSET[d] for d in combined])
68+
69+
70+
def bech32_decode(bech):
71+
"""Validate a Bech32/Bech32m string, and determine HRP and data."""
72+
if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or
73+
(bech.lower() != bech and bech.upper() != bech)):
74+
return (None, None, None)
75+
bech = bech.lower()
76+
pos = bech.rfind('1')
77+
if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:
78+
return (None, None, None)
79+
if not all(x in CHARSET for x in bech[pos+1:]):
80+
return (None, None, None)
81+
hrp = bech[:pos]
82+
data = [CHARSET.find(x) for x in bech[pos+1:]]
83+
spec = bech32_verify_checksum(hrp, data)
84+
if spec is None:
85+
return (None, None, None)
86+
return (hrp, data[:-6], spec)
87+
88+
89+
def convertbits(data, frombits, tobits, pad=True):
90+
"""General power-of-2 base conversion."""
91+
acc = 0
92+
bits = 0
93+
ret = []
94+
maxv = (1 << tobits) - 1
95+
max_acc = (1 << (frombits + tobits - 1)) - 1
96+
for value in data:
97+
if value < 0 or (value >> frombits):
98+
return None
99+
acc = ((acc << frombits) | value) & max_acc
100+
bits += frombits
101+
while bits >= tobits:
102+
bits -= tobits
103+
ret.append((acc >> bits) & maxv)
104+
if pad:
105+
if bits:
106+
ret.append((acc << (tobits - bits)) & maxv)
107+
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
108+
return None
109+
return ret
110+
111+
112+
def decode(hrp, addr):
113+
"""Decode a segwit address."""
114+
hrpgot, data, spec = bech32_decode(addr)
115+
if hrpgot != hrp:
116+
return (None, None)
117+
decoded = convertbits(data[1:], 5, 8, False)
118+
if decoded is None or len(decoded) < 2 or len(decoded) > 40:
119+
return (None, None)
120+
if data[0] > 16:
121+
return (None, None)
122+
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
123+
return (None, None)
124+
if data[0] == 0 and spec != Encoding.BECH32 \
125+
or data[0] != 0 and spec != Encoding.BECH32M:
126+
return (None, None)
127+
return (data[0], decoded)
128+
129+
130+
def encode(witprog):
131+
hrp, witver = "bc", 1
132+
"""Encode a segwit address."""
133+
spec = Encoding.BECH32M
134+
ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5), spec)
135+
if decode(hrp, ret) == (None, None):
136+
return None
137+
return ret
138+
139+
140+
def from_taproot(tpk):
141+
"""Input Tweaked Public Key."""
142+
tpk = [int(tpk[i:i+2], 16) for i in range(0, len(tpk), 2)]
143+
return encode(tpk)

requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
python-bitcoinlib==0.11.0
22
plyvel==1.5.1
33
ripemd-hash==1.0.1
4-
coverage==7.4.4
4+
pytest==8.1.1

tests.sh

-5
This file was deleted.

0 commit comments

Comments
 (0)