Skip to content

feat(crypto): integrate leanMultisig devnet5 + leanSpec PR #717 wire format#370

Open
MegaRedHand wants to merge 5 commits into
devnet5from
feat/integrate-leanmultisig-devnet5
Open

feat(crypto): integrate leanMultisig devnet5 + leanSpec PR #717 wire format#370
MegaRedHand wants to merge 5 commits into
devnet5from
feat/integrate-leanmultisig-devnet5

Conversation

@MegaRedHand
Copy link
Copy Markdown
Collaborator

Summary

  • Bump lean-multisig / leansig_wrapper to devnet5 HEAD (0242c909) and rewrite ethlambda-crypto on the new Type-1 / Type-2 API (aggregate_type_1, verify_type_1, merge_many_type_1, verify_type_2, split_type_2_by_message).
  • Slim the SSZ envelope to match leanSpec PR #717: TypeOneInfo { participants, proof }, TypeTwoMultiSignature { info, proof }message / slot / bytecode_claim rederived from the block body during verify.
  • Gate the new Type-2 SNARK behind --crypto-merge-t1-into-t2 (default off). With the flag off, block production stays fast and the chain finalizes; with it on, full devnet5 crypto runs on the block-building hot path.

Commits

sha what
1cd80dd Crate-level integration: new Type-1 / Type-2 wrappers, real merge in propose_block, real verify_type_2 in verify_block_signatures, re-enabled test_invalid_proposer_signature.
2c9dec0 Wire envelope slimmed to PR #717: TypeOneInfo becomes {participants, proof}, TypeTwoMultiSignature drops bytecode_claim. Verifier rederives bindings from block body. split_type_2_signature(index)split_type_2_by_message(message).
5361136 Strip per-component Type-1 bytes when packing the Type-2 envelope — real lean_multisig Type-1s are ~225 KiB, so N+1 copies blow the 1 MiB ByteListMiB cap. Outer merged proof still binds the full signature set; split is SNARK-backed.
3199e7d Add --crypto-merge-t1-into-t2 flag (default off). Off path ships a metadata-only Type-2 envelope; verify_block_signatures detects the empty SNARK and skips verify_type_2. Per-attestation crypto still runs at gossip ingestion. Off by default until the SNARK work is moved off the actor thread (it currently takes ~5s and starves interval-1 attestation production).

Crypto crate API

function wraps notes
aggregate_signatures(pks, sigs, msg, slot) aggregate_type_1([], raw_xmss, …) Type-1 from raw XMSS only
aggregate_mixed(children, raw_pks, raw_sigs, msg, slot) aggregate_type_1(children, raw_xmss, …) mixed Type-1 children + raw XMSS
aggregate_proofs(children, msg, slot) aggregate_type_1(children, [], …) recursive Type-1 merge
verify_aggregated_signature(proof, pks, msg, slot) verify_type_1 Type-1 SNARK verify + explicit binding check
merge_type_1s_into_type_2(parts) merge_many_type_1 bundle N Type-1s into a Type-2
verify_type_2_signature(proof, pks_per_component, expected_bindings) verify_type_2 Type-2 SNARK verify + per-component binding check
split_type_2_by_message(proof, pks_per_component, message) split_type_2 (after locating index by message) disaggregate to one Type-1; mirrors leanSpec split_by_msg

Type-1 / Type-2 proof bytes on the wire are compress_without_pubkeys() form. Test scheme corrected from Dim64 to Dim46 to match production ValidatorPublicKey (Dim46-aborting-target-sum, lifetime $2^{32}$).

Wire format (after PR #717 alignment)

TypeOneInfo            { participants: AggregationBits, proof: ByteListMiB }
TypeOneMultiSignature  { info: TypeOneInfo, proof: ByteListMiB }      // info.proof == outer.proof
TypeTwoMultiSignature  { info: TypeOneInfos, proof: ByteListMiB }     // info[i].proof intentionally empty on-wire (size cap)

Removed types: BytecodeClaim (was a placeholder H256, always ZERO).

This is not byte-compatible with the previous Type-1 / Type-2 envelope (PR #361 wire format). Cross-client interop on devnet5 requires the other clients to land equivalent slim shapes.

Devnet validation

Single-node 8-validator devnet (aggregator on, default --crypto-merge-t1-into-t2=false):

Fork Choice Tree:
  Finalized: slot 360 | root 15925705
  Justified: slot 361 | root 811c1034
  Head:      slot 363 | root 047a7dce

Chain advances one slot per slot from genesis, finalization tracks head minus ~2. verify_block_signatures runs the structural pre-checks (~10 µs) and skips verify_type_2 on the empty-SNARK path.

A 4-minute pause was observed mid-run; tracing showed it was a wall-clock gap (host sleep / Docker daemon suspend) rather than a finalization fault — chain resumed cleanly at the next valid slot.

Crypto path is exercised via the ignored unit test test_type_2_merge_verify_split_round_trip in ethlambda-crypto, which produces 2 Type-1s, merges them into a Type-2, verifies the Type-2, then splits the first component back into a Type-1 and verifies it. Round-trip passes in ~13s release.

Known limitations / follow-ups

  1. SNARK on actor thread blocks attestations. propose_block calls aggregate_signatures (for the proposer Type-1) and merge_type_1s_into_type_2 synchronously. Each call is ~5 s, the actor's message loop is single-threaded, and interval-1 attestation ticks get starved. Off-load to spawn_blocking + result message — mirror what the aggregator already does in run_aggregation_worker. Until then the crypto path stays off by default.
  2. lean-quickstart compatibility. lean-quickstart/client-cmds/ethlambda-cmd.sh had stale CLI flags (--genesis, --validators, …). Updated locally to use --custom-network-config-dir. Should be upstreamed.
  3. Envelope size pressure. PR #717's "embed per-component Type-1 bytes in info[i].proof for cheap split" overflows our 1 MiB envelope cap at realistic Type-1 sizes. We strip the bytes on send; split-by-msg becomes SNARK-backed instead of free-by-deserialize. Worth flagging on the upstream PR.

Test plan

  • cargo build --workspace --all-targets — green
  • cargo fmt --check — green
  • cargo clippy --workspace --all-targets -- -D warnings — green
  • cargo test --workspace --release --lib — 101 pass / 0 fail / 7 ignored
  • cargo test --release -p ethlambda-crypto --lib -- --ignored — 5 pass (incl. Type-2 merge / verify / split round-trip)
  • Local single-node devnet: finalizes one slot per slot for 360+ slots
  • Multi-node devnet on the cross-client wire format (requires another devnet5 client to ship the slim envelope)
  • Hive verify_signatures driver against the real verify_type_2 (needs leanSpec PR #717 fixtures)

Bump lean-multisig + leansig_wrapper to devnet5 HEAD (0242c909) and
rewrite ethlambda-crypto on the new Type-1 / Type-2 API:

- aggregate_signatures / aggregate_mixed / aggregate_proofs now wrap
  aggregate_type_1; per-attestation proof bytes are TypeOneMultiSignature
  compress_without_pubkeys().
- verify_aggregated_signature wraps verify_type_1 with an explicit
  (message, slot) binding check.
- New merge_type_1s_into_type_2 (real cryptographic block-level merge),
  verify_type_2_signature (binding-checked verifier), and
  split_type_2_signature (disaggregation).

Wire production paths to the real primitives:

- propose_block wraps the proposer XMSS as a singleton Type-1 SNARK,
  resolves per-component pubkeys, and calls merge_type_1s_into_type_2.
  The SignedBlock.proof envelope now carries the real merged Type-2.
- verify_block_signatures runs structural checks first, then crypto-
  verifies the Type-2 via verify_type_2_signature with bindings derived
  from the block body and proposer index.
- signature_spectests' SKIP_TESTS list emptied: block-level crypto is
  back, so test_invalid_proposer_signature runs against the real path.

TypeTwoMultiSignature::from_type_1s is kept as a documented test-only
structural envelope (empty proof bytes) for fast-fail unit tests.

Fixes the test signature scheme to the production Dim46 instantiation so
SIG_SIZE_FE matches lean-multisig's assertion; the new merge/verify/split
round-trip test passes end-to-end in ~13s release.
Slim the on-wire shape to match the spec PR's "aggregated block proof"
model. The verifier no longer relies on duplicated metadata inside the
proof envelope — message, slot, and bytecode_claim live solely on the
block body it already trusts.

  TypeOneInfo:          {message, slot, participants, bytecode_claim}
                      → {participants, proof}
  TypeOneMultiSignature: {info, proof} (info.proof == outer proof)
  TypeTwoMultiSignature: {info, bytecode_claim, proof}
                      → {info, proof}

The per-component Type-1 bytes now live inside TypeOneInfo.proof, so a
node receiving a block can recover a standalone Type-1 (e.g. for fork-
choice payload caching or re-broadcast) without running a fresh SNARK.
on_block_core wires this through: known_aggregated_payloads entries now
carry the real per-attestation Type-1 wire, not an empty placeholder.

verify_block_signatures drops the duplicate (message, slot) cross-check
on each info entry; bindings are rederived from block.body.attestations
+ (block_root, block.slot) and handed to verify_type_2_signature.

Disaggregation API swapped from split_type_2_signature(index) to
split_type_2_by_message, mirroring leanSpec's split_by_msg primitive.
The wrapper decompresses the Type-2, finds the unique component whose
internal native message matches, and delegates to lean_multisig's
split_type_2. New AggregationError::UnknownMessage / MultipleMessages
variants replace the now-unused SplitIndexOutOfBounds.

build_block_caps_attestation_data_entries: bump synthetic PROOF_SIZE
down from 253 KiB → 50 KiB to reflect that the envelope now carries N+1
copies of the per-component bytes, and to roughly match an expected
lean-multisig devnet5 Type-1 SNARK size.
leanSpec PR #717 embeds each component's standalone Type-1 SNARK inside
`TypeOneInfo.proof` so split-by-msg can recover a Type-1 without running
a fresh SNARK. With realistic lean-multisig devnet5 Type-1 sizes
(~225 KiB observed locally) bundling N+1 copies of those bytes plus the
merged Type-2 proof blows past the 1 MiB `ByteListMiB` cap on the outer
`SignedBlock.proof` envelope: the proposer panicked with
`OverCapacity { max: 1048576, got: 1354324 }` already at slot 6 with
5 attestations.

Strip `info[i].proof` to empty bytes when packing the Type-2 envelope
in `propose_block`. The merged proof bytes alone still bind the full
signature set, so `verify_block_signatures` keeps working. Recovery of
a standalone Type-1 is still possible via
`split_type_2_by_message`, which is SNARK-backed regardless.

On_block_core stops trying to read per-component bytes back; the
fork-choice payload-buffer entries it inserts are info-only, matching
their pre-PR-717 shape.

Verified with a 2-node ethlambda-only devnet run over 21 slots: every
block (attestation_count 0..7) is `Block Type-2 proof verified`,
`crypto_elapsed ~38 ms`, no panic. Finalization didn't advance with
only 2 validators in a single committee, but that's orthogonal —
fork-choice reorgs blocking 2/3+ vote accumulation, not the wire
format.
Block production previously ran two SNARKs on the actor thread (proposer
Type-1 wrap + merge_many_type_1). Each currently takes ~5 s, so the tick
handler at interval 0 stalls past interval 1 and validators never publish
attestations beyond slot 0. With no attestations after the first slot,
justification stays at 0 forever and the chain cannot finalize.

Add a `--crypto-merge-t1-into-t2` flag (default `false`) that gates both
SNARKs:

* off (default): proposer ships a metadata-only Type-2 envelope
  (per-component `participants` + empty proof bytes), `propose_block`
  stays fast, interval-1 attestations run on time. `verify_block_signatures`
  detects the empty SNARK and skips `verify_type_2`, keeping the existing
  structural checks. Per-attestation crypto still runs at gossip ingestion.
* on: full devnet5 cryptography (real proposer Type-1 + merge_many_type_1
  + verify_type_2 on import).

Default off until the SNARK work is moved off the actor thread —
spawn_blocking + result message, mirroring how the aggregator already
runs `aggregate_job` on a worker thread.

Single-node devnet (8 validators on ethlambda_0 with --is-aggregator,
flag default-off) finalizes:

  Fork Choice Tree:
    Finalized: slot 4 | root 1376f65e
    Justified: slot 5 | root b89c21ad
    Head:      slot 7 | root f89ebf32

Justification at slot 2, finalization at slot 3 — chain progresses one
slot per slot from there.
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

This PR introduces significant changes to the signature aggregation architecture (Type-1/Type-2 multisig migration per leanSpec PR #717). Overall the code is well-structured with proper error handling and security-conscious validation, but there are performance concerns and minor optimizations needed.

Critical/Security Issues

None identified. Bounds checking on validator indices is consistent, cryptographic bindings are properly verified, and the structural pre-check before expensive SNARK verification is correct.

Performance & Concurrency

Item 1. Blocking actor thread with SNARK operations
File: crates/blockchain/src/lib.rs, lines 378–430
The crypto_merge_t1_into_t2 path calls aggregate_signatures and merge_type_1s_into_type_2 (each taking seconds per comments) directly on the BlockChainServer actor thread. This starves interval-1 attestation production. While the CLI flag defaults to false and documents this, consider adding a timeout or moving this to a blocking thread pool (e.g., tokio::task::spawn_blocking) with a oneshot channel reply to prevent accidental activation from stalling consensus.

Item 2. Redundant proposer pubkey decoding
File: crates/blockchain/src/lib.rs, lines 378 and 396–401
proposer_validator.get_proposal_pubkey() is called twice when crypto_merge_t1_into_t2 is true. Cache the result after the first call to avoid duplicate deserialization overhead.

Item 3. Cloning proof data in compaction loop
File: crates/blockchain/src/lib.rs, line 408
t1.proof.clone() clones ByteListMiB (potentially large SNARK data) for every attestation. Since merge_inputs takes ownership, consider using into_iter() on type_one_proofs if ownership semantics allow, or document the clone cost if the data must be retained for later use.

Code Correctness

Item 4. Unused index variable
File: crates/blockchain/src/store.rs, line 1178

let _ = idx; // index reserved for richer diagnostics if needed

If diagnostics are needed, implement them; otherwise remove the binding.

Item 5. Error context in split deserialization
File: crates/common/crypto/src/lib.rs, line 445
decompress_type1 is called with index 0 for the Type-2 deserialization failure in split_type_2_by_message, but this is the Type-2 level, not Type-1 children. Use a distinct error variant or pass usize::MAX to indicate this is the container deserialization, not a child component.

Rust Best Practices

Item 6. Panic in test helper
File: crates/blockchain/src/store.rs, lines 1339–1345 (test module)
make_signed_block_proof uses .expect() for SSZ conversion. While this is test code, consider using ? propagation or assert!(..., "message") for better failure diagnostics.

Item 7. Constant naming
File: crates/common/crypto/src/lib.rs, line 17
LOG_INV_RATE is good, but document why 2 is the correct value (devnet-4 convention) in the doc comment since this affects cross-client compatibility.

Architecture & Maintainability

Item 8. Wire format change documentation
File: crates/common/types/src/block.rs
The removal of message, slot, and bytecode_claim from TypeOneInfo is a breaking wire format change. Ensure this is documented in the PR description for node operators, as it affects SSZ serialization on the network.

Item 9. Dependency audit trail
File: Cargo.lock
The update to leanMultisig rev 0242c909 introduces new transitive dependencies (objc2-foundation, system-info, zk-alloc). Verify that:

  1. objc2-foundation (macOS-specific) is only used in test/build contexts or appropriately gated
  2. The new system-info usage in rec_aggregation doesn't expose system fingerprinting in production builds

Positive Acknowledgments

  • Excellent documentation on the CLI flag explaining the performance trade-off.
  • Proper handling of empty proof bytes (stub path) in verify_block_signatures prevents accidental acceptance of placeholder proofs when crypto is expected.
  • The binding mismatch check in verify_type_2_signature (crypto/src/lib.rs lines 322–332) correctly prevents cross-slot/cross-message replay attacks.
  • Good use of inspect_err for logging without breaking method chains.

Summary

The PR is approved with suggestions. Address Items 1–3 before enabling crypto_merge_t1_into_t2 in production; Items 4–7 are minor cleanups. The consensus logic (fork choice, attestation processing) correctly handles the new Type-2 envelope format.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Now I have a complete picture. Here is my review:


PR #370 — feat(crypto): integrate leanMultisig devnet5 + leanSpec PR #717 wire format

Overview

This PR bumps lean-multisig to commit 0242c909 (devnet5), rewrites the ethlambda-crypto crate to the new aggregate_type_1 / verify_type_1 / merge_many_type_1 / verify_type_2 API, and slims the wire envelope per leanSpec PR #717: TypeOneInfo now carries { participants, proof } instead of { message, slot, participants, bytecode_claim }. Message and slot are rederived from the block body at verify time. A --crypto-merge-t1-into-t2 flag (default off) gates the real SNARK on the block-building hot path.

The overall design is sound. The known-limitations section in the PR description is thorough and accurate.


Correctness and Security

Structural pre-checks weakened on the stub path (important to track)

The previous verify_block_signatures explicitly checked:

if info.slot != attestation.data.slot { return Err(...); }
if info.message != attestation.data.hash_tree_root() { return Err(...); }

Both checks are now removed because message and slot are no longer fields on TypeOneInfo. On the SNARK path this is fine — the verifier reconstructs bindings from the block body and the SNARK enforces them cryptographically. On the stub path (default, empty merged.proof), those message/slot alignment invariants are never checked at the block level.

In practice, the security exposure is bounded: (1) the participants bitfield is still checked (attestation.aggregation_bits != info.participants), and (2) per-attestation crypto runs at gossip ingestion. But it is worth tracking explicitly that the stub path accepts a block whose Type-2 info list could misalign components with attestation data entries, with no structural fallback beyond the participant count.

Recommendation: Add a comment at the merged.proof.is_empty() early-return site calling out that message/slot alignment is implicitly trusted on this path (since it cannot be checked without the fields), to prevent this from being silently "fixed" in a way that looks safe but isn't.

Proposer pubkey decoded twice when crypto_merge_t1_into_t2=true

In lib.rs:propose_block (crypto path):

let Ok(proposer_pubkey) = proposer_validator.get_proposal_pubkey()...
// ... used to build proposer_t1_bytes ...

// ↓ decoded again for merge_inputs
let proposer_pubkey = match proposer_validator.get_proposal_pubkey() {

The second decode should just reuse the first. Both decode the same validator's proposal pubkey; if the second fails it would be a consistency bug (it won't, but the asymmetric error handling is confusing). Extract to a single let proposer_pubkey before both branches.

resolve_failed flag in the pubkey-resolution loop

let mut resolve_failed = false;
for t1 in &type_one_proofs {
    ...
    if resolve_failed { break; }
    ...
}
if resolve_failed {
    metrics::inc_block_building_failures();
    return;
}

This is a valid Rust pattern but obscures the actual error (the loop continues to check resolve_failed before breaking on the next iteration rather than immediately). A cleaner approach is a named helper that returns Result, or restructuring with ? inside a closure. Not a bug, but the current form makes the error path harder to audit.


Memory and Performance

Proof bytes stored twice in TypeOneMultiSignature

TypeOneMultiSignature::new clones proof_data into both info.proof and self.proof:

Self {
    info: TypeOneInfo {
        participants,
        proof: proof_data.clone(),
    },
    proof: proof_data,
}

For lean-multisig devnet5, a real Type-1 weighs ~225 KiB. Every in-memory TypeOneMultiSignature therefore occupies ~450 KiB. The gossip store and block-building accumulation of these objects multiplies the cost. On the wire this is hidden because Type-2 components strip info[i].proof before serialization, but for the in-memory type_one_proofs vector that block production holds while building the merge input list, the double storage is real.

TypeOneInfo comment vs actual wire behavior

The doc comment on TypeOneInfo says:

Inside a Type-2 envelope, proof is the standalone Type-1 wire for this single component, enabling cheap disaggregation without running a fresh SNARK.

But propose_block explicitly strips info[i].proof to empty before shipping:

.map(|t1| TypeOneInfo {
    participants: t1.info.participants,
    proof: ByteListMiB::default(),   // ← intentionally empty
})

The doc describes the leanSpec design intent, not what ethlambda currently sends. This is acknowledged in PR Known Limitation 3 (size cap pressure), but readers of the type definition alone will be confused. The comment should say that proof is intentionally empty on the Type-2 on-wire envelope until the per-component size issue is resolved upstream.


API and Idioms

split_type_2_by_message reuses ChildDeserializationFailed(0) for a Type-2 deserialization failure

LMType2::decompress_without_pubkeys(...)
    .ok_or(AggregationError::ChildDeserializationFailed(0))?;

ChildDeserializationFailed is semantically "child N of a list couldn't be deserialized." Repurposing it with a hardcoded 0 index to mean "the Type-2 blob itself failed to decompress" is confusing. A dedicated AggregationError::Type2DeserializationFailed variant (or reusing ProverFailure) would be more precise.

let _ = idx; in verify_type_2_signature

let _ = idx; // index reserved for richer diagnostics if needed

If idx is unused, don't bind it in the for destructuring at all:

for ((expected_msg, expected_slot), info) in expected_bindings.iter().zip(sig.info.iter()) {

The comment about "richer diagnostics" is a future-work note; if useful later, add it then.

make_type_one_proof(_slot: u64) test helper still takes unused slot

The test helper at store.rs (test module) retains the _slot: u64 parameter for backward compat with call sites, which continue passing the slot value. Since the parameter is truly unused and prefixed _, the call sites are misleading. Either remove the parameter and update call sites, or add a comment explaining why it is intentionally retained.


Positive observations

  • Removing the BytecodeClaim / message / slot fields from the wire envelope is a clean, spec-correct simplification, and the rederivation approach is properly auditable.
  • Re-enabling test_invalid_proposer_signature (previously in SKIP_TESTS) is correct — the real verify_type_2 is now wired, so the test exercises a real code path.
  • The LOG_INV_RATE = 2 named constant with a cross-client comment is good practice.
  • Error types are well-organized; the new BindingMismatch, ComponentPubkeyMismatch, and Type2ComponentCountMismatch variants give precise diagnostics.
  • The --crypto-merge-t1-into-t2 flag with a clear default-off rationale and an explicit known-limitation entry is the right engineering tradeoff for unblocking devnet progress.

Summary

The PR is mergeable in its current form. The two highest-priority follow-ups are:

  1. Fix the double proposer-pubkey decode in propose_block (stub path is unaffected; crypto path decodes it twice unnecessarily).
  2. Clarify the TypeOneInfo.proof doc comment to explicitly state that it is intentionally empty in on-wire Type-2 envelopes until the size-cap issue is resolved upstream.

The stub-path security relaxation (dropping message/slot structural checks) is intentional and bounded, but deserves an inline comment so future readers understand what is and isn't checked on the empty-SNARK path.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. Critical: production block import now accepts an empty Type-2 proof as valid. BlockChainServer::propose_block() emits merged_proof_bytes = [] by default when --crypto-merge-t1-into-t2 is not set ([crates/blockchain/src/lib.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/src/lib.rs:354), [crates/blockchain/src/lib.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/src/lib.rs:405), [crates/blockchain/src/lib.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/src/lib.rs:460)), and verify_block_signatures() returns success on merged.proof.is_empty() after only participant-bitfield checks ([crates/blockchain/src/store.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/src/store.rs:1245)). on_block_core() then runs the STF and inserts those attestations into fork choice ([crates/blockchain/src/store.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/src/store.rs:481), [crates/blockchain/src/store.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/src/store.rs:534)). That bypasses proposer authentication and in-block aggregate-attestation crypto on the normal consensus path, so a peer can forge blocks/votes that influence LMD GHOST, justification, and finalization.

  2. High: imported Type-1 proofs are discarded before caching, but all later aggregation code reads the discarded field. on_block_core() stores block-carried attestations as TypeOneMultiSignature { info, proof: [] } ([crates/blockchain/src/store.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/src/store.rs:541)), while recursive compaction and interval-2 aggregation consume proof.proof ([crates/blockchain/src/store.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/src/store.rs:957), [crates/blockchain/src/aggregation.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/src/aggregation.rs:241)) and proposal-time Type-2 merging does the same ([crates/blockchain/src/lib.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/src/lib.rs:421)). So any proof learned from a block becomes unusable as a child proof. With crypto_merge_t1_into_t2=true this can make proposal-time merging fail; even with it off, it prevents proof reuse and can make interval-2 aggregation repeatedly fail on block-derived children.

  3. Medium: the signature-verification fixtures no longer exercise real verify_type_2, despite the comments claiming they do. Both fixture conversion paths build SignedBlocks with TypeTwoMultiSignature::from_type_1s(...) ([crates/common/test-fixtures/src/verify_signatures.rs](/home/runner/work/ethlambda/ethlambda/crates/common/test-fixtures/src/verify_signatures.rs:89), [crates/common/test-fixtures/src/verify_signatures.rs](/home/runner/work/ethlambda/ethlambda/crates/common/test-fixtures/src/verify_signatures.rs:176)), and that helper always sets merged.proof = [] ([crates/common/types/src/block.rs](/home/runner/work/ethlambda/ethlambda/crates/common/types/src/block.rs:160)). signature_spectests.rs still says every fixture hits the real primitive ([crates/blockchain/tests/signature_spectests.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/tests/signature_spectests.rs:16)), but it uses the lossy conversion at [crates/blockchain/tests/signature_spectests.rs](/home/runner/work/ethlambda/ethlambda/crates/blockchain/tests/signature_spectests.rs:58). The Hive /verify_signatures/run endpoint has the same problem ([crates/net/rpc/src/test_driver.rs](/home/runner/work/ethlambda/ethlambda/crates/net/rpc/src/test_driver.rs:311)). So malformed Type-2 proofs will now pass those “crypto verification” paths as long as the participant bitfields line up.

Assumptions
I assumed on_block() is still the production network import path. If this structural-only mode is intended only for dev/testing, it needs a much harder gate than a default-off CLI flag plus unconditional acceptance on import.

Testing
I could not run the relevant cargo tests here: Cargo needs network/git access and writable default Rust/Cargo homes, which this sandbox blocks.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 14, 2026

Greptile Summary

This PR integrates the lean-multisig devnet5 revision (0242c909) and rewrites the ethlambda-crypto crate to the new Type-1 / Type-2 API, while slimming the wire envelope to match leanSpec PR #717 (dropping bytecode_claim, rederiving message/slot bindings from the block body). A new --crypto-merge-t1-into-t2 flag (default false) gates the expensive block-level Type-2 SNARK off the actor thread until it can be moved to spawn_blocking.

  • Crypto API rewrite: six public functions wrap the upstream lean-multisig primitives with binding-mismatch checks; test scheme corrected from Dim64 to Dim46.
  • Wire format change: TypeOneInfo { participants, proof }, TypeTwoMultiSignature { info, proof } — per-component Type-1 bytes are stripped before transmission to stay under the 1 MiB cap; disaggregation is now SNARK-backed.
  • Default-off stub path: verify_block_signatures detects an empty outer proof and returns early after structural checks only; per-attestation crypto still runs at gossip ingestion, but the proposer's XMSS signature over the block root is not cryptographically verified in the default configuration.

Confidence Score: 3/5

The default configuration ships blocks with no proposer-level cryptographic verification; any network peer can construct a structurally valid block claiming any elected proposer without supplying a valid XMSS signature over the block root.

The default --crypto-merge-t1-into-t2=false path causes verify_block_signatures to skip all SNARK and XMSS checking on the block envelope, leaving proposer authentication entirely absent unless the state-transition function independently enforces it. This is intentional and documented for devnet throughput, but the security gap is real on the default path. Memory duplication in TypeOneMultiSignature::new and the misleading error variants are minor but add up over time.

Focus on crates/blockchain/src/store.rs (verify_block_signatures empty-proof early return), crates/blockchain/src/lib.rs (proposer proof assembly when flag is off), and crates/common/types/src/block.rs (TypeOneMultiSignature::new clone duplication).

Security Review

  • No proposer signature verification on default path (crates/blockchain/src/store.rs, verify_block_signatures): when --crypto-merge-t1-into-t2=false (the default), the outer Type-2 proof field is empty and the function returns after structural checks only. The proposer's XMSS signature is never verified, so any peer knowing the elected proposer index can submit a structurally valid but cryptographically unsigned block. Documented as intentional for devnet throughput; merits an explicit follow-up before use in a stronger trust environment.

Important Files Changed

Filename Overview
crates/common/crypto/src/lib.rs New Type-1/Type-2 aggregation and verification wrappers; split_type_2_by_message uses a misleading error variant on Type-2 decompression failure
crates/blockchain/src/lib.rs Adds crypto_merge_t1_into_t2 flag; the default-off path ships empty proof bytes causing verify_block_signatures to skip all proposer signature crypto; redundant pubkey fetch when flag is on
crates/blockchain/src/store.rs Rewrites verify_block_signatures to the new Type-2 API; correctly gates crypto on non-empty proof; slot-overflow error is mapped to the wrong StoreError variant
crates/common/types/src/block.rs New slim wire format for TypeOneInfo/TypeTwoMultiSignature; TypeOneMultiSignature::new stores identical proof bytes in both info.proof and proof, doubling per-proof in-memory size
crates/blockchain/src/aggregation.rs Aggregation worker updated to use new TypeOneInfo fields; logic unchanged, clean migration
bin/ethlambda/src/main.rs Adds --crypto-merge-t1-into-t2 CLI flag (default false); wired correctly to BlockChain::spawn
crates/common/test-fixtures/src/verify_signatures.rs Lossy and lossless TestSignedBlock conversions updated to new wire format; try_into_signed_block_with_proofs uses from_type_1s which produces an empty outer proof, so Hive fixtures will always exercise structural-only verification
crates/net/rpc/src/test_driver.rs Minor import/usage updates to align with new verify_signatures path; no logic changes

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[propose_block] -->|crypto_merge_t1_into_t2=true| B[aggregate_signatures\nproposer Type-1 SNARK]
    A -->|crypto_merge_t1_into_t2=false| C[proposer_proof_bytes = empty]
    B --> D[merge_type_1s_into_type_2\nattestation T1s + proposer T1]
    C --> E[merged_proof_bytes = empty]
    D --> F[TypeTwoMultiSignature\nwith real SNARK proof]
    E --> G[TypeTwoMultiSignature\nmetadata-only envelope]
    F --> H[verify_block_signatures]
    G --> H
    H -->|merged.proof.is_empty| I[Structural checks only\nparticipant bitfields\nno crypto]
    H -->|merged.proof non-empty| J[verify_type_2_signature\nfull SNARK + binding check]
    I --> K[OK - default devnet path]
    J --> L[OK - full crypto path]
Loading
Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
crates/blockchain/src/store.rs:1244-1258
**Empty-proof path silently skips all proposer signature verification**

When `--crypto-merge-t1-into-t2=false` (the default), the proposer sets `merged_proof_bytes = ByteListMiB::default()` and the proposer's XMSS signature is never included in any cryptographically bound proof. The structural check at line 1236 only confirms that `proposer_bits == [block.proposer_index]` — it does *not* verify that the declared `proposer_index` actually signed `block_root`. Any peer that knows the expected proposer for a slot can submit a block with a valid-looking envelope and it will pass all signature checks here. It is worth confirming that `ethlambda_state_transition::state_transition` independently enforces that `block.proposer_index` matches the slot's elected proposer so the chain at least rejects impersonation at that layer.

### Issue 2 of 5
crates/common/crypto/src/lib.rs:387-389
`split_type_2_by_message` returns `ChildDeserializationFailed(0)` when the outer Type-2 proof fails to decompress. `ChildDeserializationFailed` carries an index implying a specific Type-1 child at position 0 failed — but this fires on the top-level Type-2 object before any child is accessed, which will mislead diagnostics.

```suggestion
    let type_2 =
        LMType2::decompress_without_pubkeys(proof_data.iter().as_slice(), pubkeys_per_info)
            .ok_or(AggregationError::DeserializationFailed)?;
```

### Issue 3 of 5
crates/common/types/src/block.rs:118-129
**`TypeOneMultiSignature::new` stores identical proof bytes in both `info.proof` and `proof`**

Every standalone Type-1 costs ~2x its serialized size in memory: ~225 KiB is cloned into `info.proof` and again into `proof`. The `propose_block` path strips `info.proof` when packing the wire Type-2, so the duplication is never sent on the wire — but the redundant clone is live in the actor's heap for the full lifetime of each aggregated proof. Consider using an `Arc<ByteListMiB>` to share the allocation.

### Issue 4 of 5
crates/blockchain/src/store.rs:1292-1293
A slot `u64` to `u32` conversion failure is mapped to `ProposerSignatureVerificationFailed`, surfacing as a misleading "signature failed" error in logs. At ~4 billion slots this is unreachable in practice, but the error category is wrong and would make it hard to diagnose if it ever triggered.

```suggestion
    let block_slot_u32 =
        u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?;
```

### Issue 5 of 5
crates/blockchain/src/lib.rs:377-450
**Proposer pubkey decoded twice when `crypto_merge_t1_into_t2=true`**

`proposer_validator.get_proposal_pubkey()` is called at line ~378 (to wrap the raw XMSS signature into a Type-1) and again at line ~413 (to build the merge inputs). Both calls occur on the actor thread's hot path and `get_proposal_pubkey` deserializes the key bytes each time. Compute the pubkey once and reuse it for both uses.

Reviews (1): Last reviewed commit: "feat(blockchain): gate Type-2 SNARK behi..." | Re-trigger Greptile

Comment on lines +1244 to +1258

// Skip crypto when the merged proof carries no SNARK bytes (the stub path
// used while the actor-thread SNARK work is being moved off-thread —
// per-attestation crypto still runs at gossip ingestion).
if merged.proof.is_empty() {
let total_elapsed = total_start.elapsed();
info!(
slot = block.slot,
attestation_count = attestations.len(),
?structural_elapsed,
?total_elapsed,
"Block Type-2 proof structural-only (empty SNARK bytes)"
);
return Ok(());
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Empty-proof path silently skips all proposer signature verification

When --crypto-merge-t1-into-t2=false (the default), the proposer sets merged_proof_bytes = ByteListMiB::default() and the proposer's XMSS signature is never included in any cryptographically bound proof. The structural check at line 1236 only confirms that proposer_bits == [block.proposer_index] — it does not verify that the declared proposer_index actually signed block_root. Any peer that knows the expected proposer for a slot can submit a block with a valid-looking envelope and it will pass all signature checks here. It is worth confirming that ethlambda_state_transition::state_transition independently enforces that block.proposer_index matches the slot's elected proposer so the chain at least rejects impersonation at that layer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/src/store.rs
Line: 1244-1258

Comment:
**Empty-proof path silently skips all proposer signature verification**

When `--crypto-merge-t1-into-t2=false` (the default), the proposer sets `merged_proof_bytes = ByteListMiB::default()` and the proposer's XMSS signature is never included in any cryptographically bound proof. The structural check at line 1236 only confirms that `proposer_bits == [block.proposer_index]` — it does *not* verify that the declared `proposer_index` actually signed `block_root`. Any peer that knows the expected proposer for a slot can submit a block with a valid-looking envelope and it will pass all signature checks here. It is worth confirming that `ethlambda_state_transition::state_transition` independently enforces that `block.proposer_index` matches the slot's elected proposer so the chain at least rejects impersonation at that layer.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread crates/common/crypto/src/lib.rs Outdated
Comment on lines +387 to +389
let type_2 =
LMType2::decompress_without_pubkeys(proof_data.iter().as_slice(), pubkeys_per_info)
.ok_or(AggregationError::ChildDeserializationFailed(0))?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 split_type_2_by_message returns ChildDeserializationFailed(0) when the outer Type-2 proof fails to decompress. ChildDeserializationFailed carries an index implying a specific Type-1 child at position 0 failed — but this fires on the top-level Type-2 object before any child is accessed, which will mislead diagnostics.

Suggested change
let type_2 =
LMType2::decompress_without_pubkeys(proof_data.iter().as_slice(), pubkeys_per_info)
.ok_or(AggregationError::ChildDeserializationFailed(0))?;
let type_2 =
LMType2::decompress_without_pubkeys(proof_data.iter().as_slice(), pubkeys_per_info)
.ok_or(AggregationError::DeserializationFailed)?;
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/common/crypto/src/lib.rs
Line: 387-389

Comment:
`split_type_2_by_message` returns `ChildDeserializationFailed(0)` when the outer Type-2 proof fails to decompress. `ChildDeserializationFailed` carries an index implying a specific Type-1 child at position 0 failed — but this fires on the top-level Type-2 object before any child is accessed, which will mislead diagnostics.

```suggestion
    let type_2 =
        LMType2::decompress_without_pubkeys(proof_data.iter().as_slice(), pubkeys_per_info)
            .ok_or(AggregationError::DeserializationFailed)?;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 118 to 129
impl TypeOneMultiSignature {
/// Build a Type-1 proof with the given participants, message, slot and
/// raw proof bytes.
pub fn new(
participants: AggregationBits,
message: H256,
slot: u64,
proof_data: ByteListMiB,
) -> Self {
/// Build a Type-1 proof carrying the given participant bitfield and the
/// aggregated proof bytes.
pub fn new(participants: AggregationBits, proof_data: ByteListMiB) -> Self {
Self {
info: TypeOneInfo {
message,
slot,
participants,
bytecode_claim: BytecodeClaim::ZERO,
proof: proof_data.clone(),
},
proof: proof_data,
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 TypeOneMultiSignature::new stores identical proof bytes in both info.proof and proof

Every standalone Type-1 costs ~2x its serialized size in memory: ~225 KiB is cloned into info.proof and again into proof. The propose_block path strips info.proof when packing the wire Type-2, so the duplication is never sent on the wire — but the redundant clone is live in the actor's heap for the full lifetime of each aggregated proof. Consider using an Arc<ByteListMiB> to share the allocation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/common/types/src/block.rs
Line: 118-129

Comment:
**`TypeOneMultiSignature::new` stores identical proof bytes in both `info.proof` and `proof`**

Every standalone Type-1 costs ~2x its serialized size in memory: ~225 KiB is cloned into `info.proof` and again into `proof`. The `propose_block` path strips `info.proof` when packing the wire Type-2, so the duplication is never sent on the wire — but the redundant clone is live in the actor's heap for the full lifetime of each aggregated proof. Consider using an `Arc<ByteListMiB>` to share the allocation.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread crates/blockchain/src/store.rs Outdated
Comment on lines +1292 to +1293
let block_slot_u32 =
u32::try_from(block.slot).map_err(|_| StoreError::ProposerSignatureVerificationFailed)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 A slot u64 to u32 conversion failure is mapped to ProposerSignatureVerificationFailed, surfacing as a misleading "signature failed" error in logs. At ~4 billion slots this is unreachable in practice, but the error category is wrong and would make it hard to diagnose if it ever triggered.

Suggested change
let block_slot_u32 =
u32::try_from(block.slot).map_err(|_| StoreError::ProposerSignatureVerificationFailed)?;
let block_slot_u32 =
u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?;
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/src/store.rs
Line: 1292-1293

Comment:
A slot `u64` to `u32` conversion failure is mapped to `ProposerSignatureVerificationFailed`, surfacing as a misleading "signature failed" error in logs. At ~4 billion slots this is unreachable in practice, but the error category is wrong and would make it hard to diagnose if it ever triggered.

```suggestion
    let block_slot_u32 =
        u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread crates/blockchain/src/lib.rs Outdated
Comment on lines +377 to +450
let proposer_proof_bytes = if self.crypto_merge_t1_into_t2 {
let Ok(proposer_pubkey) = proposer_validator.get_proposal_pubkey().inspect_err(
|err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"),
) else {
metrics::inc_block_building_failures();
return;
};
let Ok(proposer_validator_signature) =
ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| {
error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes")
})
else {
metrics::inc_block_building_failures();
return;
};
let Ok(proposer_t1_bytes) = ethlambda_crypto::aggregate_signatures(
vec![proposer_pubkey.clone()],
vec![proposer_validator_signature],
&block_root,
slot as u32,
)
.inspect_err(|err| {
error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as Type-1")
}) else {
metrics::inc_block_building_failures();
return;
};
proposer_t1_bytes
} else {
ByteListMiB::default()
};

let proposer_t1 =
TypeOneMultiSignature::for_proposer(validator_id, proposer_proof_bytes.clone());

let merged_proof_bytes = if self.crypto_merge_t1_into_t2 {
let proposer_pubkey = match proposer_validator.get_proposal_pubkey() {
Ok(pk) => pk,
Err(err) => {
error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey");
metrics::inc_block_building_failures();
return;
}
};
let mut merge_inputs: Vec<(Vec<ValidatorPublicKey>, ByteListMiB)> =
Vec::with_capacity(type_one_proofs.len() + 1);
let mut resolve_failed = false;
for t1 in &type_one_proofs {
let mut pubkeys = Vec::new();
for vid in t1.participant_indices() {
let Some(validator) = validators.get(vid as usize) else {
error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys");
resolve_failed = true;
break;
};
match validator.get_attestation_pubkey() {
Ok(pk) => pubkeys.push(pk),
Err(err) => {
error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey");
resolve_failed = true;
break;
}
}
}
if resolve_failed {
break;
}
merge_inputs.push((pubkeys, t1.proof.clone()));
}
if resolve_failed {
metrics::inc_block_building_failures();
return;
}
merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Proposer pubkey decoded twice when crypto_merge_t1_into_t2=true

proposer_validator.get_proposal_pubkey() is called at line ~378 (to wrap the raw XMSS signature into a Type-1) and again at line ~413 (to build the merge inputs). Both calls occur on the actor thread's hot path and get_proposal_pubkey deserializes the key bytes each time. Compute the pubkey once and reuse it for both uses.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/src/lib.rs
Line: 377-450

Comment:
**Proposer pubkey decoded twice when `crypto_merge_t1_into_t2=true`**

`proposer_validator.get_proposal_pubkey()` is called at line ~378 (to wrap the raw XMSS signature into a Type-1) and again at line ~413 (to build the merge inputs). Both calls occur on the actor thread's hot path and `get_proposal_pubkey` deserializes the key bytes each time. Compute the pubkey once and reuse it for both uses.

How can I resolve this? If you propose a fix, please make it concise.

@pablodeymo
Copy link
Copy Markdown
Collaborator

Reviewed locally: cargo check ✅, cargo clippy -- -D warnings ✅ (with the new lean-multisig 0242c909 fetched).

The PR splits cleanly into three layers and reads well:

  1. crypto crate — clean wrappers around aggregate_type_1 / merge_many_type_1 / verify_type_1 / verify_type_2 / split_type_2, with binding pre-checks before each SNARK verify.
  2. wire format — slim envelope matching leanSpec PR #717, drops message / slot / bytecode_claim.
  3. integration — real verify_type_2 on the verify path, plus the --crypto-merge-t1-into-t2 flag (default off) for the still-too-slow prover path.

Worth raising (rough priority)

1. Off-default path skips proposer-signature verification entirely. With crypto_merge_t1_into_t2=false (the default), propose_block ships merged.proof = empty, and verify_block_signatures (crates/blockchain/src/store.rs:1248) early-returns Ok after the structural check. A peer can broadcast a block claiming any proposer_index and it will be accepted. Per-attestation crypto still runs at gossip ingestion, so attestations inside the block are real — but the block itself is unauthenticated. This is documented as intentional, but worth:

  • Emitting a one-shot warn! at startup when the flag is false (so operators see the tradeoff in logs)
  • Optionally a feature gate / explicit devnet marker so a default-off path can't accidentally ship to a non-devnet build

2. Doc/wire-format inconsistency on TypeOneInfo::proof. crates/common/types/src/block.rs:71-74 says info.proof is "Standalone Type-1 proof bytes ... Used by split-by-msg and by re-broadcast paths." And TypeOneMultiSignature::new (block.rs:121-129) clones the bytes into both info.proof and outer proof, with a doc comment claiming they're kept aligned. But the production block builder (crates/blockchain/src/lib.rs:466-471) always strips info[i].proof to empty before serialization — so on the wire info[i].proof is never populated. The "info[i].proof always empty on-wire to fit the 1 MiB cap" invariant is in the PR description and the 5361136 commit message but not in any source comment. Worth a sentence on TypeTwoMultiSignature (or the wire-format header block) so future readers don't trust info[i].proof as a usable field.

3. propose_block resolves the proposer pubkey twice. crates/blockchain/src/lib.rs:378 and lib.rs:413 both call proposer_validator.get_proposal_pubkey() in the crypto_merge_t1_into_t2=true path. Symmetric error handling but redundant work — hoist the resolution out of the two branches.

4. Dead let _ = idx; in verify_type_2_signature. crates/common/crypto/src/lib.rs:360 — either drop it or actually use idx in a richer error. The "reserved for diagnostics" comment doesn't pull its weight.

5. proposer_proof_bytes.clone() at crates/blockchain/src/lib.rs:410. In the crypto_merge_t1_into_t2=true path this is a ~225 KiB Type-1 SNARK; the second use at lib.rs:450 moves it. Easy to pass-by-move and skip the clone (or restructure to compute once and use in both for_proposer + merge_inputs).

6. Unrealistic Type-1 size in the payload-buffer test. crates/blockchain/src/store.rs:1465-1467 uses PROOF_SIZE = 50 KiB with a comment saying that's "roughly what a real lean-multisig devnet5 Type-1 SNARK weighs in at" — but the PR description elsewhere says real Type-1s are ~225 KiB (the very reason info[i].proof is stripped). Reconcile: either bump to 225 KiB and confirm the envelope-stripping path holds, or rephrase the comment to "artificial size chosen so packed bytes fit inline for this structural test."

7. Minor cleanup. Dropped-but-recomputed locals in test fixtures (crates/common/test-fixtures/src/fork_choice.rs:170-173, crates/blockchain/src/store.rs:1437-1439let _ = block_root; patterns). Either delete the unused calculations or push them past their last use.

Things that look right

  • verify_aggregated_signature and verify_type_2_signature both re-derive bindings from caller-supplied (message, slot) and check them against what the proof binds before running the SNARK — the correct ordering.
  • verify_block_signatures builds expected_bindings from the block body, not from anything the proof envelope carries — correct given the slim wire format.
  • split_type_2_by_message distinguishes UnknownMessage from MultipleMessages.
  • aggregate_mixed requires at least 1 raw sig OR at least 2 children (no useless one-child re-aggregation).
  • BytecodeClaim is fully removed (no stragglers).
  • signature_spectests.rs un-skips test_invalid_proposer_signature now that real verify_type_2 runs.

Test-plan gap

The "Local single-node devnet: finalizes one slot per slot for 360+ slots" checkbox was done with the default flag off — so it doesn't exercise the block-level crypto path. The test_type_2_merge_verify_split_round_trip ignored unit test is the only end-to-end exercise of the prover. Fine for now, but worth flagging that the multi-node devnet checkbox (still unchecked) is the real gate before flipping the default to true.

* lib.rs (#5): decode proposer proposal pubkey once and reuse it for the
  singleton Type-1 wrap and the merge inputs; was deserialized twice on
  the crypto hot path.
* store.rs (#4): block / attestation slot u64→u32 overflow now maps to
  a dedicated `SlotOutOfRange(u64)` variant instead of being misreported
  as `ProposerSignatureVerificationFailed`.
* store.rs (#1): explicitly document the security caveat of the empty-
  SNARK structural-only branch (proposer XMSS not crypto-verified) and
  note the two upstream mitigations (STF `process_block_header` rejects
  wrong proposer_index; per-attestation crypto still runs at gossip
  ingestion). The structural log line now flags it explicitly.
* crypto/src/lib.rs (#2): outer Type-2 decompression failure in
  `split_type_2_by_message` returns a new `DeserializationFailed`
  variant instead of `ChildDeserializationFailed(0)`, which had implied
  a child at index 0 had failed.
* types/src/block.rs (#3): annotate the intentional duplication of
  proof bytes between `TypeOneMultiSignature::info.proof` and the outer
  `proof` field — mirrors leanSpec PR #717's shape so a Type-1 embedded
  inside a Type-2's info[i] reads the same as a standalone Type-1.
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.

2 participants