Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions hathor/transaction/scripts/hathor_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@
import struct
from typing import Union

from typing_extensions import assert_never

from hathor.transaction.scripts.opcode import Opcode
from hathor.transaction.scripts.sighash import (
InputsOutputsLimit,
SighashAll,
SighashBitmask,
SighashRange,
SighashType,
)


class HathorScript:
Expand Down Expand Up @@ -49,3 +58,27 @@ def pushData(self, data: Union[int, bytes]) -> None:
self.data += (bytes([len(data)]) + data)
else:
self.data += (bytes([Opcode.OP_PUSHDATA1]) + bytes([len(data)]) + data)

def push_sighash(self, sighash: SighashType) -> None:
"""Push a custom sighash to the script."""
match sighash:
case SighashAll():
pass
case SighashBitmask():
self.pushData(sighash.inputs)
self.pushData(sighash.outputs)
self.addOpcode(Opcode.OP_SIGHASH_BITMASK)
case SighashRange():
self.pushData(sighash.input_start)
self.pushData(sighash.input_end)
self.pushData(sighash.output_start)
self.pushData(sighash.output_end)
self.addOpcode(Opcode.OP_SIGHASH_RANGE)
case _:
assert_never(sighash)

def push_inputs_outputs_limit(self, limit: InputsOutputsLimit) -> None:
"""Push a custom inputs and outputs limit to the script."""
self.pushData(limit.max_inputs)
self.pushData(limit.max_outputs)
self.addOpcode(Opcode.OP_MAX_INPUTS_OUTPUTS)
13 changes: 12 additions & 1 deletion hathor/transaction/scripts/multi_sig.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from hathor.transaction.scripts.execute import Stack, get_script_op
from hathor.transaction.scripts.hathor_script import HathorScript
from hathor.transaction.scripts.opcode import Opcode, op_pushdata, op_pushdata1
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashAll, SighashType


class MultiSig(BaseScript):
Expand Down Expand Up @@ -111,7 +112,14 @@ def create_output_script(cls, address: bytes, timelock: Optional[Any] = None) ->
return s.data

@classmethod
def create_input_data(cls, redeem_script: bytes, signatures: list[bytes]) -> bytes:
def create_input_data(
cls,
redeem_script: bytes,
signatures: list[bytes],
*,
sighash: SighashType = SighashAll(),
inputs_outputs_limit: InputsOutputsLimit | None = None
) -> bytes:
"""
:param redeem_script: script to redeem the tokens: <M> <pubkey1> ... <pubkeyN> <N> <OP_CHECKMULTISIG>
:type redeem_script: bytes
Expand All @@ -122,6 +130,9 @@ def create_input_data(cls, redeem_script: bytes, signatures: list[bytes]) -> byt
:rtype: bytes
"""
s = HathorScript()
s.push_sighash(sighash)
if inputs_outputs_limit:
s.push_inputs_outputs_limit(inputs_outputs_limit)
for signature in signatures:
s.pushData(signature)
s.pushData(redeem_script)
Expand Down
6 changes: 5 additions & 1 deletion hathor/transaction/scripts/opcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,21 +591,25 @@ def op_checkmultisig(context: ScriptContext) -> None:
# For each signature we check if it's valid with one of the public keys
# Signatures must be in order (same as the public keys in the multi sig wallet)
pubkey_index = 0
old_stack = context.stack
for signature in signatures:
while pubkey_index < len(pubkeys):
pubkey = pubkeys[pubkey_index]
new_stack = [signature, pubkey]
op_checksig(ScriptContext(stack=new_stack, logs=context.logs, extras=context.extras, settings=settings))
context.stack = new_stack
op_checksig(context)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that's the bug fix, right? I'd like more information about the bug, its origin, and the fix.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a preexisting bug, it's just an issue caused by the new sighash features.

op_checkmultisig calls op_checksig for each signature-pubkey pair, trying to find matches. Before, it would call op_checksig with a new ad-hoc ScriptContext, with a new stack.

Now, since the sighash configuration is persisted in the ScriptContext by the new sighash opcodes, this information was missing from this ad-hoc context passed to op_checksig. So I simply changed the code to reuse the same context, only changing the stack (and later changing it back to the original one).

The behavior is exactly the same as before, except now the same context is reused, guaranteeing the sighash config is considered.

result = new_stack.pop()
pubkey_index += 1
if result == 1:
break
else:
# finished all pubkeys and did not verify all signatures
context.stack = old_stack
context.stack.append(0)
return

# If all signatures are valids we push 1
context.stack = old_stack
context.stack.append(1)


Expand Down
33 changes: 3 additions & 30 deletions hathor/transaction/scripts/p2pkh.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,12 @@
import struct
from typing import Any, Optional

from typing_extensions import assert_never

from hathor.crypto.util import decode_address, get_address_b58_from_public_key_hash
from hathor.transaction.scripts.base_script import BaseScript
from hathor.transaction.scripts.construct import get_pushdata, re_compile
from hathor.transaction.scripts.hathor_script import HathorScript
from hathor.transaction.scripts.opcode import Opcode
from hathor.transaction.scripts.sighash import (
InputsOutputsLimit,
SighashAll,
SighashBitmask,
SighashRange,
SighashType,
)
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashAll, SighashType


class P2PKH(BaseScript):
Expand Down Expand Up @@ -115,28 +107,9 @@ def create_input_data(
:rtype: bytes
"""
s = HathorScript()

match sighash:
case SighashAll():
pass
case SighashBitmask():
s.pushData(sighash.inputs)
s.pushData(sighash.outputs)
s.addOpcode(Opcode.OP_SIGHASH_BITMASK)
case SighashRange():
s.pushData(sighash.input_start)
s.pushData(sighash.input_end)
s.pushData(sighash.output_start)
s.pushData(sighash.output_end)
s.addOpcode(Opcode.OP_SIGHASH_RANGE)
case _:
assert_never(sighash)

s.push_sighash(sighash)
if inputs_outputs_limit:
s.pushData(inputs_outputs_limit.max_inputs)
s.pushData(inputs_outputs_limit.max_outputs)
s.addOpcode(Opcode.OP_MAX_INPUTS_OUTPUTS)

s.push_inputs_outputs_limit(inputs_outputs_limit)
s.pushData(signature)
s.pushData(public_key_bytes)

Expand Down
12 changes: 10 additions & 2 deletions hathor/wallet/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def generate_multisig_address(redeem_script: bytes, version_byte: Optional[bytes


def generate_signature(tx: Transaction, private_key_bytes: bytes, password: Optional[bytes] = None) -> bytes:
""" Create a signature for the tx
""" Create a signature for the tx using the default sighash_all

:param tx: transaction with the data to be signed
:type tx: :py:class:`hathor.transaction.transaction.Transaction`
Expand All @@ -102,8 +102,16 @@ def generate_signature(tx: Transaction, private_key_bytes: bytes, password: Opti
:return: signature of the tx
:rtype: bytes
"""
return generate_signature_for_data(tx.get_sighash_all(), private_key_bytes, password)


def generate_signature_for_data(
data_to_sign: bytes,
private_key_bytes: bytes,
password: Optional[bytes] = None
) -> bytes:
"""Create a signature for some custom data."""
private_key = get_private_key_from_bytes(private_key_bytes, password=password)
data_to_sign = tx.get_sighash_all()
hashed_data = hashlib.sha256(data_to_sign).digest()
signature = private_key.sign(hashed_data, ec.ECDSA(hashes.SHA256()))
return signature
6 changes: 3 additions & 3 deletions tests/tx/scripts/test_sighash_bitmask.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def test_sighash_bitmask(self) -> None:
alice_output_script = P2PKH.create_output_script(self.address1)
htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script)

# Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR
# Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output
atomic_swap_tx = Transaction(
weight=1,
inputs=[tokens_input],
Expand Down Expand Up @@ -145,7 +145,7 @@ def test_sighash_bitmask_with_limit(self) -> None:
alice_output_script = P2PKH.create_output_script(self.address1)
htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script)

# Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR
# Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output
atomic_swap_tx = Transaction(
weight=1,
inputs=[tokens_input],
Expand Down Expand Up @@ -279,7 +279,7 @@ def test_sighash_bitmask_nonexistent_input(self) -> None:
alice_output_script = P2PKH.create_output_script(self.address1)
htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script)

# Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR
# Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output
atomic_swap_tx = Transaction(
weight=1,
inputs=[tokens_input],
Expand Down
120 changes: 117 additions & 3 deletions tests/tx/scripts/test_sighash_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@
from hathor.manager import HathorManager
from hathor.transaction import Transaction, TxInput, TxOutput
from hathor.transaction.exceptions import InputOutputMismatch, InvalidInputData, InvalidScriptError
from hathor.transaction.scripts import MultiSig
from hathor.transaction.scripts.p2pkh import P2PKH
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashRange
from hathor.transaction.static_metadata import TransactionStaticMetadata
from hathor.util import not_none
from hathor.wallet.util import generate_multisig_address, generate_multisig_redeem_script, generate_signature_for_data
from tests import unittest
from tests.utils import add_blocks_unlock_reward, create_tokens, get_genesis_key

Expand Down Expand Up @@ -69,7 +71,7 @@ def test_sighash_range(self) -> None:
alice_output_script = P2PKH.create_output_script(self.address1)
htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script)

# Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR
# Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output
atomic_swap_tx = Transaction(
weight=1,
inputs=[tokens_input],
Expand Down Expand Up @@ -129,6 +131,118 @@ def test_sighash_range(self) -> None:
self.manager1.verification_service.verify(atomic_swap_tx_clone)
self.manager1.propagate_tx(atomic_swap_tx_clone, fails_silently=False)

@patch('hathor.transaction.scripts.opcode.is_opcode_valid', lambda _: True)
def test_sighash_range_with_multisig(self) -> None:
# Create a new test token
token_creation_tx = create_tokens(self.manager1, self.address1_b58)
token_uid = token_creation_tx.tokens[0]
token_creation_utxo = token_creation_tx.outputs[0]
genesis_utxo = self.genesis_block.outputs[0]
parents = self.manager1.get_new_tx_parents()

public_keys = [
bytes.fromhex('0250bf5890c9c6e9b4ab7f70375d31b827d45d0b7b4e3ba1918bcbe71b412c11d7'),
bytes.fromhex('02d83dd1e9e0ac7976704eedab43fe0b79309166a47d70ec3ce8bbb08b8414db46'),
bytes.fromhex('02358c539fa7474bf12f774749d0e1b5a9bc6e50920464818ebdb0043b143ae2ba'),
]

private_keys = [
'3081de304906092a864886f70d01050d303c301b06092a864886f70d01050c300e04089abeae5e8a8f75d302020800301d0609608'
'64801650304012a0410abbde27221fd302280c13fca7887c85e048190c41403f39b1e9bbc5b6b7c3be4729c054fae9506dc0f8361'
'adcff0ea393f0bb3ca9f992fc2eea83d532691bc9a570ed7fb9e939e6d1787881af40b19fb467f06595229e29b5a6268d831f0287'
'530c7935d154deac61dd4ced988166f9c98054912935b607e2fb332e11c95b30ea4686eb0bda7dd57ed1eeb25b07cea9669dde521'
'0528a00653159626a5baa61cdee7f4',
'3081de304906092a864886f70d01050d303c301b06092a864886f70d01050c300e040817ca6c6c47ade0de02020800301d0609608'
'64801650304012a041003746599b1d7dde5b875e4d8e2c4c157048190a25ccabb17e603260f8a1407bdca24904b6ae0aa9ae225d8'
'7552e5a9aa62d98b35b2c6c78f33cb051f3a3932387b4cea6f49e94f14ee856d0b630d77c1299ad7207b0be727d338cf92a3fffe2'
'32aff59764240aff84e079a5f6fb3355048ac15703290a005a9a033fdcb7fcf582a5ddf6fd7b7c1193bd7912cd275a88a8a6823b6'
'c3ed291b4a3f4724875a3ae058054c',
'3081de304906092a864886f70d01050d303c301b06092a864886f70d01050c300e0408089f48fbf59fa92902020800301d0609608'
'64801650304012a041072f553e860b77654fd5fb80e5891e7c90481900fde272b88f9a70e7220b2d5adeda1ed29667527caedc238'
'5be7f9e0d63defdde20557e90726e102f879eaf2233cceca8d4af239d5b2a159467255446f001c99b69e570bb176b95248fc21cb7'
'52d463b494c2195411639989086336a530d1f4eae91493faf89368f439991baa947ebeca00be7f5099ed69606dc78a4cc384d4154'
'2350a9054c5fa1295305dfc37e5989', # noqa: E501
]

# Change the created token utxo to a MultiSig requiring 2 signatures
redeem_script = generate_multisig_redeem_script(signatures_required=2, public_key_bytes=public_keys)
multisig_address_b58 = generate_multisig_address(redeem_script)
multisig_address = decode_address(multisig_address_b58)
multisig_script = MultiSig.create_output_script(multisig_address)

token_creation_utxo.script = multisig_script

# Alice creates an input spending all created test tokens
tokens_input = TxInput(not_none(token_creation_tx.hash), 0, b'')

# Alice creates an output sending half genesis HTR to herself
alice_output_script = P2PKH.create_output_script(self.address1)
htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script)

# Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output
atomic_swap_tx = Transaction(
weight=1,
inputs=[tokens_input],
outputs=[htr_output],
parents=parents,
tokens=[token_uid],
storage=self.manager1.tx_storage,
timestamp=token_creation_tx.timestamp + 1
)
self.manager1.cpu_mining_service.resolve(atomic_swap_tx)

# Alice signs her input using sighash range, instead of sighash_all.
sighash_range = SighashRange(input_start=0, input_end=1, output_start=0, output_end=1)
data_to_sign1 = atomic_swap_tx.get_custom_sighash_data(sighash_range)

signatures = []
for private_key_hex in private_keys[:2]:
signature = generate_signature_for_data(data_to_sign1, bytes.fromhex(private_key_hex), password=b'1234')
signatures.append(signature)

tokens_input.data = MultiSig.create_input_data(
redeem_script=redeem_script,
signatures=signatures,
sighash=sighash_range,
)

# At this point, the tx is partial. The inputs are valid, but they're mismatched with outputs
self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx)
with pytest.raises(InputOutputMismatch):
self.manager1.verification_service.verify(atomic_swap_tx)

# Alice sends the tx bytes to Bob, represented here by cloning the tx
atomic_swap_tx_clone = cast(Transaction, atomic_swap_tx.clone())
self.manager1.cpu_mining_service.resolve(atomic_swap_tx_clone)

# Bob creates an input spending all genesis HTR and adds it to the atomic swap tx
htr_input = TxInput(not_none(self.genesis_block.hash), 0, b'')
atomic_swap_tx_clone.inputs.append(htr_input)

# Bob adds an output to receive all test tokens
bob_output_script = P2PKH.create_output_script(self.address2)
tokens_output = TxOutput(token_creation_utxo.value, bob_output_script, 1)
atomic_swap_tx_clone.outputs.append(tokens_output)

# Bob adds a change output for his HTR
htr_output = TxOutput(int(genesis_utxo.value / 2), bob_output_script)
atomic_swap_tx_clone.outputs.append(htr_output)

# Bob signs his input using sighash_all to complete the tx
data_to_sign2 = atomic_swap_tx_clone.get_sighash_all()
assert self.manager2.wallet
public_bytes2, signature2 = self.manager2.wallet.get_input_aux_data(data_to_sign2, self.genesis_private_key)
htr_input.data = P2PKH.create_input_data(public_bytes2, signature2)

static_metadata = TransactionStaticMetadata.create_from_storage(
atomic_swap_tx_clone, self._settings, self.manager1.tx_storage
)
atomic_swap_tx_clone.set_static_metadata(static_metadata)

# The atomic swap tx is now completed and valid, and can be propagated
self.manager1.verification_service.verify(atomic_swap_tx_clone)
self.manager1.propagate_tx(atomic_swap_tx_clone, fails_silently=False)

@patch('hathor.transaction.scripts.opcode.is_opcode_valid', lambda _: True)
def test_sighash_range_with_limit(self) -> None:
# Create a new test token
Expand All @@ -145,7 +259,7 @@ def test_sighash_range_with_limit(self) -> None:
alice_output_script = P2PKH.create_output_script(self.address1)
htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script)

# Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR
# Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output
atomic_swap_tx = Transaction(
weight=1,
inputs=[tokens_input],
Expand Down Expand Up @@ -279,7 +393,7 @@ def test_sighash_range_nonexistent_input(self) -> None:
alice_output_script = P2PKH.create_output_script(self.address1)
htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script)

# Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR
# Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output
atomic_swap_tx = Transaction(
weight=1,
inputs=[tokens_input],
Expand Down