Skip to content

Commit 6bbc55a

Browse files
committed
feat(sighash): implement sighash bitmask
1 parent dbcff55 commit 6bbc55a

14 files changed

+450
-138
lines changed

hathor/transaction/exceptions.py

+26
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ class ConflictingInputs(TxValidationError):
7474
"""Inputs in the tx are spending the same output"""
7575

7676

77+
class OutputNotSelected(TxValidationError):
78+
"""At least one output is not selected for signing by some input."""
79+
80+
7781
class TooManyOutputs(TxValidationError):
7882
"""More than 256 outputs"""
7983

@@ -202,3 +206,25 @@ class VerifyFailed(ScriptError):
202206

203207
class TimeLocked(ScriptError):
204208
"""Transaction is invalid because it is time locked"""
209+
210+
211+
class InputNotSelectedError(ScriptError):
212+
"""Raised when an input does not select itself for signing in its script."""
213+
214+
215+
class MaxInputsExceededError(ScriptError):
216+
"""The transaction has more inputs than the maximum configured in the script."""
217+
218+
219+
class MaxOutputsExceededError(ScriptError):
220+
"""The transaction has more outputs than the maximum configured in the script."""
221+
222+
223+
class InputsOutputsLimitModelInvalid(ScriptError):
224+
"""
225+
Raised when the inputs outputs limit model could not be constructed from the arguments provided in the script.
226+
"""
227+
228+
229+
class CustomSighashModelInvalid(ScriptError):
230+
"""Raised when the sighash model could not be constructed from the arguments provided in the script."""

hathor/transaction/scripts/execute.py

+38-24
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,33 @@
1313
# limitations under the License.
1414

1515
import struct
16-
from typing import NamedTuple, Optional, Union
16+
from dataclasses import dataclass
17+
from typing import TYPE_CHECKING, NamedTuple, Optional, Union
1718

19+
from hathor.conf.get_settings import get_global_settings
1820
from hathor.transaction import BaseTransaction, Transaction, TxInput
1921
from hathor.transaction.exceptions import DataIndexError, FinalStackInvalid, InvalidScriptError, OutOfData
2022

23+
if TYPE_CHECKING:
24+
from hathor.transaction.scripts.script_context import ScriptContext
25+
2126

22-
class ScriptExtras(NamedTuple):
27+
@dataclass(slots=True, frozen=True, kw_only=True)
28+
class ScriptExtras:
29+
"""
30+
A simple container for auxiliary data that may be used during execution of scripts.
31+
"""
2332
tx: Transaction
24-
txin: TxInput
33+
input_index: int
2534
spent_tx: BaseTransaction
2635

36+
@property
37+
def txin(self) -> TxInput:
38+
return self.tx.inputs[self.input_index]
39+
40+
def __post_init__(self) -> None:
41+
assert self.txin.tx_id == self.spent_tx.hash
42+
2743

2844
# XXX: Because the Stack is a heterogeneous list of bytes and int, and some OPs only work for when the stack has some
2945
# or the other type, there are many places that require an assert to prevent the wrong type from being used,
@@ -39,7 +55,7 @@ class OpcodePosition(NamedTuple):
3955
position: int
4056

4157

42-
def execute_eval(data: bytes, log: list[str], extras: ScriptExtras) -> None:
58+
def execute_eval(data: bytes, log: list[str], extras: ScriptExtras) -> 'ScriptContext':
4359
""" Execute eval from data executing opcode methods
4460
4561
:param data: data to be evaluated that contains data and opcodes
@@ -56,8 +72,9 @@ def execute_eval(data: bytes, log: list[str], extras: ScriptExtras) -> None:
5672
"""
5773
from hathor.transaction.scripts.opcode import Opcode, execute_op_code
5874
from hathor.transaction.scripts.script_context import ScriptContext
75+
settings = get_global_settings()
5976
stack: Stack = []
60-
context = ScriptContext(stack=stack, logs=log, extras=extras)
77+
context = ScriptContext(settings=settings, stack=stack, logs=log, extras=extras)
6178
data_len = len(data)
6279
pos = 0
6380
while pos < data_len:
@@ -70,6 +87,8 @@ def execute_eval(data: bytes, log: list[str], extras: ScriptExtras) -> None:
7087

7188
evaluate_final_stack(stack, log)
7289

90+
return context
91+
7392

7493
def evaluate_final_stack(stack: Stack, log: list[str]) -> None:
7594
""" Checks the final state of the stack.
@@ -88,25 +107,20 @@ def evaluate_final_stack(stack: Stack, log: list[str]) -> None:
88107
raise FinalStackInvalid('\n'.join(log))
89108

90109

91-
def script_eval(tx: Transaction, txin: TxInput, spent_tx: BaseTransaction) -> None:
92-
"""Evaluates the output script and input data according to
93-
a very limited subset of Bitcoin's scripting language.
94-
95-
:param tx: the transaction being validated, the 'owner' of the input data
96-
:type tx: :py:class:`hathor.transaction.Transaction`
97-
98-
:param txin: transaction input being evaluated
99-
:type txin: :py:class:`hathor.transaction.TxInput`
100-
101-
:param spent_tx: the transaction referenced by the input
102-
:type spent_tx: :py:class:`hathor.transaction.BaseTransaction`
110+
def script_eval(*, tx: Transaction, spent_tx: BaseTransaction, input_index: int) -> 'ScriptContext':
111+
"""
112+
Evaluates the output script and input data according to a very limited subset of Bitcoin's scripting language.
113+
Raises ScriptError if script verification fails
103114
104-
:raises ScriptError: if script verification fails
115+
Args:
116+
tx: the transaction being validated, the 'owner' of the input data
117+
spent_tx: the transaction referenced by the input
118+
input_index: index of the transaction input being evaluated
105119
"""
106-
input_data = txin.data
107-
output_script = spent_tx.outputs[txin.index].script
120+
extras = ScriptExtras(tx=tx, input_index=input_index, spent_tx=spent_tx)
121+
input_data = extras.txin.data
122+
output_script = spent_tx.outputs[extras.txin.index].script
108123
log: list[str] = []
109-
extras = ScriptExtras(tx=tx, txin=txin, spent_tx=spent_tx)
110124

111125
from hathor.transaction.scripts import MultiSig
112126
if MultiSig.re_match.search(output_script):
@@ -115,17 +129,17 @@ def script_eval(tx: Transaction, txin: TxInput, spent_tx: BaseTransaction) -> No
115129
# we can't use input_data + output_script because it will end with an invalid stack
116130
# i.e. the signatures will still be on the stack after ouput_script is executed
117131
redeem_script_pos = MultiSig.get_multisig_redeem_script_pos(input_data)
118-
full_data = txin.data[redeem_script_pos:] + output_script
132+
full_data = extras.txin.data[redeem_script_pos:] + output_script
119133
execute_eval(full_data, log, extras)
120134

121135
# Second, we need to validate that the signatures on the input_data solves the redeem_script
122136
# we pop and append the redeem_script to the input_data and execute it
123137
multisig_data = MultiSig.get_multisig_data(extras.txin.data)
124-
execute_eval(multisig_data, log, extras)
138+
return execute_eval(multisig_data, log, extras)
125139
else:
126140
# merge input_data and output_script
127141
full_data = input_data + output_script
128-
execute_eval(full_data, log, extras)
142+
return execute_eval(full_data, log, extras)
129143

130144

131145
def decode_opn(opcode: int) -> int:

hathor/transaction/scripts/opcode.py

+91-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import struct
1717
from enum import IntEnum
1818

19+
import pydantic
1920
from cryptography.exceptions import InvalidSignature
2021
from cryptography.hazmat.primitives import hashes
2122
from cryptography.hazmat.primitives.asymmetric import ec
@@ -28,9 +29,14 @@
2829
is_pubkey_compressed,
2930
)
3031
from hathor.transaction.exceptions import (
32+
CustomSighashModelInvalid,
3133
EqualVerifyFailed,
34+
InputNotSelectedError,
35+
InputsOutputsLimitModelInvalid,
3236
InvalidScriptError,
3337
InvalidStackData,
38+
MaxInputsExceededError,
39+
MaxOutputsExceededError,
3440
MissingStackItems,
3541
OracleChecksigFailed,
3642
ScriptError,
@@ -39,6 +45,8 @@
3945
)
4046
from hathor.transaction.scripts.execute import Stack, binary_to_int, decode_opn, get_data_value, get_script_op
4147
from hathor.transaction.scripts.script_context import ScriptContext
48+
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask
49+
from hathor.transaction.util import bytes_to_int
4250

4351

4452
class Opcode(IntEnum):
@@ -72,6 +80,9 @@ class Opcode(IntEnum):
7280
OP_DATA_GREATERTHAN = 0xC1
7381
OP_FIND_P2PKH = 0xD0
7482
OP_DATA_MATCH_VALUE = 0xD1
83+
OP_SIGHASH_BITMASK = 0xE0
84+
OP_SIGHASH_RANGE = 0xE1
85+
OP_MAX_INPUTS_OUTPUTS = 0xE2
7586

7687
@classmethod
7788
def is_pushdata(cls, opcode: int) -> bool:
@@ -249,7 +260,8 @@ def op_checksig(context: ScriptContext) -> None:
249260
# pubkey is not compressed public key
250261
raise ScriptError('OP_CHECKSIG: pubkey is not a public key') from e
251262
try:
252-
public_key.verify(signature, context.extras.tx.get_sighash_all_data(), ec.ECDSA(hashes.SHA256()))
263+
sighash_data = context.get_tx_sighash_data(context.extras.tx)
264+
public_key.verify(signature, sighash_data, ec.ECDSA(hashes.SHA256()))
253265
# valid, push true to stack
254266
context.stack.append(1)
255267
except InvalidSignature:
@@ -583,7 +595,7 @@ def op_checkmultisig(context: ScriptContext) -> None:
583595
while pubkey_index < len(pubkeys):
584596
pubkey = pubkeys[pubkey_index]
585597
new_stack = [signature, pubkey]
586-
op_checksig(ScriptContext(stack=new_stack, logs=context.logs, extras=context.extras))
598+
op_checksig(ScriptContext(stack=new_stack, logs=context.logs, extras=context.extras, settings=settings))
587599
result = new_stack.pop()
588600
pubkey_index += 1
589601
if result == 1:
@@ -617,6 +629,59 @@ def op_integer(opcode: int, stack: Stack) -> None:
617629
raise ScriptError(e) from e
618630

619631

632+
def op_sighash_bitmask(context: ScriptContext) -> None:
633+
"""Pop two items from the stack, constructing a sighash bitmask and setting it in the script context."""
634+
if len(context.stack) < 2:
635+
raise MissingStackItems(f'OP_SIGHASH_BITMASK: expected 2 elements on stack, has {len(context.stack)}')
636+
637+
outputs = context.stack.pop()
638+
inputs = context.stack.pop()
639+
assert isinstance(inputs, bytes)
640+
assert isinstance(outputs, bytes)
641+
642+
try:
643+
sighash = SighashBitmask(
644+
inputs=bytes_to_int(inputs),
645+
outputs=bytes_to_int(outputs)
646+
)
647+
except pydantic.ValidationError as e:
648+
raise CustomSighashModelInvalid('Could not construct sighash bitmask.') from e
649+
650+
if context.extras.input_index not in sighash.get_input_indexes():
651+
raise InputNotSelectedError(
652+
f'Input at index {context.extras.input_index} must select itself when using a custom sighash.'
653+
)
654+
655+
context.set_sighash(sighash)
656+
657+
658+
def op_max_inputs_outputs(context: ScriptContext) -> None:
659+
"""Pop two items from the stack, constructing an inputs and outputs limit and setting it in the script context."""
660+
if len(context.stack) < 2:
661+
raise MissingStackItems(f'OP_MAX_INPUTS_OUTPUTS: expected 2 elements on stack, has {len(context.stack)}')
662+
663+
max_outputs = context.stack.pop()
664+
max_inputs = context.stack.pop()
665+
assert isinstance(max_inputs, bytes)
666+
assert isinstance(max_outputs, bytes)
667+
668+
try:
669+
limit = InputsOutputsLimit(
670+
max_inputs=bytes_to_int(max_inputs),
671+
max_outputs=bytes_to_int(max_outputs)
672+
)
673+
except pydantic.ValidationError as e:
674+
raise InputsOutputsLimitModelInvalid("Could not construct inputs and outputs limits.") from e
675+
676+
tx_inputs_len = len(context.extras.tx.inputs)
677+
if tx_inputs_len > limit.max_inputs:
678+
raise MaxInputsExceededError(f'Maximum number of inputs exceeded ({tx_inputs_len} > {limit.max_inputs}).')
679+
680+
tx_outputs_len = len(context.extras.tx.outputs)
681+
if tx_outputs_len > limit.max_outputs:
682+
raise MaxOutputsExceededError(f'Maximum number of outputs exceeded ({tx_outputs_len} > {limit.max_outputs}).')
683+
684+
620685
def execute_op_code(opcode: Opcode, context: ScriptContext) -> None:
621686
"""
622687
Execute a function opcode.
@@ -625,6 +690,8 @@ def execute_op_code(opcode: Opcode, context: ScriptContext) -> None:
625690
opcode: the opcode to be executed.
626691
context: the script context to be manipulated.
627692
"""
693+
if not is_opcode_valid(opcode):
694+
raise ScriptError(f'Opcode "{opcode.name}" is invalid.')
628695
context.logs.append(f'Executing function opcode {opcode.name} ({hex(opcode.value)})')
629696
match opcode:
630697
case Opcode.OP_DUP: op_dup(context)
@@ -639,4 +706,26 @@ def execute_op_code(opcode: Opcode, context: ScriptContext) -> None:
639706
case Opcode.OP_DATA_MATCH_VALUE: op_data_match_value(context)
640707
case Opcode.OP_CHECKDATASIG: op_checkdatasig(context)
641708
case Opcode.OP_FIND_P2PKH: op_find_p2pkh(context)
709+
case Opcode.OP_SIGHASH_BITMASK: op_sighash_bitmask(context)
710+
case Opcode.OP_MAX_INPUTS_OUTPUTS: op_max_inputs_outputs(context)
642711
case _: raise ScriptError(f'unknown opcode: {opcode}')
712+
713+
714+
def is_opcode_valid(opcode: Opcode) -> bool:
715+
"""Return whether an opcode is valid, that is, it's currently enabled."""
716+
valid_opcodes = [
717+
Opcode.OP_DUP,
718+
Opcode.OP_EQUAL,
719+
Opcode.OP_EQUALVERIFY,
720+
Opcode.OP_CHECKSIG,
721+
Opcode.OP_HASH160,
722+
Opcode.OP_GREATERTHAN_TIMESTAMP,
723+
Opcode.OP_CHECKMULTISIG,
724+
Opcode.OP_DATA_STREQUAL,
725+
Opcode.OP_DATA_GREATERTHAN,
726+
Opcode.OP_DATA_MATCH_VALUE,
727+
Opcode.OP_CHECKDATASIG,
728+
Opcode.OP_FIND_P2PKH,
729+
]
730+
731+
return opcode in valid_opcodes

hathor/transaction/scripts/p2pkh.py

+21-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from hathor.transaction.scripts.construct import get_pushdata, re_compile
2121
from hathor.transaction.scripts.hathor_script import HathorScript
2222
from hathor.transaction.scripts.opcode import Opcode
23+
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask
2324

2425

2526
class P2PKH(BaseScript):
@@ -91,16 +92,35 @@ def create_output_script(cls, address: bytes, timelock: Optional[Any] = None) ->
9192
return s.data
9293

9394
@classmethod
94-
def create_input_data(cls, public_key_bytes: bytes, signature: bytes) -> bytes:
95+
def create_input_data(
96+
cls,
97+
public_key_bytes: bytes,
98+
signature: bytes,
99+
*,
100+
sighash: SighashBitmask | None = None,
101+
inputs_outputs_limit: InputsOutputsLimit | None = None
102+
) -> bytes:
95103
"""
96104
:param private_key: key corresponding to the address we want to spend tokens from
97105
:type private_key: :py:class:`cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey`
98106
99107
:rtype: bytes
100108
"""
101109
s = HathorScript()
110+
111+
if sighash:
112+
s.pushData(sighash.inputs)
113+
s.pushData(sighash.outputs)
114+
s.addOpcode(Opcode.OP_SIGHASH_BITMASK)
115+
116+
if inputs_outputs_limit:
117+
s.pushData(inputs_outputs_limit.max_inputs)
118+
s.pushData(inputs_outputs_limit.max_outputs)
119+
s.addOpcode(Opcode.OP_MAX_INPUTS_OUTPUTS)
120+
102121
s.pushData(signature)
103122
s.pushData(public_key_bytes)
123+
104124
return s.data
105125

106126
@classmethod

0 commit comments

Comments
 (0)