Summary
On 48a90420, wire_keys still merges XMSS claim groups by epoch alone and returns ConflictingMessages when two claims at one slot carry different messages. It runs on the block import path, so a block carrying two distinct AttestationData for the same slot is rejected before leanVM sees it — and leanVM would accept it.
A mixed devnet split permanently on a single block because of this.
Two things make it worth looking at beyond the interop failure:
- the crate's own build path no longer has this restriction, so it can produce proofs its verifier rejects;
- leanSpec's reference implementation both produces and accepts such blocks, and its fork choice explicitly handles two distinct votes at one slot.
Observed
Local devnet, 8 gean + 6 ethlambda, 4 committees, all nodes started within 25 s of each other.
ethlambda image ghcr.io/lambdaclass/ethlambda:devnet5-leanvm-b, revision aada66bf (build/leanvm-unified-aggregate-api). Both clients on leanVM 48a904208d682848dac0e18ef8b01ebfc40df9ad.
The network was healthy for 29 slots. Both clients proposed and each accepted the other's blocks — ethlambda proposed 10, 11, 12, 13, 24, 25, 26, 27, gean proposed 14–21, 28, 29 — and finality advanced normally to slot 21.
Then gean proposed block 30 with three attestations, two of them at slot 29 with different messages:
INFO Received block from gossip slot=30 proposer=2 block_root=e6bb3b6a parent_root=b2c2dd4c attestation_count=3
WARN Failed to process block slot=30 proposer=2 block_root=e6bb3b6a parent_root=b2c2dd4c
err=Block proof verification failed: slot 29 carries two different messages in one aggregate
All six ethlambda nodes rejected it. Each then orphaned everything built on it:
INFO Block parent missing, storing as pending slot=31 parent_root=0xe6bb3b6a...
INFO Requested missing block from network block_root=0xe6bb3b6a...
WARN Failed to process block slot=30 ... two different messages in one aggregate
Block 30 is re-fetched and re-rejected indefinitely. It is the only block ever rejected in the run — one block, four retries, permanent split.
State a few minutes later:
|
gean (8 validators) |
ethlambda (6 validators) |
| head |
35, frozen |
94–97 |
| justified |
24 |
27 |
| finalized |
21 |
21 |
Finality is stuck at 21 on both sides and cannot recover: a supermajority needs 10 of 14, and neither branch can reach it.
Cause
crates/common/crypto/src/lib.rs:195, wire_keys:
.find(|group| group.epoch == component.slot) // matches on epoch alone
Some(group) => {
if group.message != component.message.0 {
return Err(ConflictingMessages { slot: component.slot });
}
...
groups.sort_unstable_by_key(|group| group.epoch); // orders by epoch alone
Reached from block import:
on_block_core crates/blockchain/src/store.rs:624
verify_block_signatures store.rs:703
verify_type_2_signature store.rs:1251
wire_keys crypto/src/lib.rs:542
verify_block_signatures builds one SignerSet per attestation with no slot deduplication, so wire_keys is the only place the restriction is applied.
The build path no longer agrees with the verify path
merge_type_1s_into_type_2 does not call wire_keys. It calls one_group per claim and hands the set to leanVM's aggregate, which since 48a90420 groups by (epoch, message) itself. So the crate can now build a proof carrying two messages at one slot, which verify_type_2_signature would then reject.
This has not surfaced in practice only because ethlambda proposers did not emit such a block during the run.
What leanSpec does
Checked at 0b7d33ec:
-
No rejection reason covers this. errors.py defines thirteen; the only two about attestations are TOO_MANY_ATTESTATION_DATA (more than MAX_ATTESTATIONS_DATA distinct) and DUPLICATE_ATTESTATION_DATA (the same AttestationData repeated).
-
The reference block builder produces such blocks. block_production.py deduplicates on the whole AttestationData (processed_attestation_data: set[AttestationData]) and applies no per-slot rule.
-
Fork choice explicitly handles the case. fork_choice.py:682:
An equivocator can cast two distinct votes at one slot. An equal-slot tie breaks toward the larger canonical attestation-data root. The result is therefore independent of arrival or insertion order.
That is the equivocating case; two different validators disagreeing at one slot is the ordinary one.
-
The one-message-per-slot rule that does exist is per key. xmss/interface.py:188: "A secret key must never sign two different messages for the same slot." That is one-time-signature safety for a single key, not a constraint on a block. Two validators with different keys signing different messages at one slot reuses nothing.
Validators disagreeing within a slot is routine rather than exceptional — they attest moments apart and see justification advance in between. On an 11-hour gean-only run, 5,229 of ~9,900 slots (53%) carried more than one distinct AttestationData. In the case decoded from a block, the two votes shared a head and differed only in target (397 vs 398).
Suggested fix
Key the groups on the pair, in both the match and the sort:
.find(|group| group.epoch == component.slot && group.message == component.message.0)
Some(group) => {
group.keys.extend(keys); // no conflict possible
}
...
groups.sort_unstable_by_key(|group| (group.epoch, group.message));
ConflictingMessages then has no producer and can go; nothing else in the repo references it.
The sort change is load-bearing and fails silently if missed. The existing comment says why — "one group per slot makes the epoch alone a strict order". With two groups at one slot, sorting by epoch alone yields a different signer-set digest, which surfaces as a verification failure on a valid proof rather than a clean error.
wire_keys_rejects_two_messages_at_one_slot asserts the current behaviour and would invert. wire_keys_groups_by_slot_and_sorts still passes (its two slot-9 entries share a message) but its name and .expect("one message per slot") no longer describe it.
gean made the same two edits in xmss/rust/multisig-glue/src/lib.rs (signature_claims) if a reference is useful. Both implementations order [u8; 32] lexicographically, so the digests agree.
Related
Summary
On
48a90420,wire_keysstill merges XMSS claim groups by epoch alone and returnsConflictingMessageswhen two claims at one slot carry different messages. It runs on the block import path, so a block carrying two distinctAttestationDatafor the same slot is rejected before leanVM sees it — and leanVM would accept it.A mixed devnet split permanently on a single block because of this.
Two things make it worth looking at beyond the interop failure:
Observed
Local devnet, 8 gean + 6 ethlambda, 4 committees, all nodes started within 25 s of each other.
ethlambda image
ghcr.io/lambdaclass/ethlambda:devnet5-leanvm-b, revisionaada66bf(build/leanvm-unified-aggregate-api). Both clients on leanVM48a904208d682848dac0e18ef8b01ebfc40df9ad.The network was healthy for 29 slots. Both clients proposed and each accepted the other's blocks — ethlambda proposed 10, 11, 12, 13, 24, 25, 26, 27, gean proposed 14–21, 28, 29 — and finality advanced normally to slot 21.
Then gean proposed block 30 with three attestations, two of them at slot 29 with different messages:
All six ethlambda nodes rejected it. Each then orphaned everything built on it:
Block 30 is re-fetched and re-rejected indefinitely. It is the only block ever rejected in the run — one block, four retries, permanent split.
State a few minutes later:
Finality is stuck at 21 on both sides and cannot recover: a supermajority needs 10 of 14, and neither branch can reach it.
Cause
crates/common/crypto/src/lib.rs:195,wire_keys:Reached from block import:
verify_block_signaturesbuilds oneSignerSetper attestation with no slot deduplication, sowire_keysis the only place the restriction is applied.The build path no longer agrees with the verify path
merge_type_1s_into_type_2does not callwire_keys. It callsone_groupper claim and hands the set to leanVM'saggregate, which since48a90420groups by(epoch, message)itself. So the crate can now build a proof carrying two messages at one slot, whichverify_type_2_signaturewould then reject.This has not surfaced in practice only because ethlambda proposers did not emit such a block during the run.
What leanSpec does
Checked at
0b7d33ec:No rejection reason covers this.
errors.pydefines thirteen; the only two about attestations areTOO_MANY_ATTESTATION_DATA(more thanMAX_ATTESTATIONS_DATAdistinct) andDUPLICATE_ATTESTATION_DATA(the sameAttestationDatarepeated).The reference block builder produces such blocks.
block_production.pydeduplicates on the wholeAttestationData(processed_attestation_data: set[AttestationData]) and applies no per-slot rule.Fork choice explicitly handles the case.
fork_choice.py:682:That is the equivocating case; two different validators disagreeing at one slot is the ordinary one.
The one-message-per-slot rule that does exist is per key.
xmss/interface.py:188: "A secret key must never sign two different messages for the same slot." That is one-time-signature safety for a single key, not a constraint on a block. Two validators with different keys signing different messages at one slot reuses nothing.Validators disagreeing within a slot is routine rather than exceptional — they attest moments apart and see justification advance in between. On an 11-hour gean-only run, 5,229 of ~9,900 slots (53%) carried more than one distinct
AttestationData. In the case decoded from a block, the two votes shared a head and differed only in target (397 vs 398).Suggested fix
Key the groups on the pair, in both the match and the sort:
ConflictingMessagesthen has no producer and can go; nothing else in the repo references it.The sort change is load-bearing and fails silently if missed. The existing comment says why — "one group per slot makes the epoch alone a strict order". With two groups at one slot, sorting by epoch alone yields a different signer-set digest, which surfaces as a verification failure on a valid proof rather than a clean error.
wire_keys_rejects_two_messages_at_one_slotasserts the current behaviour and would invert.wire_keys_groups_by_slot_and_sortsstill passes (its two slot-9 entries share a message) but its name and.expect("one message per slot")no longer describe it.gean made the same two edits in
xmss/rust/multisig-glue/src/lib.rs(signature_claims) if a reference is useful. Both implementations order[u8; 32]lexicographically, so the digests agree.Related