Skip to content

Commit d7aa643

Browse files
committed
feat(sighash): implement sighash range
1 parent 75790a3 commit d7aa643

File tree

10 files changed

+638
-31
lines changed

10 files changed

+638
-31
lines changed

hathor/transaction/scripts/opcode.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
)
4646
from hathor.transaction.scripts.execute import Stack, binary_to_int, decode_opn, get_data_value, get_script_op
4747
from hathor.transaction.scripts.script_context import ScriptContext
48-
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask
48+
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask, SighashRange
4949
from hathor.transaction.util import bytes_to_int
5050

5151

@@ -655,6 +655,38 @@ def op_sighash_bitmask(context: ScriptContext) -> None:
655655
context.set_sighash(sighash)
656656

657657

658+
def op_sighash_range(context: ScriptContext) -> None:
659+
"""Pop four items from the stack, constructing a sighash range and setting it in the script context."""
660+
if len(context.stack) < 4:
661+
raise MissingStackItems(f'OP_SIGHASH_RANGE: expected 4 elements on stack, has {len(context.stack)}')
662+
663+
output_end = context.stack.pop()
664+
output_start = context.stack.pop()
665+
input_end = context.stack.pop()
666+
input_start = context.stack.pop()
667+
assert isinstance(output_end, bytes)
668+
assert isinstance(output_start, bytes)
669+
assert isinstance(input_end, bytes)
670+
assert isinstance(input_start, bytes)
671+
672+
try:
673+
sighash = SighashRange(
674+
input_start=bytes_to_int(input_start),
675+
input_end=bytes_to_int(input_end),
676+
output_start=bytes_to_int(output_start),
677+
output_end=bytes_to_int(output_end),
678+
)
679+
except Exception as e:
680+
raise CustomSighashModelInvalid('Could not construct sighash range.') from e
681+
682+
if context.extras.input_index not in sighash.get_input_indexes():
683+
raise InputNotSelectedError(
684+
f'Input at index {context.extras.input_index} must select itself when using a custom sighash.'
685+
)
686+
687+
context.set_sighash(sighash)
688+
689+
658690
def op_max_inputs_outputs(context: ScriptContext) -> None:
659691
"""Pop two items from the stack, constructing an inputs and outputs limit and setting it in the script context."""
660692
if len(context.stack) < 2:
@@ -707,6 +739,7 @@ def execute_op_code(opcode: Opcode, context: ScriptContext) -> None:
707739
case Opcode.OP_CHECKDATASIG: op_checkdatasig(context)
708740
case Opcode.OP_FIND_P2PKH: op_find_p2pkh(context)
709741
case Opcode.OP_SIGHASH_BITMASK: op_sighash_bitmask(context)
742+
case Opcode.OP_SIGHASH_RANGE: op_sighash_range(context)
710743
case Opcode.OP_MAX_INPUTS_OUTPUTS: op_max_inputs_outputs(context)
711744
case _: raise ScriptError(f'unknown opcode: {opcode}')
712745

hathor/transaction/scripts/p2pkh.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,20 @@
1515
import struct
1616
from typing import Any, Optional
1717

18+
from typing_extensions import assert_never
19+
1820
from hathor.crypto.util import decode_address, get_address_b58_from_public_key_hash
1921
from hathor.transaction.scripts.base_script import BaseScript
2022
from hathor.transaction.scripts.construct import get_pushdata, re_compile
2123
from hathor.transaction.scripts.hathor_script import HathorScript
2224
from hathor.transaction.scripts.opcode import Opcode
23-
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask
25+
from hathor.transaction.scripts.sighash import (
26+
InputsOutputsLimit,
27+
SighashAll,
28+
SighashBitmask,
29+
SighashRange,
30+
SighashType,
31+
)
2432

2533

2634
class P2PKH(BaseScript):
@@ -97,7 +105,7 @@ def create_input_data(
97105
public_key_bytes: bytes,
98106
signature: bytes,
99107
*,
100-
sighash: SighashBitmask | None = None,
108+
sighash: SighashType = SighashAll(),
101109
inputs_outputs_limit: InputsOutputsLimit | None = None
102110
) -> bytes:
103111
"""
@@ -108,10 +116,21 @@ def create_input_data(
108116
"""
109117
s = HathorScript()
110118

111-
if sighash:
112-
s.pushData(sighash.inputs)
113-
s.pushData(sighash.outputs)
114-
s.addOpcode(Opcode.OP_SIGHASH_BITMASK)
119+
match sighash:
120+
case SighashAll():
121+
pass
122+
case SighashBitmask():
123+
s.pushData(sighash.inputs)
124+
s.pushData(sighash.outputs)
125+
s.addOpcode(Opcode.OP_SIGHASH_BITMASK)
126+
case SighashRange():
127+
s.pushData(sighash.input_start)
128+
s.pushData(sighash.input_end)
129+
s.pushData(sighash.output_start)
130+
s.pushData(sighash.output_end)
131+
s.addOpcode(Opcode.OP_SIGHASH_RANGE)
132+
case _:
133+
assert_never(sighash)
115134

116135
if inputs_outputs_limit:
117136
s.pushData(inputs_outputs_limit.max_inputs)

hathor/transaction/scripts/script_context.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from hathor.transaction import Transaction
2121
from hathor.transaction.exceptions import ScriptError
2222
from hathor.transaction.scripts.execute import ScriptExtras, Stack
23-
from hathor.transaction.scripts.sighash import SighashAll, SighashBitmask, SighashType
23+
from hathor.transaction.scripts.sighash import SighashAll, SighashBitmask, SighashRange, SighashType
2424

2525

2626
class ScriptContext:
@@ -52,7 +52,7 @@ def get_tx_sighash_data(self, tx: Transaction) -> bytes:
5252
match self._sighash:
5353
case SighashAll():
5454
return tx.get_sighash_all_data()
55-
case SighashBitmask():
55+
case SighashBitmask() | SighashRange():
5656
data = tx.get_custom_sighash_data(self._sighash)
5757
return hashlib.sha256(data).digest()
5858
case _:
@@ -63,7 +63,7 @@ def get_selected_outputs(self) -> set[int]:
6363
match self._sighash:
6464
case SighashAll():
6565
return set(range(self._settings.MAX_NUM_OUTPUTS))
66-
case SighashBitmask():
66+
case SighashBitmask() | SighashRange():
6767
return set(self._sighash.get_output_indexes())
6868
case _:
6969
assert_never(self._sighash)

hathor/transaction/scripts/sighash.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414

1515
from abc import ABC, abstractmethod
1616
from dataclasses import dataclass
17-
from typing import TypeAlias
17+
from typing import Any, TypeAlias
1818

19-
from pydantic import Field
19+
from pydantic import Field, validator
2020
from typing_extensions import override
2121

2222
from hathor.utils.pydantic import BaseModel
@@ -60,7 +60,37 @@ def _get_indexes(bitmask: int) -> list[int]:
6060
return [index for index in range(8) if (bitmask >> index) & 1]
6161

6262

63-
SighashType: TypeAlias = SighashAll | SighashBitmask
63+
class SighashRange(CustomSighash):
64+
"""A model representing the sighash range type config. Range ends are not inclusive."""
65+
input_start: int = Field(ge=0, le=255)
66+
input_end: int = Field(ge=0, le=255)
67+
output_start: int = Field(ge=0, le=255)
68+
output_end: int = Field(ge=0, le=255)
69+
70+
@validator('input_end')
71+
def _validate_input_end(cls, input_end: int, values: dict[str, Any]) -> int:
72+
if input_end < values['input_start']:
73+
raise ValueError('input_end must be greater than or equal to input_start.')
74+
75+
return input_end
76+
77+
@validator('output_end')
78+
def _validate_output_end(cls, output_end: int, values: dict[str, Any]) -> int:
79+
if output_end < values['output_start']:
80+
raise ValueError('output_end must be greater than or equal to output_start.')
81+
82+
return output_end
83+
84+
@override
85+
def get_input_indexes(self) -> list[int]:
86+
return list(range(self.input_start, self.input_end))
87+
88+
@override
89+
def get_output_indexes(self) -> list[int]:
90+
return list(range(self.output_start, self.output_end))
91+
92+
93+
SighashType: TypeAlias = SighashAll | SighashBitmask | SighashRange
6494

6595

6696
class InputsOutputsLimit(BaseModel):

tests/tx/scripts/test_p2pkh.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# limitations under the License.
1414

1515
from hathor.transaction.scripts import P2PKH, Opcode
16-
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask
16+
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashAll, SighashBitmask, SighashRange
1717

1818

1919
def test_create_input_data_simple() -> None:
@@ -29,6 +29,19 @@ def test_create_input_data_simple() -> None:
2929
])
3030

3131

32+
def test_create_input_data_with_sighash_all() -> None:
33+
pub_key = b'my_pub_key'
34+
signature = b'my_signature'
35+
data = P2PKH.create_input_data(public_key_bytes=pub_key, signature=signature, sighash=SighashAll())
36+
37+
assert data == bytes([
38+
len(signature),
39+
*signature,
40+
len(pub_key),
41+
*pub_key
42+
])
43+
44+
3245
def test_create_input_data_with_sighash_bitmask() -> None:
3346
pub_key = b'my_pub_key'
3447
signature = b'my_signature'
@@ -50,6 +63,38 @@ def test_create_input_data_with_sighash_bitmask() -> None:
5063
])
5164

5265

66+
def test_create_input_data_with_sighash_range() -> None:
67+
pub_key = b'my_pub_key'
68+
signature = b'my_signature'
69+
input_start = 123
70+
input_end = 145
71+
output_start = 10
72+
output_end = 20
73+
sighash = SighashRange(
74+
input_start=input_start,
75+
input_end=input_end,
76+
output_start=output_start,
77+
output_end=output_end,
78+
)
79+
data = P2PKH.create_input_data(public_key_bytes=pub_key, signature=signature, sighash=sighash)
80+
81+
assert data == bytes([
82+
1,
83+
input_start,
84+
1,
85+
input_end,
86+
1,
87+
output_start,
88+
1,
89+
output_end,
90+
Opcode.OP_SIGHASH_RANGE,
91+
len(signature),
92+
*signature,
93+
len(pub_key),
94+
*pub_key
95+
])
96+
97+
5398
def test_create_input_data_with_inputs_outputs_limit() -> None:
5499
pub_key = b'my_pub_key'
55100
signature = b'my_signature'
@@ -103,3 +148,48 @@ def test_create_input_data_with_sighash_bitmask_and_inputs_outputs_limit() -> No
103148
len(pub_key),
104149
*pub_key
105150
])
151+
152+
153+
def test_create_input_data_with_sighash_range_and_inputs_outputs_limit() -> None:
154+
pub_key = b'my_pub_key'
155+
signature = b'my_signature'
156+
input_start = 123
157+
input_end = 145
158+
output_start = 10
159+
output_end = 20
160+
max_inputs = 2
161+
max_outputs = 3
162+
sighash = SighashRange(
163+
input_start=input_start,
164+
input_end=input_end,
165+
output_start=output_start,
166+
output_end=output_end,
167+
)
168+
limit = InputsOutputsLimit(max_inputs=max_inputs, max_outputs=max_outputs)
169+
data = P2PKH.create_input_data(
170+
public_key_bytes=pub_key,
171+
signature=signature,
172+
sighash=sighash,
173+
inputs_outputs_limit=limit
174+
)
175+
176+
assert data == bytes([
177+
1,
178+
input_start,
179+
1,
180+
input_end,
181+
1,
182+
output_start,
183+
1,
184+
output_end,
185+
Opcode.OP_SIGHASH_RANGE,
186+
1,
187+
max_inputs,
188+
1,
189+
max_outputs,
190+
Opcode.OP_MAX_INPUTS_OUTPUTS,
191+
len(signature),
192+
*signature,
193+
len(pub_key),
194+
*pub_key
195+
])

tests/tx/scripts/test_script_context.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from hathor.transaction import Transaction, TxInput, TxOutput
2222
from hathor.transaction.exceptions import ScriptError
2323
from hathor.transaction.scripts.script_context import ScriptContext
24-
from hathor.transaction.scripts.sighash import SighashAll, SighashBitmask
24+
from hathor.transaction.scripts.sighash import SighashAll, SighashBitmask, SighashRange
2525

2626

2727
@pytest.mark.parametrize(['max_num_outputs'], [(99,), (255,)])
@@ -95,3 +95,42 @@ def test_sighash_bitmask(outputs_bitmask: int, selected_outputs: set[int]) -> No
9595

9696
assert str(e.value) == 'Cannot modify sighash after it is already set.'
9797
assert context.get_selected_outputs() == selected_outputs
98+
99+
100+
@pytest.mark.parametrize(
101+
['output_start', 'output_end', 'selected_outputs'],
102+
[
103+
(100, 100, set()),
104+
(0, 1, {0}),
105+
(1, 2, {1}),
106+
(0, 2, {0, 1}),
107+
]
108+
)
109+
def test_sighash_range(output_start: int, output_end: int, selected_outputs: set[int]) -> None:
110+
settings = Mock()
111+
settings.MAX_NUM_INPUTS = 88
112+
settings.MAX_NUM_OUTPUTS = 99
113+
114+
context = ScriptContext(settings=settings, stack=Mock(), logs=[], extras=Mock())
115+
tx = Transaction(
116+
inputs=[
117+
TxInput(tx_id=b'tx1', index=0, data=b''),
118+
TxInput(tx_id=b'tx2', index=1, data=b''),
119+
],
120+
outputs=[
121+
TxOutput(value=11, script=b''),
122+
TxOutput(value=22, script=b''),
123+
]
124+
)
125+
126+
sighash_range = SighashRange(input_start=0, input_end=2, output_start=output_start, output_end=output_end)
127+
context.set_sighash(sighash_range)
128+
129+
data = tx.get_custom_sighash_data(sighash_range)
130+
assert context.get_tx_sighash_data(tx) == hashlib.sha256(data).digest()
131+
132+
with pytest.raises(ScriptError) as e:
133+
context.set_sighash(Mock())
134+
135+
assert str(e.value) == 'Cannot modify sighash after it is already set.'
136+
assert context.get_selected_outputs() == selected_outputs

tests/tx/scripts/test_sighash.py renamed to tests/tx/scripts/test_sighash_bitmask.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from tests.utils import add_blocks_unlock_reward, create_tokens, get_genesis_key
3131

3232

33-
class SighashTest(unittest.TestCase):
33+
class SighashBitmaskTest(unittest.TestCase):
3434
def setUp(self) -> None:
3535
super().setUp()
3636
self.manager1: HathorManager = self.create_peer('testnet', unlock_wallet=True, wallet_index=True)

0 commit comments

Comments
 (0)