fix(crypto): key a proof's signer set on (slot, message), not slot - #620
Conversation
`wire_keys` merged a Type-2's claims by epoch alone and returned `ConflictingMessages` when a second claim at that slot carried a different message. It runs on the block import path, so a block holding two distinct `AttestationData` at one slot was rejected before leanVM saw it. That is an ordinary block, not an equivocating one: validators attest moments apart and can see justification advance in between, so they disagree inside a slot routinely. On a mixed devnet one gean block was enough. All six ethlambda nodes rejected it, orphaned everything built on it, and the network split for good with both sides frozen at the same finalized slot. Nothing below the wrapper wanted the restriction. leanVM has keyed its XMSS groups on `(epoch, message)` since 48a90420, this crate's own build path hands it one claim at a time and never applied the rule, and leanSpec both produces and accepts such blocks. The verify path was the only place it lived, which left the crate able to build proofs its own verifier rejects. Key the groups on the pair in the match and in the sort. The sort is the load-bearing half and fails quietly if missed: with two groups at one slot, ordering on the epoch alone leaves them in the caller's order, and leanVM turns that away at decode as a malformed signer set on a proof that is perfectly valid. `ConflictingMessages` loses its only producer and goes. Closes #619
🤖 Kimi Code ReviewI'll review this PR which changes the aggregation grouping from per-slot to per- Security & CorrectnessCritical:
|
| Priority | Issue | Location |
|---|---|---|
| Critical | merge_type_1s_into_type_2 uses slot-only key, breaking multi-message-per-slot merges |
crates/common/crypto/src/lib.rs ~line 480, by_slot |
| High | #[ignore] on only interop test; add fast unit test for merge path |
test at line ~896 |
| Medium | split_type_2_by_message comment incorrectly states message uniqueness |
line ~555 |
| Low | sort_unstable_by_key tuple allocation |
line ~204 |
| Low | H256.0 direct field access in tests |
lines ~662, ~689 |
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code Review
Beyond that, the actual I could not run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewReview: PR 620 — key a proof's signer set on (slot, message)Summary: This correctly fixes a real network-splitting bug: Strengths
Finding
/// The returned blob is the `to_bytes_without_pubkeys()` form of the merged
/// aggregate, whose signer set is the union of the claims grouped by slot. A
/// verifier decoding it back needs the same claims, in any order.
///
/// Two claims at one slot under different messages cannot be merged at all:
/// leanVM rejects the pair rather than producing a proof (see the module docs).
pub fn merge_type_1s_into_type_2(This block wasn't touched by the diff, but it directly contradicts what the PR just proved: Other notes
Automated review by Claude (Anthropic) · sonnet · custom prompt |
006453f
into
build/leanvm-unified-aggregate-api
#620 keyed the signer set on `(slot, message)` but left three doc sites describing the invariant it removed. The one on `merge_type_1s_into_type_2` is the dangerous one: it states that two claims at one slot under different messages cannot be merged at all, which the round-trip test added in that same PR disproves by merging exactly such a pair. A reader trusting it could reinstate the restriction that split a devnet. The other two are quieter: the merge blob's signer set is grouped by `(slot, message)`, not by slot, and leanVM no longer counts a slot carrying two messages among the malformed requests it rejects. Caught in review of #620 by Codex and Claude.
…f-gossip-leanvm-unified Brings in the #606 commits since 9b2ca35: the leanVM bumps to 022ec377 and then 48a90420, #620 keying a proof's signer set on (slot, message) rather than on slot, and the crypto docs that follow from it. The leanVM bump changes key derivation, so an image built from here cannot join a chain whose keys were generated before it. #606 had already merged main (296ce8d), and this branch contains both of that merge's parents, so the merge was resolved against 296ce8d as the base. The only textual conflict, in aggregation.rs, came from 296ce8d's resolution of the interval-2 session code, which this branch replaced with the always-on worker; with that base the merge is clean and applies only what #606 added afterwards.
Closes #619.
wire_keysbuilds the signer set a Type-2 proof is decoded and verified against. It merged claims by epoch alone and returnedConflictingMessageswhen a second claim at that slot carried a different message. It sits on the block import path, so a block holding two distinctAttestationDataat one slot was rejected before leanVM ever saw it.That is an ordinary block. Validators attest moments apart and can see justification advance in between, so disagreeing inside a slot is routine rather than equivocation: on an 11-hour gean-only run, 53% of slots carried more than one distinct
AttestationData. On the mixed devnet in #619 a single gean block was enough. All six ethlambda nodes rejected it, orphaned everything built on it, re-fetched and re-rejected it, and the network split permanently with both sides frozen at the same finalized slot.Why the restriction had no owner
48a90420(epoch, message)merge_type_1s_into_type_2→one_groupper claim)(epoch, message)wire_keys)epochblock_production.py/fork_choice.pyAttestationDataSo the crate could build proofs its own verifier rejects. It had not surfaced only because no ethlambda proposer emitted such a block during the run.
The one-message-per-slot rule that genuinely exists is per key (
xmss/interface.py:188: a secret key must never sign two different messages for one slot). Two validators with different keys signing different messages at one slot reuses nothing.The change
Key the groups on the pair, in the match and in the sort:
wire_keysbecomes infallible, soConflictingMessagesand its two#[from]variants go with it. gean makes the same two edits insignature_claims(xmss/rust/multisig-glue/src/lib.rs), and both implementations order[u8; 32]lexicographically, so the digests agree.The sort is the load-bearing half. With two groups at one slot, ordering on the epoch alone leaves them in the caller's order, which is block-body order and sorted by nothing.
Tests
wire_keys_keeps_two_messages_at_one_slot(fast, runs in CI) pins the whole shape: input is deliberately unsorted on both slot and message, and it asserts the(slot, message)sequence and which keys landed in which group.test_type_2_two_messages_at_one_slot_round_trip(#[ignore], real XMSS and real proving, ~35s) is the interop case end to end: two validators, one slot, two messages, merged into a Type-2 and verified. It hands the claims over with the larger message first, so only a correct sort can reproduce leanVM's canonical order.Verified against the three variants:
ConflictingMessages { slot: 6 }epochaloneDeserializationFailedon a valid prooftest_type_2_merge_verify_split_round_tripis unregressedThe middle row is why the sort gets its own comment: leanVM turns a mis-ordered signer set away at decode, so a missed sort reads as a corrupt proof rather than a wrong signer set.
wire_keys_rejects_two_messages_at_one_slotasserted the removed behaviour and is replaced.wire_keys_groups_by_slot_and_sortsstill passes unchanged (its two slot-9 entries share a message); it is renamed towire_keys_unions_keys_sharing_a_group_and_sorts, since what it actually covers is key-unioning and ordering.Module docs and the
Aggregation shapesection ofCLAUDE.mdsaid a slot carries one message. Both updated.Related