Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
6 changes: 5 additions & 1 deletion starknet_py/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from starknet_py.common import create_compiled_contract, create_sierra_compiled_contract
from starknet_py.constants import DEFAULT_DEPLOYER_ADDRESS
from starknet_py.contract_utils import _extract_compiled_class_hash, _unpack_provider
from starknet_py.hash.casm_class_hash import get_casm_hash_method_for_rpc_version
from starknet_py.hash.selector import get_selector_from_name
from starknet_py.net.account.base_account import BaseAccount
from starknet_py.net.client import Client
Expand Down Expand Up @@ -721,8 +722,11 @@ async def declare_v3(
:return: DeclareResult instance.
"""

rpc_version = await account.client.spec_version()
hash_method = get_casm_hash_method_for_rpc_version(rpc_version)

compiled_class_hash = _extract_compiled_class_hash(
compiled_contract_casm, compiled_class_hash
compiled_contract_casm, compiled_class_hash, hash_method=hash_method
)

declare_tx = await account.sign_declare_v3(
Expand Down
4 changes: 3 additions & 1 deletion starknet_py/contract_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

from starknet_py.common import create_casm_class
from starknet_py.hash.casm_class_hash import compute_casm_class_hash
from starknet_py.hash.hash_method import HashMethod
from starknet_py.net.account.base_account import BaseAccount
from starknet_py.net.client import Client


def _extract_compiled_class_hash(
compiled_contract_casm: Optional[str] = None,
compiled_class_hash: Optional[int] = None,
hash_method: HashMethod = HashMethod.POSEIDON,
) -> int:
if compiled_class_hash is None and compiled_contract_casm is None:
raise ValueError(
Expand All @@ -19,7 +21,7 @@ def _extract_compiled_class_hash(
if compiled_class_hash is None:
assert compiled_contract_casm is not None
compiled_class_hash = compute_casm_class_hash(
create_casm_class(compiled_contract_casm)
create_casm_class(compiled_contract_casm), hash_method=hash_method
)

return compiled_class_hash
Expand Down
81 changes: 81 additions & 0 deletions starknet_py/hash/blake2s.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import hashlib
from typing import List

from starknet_py.constants import FIELD_PRIME

SMALL_THRESHOLD = 2**63
BIG_MARKER = 1 << 31 # MSB mask for the first u32 in the 8-limb case


def encode_felts_to_u32s(felts: List[int]) -> List[int]:
"""
Encode each Felt into 32-bit words:
- Small values < 2^63 get 2 words: [high_32_bits, low_32_bits] from the last 8 bytes
- Large values >= 2^63 get 8 words: the full 32-byte big-endian split, with the
MSB of the first word set as a marker (+2^255)
Returns a flat list of u32 values.
"""
unpacked_u32s = []
for felt in felts:
# Convert felt to 32-byte big-endian representation
felt_as_be_bytes = felt.to_bytes(32, byteorder="big")

if felt < SMALL_THRESHOLD:
# Small: 2 limbs only, high-32 then low-32 of the last 8 bytes
hi = int.from_bytes(felt_as_be_bytes[24:28], byteorder="big")
lo = int.from_bytes(felt_as_be_bytes[28:32], byteorder="big")
unpacked_u32s.append(hi)
unpacked_u32s.append(lo)
else:
# Big: 8 limbs, big-endian order
start = len(unpacked_u32s)
for i in range(0, 32, 4):
limb = int.from_bytes(felt_as_be_bytes[i : i + 4], byteorder="big")
unpacked_u32s.append(limb)
# Set the MSB of the very first limb as the Cairo hint does with "+ 2**255"
unpacked_u32s[start] |= BIG_MARKER

return unpacked_u32s


def pack_256_le_to_felt(hash_bytes: bytes) -> int:
"""
Packs the first 32 bytes (256 bits) of hash_bytes into a Felt (252 bits).
Interprets the bytes as a Felt (252 bits)
"""
assert len(hash_bytes) >= 32, "need at least 32 bytes to pack"
# Interpret the 32-byte buffer as a little-endian integer and convert to Felt
return int.from_bytes(hash_bytes[:32], byteorder="little") % FIELD_PRIME


def blake2s_to_felt(data: bytes) -> int:
"""
Compute Blake2s-256 over data and return as a Felt (252-bit field element).
"""
hash_bytes = hashlib.blake2s(data, digest_size=32).digest()
return pack_256_le_to_felt(hash_bytes)


def encode_felt252_data_and_calc_blake_hash(felts: List[int]) -> int:
"""
Encodes a list of Felt values into 32-bit words exactly as Cairo's
encode_felt252_to_u32s hint does, then hashes the resulting byte stream
with Blake2s-256 and returns the 256-bit digest as a 252-bit field element.
"""
# 1) Unpack each Felt into 2 or 8 u32 limbs
u32_words = encode_felts_to_u32s(felts)

# 2) Serialize the u32 limbs into a little-endian byte stream
byte_stream = b"".join(word.to_bytes(4, byteorder="little") for word in u32_words)

# 3) Compute Blake2s-256 over the bytes and pack the result into a Felt
return blake2s_to_felt(byte_stream)


def blake2s_hash_many(values: List[int]) -> int:
"""
Hash multiple Felt values using the Cairo-compatible Blake2s encoding.
This is the proper way to hash Felt values for Starknet.
"""
return encode_felt252_data_and_calc_blake_hash(values)
45 changes: 31 additions & 14 deletions starknet_py/hash/casm_class_hash.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from typing import List, Optional, Sequence, Tuple

from poseidon_py.poseidon_hash import poseidon_hash_many

from starknet_py.cairo.felt import encode_shortstring
from starknet_py.hash.compiled_class_hash_objects import (
BytecodeLeaf,
Expand All @@ -10,40 +8,57 @@
BytecodeSegmentStructure,
NestedIntList,
)
from starknet_py.hash.hash_method import HashMethod
from starknet_py.net.client_models import CasmClassEntryPoint
from starknet_py.net.executable_models import CasmClass

CASM_CLASS_VERSION = "COMPILED_CLASS_V1"


def compute_casm_class_hash(casm_contract_class: CasmClass) -> int:
def get_casm_hash_method_for_rpc_version(rpc_version: str) -> HashMethod:
try:
version_parts = [int(x) for x in rpc_version.split(".")]

# RPC 0.10.0 and later use Blake2s
if version_parts[0] > 0 or (version_parts[0] == 0 and version_parts[1] >= 10):
return HashMethod.BLAKE2S
except (ValueError, IndexError):
# If we can't parse the version, default to Poseidon
pass

return HashMethod.POSEIDON


def compute_casm_class_hash(
casm_contract_class: CasmClass, hash_method: HashMethod = HashMethod.POSEIDON
) -> int:
"""
Calculate class hash of a CasmClass.
"""
casm_class_version = encode_shortstring(CASM_CLASS_VERSION)

_entry_points = casm_contract_class.entry_points_by_type

external_entry_points_hash = poseidon_hash_many(
_entry_points_array(_entry_points.external)
external_entry_points_hash = hash_method.hash_many(
_entry_points_array(_entry_points.external, hash_method)
)
l1_handler_entry_points_hash = poseidon_hash_many(
_entry_points_array(_entry_points.l1_handler)
l1_handler_entry_points_hash = hash_method.hash_many(
_entry_points_array(_entry_points.l1_handler, hash_method)
)
constructor_entry_points_hash = poseidon_hash_many(
_entry_points_array(_entry_points.constructor)
constructor_entry_points_hash = hash_method.hash_many(
_entry_points_array(_entry_points.constructor, hash_method)
)

if casm_contract_class.bytecode_segment_lengths is not None:
bytecode_hash = create_bytecode_segment_structure(
bytecode=casm_contract_class.bytecode,
bytecode_segment_lengths=casm_contract_class.bytecode_segment_lengths,
visited_pcs=None,
).hash()
).hash(hash_method)
else:
bytecode_hash = poseidon_hash_many(casm_contract_class.bytecode)
bytecode_hash = hash_method.hash_many(casm_contract_class.bytecode)

return poseidon_hash_many(
return hash_method.hash_many(
[
casm_class_version,
external_entry_points_hash,
Expand All @@ -54,12 +69,14 @@ def compute_casm_class_hash(casm_contract_class: CasmClass) -> int:
)


def _entry_points_array(entry_points: List[CasmClassEntryPoint]) -> List[int]:
def _entry_points_array(
entry_points: List[CasmClassEntryPoint], hash_method: HashMethod
) -> List[int]:
entry_points_array = []
for entry_point in entry_points:
assert entry_point.builtins is not None
_encoded_builtins = [encode_shortstring(val) for val in entry_point.builtins]
builtins_hash = poseidon_hash_many(_encoded_builtins)
builtins_hash = hash_method.hash_many(_encoded_builtins)

entry_points_array.extend(
[entry_point.selector, entry_point.offset, builtins_hash]
Expand Down
32 changes: 20 additions & 12 deletions starknet_py/hash/compiled_class_hash_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
import dataclasses
import itertools
from abc import ABC, abstractmethod
from typing import Any, List, Union
from typing import TYPE_CHECKING, Any, List, Union

from poseidon_py.poseidon_hash import poseidon_hash_many
if TYPE_CHECKING:
from starknet_py.hash.hash_method import HashMethod


class BytecodeSegmentStructure(ABC):
Expand All @@ -17,9 +18,11 @@ class BytecodeSegmentStructure(ABC):
"""

@abstractmethod
def hash(self) -> int:
def hash(self, hash_method: "HashMethod") -> int:
"""
Computes the hash of the node.

:param hash_method: Hash method to use.
"""

def bytecode_with_skipped_segments(self):
Expand All @@ -46,8 +49,8 @@ class BytecodeLeaf(BytecodeSegmentStructure):

data: List[int]

def hash(self) -> int:
return poseidon_hash_many(self.data)
def hash(self, hash_method: "HashMethod") -> int:
return hash_method.hash_many(self.data)

def add_bytecode_with_skipped_segments(self, data: List[int]):
data.extend(self.data)
Expand All @@ -62,14 +65,19 @@ class BytecodeSegmentedNode(BytecodeSegmentStructure):

segments: List["BytecodeSegment"]

def hash(self) -> int:
def hash(self, hash_method: "HashMethod") -> int:
return (
poseidon_hash_many(
itertools.chain( # pyright: ignore
*[
(node.segment_length, node.inner_structure.hash())
for node in self.segments
]
hash_method.hash_many(
list(
itertools.chain( # pyright: ignore
*[
(
node.segment_length,
node.inner_structure.hash(hash_method),
)
for node in self.segments
]
)
)
)
+ 1
Expand Down
6 changes: 6 additions & 0 deletions starknet_py/hash/hash_method.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from poseidon_py.poseidon_hash import poseidon_hash, poseidon_hash_many

from starknet_py.hash.blake2s import blake2s_hash_many
from starknet_py.hash.utils import compute_hash_on_elements, pedersen_hash


Expand All @@ -13,17 +14,22 @@ class HashMethod(Enum):

PEDERSEN = "pedersen"
POSEIDON = "poseidon"
BLAKE2S = "blake2s"

def hash(self, left: int, right: int):
if self == HashMethod.PEDERSEN:
return pedersen_hash(left, right)
if self == HashMethod.POSEIDON:
return poseidon_hash(left, right)
if self == HashMethod.BLAKE2S:
return blake2s_hash_many([left, right])
raise ValueError(f"Unsupported hash method: {self}.")

def hash_many(self, values: List[int]):
if self == HashMethod.PEDERSEN:
return compute_hash_on_elements(values)
if self == HashMethod.POSEIDON:
return poseidon_hash_many(values)
if self == HashMethod.BLAKE2S:
return blake2s_hash_many(values)
raise ValueError(f"Unsupported hash method: {self}.")
8 changes: 8 additions & 0 deletions starknet_py/net/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,11 @@ async def get_compiled_casm(self, class_hash: int) -> CasmClass:
:param class_hash: Hash of the contract class whose CASM will be returned
:return: CasmClass object
"""

@abstractmethod
async def spec_version(self) -> str:
"""
Returns the version of the Starknet JSON-RPC specification being used.

:return: String with version of the Starknet JSON-RPC specification.
"""
54 changes: 54 additions & 0 deletions starknet_py/tests/unit/hash/blake2s_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# pylint: disable=line-too-long
# fmt: off
"""
Test the encode_felt252_data_and_calc_blake_hash function
with the same results as the Cairo v0.14 implementation.
Reference: https://github.com/starkware-libs/cairo-lang/releases/tag/v0.14.0
"""
import pytest

from starknet_py.hash.blake2s import encode_felt252_data_and_calc_blake_hash


@pytest.mark.parametrize(
"input_felts, expected_result",
[
# Empty array
(
[],
874258848688468311465623299960361657518391155660316941922502367727700287818
),
# Boundary: small felt at (2^63 - 1)
(
[(1 << 63) - 1],
94160078030592802631039216199460125121854007413180444742120780261703604445
),
# Boundary: at 2^63
(
[1 << 63],
318549634615606806810268830802792194529205864650702991817600345489579978482
),
# Very large felt
(
[0x800000000000011000000000000000000000000000000000000000000000000],
3505594194634492896230805823524239179921427575619914728883524629460058657521
),
# Mixed: small and large felts
(
[42, 1 << 63, 1337],
1127477916086913892828040583976438888091205536601278656613505514972451246501
),
],
ids=[
"empty",
"boundary_small_felt",
"boundary_at_2_63",
"very_large_felt",
"mixed_small_large"
]
)
def test_encode_felt252_data_and_calc_blake_hash(input_felts, expected_result):
result = encode_felt252_data_and_calc_blake_hash(input_felts)
assert result == expected_result, (
f"StarknetPy implementation: {result} != Cairo implementation: {expected_result}"
)
Loading