Skip to content

wire_keys rejects two AttestationData at one slot on the import path, splitting a mixed devnet #619

Description

@dimka90

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions