eth-ssz-specs 0.0.1.dev1__tar.gz → 0.0.1.dev2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/CONTRIBUTING.md +1 -1
  2. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/PKG-INFO +4 -4
  3. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/README.md +3 -3
  4. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/pyproject.toml +1 -1
  5. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/__init__.py +2 -1
  6. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/bitfields.py +5 -5
  7. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/boolean.py +19 -5
  8. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/byte_arrays.py +125 -28
  9. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/collections.py +36 -13
  10. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/container.py +19 -10
  11. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/exceptions.py +2 -2
  12. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/merkleization.py +367 -58
  13. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/proofs.py +11 -4
  14. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/ssz_base.py +215 -25
  15. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/uint.py +232 -57
  16. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/uv.lock +2 -2
  17. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/.gitignore +0 -0
  18. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/Justfile +0 -0
  19. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/LICENSE +0 -0
  20. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/base.py +0 -0
  21. {eth_ssz_specs-0.0.1.dev1 → eth_ssz_specs-0.0.1.dev2}/src/ssz/union.py +0 -0
@@ -46,7 +46,7 @@ If anything fails before the release job, no tag was created: fix and re-dispatc
46
46
  only the PyPI publish fails, re-run the failed job on the same run instead.
47
47
 
48
48
  Before the first release: add a PyPI trusted publisher (project `eth-ssz-specs`, owner
49
- `leanEthereum`, repository `ssz-specs`, workflow `release.yaml`, environment `pypi`)
49
+ `ethereum`, repository `ssz-specs`, workflow `release.yaml`, environment `pypi`)
50
50
  and create the `pypi` environment in the GitHub repository settings.
51
51
 
52
52
  Reproduce the artifacts locally with `just build` and `just pack-fixtures <tag>`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eth-ssz-specs
3
- Version: 0.0.1.dev1
3
+ Version: 0.0.1.dev2
4
4
  Summary: Ethereum SSZ (Simple Serialize) specification and Merkleization
5
5
  Project-URL: Homepage, https://github.com/ethereum/ssz-specs
6
6
  Project-URL: Source, https://github.com/ethereum/ssz-specs
@@ -65,7 +65,7 @@ implementations can run to ensure compliance with the specifications.
65
65
  ## Releases
66
66
 
67
67
  Each release ships the `eth-ssz-specs` package on PyPI and the reference tests on the
68
- [releases page](https://github.com/leanEthereum/ssz-specs/releases), both built from
68
+ [releases page](https://github.com/ethereum/ssz-specs/releases), both built from
69
69
  the tagged commit.
70
70
 
71
71
  ```bash
@@ -74,8 +74,8 @@ pip install eth-ssz-specs
74
74
 
75
75
  ```bash
76
76
  TAG=v0.1.0
77
- curl -sSLO "https://github.com/leanEthereum/ssz-specs/releases/download/$TAG/ssz-test-vectors-$TAG.tar.gz"
78
- curl -sSLO "https://github.com/leanEthereum/ssz-specs/releases/download/$TAG/ssz-test-vectors-$TAG.tar.gz.sha256"
77
+ curl -sSLO "https://github.com/ethereum/ssz-specs/releases/download/$TAG/ssz-test-vectors-$TAG.tar.gz"
78
+ curl -sSLO "https://github.com/ethereum/ssz-specs/releases/download/$TAG/ssz-test-vectors-$TAG.tar.gz.sha256"
79
79
  sha256sum --check "ssz-test-vectors-$TAG.tar.gz.sha256"
80
80
  tar -xzf "ssz-test-vectors-$TAG.tar.gz" # extracts fixtures/
81
81
  ```
@@ -24,7 +24,7 @@ implementations can run to ensure compliance with the specifications.
24
24
  ## Releases
25
25
 
26
26
  Each release ships the `eth-ssz-specs` package on PyPI and the reference tests on the
27
- [releases page](https://github.com/leanEthereum/ssz-specs/releases), both built from
27
+ [releases page](https://github.com/ethereum/ssz-specs/releases), both built from
28
28
  the tagged commit.
29
29
 
30
30
  ```bash
@@ -33,8 +33,8 @@ pip install eth-ssz-specs
33
33
 
34
34
  ```bash
35
35
  TAG=v0.1.0
36
- curl -sSLO "https://github.com/leanEthereum/ssz-specs/releases/download/$TAG/ssz-test-vectors-$TAG.tar.gz"
37
- curl -sSLO "https://github.com/leanEthereum/ssz-specs/releases/download/$TAG/ssz-test-vectors-$TAG.tar.gz.sha256"
36
+ curl -sSLO "https://github.com/ethereum/ssz-specs/releases/download/$TAG/ssz-test-vectors-$TAG.tar.gz"
37
+ curl -sSLO "https://github.com/ethereum/ssz-specs/releases/download/$TAG/ssz-test-vectors-$TAG.tar.gz.sha256"
38
38
  sha256sum --check "ssz-test-vectors-$TAG.tar.gz.sha256"
39
39
  tar -xzf "ssz-test-vectors-$TAG.tar.gz" # extracts fixtures/
40
40
  ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "eth-ssz-specs"
7
- version = "0.0.1.dev1"
7
+ version = "0.0.1.dev2"
8
8
  description = "Ethereum SSZ (Simple Serialize) specification and Merkleization"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -24,7 +24,7 @@ from ssz.exceptions import (
24
24
  SSZUnionOptionsError,
25
25
  SSZValueError,
26
26
  )
27
- from ssz.merkleization import ZERO_ROOT, Chunk, Root
27
+ from ssz.merkleization import ZERO_ROOT, Chunk, Root, hash_tree_root
28
28
  from ssz.proofs import (
29
29
  ACTIVE_FIELDS_KEY,
30
30
  LENGTH_KEY,
@@ -110,6 +110,7 @@ __all__ = [
110
110
  "gindex_length",
111
111
  "gindex_parent",
112
112
  "gindex_sibling",
113
+ "hash_tree_root",
113
114
  "item_length",
114
115
  "verify_merkle_multiproof",
115
116
  "verify_merkle_proof",
@@ -72,7 +72,7 @@ class BitVector(SSZCollection[Boolean]):
72
72
  """
73
73
  super().__pydantic_init_subclass__(**kwargs)
74
74
 
75
- if not hasattr(cls, "LENGTH"):
75
+ if cls.LENGTH is None:
76
76
  return
77
77
 
78
78
  length = cls.LENGTH
@@ -100,7 +100,7 @@ class BitVector(SSZCollection[Boolean]):
100
100
  def _coerce_and_validate(cls, bits_input: Any) -> list[Boolean]:
101
101
  """Enforce the exact bit count and coerce inputs into booleans."""
102
102
  # Subclasses must declare LENGTH before any instances can be validated.
103
- if not hasattr(cls, "LENGTH"):
103
+ if cls.LENGTH is None:
104
104
  raise SSZDefinitionError(cls.__name__, "LENGTH")
105
105
 
106
106
  # Materialize generic iterables into a tuple so the length check works.
@@ -264,14 +264,14 @@ class _SSZBitList(SSZCollection[Boolean]):
264
264
 
265
265
  def append(self, value: Boolean) -> None:
266
266
  """Add one bit at the end, validating it and the resulting length."""
267
- self._require_mutable()
267
+ self._begin_mutation()
268
268
  element = self._validate_element(value)
269
269
  self._validate_length(len(self.data) + 1)
270
270
  cast("list[Boolean]", self.data).append(element)
271
271
 
272
272
  def pop(self) -> Boolean:
273
273
  """Remove and return the last bit, validating the resulting length."""
274
- self._require_mutable()
274
+ self._begin_mutation()
275
275
  self._validate_length(len(self.data) - 1)
276
276
  return cast("list[Boolean]", self.data).pop()
277
277
 
@@ -495,7 +495,7 @@ class BitList(_SSZBitList):
495
495
  def _coerce_and_validate(cls, bits_input: Any) -> list[Boolean]:
496
496
  """Enforce the maximum bit count and coerce inputs into booleans."""
497
497
  # Subclasses must declare LIMIT before any instances can be validated.
498
- if not hasattr(cls, "LIMIT"):
498
+ if cls.LIMIT is None:
499
499
  raise SSZDefinitionError(cls.__name__, "LIMIT")
500
500
 
501
501
  # Accept the natural input shapes:
@@ -18,7 +18,7 @@ class Boolean(int, SSZType):
18
18
  - Inherits from int so true/false work natively in truthiness checks.
19
19
  - Arithmetic (+ - * /) is disabled to prevent ambiguous operations.
20
20
  - Bitwise ops (& | ^) reject operands of any other type.
21
- - Equality rejects comparisons with anything but another boolean.
21
+ - Equality rejects comparisons with anything but another boolean, and hashing agrees.
22
22
 
23
23
  Wire format:
24
24
 
@@ -211,7 +211,15 @@ class Boolean(int, SSZType):
211
211
  return self.__xor__(other)
212
212
 
213
213
  def __eq__(self, other: object) -> bool:
214
- """Strict equality — only another boolean compares; anything else raises."""
214
+ """
215
+ Strict equality — only another boolean compares; anything else raises.
216
+
217
+ Invariant: the bit is the whole of the value, so the hash below is the bit's hash.
218
+ A raw bool is still refused, which is the point of wrapping the bit at all.
219
+
220
+ Raises:
221
+ TypeError: If other is not a Boolean.
222
+ """
215
223
  if not isinstance(other, Boolean):
216
224
  raise TypeError(
217
225
  f"Unsupported operand type(s) for ==: "
@@ -225,6 +233,9 @@ class Boolean(int, SSZType):
225
233
 
226
234
  Defined explicitly because the parent class's not-equal would otherwise
227
235
  bypass the strict equality above.
236
+
237
+ Raises:
238
+ TypeError: If other is not a Boolean.
228
239
  """
229
240
  if not isinstance(other, Boolean):
230
241
  raise TypeError(
@@ -241,9 +252,12 @@ class Boolean(int, SSZType):
241
252
  """Return the user-facing form: True or False."""
242
253
  return str(bool(self))
243
254
 
244
- def __hash__(self) -> int:
245
- """Return a hash distinct from the equivalent raw bool, matching strict equality."""
246
- return hash((type(self), int(self)))
255
+ # A boolean hashes as the bit it holds, because equality compares the bit alone.
256
+ # Defining equality would otherwise clear the inherited hash, so it is spelled out.
257
+ #
258
+ # A raw bool now shares a bucket, so a membership test reaches the refusal instead of
259
+ # answering False from an empty one.
260
+ __hash__ = int.__hash__
247
261
 
248
262
 
249
263
  Bit: TypeAlias = Boolean
@@ -13,7 +13,7 @@ Both flavors serialize as the raw bytes themselves — no length prefix, no deli
13
13
 
14
14
  from collections.abc import Iterable, Sequence
15
15
  from enum import Enum
16
- from typing import IO, Any, ClassVar, Self, override
16
+ from typing import IO, TYPE_CHECKING, Any, ClassVar, Self, override
17
17
 
18
18
  from pydantic import Field, field_serializer, field_validator
19
19
  from pydantic.annotated_handlers import GetCoreSchemaHandler
@@ -49,7 +49,7 @@ class ByteVector(bytes, SSZType):
49
49
 
50
50
  - Inherits from bytes so the instance is usable wherever a bytes value is expected.
51
51
  - Subclasses pin the byte count by setting the class-level length.
52
- - Equality is strict — only another byte-array instance compares.
52
+ - Equality relates a type only to its ancestors and descendants; hashing agrees.
53
53
 
54
54
  The spec reads a fixed byte array as a vector of single bytes, so its default is every
55
55
  byte zero.
@@ -110,7 +110,7 @@ class ByteVector(bytes, SSZType):
110
110
  SSZValueError: If the coerced byte count differs from the declared length.
111
111
  TypeError: If a value is passed that no coercion accepts.
112
112
  """
113
- if not hasattr(cls, "LENGTH"):
113
+ if cls.LENGTH is None:
114
114
  raise SSZDefinitionError(cls.__name__, "LENGTH")
115
115
 
116
116
  # An omitted value is the default, which is every byte zero.
@@ -130,6 +130,25 @@ class ByteVector(bytes, SSZType):
130
130
  raise SSZLengthError(cls.__name__, cls.LENGTH, len(coerced_bytes), unit="bytes")
131
131
  return super().__new__(cls, coerced_bytes)
132
132
 
133
+ @classmethod
134
+ def _trusted(cls, data: bytes) -> Self:
135
+ """
136
+ Wrap bytes of an already-established length, skipping every check.
137
+
138
+ # Safety
139
+
140
+ The byte count must follow from how the value was built, never from a caller.
141
+ A wrong width yields an instance.
142
+ It merkleizes to a wrong root rather than failing.
143
+
144
+ Args:
145
+ data: Exactly the declared number of bytes.
146
+
147
+ Returns:
148
+ An instance wrapping those bytes, unvalidated.
149
+ """
150
+ return bytes.__new__(cls, data)
151
+
133
152
  @classmethod
134
153
  def zero(cls) -> Self:
135
154
  """Return a new instance filled with zero bytes, which is also the default."""
@@ -176,8 +195,8 @@ class ByteVector(bytes, SSZType):
176
195
  serialized_bytes = stream.read(scope)
177
196
  if len(serialized_bytes) != scope:
178
197
  raise SSZScopeError(cls.__name__, scope, len(serialized_bytes))
179
- # Length already verified — bypass __new__'s coerce + revalidation.
180
- return bytes.__new__(cls, serialized_bytes)
198
+ # The read delivered exactly the checked count, which the guard above pinned.
199
+ return cls._trusted(serialized_bytes)
181
200
 
182
201
  @override
183
202
  def encode_bytes(self) -> bytes:
@@ -246,8 +265,34 @@ class ByteVector(bytes, SSZType):
246
265
  return f"{type_name}({self.hex()})"
247
266
 
248
267
  def __eq__(self, other: object) -> bool:
249
- """Strict equality — only another byte-array instance compares; anything else raises."""
250
- if not isinstance(other, ByteVector):
268
+ r"""
269
+ Equality between two byte arrays whose types are related by inheritance.
270
+
271
+ One of the two types must derive from the other, and nothing but a byte array may
272
+ meet one at all. A root is declared a kind of chunk so it is usable where a chunk
273
+ is expected, and this comparison honours that:
274
+
275
+ x = b"\x11" * 32
276
+
277
+ Root(x) == Chunk(x) -> True a root is a kind of chunk
278
+ Bytes32(x) == Root(x) -> refused siblings, neither derives from the other
279
+ Bytes32(x) == x -> refused raw bytes are not a byte array at all
280
+
281
+ Invariant: whenever this returns True, __hash__ agrees. Python requires that of
282
+ every type, because dict and set find a value by its hash before they compare
283
+ it. That requirement is what fixes the hash below: Root(x) and Chunk(x) are one
284
+ value here, so no hash that named the concrete type could keep step.
285
+
286
+ Args:
287
+ other: The value to compare against.
288
+
289
+ Raises:
290
+ TypeError: If other is not a byte array, or is one whose type is a sibling
291
+ of this one rather than an ancestor or a descendant.
292
+ """
293
+ if not isinstance(other, ByteVector) or not (
294
+ isinstance(other, type(self)) or isinstance(self, type(other))
295
+ ):
251
296
  raise TypeError(
252
297
  f"Unsupported operand type(s) for ==: "
253
298
  f"'{type(self).__name__}' and '{type(other).__name__}'"
@@ -256,21 +301,31 @@ class ByteVector(bytes, SSZType):
256
301
 
257
302
  def __ne__(self, other: object) -> bool:
258
303
  """
259
- Strict inequality — only another byte-array instance compares; anything else raises.
304
+ Inequality under the same relation as equality; anything else raises.
260
305
 
261
306
  Defined explicitly because the parent bytes class has its own not-equal
262
- that would otherwise bypass the strict type contract.
307
+ that would otherwise bypass the type rule above.
308
+
309
+ Raises:
310
+ TypeError: If other is not a byte array, or is one whose type is a sibling
311
+ of this one rather than an ancestor or a descendant.
263
312
  """
264
- if not isinstance(other, ByteVector):
313
+ if not isinstance(other, ByteVector) or not (
314
+ isinstance(other, type(self)) or isinstance(self, type(other))
315
+ ):
265
316
  raise TypeError(
266
317
  f"Unsupported operand type(s) for !=: "
267
318
  f"'{type(self).__name__}' and '{type(other).__name__}'"
268
319
  )
269
320
  return bytes.__ne__(self, other)
270
321
 
271
- def __hash__(self) -> int:
272
- """Return a hash distinct from raw bytes — matches the strict equality contract."""
273
- return hash((type(self), bytes(self)))
322
+ # A byte array hashes as the bytes it holds, because equality calls a root and a chunk
323
+ # of the same bytes one value, which leaves the concrete type no room in the hash.
324
+ #
325
+ # Two types the comparison refuses to relate now share a bucket when their bytes agree,
326
+ # so a lookup reaches the refusal instead of missing in silence. When the bytes differ
327
+ # so do the hashes, and absent is the right answer whatever the types were.
328
+ __hash__ = bytes.__hash__
274
329
 
275
330
 
276
331
  class ByteList(SSZCollection[int]):
@@ -279,7 +334,7 @@ class ByteList(SSZCollection[int]):
279
334
 
280
335
  - Subclasses pin the maximum byte count by setting the class-level limit.
281
336
  - Serialization writes the raw bytes; the length is recovered from the wrapping context.
282
- - Equality is strict — only another byte-list instance compares.
337
+ - Equality relates a type only to its ancestors and descendants; hashing agrees.
283
338
 
284
339
  For example, a 4-byte payload under a limit of 10:
285
340
 
@@ -299,7 +354,7 @@ class ByteList(SSZCollection[int]):
299
354
  def _validate_byte_list_data(cls, value: Any) -> bytes:
300
355
  """Enforce the maximum byte count and coerce inputs into a plain bytes object."""
301
356
  # Subclasses must declare LIMIT before any instances can be validated.
302
- if not hasattr(cls, "LIMIT"):
357
+ if cls.LIMIT is None:
303
358
  raise SSZDefinitionError(cls.__name__, "LIMIT")
304
359
 
305
360
  # Coerce the input first, then enforce the upper bound.
@@ -313,27 +368,46 @@ class ByteList(SSZCollection[int]):
313
368
  """Serialize the raw bytes to a 0x-prefixed hex string for JSON output."""
314
369
  return "0x" + value.hex()
315
370
 
371
+ def _store(self, working: bytearray) -> None:
372
+ """
373
+ Store mutated bytes, checking the byte count and nothing else.
374
+
375
+ Assigning to the field would re-coerce a payload that is already bytes.
376
+
377
+ Raises:
378
+ SSZLimitError: When the mutation leaves more bytes than the limit allows.
379
+ """
380
+ self._validate_length(len(working))
381
+ payload = bytes(working)
382
+ # A type checker reads this field through an inherited sequence declaration, so the
383
+ # assignment spelling is kept for it and the dictionary write for the runtime.
384
+ if not TYPE_CHECKING:
385
+ self.__dict__["data"] = payload
386
+ self.__pydantic_fields_set__.add("data")
387
+ else: # pragma: no cover
388
+ self.data = payload
389
+
316
390
  @override
317
391
  def __setitem__(self, index: int | slice, value: int | Sequence[int]) -> None:
318
392
  """Replace the byte(s) at ``index``, revalidating the stored payload."""
319
- self._require_mutable()
393
+ self._begin_mutation()
320
394
  working = bytearray(self.data)
321
395
  working[index] = value # ty: ignore[invalid-assignment]
322
- self.data = bytes(working)
396
+ self._store(working)
323
397
 
324
398
  def append(self, value: int) -> None:
325
399
  """Add one byte at the end, revalidating the stored payload."""
326
- self._require_mutable()
400
+ self._begin_mutation()
327
401
  working = bytearray(self.data)
328
402
  working.append(value)
329
- self.data = bytes(working)
403
+ self._store(working)
330
404
 
331
405
  def pop(self) -> int:
332
406
  """Remove and return the last byte, revalidating the stored payload."""
333
- self._require_mutable()
407
+ self._begin_mutation()
334
408
  working = bytearray(self.data)
335
409
  last = working.pop()
336
- self.data = bytes(working)
410
+ self._store(working)
337
411
  return last
338
412
 
339
413
  @classmethod
@@ -418,8 +492,25 @@ class ByteList(SSZCollection[int]):
418
492
  return f"{type_name}({self.data.hex()})"
419
493
 
420
494
  def __eq__(self, other: object) -> bool:
421
- """Strict equality — only another byte-list instance compares; anything else raises."""
422
- if not isinstance(other, ByteList):
495
+ """
496
+ Equality between two byte lists whose types are related by inheritance.
497
+
498
+ One of the two types must derive from the other, and nothing but a byte list may
499
+ meet one at all. Two unrelated limits are refused even when the payloads match,
500
+ because a payload under one limit is not the same value as under another.
501
+
502
+ Invariant: whenever this returns True, the hash agrees.
503
+
504
+ Args:
505
+ other: The value to compare against.
506
+
507
+ Raises:
508
+ TypeError: If other is not a byte list, or is one whose type is a sibling
509
+ of this one rather than an ancestor or a descendant.
510
+ """
511
+ if not isinstance(other, ByteList) or not (
512
+ isinstance(other, type(self)) or isinstance(self, type(other))
513
+ ):
423
514
  raise TypeError(
424
515
  f"Unsupported operand type(s) for ==: "
425
516
  f"'{type(self).__name__}' and '{type(other).__name__}'"
@@ -428,11 +519,17 @@ class ByteList(SSZCollection[int]):
428
519
 
429
520
  def __ne__(self, other: object) -> bool:
430
521
  """
431
- Strict inequality — only another byte-list instance compares; anything else raises.
522
+ Inequality under the same relation as equality; anything else raises.
432
523
 
433
- Mirrors the strict equality contract — both operators require a matching type.
524
+ Mirrors the equality contract — both operators apply one type rule.
525
+
526
+ Raises:
527
+ TypeError: If other is not a byte list, or is one whose type is a sibling
528
+ of this one rather than an ancestor or a descendant.
434
529
  """
435
- if not isinstance(other, ByteList):
530
+ if not isinstance(other, ByteList) or not (
531
+ isinstance(other, type(self)) or isinstance(self, type(other))
532
+ ):
436
533
  raise TypeError(
437
534
  f"Unsupported operand type(s) for !=: "
438
535
  f"'{type(self).__name__}' and '{type(other).__name__}'"
@@ -440,8 +537,8 @@ class ByteList(SSZCollection[int]):
440
537
  return self.data != other.data
441
538
 
442
539
  def __hash__(self) -> int:
443
- """Return a hash that ties the value to its concrete type."""
444
- return hash((type(self), self.data))
540
+ """Return the hash of the payload alone — the one thing equality above compares."""
541
+ return hash(self.data)
445
542
 
446
543
  def hex(self) -> str:
447
544
  """Return the hexadecimal string representation of the underlying bytes."""
@@ -109,19 +109,25 @@ def _coerce_elements(element_type: type[SSZType], elements: Sequence[Any]) -> li
109
109
  - Ancestor-class values go through the element type's constructor.
110
110
  - A coercion failure re-raises with the high-level expectation in the message.
111
111
  - The chained cause preserves the underlying coercion detail.
112
+
113
+ An element of exactly the declared class is settled first, before the ancestor test.
114
+ A class is a subclass of itself, so the test that order skips is one that could only
115
+ have passed. What changes is the cost of the common case: an identity check on two type
116
+ objects, rather than a walk of an abstract base class's registry.
112
117
  """
113
118
  coerced: list[SSZType] = []
114
119
  for element in elements:
115
- if not issubclass(element_type, type(element)):
116
- raise SSZTypeMismatch(element_type.__name__, type(element))
117
- if type(element) is element_type:
120
+ element_class = type(element)
121
+ if element_class is element_type:
118
122
  coerced.append(element)
119
123
  continue
124
+ if not issubclass(element_type, element_class):
125
+ raise SSZTypeMismatch(element_type.__name__, element_class)
120
126
  try:
121
127
  coerced.append(cast(Any, element_type)(element))
122
128
  except (SSZTypeError, SSZValueError, TypeError, ValueError) as exception:
123
129
  raise SSZTypeMismatch(
124
- element_type.__name__, type(element), detail=str(exception)
130
+ element_type.__name__, element_class, detail=str(exception)
125
131
  ) from exception
126
132
  return coerced
127
133
 
@@ -343,18 +349,28 @@ class Vector[T: SSZType](_SSZSequence[T]):
343
349
  """
344
350
  Give the elements their default, which is the element default at every position.
345
351
 
346
- Each element is built when the elements are left out, never shared: one instance
347
- placed at every position would alias, and an element is mutable.
352
+ One instance is shared across every position when the element type cannot be
353
+ mutated, and built per position otherwise, since two positions holding one mutable
354
+ element would alias.
348
355
  """
349
356
  super().__pydantic_init_subclass__(**kwargs)
350
357
 
351
358
  # A shape that declared neither keeps its inherited default, and fails its own
352
359
  # declaration check instead.
353
- if not (hasattr(cls, "ELEMENT_TYPE") and hasattr(cls, "LENGTH")):
360
+ if not hasattr(cls, "ELEMENT_TYPE") or cls.LENGTH is None:
354
361
  return
355
362
 
356
363
  element_type, length = cls.ELEMENT_TYPE, cls.LENGTH
357
- cls.model_fields["data"].default_factory = lambda: [element_type() for _ in range(length)]
364
+ # A uint, a boolean and a fixed byte array each subclass an immutable builtin, so
365
+ # no position can alter what another holds. The bitfield default already shares one
366
+ # boolean on that argument, and it holds for every such element type.
367
+ if issubclass(element_type, (int, bytes)):
368
+ shared = element_type()
369
+ cls.model_fields["data"].default_factory = lambda: [shared] * length
370
+ else:
371
+ cls.model_fields["data"].default_factory = lambda: [
372
+ element_type() for _ in range(length)
373
+ ]
358
374
  cls.model_rebuild(force=True)
359
375
 
360
376
  @field_validator("data", mode="before")
@@ -374,7 +390,7 @@ class Vector[T: SSZType](_SSZSequence[T]):
374
390
  The chained cause preserves the underlying coercion detail.
375
391
  """
376
392
  # Subclasses must declare both annotations before any instance can validate.
377
- if not hasattr(cls, "ELEMENT_TYPE") or not hasattr(cls, "LENGTH"):
393
+ if not hasattr(cls, "ELEMENT_TYPE") or cls.LENGTH is None:
378
394
  raise SSZDefinitionError(cls.__name__, "ELEMENT_TYPE and LENGTH")
379
395
 
380
396
  # Reject strings and non-iterables, then materialize into a sequence.
@@ -501,14 +517,14 @@ class _SSZList[T: SSZType](_SSZSequence[T]):
501
517
 
502
518
  def append(self, value: T) -> None:
503
519
  """Add one element at the end, validating it and the resulting length."""
504
- self._require_mutable()
520
+ self._begin_mutation()
505
521
  element = self._validate_element(value)
506
522
  self._validate_length(len(self.data) + 1)
507
523
  cast("list[T]", self.data).append(element)
508
524
 
509
525
  def pop(self) -> T:
510
526
  """Remove and return the last element, validating the resulting length."""
511
- self._require_mutable()
527
+ self._begin_mutation()
512
528
  self._validate_length(len(self.data) - 1)
513
529
  return cast("list[T]", self.data).pop()
514
530
 
@@ -653,7 +669,14 @@ class List[T: SSZType](_SSZList[T]):
653
669
  - Variable-size elements use an offset table that also reveals the count.
654
670
 
655
671
  The hash tree root mixes in the element count alongside the chunked data.
656
- Two lists with identical contents but different LIMITs hash differently.
672
+
673
+ A declared capacity reaches the root only through the width of the tree it bounds.
674
+
675
+ - That width is the next power of two at or above the capacity counted in chunks.
676
+ - Two capacities that round to the same width bound the same tree.
677
+ - The same contents then root identically under both.
678
+ - Eight-byte elements pack four to a chunk, so a capacity of three roots as four does.
679
+ - So do twelve and sixteen, whose three chunks and four fill one four-leaf tree.
657
680
 
658
681
  Subclasses declare LIMIT directly in the class body.
659
682
  The element type is inferred from the generic parameter.
@@ -692,7 +715,7 @@ class List[T: SSZType](_SSZList[T]):
692
715
  The chained cause preserves the underlying coercion detail.
693
716
  """
694
717
  # Subclasses must declare both annotations before any instance can validate.
695
- if not hasattr(cls, "ELEMENT_TYPE") or not hasattr(cls, "LIMIT"):
718
+ if not hasattr(cls, "ELEMENT_TYPE") or cls.LIMIT is None:
696
719
  raise SSZDefinitionError(cls.__name__, "ELEMENT_TYPE and LIMIT")
697
720
 
698
721
  # Reject strings and non-iterables, then materialize into a sequence.
@@ -11,6 +11,7 @@ Both encode identically: fixed-size fields inline, variable-size fields behind o
11
11
  """
12
12
 
13
13
  import io
14
+ from functools import cache
14
15
  from itertools import pairwise
15
16
  from typing import IO, Any, ClassVar, Final, Self, override
16
17
 
@@ -85,10 +86,7 @@ def active_fields(width: int, gaps: tuple[int, ...] = ()) -> tuple[int, ...]:
85
86
  # Narrow before anything reads the value.
86
87
  #
87
88
  # A width may be written with a uint.
88
- # A uint refuses a plain integer operand.
89
- # The comparison below would therefore raise rather than answer.
90
- # A uint left among the positions would also miss the plain position it names.
91
- # The two hash apart.
89
+ # Narrowing keeps the positions below plain, as well as the messages naming them.
92
90
  width = int(width)
93
91
  if width < 1:
94
92
  raise SSZValueError(f"a layout holds at least one position, got a width of {width}")
@@ -136,10 +134,7 @@ class _SSZContainer(SSZModel):
136
134
 
137
135
  def __hash__(self) -> int:
138
136
  """Hash by Merkle tree root — equal containers hash equally."""
139
- # Deferred import: the merkleization module imports the SSZ types.
140
- from ssz.merkleization import hash_tree_root
141
-
142
- return hash(hash_tree_root(self))
137
+ return hash(self.hash_tree_root())
143
138
 
144
139
  @model_validator(mode="wrap")
145
140
  @classmethod
@@ -220,8 +215,7 @@ class _SSZContainer(SSZModel):
220
215
  bytes_read = 0
221
216
 
222
217
  # Phase 1: each slot is either the field itself or an offset to its tail payload.
223
- for name, field_definition in cls.model_fields.items():
224
- field_type: type[SSZType] = field_definition.annotation
218
+ for name, field_type in _field_types(cls):
225
219
  if field_type.is_fixed_size():
226
220
  width = field_type.get_byte_length()
227
221
  fields[name] = field_type.deserialize(stream, width)
@@ -394,3 +388,18 @@ class ProgressiveContainer(_SSZContainer):
394
388
  f"the layout sets {active_count} positions, "
395
389
  f"and the struct declares {len(cls.model_fields)}",
396
390
  )
391
+
392
+
393
+ type _FieldType = tuple[str, type[SSZType]]
394
+ """One field: its name and the type it declares."""
395
+
396
+
397
+ @cache
398
+ def _field_types(cls: type[_SSZContainer]) -> tuple[_FieldType, ...]:
399
+ """
400
+ Each field's declared type, in the declaration order the wire format follows.
401
+
402
+ Only what the declaration settles is kept. A width is asked of the type each time,
403
+ because the attributes a type derives one from stay writable for as long as the type does.
404
+ """
405
+ return tuple((name, field.annotation) for name, field in cls.model_fields.items())
@@ -43,10 +43,10 @@ class SSZTypeMismatch(SSZTypeError): # noqa: N818
43
43
 
44
44
 
45
45
  class SSZDefinitionError(SSZTypeError):
46
- """An SSZ subclass omits an attribute its base needs to operate."""
46
+ """An SSZ subclass declares its attributes in a way its base cannot operate on."""
47
47
 
48
48
  def __init__(self, type_name: str, requirement: str) -> None:
49
- """Record the type and the attribute it failed to declare."""
49
+ """Record the type and what its declaration failed to satisfy."""
50
50
  self.type_name = type_name
51
51
  self.requirement = requirement
52
52
  super().__init__(f"{type_name} must define {requirement}")