Skip to content

Commit 9670368

Browse files
giacomocaironisourcery-ai[bot]pre-commit-ci[bot]
authored
Script interpreter (#83)
* First commit of script engine * Add support for some op_codes * Complete test vector * Old changes * Refactor * Remove proposal * Fix some tox errors * Add new op_codes * Add tests from bitcoin core * Start veryfing legacy scripts, add new functions and introduce flags * Pass validation of some edge cases * Pass tx_valid_legacy.json * Pass tx_invalid_taproot.json except for segwit * checksequenceverify and checklocktimeverify * Pass all tests apart from segwit related ones * Fix tox errors and rebase * Refactor code, split script and tapscript * Pass validation of all tests * Refactor * Start testing script_tests.json * Fix some errors in script validation * Pass most of tests in script_tests.json * Simplify script execution * Simplify op_if * Pass validation of bitcoin core tests * Fix tox errors * Fix rebase * Fix rebase * Fix errors in testnet * Fix op_nop and segwit stack limit * Fix op count limit * Refactor, fix warnings * Fix after rebase * Fix after rebase * Fix after rebase * Fix types * 'Refactored by Sourcery' (#111) Co-authored-by: Sourcery AI <> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 8d5393e commit 9670368

32 files changed

+5742
-168
lines changed

HISTORY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ Major changes includes:
157157
now it is xkey_dict.index < 0x80000000
158158
- bip32: local "./" derivation, opposed to absolute "m/" derivation,
159159
is not available anymore
160-
- bip32: indexes_from_bip32_path now returns List[int] instead of
161-
Tuple[List[bytes], bool] losing the "absolute derivation" bool
160+
- bip32: indexes_from_bip32_path now returns list[int] instead of
161+
Tuple[list[bytes], bool] losing the "absolute derivation" bool
162162
- bms: serialize/deserialize have been renamed encode/decode as they
163163
include the base64 (de)encoding, not jut the plain (de)serialization
164164
- Block: fixed bug in difficulty calculation

btclib/alias.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from __future__ import annotations
1515

1616
from io import BytesIO
17-
from typing import Any, Callable, Tuple, Union
17+
from typing import Any, Callable, List, Tuple, Union
1818

1919
# Octets are a sequence of eight-bit bytes or a hex-string (not text string)
2020
#
@@ -104,3 +104,6 @@
104104
# TaprootLeaf = Tuple[int, Script]
105105
# TaprootScriptTree = List[Union[Any, TaprootLeaf]]
106106
TaprootScriptTree = Any
107+
108+
Command = Union[int, str, bytes]
109+
ScriptList = List[Command]

btclib/b58.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from btclib.exceptions import BTClibValueError
2121
from btclib.hashes import hash160, sha256
2222
from btclib.network import NETWORKS, network_from_key_value
23-
from btclib.script import serialize
23+
from btclib.script.script import serialize
2424
from btclib.to_prv_key import PrvKey, prv_keyinfo_from_prv_key
2525
from btclib.to_pub_key import Key, pub_keyinfo_from_key
2626
from btclib.utils import bytes_from_octets

btclib/ecc/borromean.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
# No part of btclib including this file, may be copied, modified, propagated,
99
# or distributed except according to the terms contained in the LICENSE file.
1010
"""Borromean signature functions."""
11+
1112
from __future__ import annotations
1213

1314
import secrets

btclib/ecc/dsa.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,23 @@ def _serialize_scalar(scalar: int) -> bytes:
4949
return _DER_SCALAR_MARKER + var_bytes.serialize(scalar_bytes)
5050

5151

52-
def _deserialize_scalar(sig_data_stream: BytesIO) -> int:
52+
def _deserialize_scalar(sig_data_stream: BytesIO, strict: bool = True) -> int:
5353
marker = sig_data_stream.read(1)
5454
if marker != _DER_SCALAR_MARKER:
5555
err_msg = f"invalid value header: {marker.hex()}"
5656
err_msg += f", instead of integer element {_DER_SCALAR_MARKER.hex()}"
5757
raise BTClibValueError(err_msg)
5858

5959
r_bytes = var_bytes.parse(sig_data_stream, forbid_zero_size=True)
60-
if r_bytes[0] == 0 and r_bytes[1] < 0x80:
60+
if r_bytes[0] == 0 and r_bytes[1] < 0x80 and strict:
6161
raise BTClibValueError("invalid 'highest bit set' padding")
62-
if r_bytes[0] >= 0x80:
62+
63+
scalar = int.from_bytes(r_bytes, byteorder="big", signed=False)
64+
65+
if r_bytes[0] >= 0x80 and strict:
6366
raise BTClibValueError("invalid negative scalar")
6467

65-
return int.from_bytes(r_bytes, byteorder="big", signed=False)
68+
return abs(scalar)
6669

6770

6871
@dataclass(frozen=True)
@@ -161,7 +164,12 @@ def serialize(self, check_validity: bool = True) -> bytes:
161164
return _DER_SIG_MARKER + var_bytes.serialize(out)
162165

163166
@classmethod
164-
def parse(cls: type[Sig], data: BinaryData, check_validity: bool = True) -> Sig:
167+
def parse(
168+
cls: type[Sig],
169+
data: BinaryData,
170+
check_validity: bool = True,
171+
strict: bool = True,
172+
) -> Sig:
165173
"""Return a Sig by parsing binary data.
166174
167175
Deserialize a strict ASN.1 DER representation of an ECDSA
@@ -182,8 +190,8 @@ def parse(cls: type[Sig], data: BinaryData, check_validity: bool = True) -> Sig:
182190

183191
# [0x02][r-size][r][0x02][s-size][s]
184192
sig_data_substream = bytesio_from_binarydata(sig_data)
185-
r = _deserialize_scalar(sig_data_substream)
186-
s = _deserialize_scalar(sig_data_substream)
193+
r = _deserialize_scalar(sig_data_substream, strict)
194+
s = _deserialize_scalar(sig_data_substream, strict)
187195

188196
# to prevent malleability
189197
# the sig_data_substream must have been consumed entirely

btclib/hashes.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ def ripemd160(octets: Octets) -> bytes:
3434
return hashlib.new("ripemd160", octets).digest()
3535

3636

37+
def sha1(octets: Octets) -> bytes:
38+
"""Return the SHA1(*) of the input octet sequence."""
39+
octets = bytes_from_octets(octets)
40+
return hashlib.sha1(octets).digest() # nosec
41+
42+
3743
def sha256(octets: Octets) -> bytes:
3844
"""Return the SHA256(*) of the input octet sequence."""
3945
octets = bytes_from_octets(octets)

btclib/psbt/psbt.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
import random
1818
from copy import deepcopy
1919
from dataclasses import dataclass
20-
from typing import Any, Callable, Mapping, Sequence, TypeVar
20+
from typing import Any, Callable, Mapping, Sequence, TypeVar, cast
2121

22-
from btclib.alias import Octets, String
22+
from btclib.alias import Octets, ScriptList, String
2323
from btclib.bip32 import (
2424
BIP32KeyOrigin,
2525
HdKeyPaths,
@@ -417,7 +417,9 @@ def finalize_psbt(psbt: Psbt) -> Psbt:
417417
psbt_in.final_script_sig = serialize([psbt_in.redeem_script])
418418
psbt_in.final_script_witness = Witness(cmds + [psbt_in.witness_script])
419419
else:
420-
psbt_in.final_script_sig = serialize(cmds + [psbt_in.redeem_script])
420+
psbt_in.final_script_sig = serialize(
421+
cast(ScriptList, cmds + [psbt_in.redeem_script])
422+
)
421423
psbt_in.partial_sigs = {}
422424
psbt_in.sig_hash_type = None
423425
psbt_in.redeem_script = b""

btclib/script/engine/__init__.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright (C) 2017-2021 The btclib developers
4+
#
5+
# This file is part of btclib. It is subject to the license terms in the
6+
# LICENSE file found in the top-level directory of this distribution.
7+
#
8+
# No part of btclib including this file, may be copied, modified, propagated,
9+
# or distributed except according to the terms contained in the LICENSE file.
10+
"""Bitcoin Script engine."""
11+
12+
from __future__ import annotations
13+
14+
from typing import cast
15+
16+
from btclib.alias import Command, ScriptList
17+
from btclib.exceptions import BTClibValueError
18+
from btclib.hashes import sha256
19+
from btclib.script.engine import tapscript
20+
from btclib.script.engine.script import verify_script as verify_script_legacy
21+
from btclib.script.engine.script_op_codes import _to_num
22+
from btclib.script.script import parse, serialize
23+
from btclib.script.script_pub_key import is_segwit, type_and_payload
24+
from btclib.script.taproot import check_output_pubkey
25+
from btclib.script.witness import Witness
26+
from btclib.tx.tx import Tx
27+
from btclib.tx.tx_out import TxOut
28+
29+
30+
def taproot_unwrap_script(
31+
script: bytes, stack: list[bytes]
32+
) -> tuple[bytes, list[bytes], int]:
33+
pub_key = type_and_payload(script)[1]
34+
script_bytes = stack[-2]
35+
control = stack[-1]
36+
37+
if not check_output_pubkey(pub_key, script_bytes, control):
38+
raise BTClibValueError()
39+
40+
leaf_version = stack[-1][0] & 0xFE
41+
42+
return script_bytes, stack[:-2], leaf_version
43+
44+
45+
def taproot_get_annex(witness: Witness) -> bytes:
46+
annex = b""
47+
if len(witness.stack) >= 2 and witness.stack[-1][0] == 0x50:
48+
annex = witness.stack[-1]
49+
witness.stack = witness.stack[:-1]
50+
return annex
51+
52+
53+
def validate_redeem_script(redeem_script: ScriptList) -> None:
54+
for c in redeem_script:
55+
if isinstance(c, str):
56+
if c == "OP_1NEGATE":
57+
continue
58+
if c[:2] == "OP" and not c[3:].isdigit():
59+
raise BTClibValueError()
60+
61+
62+
ALL_FLAGS = [
63+
"P2SH",
64+
# Bip 62, never finalized
65+
# "SIGPUSHONLY",
66+
# "LOW_S",
67+
# "STRICTENC",
68+
# "CONST_SCRIPTCODE",
69+
# "CLEANSTACK",
70+
# "MINIMALDATA",
71+
"DERSIG",
72+
# only standard, not consensus
73+
# "NULLFAIL",
74+
# "MINMALIF",
75+
# "DISCOURAGE_UPGRADABLE_NOPS",
76+
# "DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM",
77+
"CHECKLOCKTIMEVERIFY",
78+
"CHECKSEQUENCEVERIFY",
79+
"WITNESS",
80+
"NULLDUMMY",
81+
# only standard, not strictly consensus
82+
# "WITNESS_PUBKEYTYPE",
83+
"TAPROOT",
84+
]
85+
86+
87+
def verify_input(prevouts: list[TxOut], tx: Tx, i: int, flags: list[str]) -> None:
88+
script_sig = tx.vin[i].script_sig
89+
parsed_script_sig = parse(script_sig, accept_unknown=True)
90+
if "SIGPUSHONLY" in flags:
91+
validate_redeem_script(parsed_script_sig)
92+
if "CONST_SCRIPTCODE" in flags:
93+
for x in parsed_script_sig:
94+
op_checks = [
95+
"OP_CHECKSIG",
96+
"OP_CHECKSIGVERIFY",
97+
"OP_CHECKMULTISIG",
98+
"OP_CHECKSIGVERIFY",
99+
]
100+
if x in op_checks:
101+
raise BTClibValueError()
102+
stack: list[bytes] = []
103+
verify_script_legacy(
104+
script_sig, stack, prevouts[i].value, tx, i, flags, False, False
105+
)
106+
p2sh_script = stack[-1] if stack else b"\x00"
107+
108+
script = prevouts[i].script_pub_key.script
109+
verify_script_legacy(script, stack, prevouts[i].value, tx, i, flags, False, True)
110+
111+
script_type, payload = type_and_payload(script)
112+
113+
p2sh = False
114+
if script_type == "p2sh" and "P2SH" in flags:
115+
p2sh = True
116+
validate_redeem_script(parsed_script_sig) # similar to SIGPUSHONLY
117+
script = p2sh_script
118+
verify_script_legacy(
119+
script, stack, prevouts[i].value, tx, i, flags, False, True
120+
)
121+
script_type, payload = type_and_payload(script)
122+
123+
segwit_version = _to_num(stack[-1], []) if is_segwit(script) else -1
124+
supported_segwit_version = -1
125+
if "WITNESS" in flags:
126+
supported_segwit_version = 0
127+
if "TAPROOT" in flags:
128+
supported_segwit_version = 1
129+
if segwit_version + 1 and tx.vin[i].script_sig and not p2sh:
130+
raise BTClibValueError()
131+
if not (segwit_version + 1) and tx.vin[i].script_witness:
132+
raise BTClibValueError() # witness without witness script
133+
if segwit_version > supported_segwit_version:
134+
if segwit_version + 1 and "DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM" in flags:
135+
raise BTClibValueError()
136+
return
137+
138+
if segwit_version == 1:
139+
if script_type == "p2tr":
140+
if p2sh:
141+
return # remains unencumbered
142+
witness = tx.vin[i].script_witness
143+
budget = 50 + len(witness.serialize())
144+
annex = taproot_get_annex(witness)
145+
stack = witness.stack
146+
if len(stack) == 0:
147+
raise BTClibValueError()
148+
if len(stack) == 1:
149+
tapscript.verify_key_path(script, stack, prevouts, tx, i, annex)
150+
stack = []
151+
else:
152+
script_bytes, stack, leaf_version = taproot_unwrap_script(script, stack)
153+
if leaf_version == 0xC0:
154+
tapscript.verify_script_path_vc0(
155+
script_bytes, stack, prevouts, tx, i, annex, budget, flags
156+
)
157+
else:
158+
return # unknown program, passes validation
159+
160+
if segwit_version == 0:
161+
if script_type == "p2wpkh":
162+
stack = tx.vin[i].script_witness.stack
163+
# serialization of ["OP_DUP", "OP_HASH160", payload, "OP_EQUALVERIFY", "OP_CHECKSIG"]
164+
script = b"v\xa9\x14" + payload + b"\x88\xac"
165+
elif script_type == "p2wsh":
166+
stack = tx.vin[i].script_witness.stack
167+
if any(len(x) > 520 for x in stack[:-1]):
168+
raise BTClibValueError()
169+
script = stack[-1]
170+
if payload != sha256(script):
171+
raise BTClibValueError()
172+
stack = stack[:-1]
173+
else:
174+
raise BTClibValueError()
175+
176+
if "OP_CODESEPARATOR" in parse(script):
177+
return
178+
179+
verify_script_legacy(script, stack, prevouts[i].value, tx, i, flags, True, True)
180+
181+
if stack and ("CLEANSTACK" in flags or segwit_version == 0):
182+
raise BTClibValueError()
183+
184+
185+
def verify_transaction(
186+
prevouts: list[TxOut], tx: Tx, flags: list | None = None
187+
) -> None:
188+
if flags is None:
189+
flags = ALL_FLAGS[:]
190+
if len(prevouts) != len(tx.vin):
191+
raise BTClibValueError()
192+
for i in range(len(prevouts)):
193+
verify_input(prevouts, tx, i, flags)

0 commit comments

Comments
 (0)