Skip to content

feat: add SSZ encoding/decoding support for parsigex#322

Merged
varex83 merged 29 commits into
mainfrom
bohdan/dkg-ssz
Apr 29, 2026
Merged

feat: add SSZ encoding/decoding support for parsigex#322
varex83 merged 29 commits into
mainfrom
bohdan/dkg-ssz

Conversation

@varex83agent
Copy link
Copy Markdown
Collaborator

Summary

  • Adds low-level SSZ encode/decode helpers (crates/ssz/src/encode.rs, crates/ssz/src/decode.rs)
  • Extends crates/ssz/src/types.rs with SSZ type implementations for eth2api spec types
  • Derives SSZ traits on crates/eth2api/src/spec/* types (phase0, altair, bellatrix, capella, deneb, electra, fulu)
  • Adds crates/core/src/ssz_codec.rs — SSZ codec integration for parsigex signed data
  • Extends parsigex_codec.rs with SSZ-based serialisation alongside existing JSON path
  • Adds SSZ test fixtures under crates/core/testdata/ssz/ generated by test-infra/sszfixtures/
  • Fixes yanked fastrand 2.4.02.4.1 and ignores pre-existing rand 0.8.5 advisory (RUSTSEC-2026-0097) present on base branch

Test plan

  • cargo test --workspace --all-features passes
  • cargo clippy --workspace --all-targets --all-features -- -D warnings passes
  • SSZ round-trip tests in crates/core exercise the new fixtures
  • cargo deny check passes

🤖 Generated with Claude Code

@iamquang95
Copy link
Copy Markdown
Collaborator

@claude

@claude
Copy link
Copy Markdown

claude Bot commented Apr 14, 2026

Claude finished @iamquang95's task in 4m 52s —— View job


Code Review — feat: add SSZ encoding/decoding support for parsigex

  • Gather context and read repo guidelines
  • Review crates/ssz/src/encode.rs and crates/ssz/src/decode.rs
  • Review crates/ssz/src/types.rs and crates/ssz/src/lib.rs
  • Review crates/core/src/ssz_codec.rs
  • Review crates/core/src/parsigex_codec.rs changes
  • Review crates/eth2api/src/spec/* type changes
  • Review test fixtures and fixture generator
  • Review dependency changes (Cargo.toml, deny.toml)
  • Compile findings and post review

Summary

The PR adds SSZ binary encode/decode support for Charon's parsigex signed-data types. The architecture is clean and the Go-fixture compatibility tests are a good signal for wire-format correctness. However, there are several issues ranging from a potential protocol-level asymmetry to missing bounds checks in the BitList decoder.


Findings


[High] SyncContributionAndProof serialize path has no matching deserialize path

serialize_signed_data handles SyncContributionAndProof (unsigned ContributionAndProof) via encode_contribution_and_proof, but deserialize_signed_data for DutyType::SyncContribution only decodes SignedContributionAndProof (with a 96-byte signature field). Decoding unsigned bytes as a signed struct will fail with an SSZ error, and the function returns UnsupportedDutyType.

If a peer ever serializes a SyncContributionAndProof via serialize_signed_data, the receiving peer cannot deserialize it. The test for this path (marshal_unmarshal_ssz_sync_contribution_and_proof) explicitly bypasses deserialize_signed_data and calls the test-only decode_contribution_and_proof directly — a red flag.

Impact: The serialize path for SyncContributionAndProof is either dead code or a silent protocol bug.

Evidence: crates/core/src/parsigex_codec.rs:107-113 (encode), crates/core/src/parsigex_codec.rs:248-256 (decode), crates/core/src/parsigex_codec.rs:395-415 (test)

Recommendation: Either remove the SyncContributionAndProof branch from serialize_signed_data (if it is truly never transmitted via parsigex), or add a matching deserialize branch for it. Add a round-trip test that goes through deserialize_signed_data. Fix this →


[High] BitList::Decode does not enforce MAX bound

Decode for BitList<MAX> at types.rs:377-385 calls BitList::from_ssz_bytes(bytes.to_vec()) without checking that the decoded bit count is ≤ MAX. By contrast, SszList::Decode at types.rs:103-113 returns an error when values.len() > MAX.

Impact: Malformed or attacker-controlled SSZ input can produce a BitList<MAX> with more bits than the type allows, silently violating the type invariant.

Evidence: crates/ssz/src/types.rs:377-385 (missing check); compare to crates/ssz/src/types.rs:103-113 (SszList does check)

Recommendation: After decoding, add:

if MAX > 0 && value.len() > MAX {
    return Err(DecodeError::BytesInvalid(format!(
        "bitlist length {} exceeds max {MAX}", value.len()
    )));
}

Fix this →


[High] BitList::from_ssz_bytes silently accepts an invalid zero last byte

In types.rs:295-320, when last_byte == 0 the function returns Self::default() (empty) instead of an error. Per the SSZ spec, a valid BitList encoding always has a non-zero last byte (the sentinel bit). Silently returning an empty bitlist for invalid input hides malformed data.

Additionally, the method signature is from_ssz_bytes(ssz: Vec<u8>) -> Self (infallible) while the Decode trait needs Result. This prevents the Decode impl from ever surfacing parse errors.

Evidence: crates/ssz/src/types.rs:295-320

Recommendation: Change from_ssz_bytes to return Result<Self, …> (or create a separate fallible internal helper), so the Decode impl can propagate errors. Fix this →


[Medium] encode_version always reports UnknownVersion(0) on failure

ssz_codec.rs:150-155: when DataVersion::Unknown.to_legacy_u64() fails, the error is SszCodecError::UnknownVersion(0). Version 0 is the legitimate Phase0 encoding, so the error message will read "ssz unknown version: 0" — pointing at Phase0 rather than the actual invalid version.

Evidence: crates/core/src/ssz_codec.rs:150-155

Recommendation: Either store the DataVersion debug name in the error, or use a distinct sentinel like u64::MAX. Fix this →


[Medium] decode_contribution_and_proof is #[cfg(test)] while encode_contribution_and_proof is pub

ssz_codec.rs:125-130 gates the decode function behind #[cfg(test)]. If non-test code ever needs to decode a bare ContributionAndProof (e.g., to add a proper deserialize branch as per the first finding), it will silently not compile in release builds.

Evidence: crates/core/src/ssz_codec.rs:125-130

Recommendation: Either make the function pub (not test-gated) or, if it is only ever needed in tests, add a comment explaining why.


[Medium] SignedBlockContents uses Vec<T> for blobs/KZG proofs — no length limit

deneb.rs:436-444 and electra.rs:295-303: kzg_proofs: Vec<KZGProof> and blobs: Vec<Blob> use plain Vec, which the derived Encode/Decode impls treat as SSZ lists with no maximum. The spec limit is MAX_BLOB_COMMITMENTS_PER_BLOCK = 4096. Oversized input could be silently accepted, consuming large amounts of memory during decode.

Evidence: crates/eth2api/src/spec/deneb.rs:436-444; crates/eth2api/src/spec/electra.rs:295-303

Recommendation: Use SszList<KZGProof, MAX_BLOB_COMMITMENTS_PER_BLOCK> and SszList<Blob, MAX_BLOB_COMMITMENTS_PER_BLOCK>.


[Medium] No SSZ fixture coverage for Altair–Fulu versioned types

The fixture generator (test-infra/sszfixtures/main.go) only produces Phase0 fixtures. Every versioned encode/decode function (Bellatrix, Capella, Deneb, Electra, Fulu proposals; Electra/Fulu attestations) is exercised only by hand-crafted round-trip tests, not by cross-language Go↔Rust byte-comparison tests.

Evidence: test-infra/sszfixtures/main.go:37-43

Recommendation: Add at least one fixture per fork for the most structurally distinct cases (e.g., Electra attestation with committee_bits, a Deneb SignedBlockContents with blobs, a Capella blinded proposal).


[Low] Compiled sszfixtures binary committed to git

test-infra/sszfixtures/sszfixtures is a tracked binary file (0-byte entry in the changed files list). Binaries in version control inflate repo size and can differ per architecture.

Evidence: test-infra/sszfixtures/sszfixtures (changed files listing)

Recommendation: Add it to .gitignore. The binary can always be rebuilt from go build . in the same directory.


[Low] decode_u8 accepts slices longer than 1 byte without error

decode.rs:6-13: decode_u8 checks only bytes.is_empty() but does not verify bytes.len() == 1. Callers passing a too-long slice will silently get bytes[0] back, discarding trailing data. This is inconsistent with decode_u32/decode_u64 which use try_into() and fail on wrong size.

Evidence: crates/ssz/src/decode.rs:6-13


[Low] BitList::with_bits and BitVector::with_bits do not validate bounds

types.rs:336-346 and types.rs:429-435: neither helper checks that the specified bit indices are within bounds, nor that capacity <= MAX for BitList. An out-of-bounds index will panic via bytes[bit / 8].

Evidence: crates/ssz/src/types.rs:336-346, crates/ssz/src/types.rs:429-435


Parity Matrix (wire format)

Component Go (Charon) fixture Rust fixture test Byte match Notes
phase0::Attestation raw SSZ attestation_phase0.ssz.hex go_fixture_attestation_phase0 verified
phase0::SignedAggregateAndProof raw SSZ signed_aggregate_and_proof.ssz.hex go_fixture_signed_aggregate_and_proof verified
VersionedAttestation Phase0 versioned_attestation_phase0.ssz.hex go_fixture_versioned_attestation_phase0 verified
VersionedSignedAggregateAndProof Phase0 versioned_agg_proof_phase0.ssz.hex go_fixture_versioned_agg_proof_phase0 verified
VersionedSignedProposal Phase0 versioned_proposal_phase0.ssz.hex go_fixture_versioned_proposal_phase0 verified
VersionedAttestation Electra hand-crafted only untested no Go fixture
VersionedSignedProposal Deneb (with blobs) not tested untested no fixture
Blinded proposals (Bellatrix–Fulu) not tested untested no fixture

Tests

Tests were not run (no build toolchain available). The Go-fixture compatibility tests are the primary cross-language verification. Round-trip tests are comprehensive for Phase0 types. See the Medium finding above about missing multi-fork fixture coverage.


Open Questions

  1. Is SyncContributionAndProof ever transmitted via parsigex in Charon? If not, the serialize path in parsigex_codec.rs:107-113 can be removed.
  2. Does DataVersion::Unknown ever reach encode_version? If the callers always guard against Unknown before calling, the error UnknownVersion(0) might never surface, but it's still a latent confusion.
  3. Should SszList<T, MAX=0> (zero max) be a valid type? The tree-hash semantics with minimum_leaf_count=0 may silently produce wrong roots for this case.

@iamquang95
Copy link
Copy Markdown
Collaborator

remember to remove golang code

@varex83 varex83 mentioned this pull request Apr 14, 2026
4 tasks
Base automatically changed from bohdan/dkg-parsigex to main April 21, 2026 19:50
@varex83
Copy link
Copy Markdown
Collaborator

varex83 commented Apr 28, 2026

@claude

@claude
Copy link
Copy Markdown

claude Bot commented Apr 28, 2026

Claude finished @varex83's task in 1m 15s —— View job


Removed Go sszfixtures code

  • Gather context
  • Check current state of test-infra/sszfixtures/ on branch
  • Remove Go source files from git tracking (go.mod, go.sum, main.go)
  • Commit and push

Removed test-infra/sszfixtures/go.mod, go.sum, and main.go from the branch. The generated fixtures in crates/core/testdata/ssz/ are preserved. The compiled binary was already covered by .gitignore.

The SSZ test fixtures have already been generated and committed under
crates/core/testdata/ssz/. The Go generator code is no longer needed
in-tree; the binary is already gitignored.

Co-authored-by: Bohdan Ohorodnii <varex83@users.noreply.github.com>
@varex83
Copy link
Copy Markdown
Collaborator

varex83 commented Apr 28, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented Apr 28, 2026

Claude finished @varex83's task in 7m 24s —— View job


Code Review — feat: add SSZ encoding/decoding support for parsigex

  • Read repo guidelines (CLAUDE.md / AGENTS.md)
  • Review crates/ssz/src/decode.rs and crates/ssz/src/encode.rs
  • Review crates/ssz/src/types.rs changes
  • Review crates/core/src/ssz_codec.rs
  • Review crates/core/src/parsigex_codec.rs changes
  • Review crates/eth2api/src/spec/* type changes
  • Review test fixtures and Cargo changes
  • Compile findings and post review

Summary

The PR adds SSZ binary encode/decode support for Charon's parsigex signed-data types and is a solid foundation. Cross-language fixture tests for Phase0 types are a good signal. However, there are several issues ranging from a critical wire-format incompatibility with production Go Charon nodes to missing bounds checks in the BitList decoder. The previous review's high findings still apply.


Findings


[Critical] VersionedAttestation uses wrong header format — will fail against production Go Charon nodes

In Go Charon (core/ssz.go:330-342), VersionedAttestation.MarshalSSZTo uses two different header layouts:

  • ValidatorIndex == nil: version(8) + offset(4) = 12 bytes (versionedOffset)
  • ValidatorIndex != nil: version(8) + validatorIndex(8) + offset(4) = 20 bytes (versionedValIdxOffset)

In production, ValidatorIndex is always set for pre-Electra attestations (Phase0, Altair, Bellatrix, Capella, Deneb), meaning most attestation messages from Go nodes will use the 20-byte header.

The Rust encode_versioned_attestation / decode_versioned_attestation always uses VERSIONED_SIGNED_AGGREGATE_HEADER = 12. When a Go Charon node sends a 20-byte-header VersionedAttestation over parsigex, the Rust node will try to decode offset 0x0c000000 at offset 8-12, succeed, but then treat bytes 12..end as the inner SSZ — when the actual inner SSZ starts at byte 20. The decode will silently produce a garbage result or an SSZ error.

Go's UnmarshalSSZ does have backwards-compat fallback (tries 20-byte first, falls back to 12-byte on ErrOffset), so Go nodes can decode Rust output fine — but Rust cannot decode modern Go output.

The Go fixture versioned_attestation_phase0.ssz.hex was generated with ValidatorIndex == nil (the old/legacy format), so the existing fixture test does not expose this bug.

Evidence: crates/core/src/ssz_codec.rs:169-240, charon/core/ssz.go:329-365, fixture bytes at offset 8-12 = 0c 00 00 00 (12).

Recommendation: Add a validator_index field encoding to encode_versioned_attestation (matching Go's conditional format), and implement the same dual-format decode with fallback in decode_versioned_attestation. Update decode_versioned_attestation to return the decoded validator_index in the struct instead of always None. Add a Go fixture generated with a non-nil ValidatorIndex. Fix this →


[High] SyncContributionAndProof serialize path has no matching deserialize path

serialize_signed_data encodes SyncContributionAndProof (unsigned ContributionAndProof) via encode_contribution_and_proof at parsigex_codec.rs:111-113, but deserialize_signed_data for DutyType::SyncContribution only tries decode_signed_contribution_and_proof at parsigex_codec.rs:256. Decoding unsigned bytes as a signed struct will fail.

The test for this path (marshal_unmarshal_ssz_sync_contribution_and_proof) at parsigex_codec.rs:399-419 bypasses deserialize_signed_data and calls the test-only decode_contribution_and_proof directly. Confirmed against Go Charon: proto.go:157-163 shows DutySyncContribution unmarshal uses SignedSyncContributionAndProof.

This confirms the SyncContributionAndProof serialize branch is either dead code or a protocol bug — there is no duty type in Go that would unmarshal it.

Recommendation: Remove the SyncContributionAndProof branch from serialize_signed_data if it is truly never transmitted via parsigex. Add a round-trip test through deserialize_signed_data instead of the direct codec call. Fix this →


[High] BitList::Decode does not enforce MAX bound

Decode for BitList<MAX> at types.rs:382-384 calls Self::from_ssz_bytes(bytes.to_vec()) without checking the decoded bit count against MAX. SszList::Decode at types.rs:103-113 does enforce the bound. Malformed or attacker-controlled input can produce a BitList<MAX> with more bits than the type allows.

Recommendation: After decoding, add:

if MAX > 0 && value.len() > MAX {
    return Err(DecodeError::BytesInvalid(format!(
        "bitlist length {} exceeds max {MAX}", value.len()
    )));
}

[Fix this →](https://claude.ai/code?q=In%20crates%2Fssz%2Fsrc%2Ftypes.rs%20on%20branch%20bohdan%2Fdkg-ssz%2C%20the%20Decode%20impl%20for%20BitList%3CMAX%3E%20at%20line%20382%20calls%20from_ssz_bytes%20without%20checking%20that%20the%20decoded%20bit%20count%20does%20not%20exceed%20MAX.%20Add%20a%20bounds%20check%20after%20decoding%3A%20if%20MAX%20%3E%200%20%26%26%20value.len()%20%3E%20MAX%20%7B%20return%20Err(DecodeError%3A%3ABytesInvalid(...)%20%7D.%20Compare%20to%20SszList%3A%3ADecode%20at%20lines%20103-113%20which%20does%20this%20check.&repo=NethermindEth/pluto)


[High] BitList::from_ssz_bytes is infallible and silently returns default on invalid input

types.rs:295-320: when last_byte == 0 (invalid SSZ — the sentinel bit is missing) the function returns Self::default() silently. The SSZ spec mandates a non-zero last byte for a valid BitList encoding. Because the method signature is from_ssz_bytes(ssz: Vec<u8>) -> Self (infallible), the Decode impl at types.rs:382-384 can never surface parse errors.

Recommendation: Change from_ssz_bytes to return Result<Self, DecodeError> (or a fallible internal helper), propagate the error from Decode. Fix this →


[Medium] encode_version always reports UnknownVersion(0) on failure

ssz_codec.rs:150-155: DataVersion::Unknown.to_legacy_u64() failure maps to SszCodecError::UnknownVersion(0). Version 0 is the legitimate Phase0 encoding, so error messages will incorrectly point at Phase0. Same pattern in decode_versioned_attestation:232, decode_versioned_signed_aggregate_and_proof:311, and decode_proposal_block:431.

Recommendation: Use u64::MAX as sentinel or include the DataVersion debug name in the error. Fix this →


[Medium] decode_contribution_and_proof is #[cfg(test)] while encode_contribution_and_proof is pub

ssz_codec.rs:125-130: the decode function is test-gated. If a proper deserialization path for SyncContributionAndProof is ever added (addressing the High finding above), the decode function won't compile in release builds. Even without that fix, the asymmetry is confusing for future maintainers.


[Medium] SignedBlockContents uses unbounded Vec<T> for blobs and KZG proofs

deneb.rs:441-443 and electra.rs:299-302: kzg_proofs: Vec<KZGProof> and blobs: Vec<Blob> use plain Vec with no maximum limit. The spec limit is MAX_BLOB_COMMITMENTS_PER_BLOCK = 4096. Oversized payloads during decode could silently consume large amounts of memory.

MAX_BLOB_COMMITMENTS_PER_BLOCK is already defined as a constant in deneb.rs:15. The fix is:

pub kzg_proofs: Vec<KZGProof>,  // → SszList<KZGProof, MAX_BLOB_COMMITMENTS_PER_BLOCK>
pub blobs: Vec<Blob>,            // → SszList<Blob, MAX_BLOB_COMMITMENTS_PER_BLOCK>

Note that changing these types requires removing the #[derive(Encode, Decode)] for Vec<KZGProof> since SszList already implements those traits. Fix this →


[Medium] No SSZ fixture coverage for Altair–Fulu versioned types

The fixtures cover only Phase0. Every versioned encode/decode for Bellatrix, Capella, Deneb (with blobs), Electra/Fulu attestations, and all blinded proposals is only exercised by hand-crafted Rust round-trip tests — no cross-language byte-comparison verification.

Particularly risky: the VersionedAttestation critical finding above would have been detected if a fixture with ValidatorIndex != nil had been included.

Recommendation: Add at least one Go-generated fixture per fork for the most structurally distinct cases (e.g., Electra VersionedAttestation, Deneb SignedBlockContents, a Capella blinded proposal).


[Low] decode_u8 accepts slices longer than 1 byte without error

decode.rs:6-13: only checks bytes.is_empty() but not bytes.len() == 1. Callers passing a too-long slice silently get bytes[0], discarding trailing data. Inconsistent with decode_u32/decode_u64 which use try_into() and fail on wrong size. Fix this →


[Low] BitList::with_bits and BitVector::with_bits panic on out-of-bounds index

types.rs:336-346 and types.rs:429-435: neither helper validates that indices in set_bits are within bounds. An out-of-bounds index panics via bytes[bit / 8]. Used in tests, but exposed as public API.


[Low] Unrelated dev-dependencies added in crates/core/Cargo.toml

The diff adds clap, k256, pluto-cluster, pluto-tracing, anyhow, futures-timer as dev-dependencies. None of these appear to be needed for SSZ codec tests. Verify these are intentional and not accidental inclusions from a rebase.


Parity Matrix

Component Go fixture Rust fixture test Byte match Notes
phase0::Attestation raw SSZ attestation_phase0.ssz.hex go_fixture_attestation_phase0 ✅ verified
phase0::SignedAggregateAndProof signed_aggregate_and_proof.ssz.hex go_fixture_signed_aggregate_and_proof ✅ verified
VersionedAttestation Phase0 (no validator_index) versioned_attestation_phase0.ssz.hex go_fixture_versioned_attestation_phase0 ✅ verified Only tests legacy format — see Critical finding
VersionedAttestation Phase0 (with validator_index) not tested ❌ untested Critical: will fail in production
VersionedSignedAggregateAndProof Phase0 versioned_agg_proof_phase0.ssz.hex go_fixture_versioned_agg_proof_phase0 ✅ verified
VersionedSignedProposal Phase0 versioned_proposal_phase0.ssz.hex go_fixture_versioned_proposal_phase0 ✅ verified
VersionedAttestation Electra hand-crafted only ❌ untested
VersionedSignedProposal Deneb (blobs) not tested ❌ untested
Blinded proposals (Bellatrix–Fulu) not tested ❌ untested

Open Questions

  1. ValidatorIndex field: Is the Rust node ever expected to interoperate with modern Go Charon nodes that emit VersionedAttestation with a non-nil ValidatorIndex? If yes, the critical fix is required before this can be merged.
  2. SyncContributionAndProof in parsigex: Is there any Charon flow that sends SyncContributionAndProof (unsigned) via parsigex? The Go proto.go only handles SignedSyncContributionAndProof for DutySyncContribution.
  3. SszList<T, 0> (MAX=0): Is this type intentionally uncapped, or is it only used as a default? Its tree hash (mix_in_length with minimum_leaf_count=0) will produce different results than a proper limited list.

Copy link
Copy Markdown
Collaborator

@emlautarom1 emlautarom1 left a comment

Choose a reason for hiding this comment

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

LGTM, the code is quite error prone due to repetition so I suggest taking a second look at Claude's output.

Comment thread crates/ssz/Cargo.toml Outdated
publish.workspace = true

[dependencies]
ethereum_ssz = { workspace = true }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit:

Suggested change
ethereum_ssz = { workspace = true }
ethereum_ssz.workspace = true

Comment thread Cargo.toml
Comment on lines +96 to +97
ethereum_ssz = { version = "0.10" }
ethereum_ssz_derive = { version = "0.10" }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We had some discussion in the past due to using multiple ssz libraries but I don't remember the details. Why are we adding this dependency?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We add it to easily allow encoding/decoding of some structures via derive, we still can write derive from scratch, but I think this is a good use case. I think previous discussions were about tree hash

Comment thread crates/ssz/src/lib.rs Outdated
Comment on lines +34 to +35
#[error("{0}")]
Custom(String),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: We only use this for "invalid bool bytes", better to have a proper variant for that case.

Comment thread crates/ssz/src/types.rs Outdated
Comment on lines +224 to +227
return Err(DecodeError::BytesInvalid(format!(
"vector length {} does not match required {SIZE}",
values.len(),
)));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: There are better error variants that we can use:

Suggested change
return Err(DecodeError::BytesInvalid(format!(
"vector length {} does not match required {SIZE}",
values.len(),
)));
return Err(DecodeError::InvalidByteLength {
len: values.len(),
expected: SIZE,
});

Comment thread crates/core/src/ssz_codec.rs Outdated
version
.to_legacy_u64()
.map(encode_u64)
.map_err(|_| SszCodecError::UnknownVersion(0))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Version Phase0 maps to 0 through to_legacy_u64. The error is misleading.


/// Encodes a `VersionedAttestation` to SSZ binary with Charon versioned
/// header.
pub fn encode_versioned_attestation(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This seem not cover the same ssz in charon

// MarshalSSZTo ssz marshals the VersionedAttestation object to a target array.
func (a VersionedAttestation) MarshalSSZTo(dst []byte) ([]byte, error) {
	version, err := eth2util.DataVersionFromETH2(a.Version)
	if err != nil {
		return nil, errors.Wrap(err, "invalid version")
	}

	if a.ValidatorIndex == nil {
		return marshalSSZVersionedTo(dst, version, a.sszValFromVersion)
	}

	valIdx := *a.ValidatorIndex

	return marshalSSZVersionedValidatorIdxTo(dst, version, valIdx, a.sszValFromVersion)
}

First need to check the ValidatorIndex to decide which ssz encoding needs to be used.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

func marshalSSZVersionedValidatorIdxTo(dst []byte, version eth2util.DataVersion, valIdx eth2p0.ValidatorIndex, valFunc func(eth2util.DataVersion) (sszType, error)) ([]byte, error) {
	// Field (0) 'Version'
	dst = ssz.MarshalUint64(dst, version.ToUint64())

	// Field (1) 'ValidatorIndex'
	dst = ssz.MarshalUint64(dst, uint64(valIdx))

	// Offset (2) 'Value'
	dst = ssz.WriteOffset(dst, versionedValIdxOffset)

	// TODO(corver): Add a constant length data version string field, ensure this is backwards compatible.

	val, err := valFunc(version)
	if err != nil {
		return nil, errors.Wrap(err, "sszValFromVersion from version")
	}

	// Field (2) 'Value'
	if dst, err = val.MarshalSSZTo(dst); err != nil {
		return nil, errors.Wrap(err, "marshal sszValFromVersion")
	}

	return dst, nil
}

It's 8+8+4 = 20 bytes if there is VersionIndex

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also should have golden test for this (both case with and without VersionIndex)

@varex83
Copy link
Copy Markdown
Collaborator

varex83 commented Apr 29, 2026

@claude review it please

@claude
Copy link
Copy Markdown

claude Bot commented Apr 29, 2026

Claude finished @varex83's task in 3m 39s —— View job


Code Review — feat: add SSZ encoding/decoding support for parsigex

  • Read repo guidelines (CLAUDE.md / AGENTS.md)
  • Review crates/ssz/src/decode.rs, encode.rs, types.rs
  • Review crates/core/src/ssz_codec.rs
  • Review crates/core/src/parsigex_codec.rs changes
  • Review crates/eth2api/src/spec/* type changes
  • Check status of previous review findings
  • Compile findings and post review

Summary

Good progress from the previous review — the critical VersionedAttestation header bug and the misleading encode_version error have been fixed. The new versioned_attestation_phase0_with_validator_index.ssz.hex fixture properly exercises the 20-byte path. Several previous findings remain open.


Fixed ✅

Critical → Fixed: VersionedAttestation now uses the correct 12/20-byte header format

ssz_codec.rs:170-253 now handles both formats correctly: the 20-byte header (version+validator_index+offset) when validator_index is set, and the 12-byte legacy header otherwise. decode_versioned_attestation tries the 20-byte format first and falls back to 12-byte on InvalidOffset. The new Go fixture versioned_attestation_phase0_with_validator_index.ssz.hex confirms this is byte-compatible with Go Charon output (validator_index=123, offset=0x14=20 ✓).

Medium → Fixed: encode_version error message

ssz_codec.rs:154 now emits SszCodecError::Decode(format!("unsupported data version: {version}")) instead of the misleading UnknownVersion(0).


Still Open


[High] SyncContributionAndProof serialize path has no symmetric deserialize path

parsigex_codec.rs:111-113 serializes SyncContributionAndProof (unsigned ContributionAndProof) via encode_contribution_and_proof. But deserialize_signed_data for DutyType::SyncContribution (parsigex_codec.rs:256) only decodes SignedSyncContributionAndProof. Go Charon confirms this: proto.go:157-163 shows DutySyncContribution unmarshal always uses SignedSyncContributionAndProof.

The round-trip test at parsigex_codec.rs:399-419 still bypasses deserialize_signed_data and calls the test-only decode_contribution_and_proof directly (line 417) — this masks the bug. The comment at lines 412-415 acknowledges the asymmetry without resolving it.

Recommendation: Remove the SyncContributionAndProof branch from serialize_signed_data if no duty ever transmits it (Go evidence suggests it shouldn't be there). Then fix the test to use a round-trip through deserialize_signed_data. Fix this →


[High] BitList::Decode does not enforce MAX bound

types.rs:382-384: Decode for BitList<MAX> calls Self::from_ssz_bytes(bytes.to_vec()) without checking the decoded bit count against MAX. Compare SszList::Decode at types.rs:103-113 which does if MAX > 0 && values.len() > MAX { return Err(...) }. Malformed or attacker-controlled SSZ input can produce a BitList<MAX> that silently violates the type invariant.

fn from_ssz_bytes(bytes: &[u8]) -> Result<Self, DecodeError> {
    let value = Self::from_ssz_bytes(bytes.to_vec());
    if MAX > 0 && value.len() > MAX {
        return Err(DecodeError::BytesInvalid(format!(
            "bitlist length {} exceeds max {MAX}", value.len()
        )));
    }
    Ok(value)
}

Fix this →


[High] BitList::from_ssz_bytes silently accepts invalid input (zero last byte)

types.rs:300-302: when last_byte == 0 (invalid SSZ — no sentinel bit), the function returns Self::default() silently instead of an error. The SSZ spec mandates a non-zero last byte. The method signature is infallible (-> Self), so the Decode impl at line 383 can never surface parse errors from this path.

Recommendation: Change from_ssz_bytes to return Result<Self, DecodeError> so the Decode impl can propagate errors. Fix this →


[Medium] Remaining UnknownVersion(0) misleading errors in decode functions

ssz_codec.rs:302, 374, 494: the DataVersion::Unknown match arms all return SszCodecError::UnknownVersion(0). The encode-side fix (line 154) was applied but these decode-side arms still use the placeholder 0, which is the Phase0 version number. An error message "ssz unknown version: 0" incorrectly implies Phase0 was the problem.

Recommendation: Use u64::MAX or a named constant as the sentinel:

DataVersion::Unknown => Err(SszCodecError::UnknownVersion(u64::MAX))

Fix this →


[Medium] decode_contribution_and_proof is #[cfg(test)] while encode_contribution_and_proof is pub

ssz_codec.rs:125-130: the decode function is test-gated creating an asymmetry. Since the encode side is public API, a future caller wanting to decode ContributionAndProof will be surprised to find no decode function exists in non-test builds. Even if neither function belongs in the public API, they should be consistently scoped.


[Medium] SignedBlockContents uses unbounded Vec<T> for blobs and KZG proofs

deneb.rs:441-443, electra.rs:299-302, fulu.rs:14-16: kzg_proofs: Vec<KZGProof> and blobs: Vec<Blob> have no length limit. The spec limit MAX_BLOB_COMMITMENTS_PER_BLOCK = 4096 is already defined at deneb.rs:15. Oversized decode input could silently consume hundreds of MB.

pub kzg_proofs: Vec<KZGProof>,  // → SszList<KZGProof, MAX_BLOB_COMMITMENTS_PER_BLOCK>
pub blobs: Vec<Blob>,            // → SszList<Blob, MAX_BLOB_COMMITMENTS_PER_BLOCK>

Fix this →


[Medium] No SSZ fixture coverage for Altair–Fulu versioned types

The fixture set covers only Phase0 (+ the new Phase0-with-validator_index). The VersionedAttestation Electra path, VersionedSignedProposal Deneb-with-blobs, all blinded proposals, and Fulu paths are only exercised by hand-crafted Rust round-trip tests — no cross-language Go↔Rust byte comparison. The original critical header bug would have been caught earlier with a non-nil ValidatorIndex fixture; the same applies to fork-specific structural differences.


[Low] decode_u8 accepts slices longer than 1 byte without error

decode.rs:6-13: only checks bytes.is_empty() but not bytes.len() == 1. Callers passing a too-long slice silently get bytes[0], discarding trailing data. decode_u32 and decode_u64 use try_into() and fail correctly on wrong size. Fix this →


[Low] BitList::with_bits and BitVector::with_bits panic on out-of-bounds index

types.rs:339 and types.rs:432: bytes[bit / 8] panics if a bit index falls outside the allocated byte slice. Both are public API. Consider adding a bounds check or returning a Result.


Parity Matrix

Component Go fixture Rust fixture test Byte match Notes
phase0::Attestation raw SSZ attestation_phase0.ssz.hex go_fixture_attestation_phase0 ✅ verified
phase0::SignedAggregateAndProof signed_aggregate_and_proof.ssz.hex go_fixture_signed_aggregate_and_proof ✅ verified
VersionedAttestation Phase0 (no validator_index) versioned_attestation_phase0.ssz.hex go_fixture_versioned_attestation_phase0 ✅ verified
VersionedAttestation Phase0 (validator_index=123) versioned_attestation_phase0_with_validator_index.ssz.hex go_fixture_versioned_attestation_phase0_with_validator_index ✅ verified New — critical bug fix confirmed
VersionedSignedAggregateAndProof Phase0 versioned_agg_proof_phase0.ssz.hex go_fixture_versioned_agg_proof_phase0 ✅ verified
VersionedSignedProposal Phase0 versioned_proposal_phase0.ssz.hex go_fixture_versioned_proposal_phase0 ✅ verified
VersionedAttestation Electra hand-crafted only ❌ untested
VersionedSignedProposal Deneb (blobs) not tested ❌ untested
Blinded proposals (Bellatrix–Fulu) not tested ❌ untested

@varex83
Copy link
Copy Markdown
Collaborator

varex83 commented Apr 29, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented Apr 29, 2026

Claude finished @varex83's task in 2m 38s —— View job


Code Review — feat: add SSZ encoding/decoding support for parsigex

  • Read repo guidelines (CLAUDE.md / AGENTS.md)
  • Review crates/ssz/src/decode.rs, encode.rs, types.rs
  • Review crates/core/src/ssz_codec.rs
  • Review crates/core/src/parsigex_codec.rs changes
  • Review crates/eth2api/src/spec/* type changes
  • Check status of previous review findings
  • Compile findings and post review

Summary

Good progress again — all the medium and low findings from the previous round have been addressed. Two high-severity BitList issues remain, along with the medium-priority SignedBlockContents blob bounds and fixture coverage gap. The codebase is otherwise in solid shape.


Fixed ✅

Low → Fixed: decode_u8 strict size check
decode.rs:6-13 now uses try_into() and returns InvalidLength on wrong size, consistent with decode_u32 / decode_u64.

High → Fixed: SyncContributionAndProof asymmetry removed
parsigex_codec.rs:109-111 now correctly encodes only SignedSyncContributionAndProof. The round-trip test at parsigex_codec.rs:347-367 goes through deserialize_signed_data properly. Both encode_contribution_and_proof and decode_contribution_and_proof in ssz_codec.rs are now consistently #[cfg(test)], removing the asymmetry.

Medium → Fixed: All remaining UnknownVersion(0) errors
ssz_codec.rs:302, 375, 495 all now return SszCodecError::UnknownVersion(u64::MAX). The encode_version fix from the previous round is also in place.

Nit → Fixed: ethereum_ssz.workspace = true in crates/ssz/Cargo.toml
Already using workspace key.


Still Open


[High] BitList::Decode does not enforce MAX bound

types.rs:382-384: Decode for BitList<MAX> calls Self::from_ssz_bytes(bytes.to_vec()) without checking the decoded bit count against MAX. SszList::Decode at types.rs:103-113 enforces the same bound (if MAX > 0 && values.len() > MAX). Malformed or attacker-controlled SSZ input can produce a BitList<MAX> that silently violates the type invariant.

fn from_ssz_bytes(bytes: &[u8]) -> Result<Self, DecodeError> {
    let value = Self::from_ssz_bytes(bytes.to_vec());
    if MAX > 0 && value.len() > MAX {
        return Err(DecodeError::BytesInvalid(format!(
            "bitlist length {} exceeds max {MAX}", value.len()
        )));
    }
    Ok(value)
}

Fix this →


[High] BitList::from_ssz_bytes silently accepts invalid input (zero last byte)

types.rs:300-302: when last_byte == 0 (invalid SSZ — no sentinel bit), the function returns Self::default() instead of an error. The SSZ spec mandates a non-zero last byte. Because the method signature is infallible (-> Self), the Decode impl at line 382 can never surface parse errors from this path — invalid bytes silently become an empty bitlist.

Recommendation: change from_ssz_bytes to return Result<Self, DecodeError> (or add a fallible internal variant), and propagate the error through Decode. Fix this →


[Medium] SignedBlockContents uses unbounded Vec<T> for blobs and KZG proofs

deneb.rs:441-443, electra.rs:299-302, fulu.rs:14-16: kzg_proofs: Vec<KZGProof> and blobs: Vec<Blob> use plain Vec with no length limit. The spec limit MAX_BLOB_COMMITMENTS_PER_BLOCK = 4096 is already defined at deneb.rs:15. Oversized SSZ input could silently consume hundreds of MB.

// deneb.rs, electra.rs, fulu.rs
pub kzg_proofs: Vec<KZGProof>,  // → SszList<KZGProof, MAX_BLOB_COMMITMENTS_PER_BLOCK>
pub blobs: Vec<Blob>,            // → SszList<Blob, MAX_BLOB_COMMITMENTS_PER_BLOCK>

Fix this →


[Medium] No Go fixtures for Altair–Fulu versioned types

The fixture set covers only Phase0 (with and without validator_index). Every versioned path for Bellatrix, Capella, Deneb (blobs), Electra/Fulu attestations, and all blinded proposals is only exercised by Rust-only round-trip tests — no cross-language byte comparison. The VersionedAttestation header bug was caught precisely because a new Go fixture was added; the same approach would catch structural differences in fork-specific types.


[Low] BitList::with_bits and BitVector::with_bits panic on out-of-bounds index

types.rs:340 and types.rs:432: bytes[bit / 8] panics if any bit index falls outside the allocated byte slice. Both are public API used in tests but exposed to callers. Consider a bounds check or returning a Result.


Parity Matrix

Component Go fixture Rust fixture test Byte match Notes
phase0::Attestation raw SSZ attestation_phase0.ssz.hex go_fixture_attestation_phase0 ✅ verified
phase0::SignedAggregateAndProof signed_aggregate_and_proof.ssz.hex go_fixture_signed_aggregate_and_proof ✅ verified
VersionedAttestation Phase0 (no validator_index) go_fixture_versioned_attestation_phase0 ✅ verified
VersionedAttestation Phase0 (validator_index=123) go_fixture_versioned_attestation_phase0_with_validator_index ✅ verified
VersionedSignedAggregateAndProof Phase0 go_fixture_versioned_agg_proof_phase0 ✅ verified
VersionedSignedProposal Phase0 go_fixture_versioned_proposal_phase0 ✅ verified
VersionedAttestation Electra hand-crafted only ❌ untested
VersionedSignedProposal Deneb (blobs) not tested ❌ untested
Blinded proposals (Bellatrix–Fulu) not tested ❌ untested

Copy link
Copy Markdown
Collaborator

@iamquang95 iamquang95 left a comment

Choose a reason for hiding this comment

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

LGTM

@iamquang95
Copy link
Copy Markdown
Collaborator

For these BitList, i guess we can evaluate it and address in another PR. To me, it's med-/low issues instead of high

@varex83 varex83 merged commit 1b6ed54 into main Apr 29, 2026
9 checks passed
@varex83 varex83 deleted the bohdan/dkg-ssz branch April 29, 2026 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants