# EIP-XXXX: Batched attestations
> TODO:
> - unify slashing with existing attesterSlashings
> - several rounds of simplification
## Abstract
Introduce attestation batching at origin, allowing operators running multiple validators to publish a single pre-aggregated attestation for validators assigned to the same committee. To prevent overlapping batches from circulating, a designated validator signs the batch and is slashed if a conflicting attestation is discovered using simple set algebra. We extend the existing attestation gossip topic via SSZ union type `WireAttestation` to carry either a `SingleAttestation` or `BatchAttestation`. Additionally, we reduce the number of attestation subnets by a factor of `BATCH_SUBNET_REDUCTION_FACTOR` to increase validator density per committee and reduce network complexity.
## Motivation
Ethereum has ~1MM active validators today. With 100% participation, every slot triggers N x 1/32 attestations (around 31k), distributed over 64 subnets handling ~485 attestations each. Large operators run many validators, all typically sharing the same consensus view, and therefore typically voting in unison for head.
As we push towards shorter slots and faster finality, we need to drastically reduce the volume of attestations while maintaining protocol and consensus integrity. While EIP-7251 achieves this via validator balance consolidation, uptake has been relatively slow.
By increasing attestation information efficiency in the way this EIP proposes, we can reduce the total volume of attestations without requiring a validator operation. Existing multi-validator setups already meet most preconditions to implement this solution: beacon nodes hosted by large operators tightly orchestrate validator clients, the VC <-> BN trust assumption is strong, and validator clients run mechanisms to prevent signing conflicting payloads (equivocation).
Today, the protocol supports only individual attestation messages per validator. This choice protects the network from being spammed by malicious actors sending overlapping aggregates. If this rule were not in place, an attacker controlling k co-committee validators could publish O(2^k) valid subset permutations.
Batching attestations at origin solves this by enabling operators to produce a single BLS aggregate for all validators up for duty within the same committee. This aggregate is signed by a designated batch signer, who risks being slashed if an overlapping attestation is presented.
Moreover, reducing subnet count increases expected validators per committee for large operators, improving amortization.
## Specification
These changes are applied to the consensus-specs.
### New constants
| Name | Value | Description |
| ------------------------------- | -------------------------- | ------------------------------------ |
| `DOMAIN_BATCH_SIGNATURE` | `DomainType('0x0B000000')` | Domain for batch signatures |
| `BATCH_SUBNET_REDUCTION_FACTOR` | TBD | Power-of-2 reduction in subnet count |
| `MAX_OVERLAP_SLASHINGS` | `16` | Maximum overlap slashings per block |
### New containers
`SingleAttestation` remains unchanged:
```python
class SingleAttestation(Container):
committee_index: CommitteeIndex
attester_index: ValidatorIndex
data: AttestationData
signature: BLSSignature
```
New `BatchAttestation`:
```python
class BatchAttestation(Container):
committee_index: CommitteeIndex
aggregation_bits: Bitlist[MAX_VALIDATORS_PER_COMMITTEE]
data: AttestationData
signature: BLSSignature
# New - identifies the validator endorsing this batch
batch_signer: ValidatorIndex
# New - signature over `committee_index`, `aggregation_bits`, and `data` only
# Excluding `signature` from the preimage allows an implementation to fetch this signature in parallel
batch_signature: BLSSignature
```
SSZ union for wire transport:
```python
WireAttestation = Union[SingleAttestation, BatchAttestation]
```
Slashing evidence:
```python
class OverlapEvidence(Container):
attestation_a: WireAttestation
attestation_b: WireAttestation
```
```python
class OverlapSlashing(Container):
evidence: OverlapEvidence
```
### SSZ union serialization
`WireAttestation` is serialized with a one-byte selector prefix:
| Selector | Type |
| -------- | ------------------- |
| `0x00` | `SingleAttestation` |
| `0x01` | `BatchAttestation` |
### Helper functions
```python
## TODO simplify
def get_aggregation_bits(state: BeaconState, att: WireAttestation) -> Bitlist:
"""Returns aggregation bits for any WireAttestation."""
if is_batch(att):
return att.value.aggregation_bits
single = att.value
bits = Bitlist[MAX_VALIDATORS_PER_COMMITTEE]()
committee = get_beacon_committee(state, single.data.slot, single.committee_index)
position = committee.index(single.attester_index)
bits[position] = True
return bits
```
```python
## TODO simplify
def is_batch(att: WireAttestation) -> bool:
"""Returns True if this is a BatchAttestation."""
return att.selector == 0x01
```
```python
## TODO simplify
def get_batch_signer(att: WireAttestation) -> ValidatorIndex:
"""Returns the batch signer. Only valid for BatchAttestation."""
assert is_batch(att)
return att.value.batch_signer
```
```python
## TODO simplify
def is_valid_single_attestation(state: BeaconState, att: SingleAttestation) -> bool:
"""Validates a SingleAttestation (existing logic)."""
committee = get_beacon_committee(state, att.data.slot, att.committee_index)
if att.attester_index not in committee:
return False
pubkey = state.validators[att.attester_index].pubkey
return bls.Verify(pubkey, compute_signing_root(att.data), att.signature)
```
```python
def is_valid_batch_attestation(state: BeaconState, att: BatchAttestation) -> bool:
"""Validates a BatchAttestation."""
# At least two bits set (otherwise use SingleAttestation)
if att.aggregation_bits.count() < 2:
return False
# Get attester indices
committee = get_beacon_committee(state, att.data.slot, att.committee_index)
attesters = [committee[i] for i, bit in enumerate(att.aggregation_bits) if bit]
# Batch signer must be in the attester set
# TODO could try doing this statelessly
if att.batch_signer not in attesters:
return False
# Verify aggregate attestation signature
pubkeys = [state.validators[i].pubkey for i in attesters]
if not bls.FastAggregateVerify(pubkeys, compute_signing_root(att.data), att.signature):
return False
# Verify batch signature
batch_message = BatchMessage(
slot=att.data.slot,
committee_index=att.committee_index,
aggregation_bits=att.aggregation_bits,
)
signer_pubkey = state.validators[att.batch_signer].pubkey
domain = get_domain(state, DOMAIN_BATCH_SIGNATURE, compute_epoch_at_slot(att.data.slot))
if not bls.Verify(signer_pubkey, compute_signing_root(batch_message, domain), att.batch_signature):
return False
return True
```
```python
def is_valid_wire_attestation(state: BeaconState, att: WireAttestation) -> bool:
"""Validates any WireAttestation."""
if att.selector == 0x00:
return is_valid_single_attestation(state, att.value)
else:
return is_valid_batch_attestation(state, att.value)
```
### Slashing condition
The core invariant is that each validator's signature for a given (slot, committee, data) must appear in at most one wire attestation. Any overlap between attestations results in all batch signers being slashed.
```python
def is_valid_overlap_evidence(state: BeaconState, evidence: OverlapEvidence) -> bool:
a = evidence.attestation_a
b = evidence.attestation_b
# Same attestation data
# TODO TBD equivocation where the same validator signs two sides
if a.value.data != b.value.data:
return False
# Same committee
if a.value.committee_index != b.value.committee_index:
return False
a_bits = get_aggregation_bits(state, a)
b_bits = get_aggregation_bits(state, b)
# Non-empty intersection
if (a_bits & b_bits).count() == 0:
return False
# Reject true duplicates (identical bits, same batch signer or both single)
if a_bits == b_bits:
if not is_batch(a) and not is_batch(b):
return False # Two identical singles
if is_batch(a) and is_batch(b):
if a.value.batch_signer == b.value.batch_signer:
return False # Same signer, identical bits
# At least one must be a batch
if not is_batch(a) and not is_batch(b):
return False
# Both must be valid
if not is_valid_wire_attestation(state, a):
return False
if not is_valid_wire_attestation(state, b):
return False
return True
```
This rule handles all overlap cases:
| attestation_a | attestation_b | Relationship | Slashed |
| ---------------- | ------------------ | ------------------------------ | ------- |
| Single(V) | Batch(S, {V,W}) | Overlap | S |
| Batch(S1, {V,W}) | Batch(S2, {V,W,X}) | Superset | S1, S2 |
| Batch(S1, {V,W}) | Batch(S2, {V,X}) | Partial overlap | S1, S2 |
| Batch(S1, {V,W}) | Batch(S2, {V,W}) | Identical, different signers | S1, S2 |
| Batch(S, {V,W}) | Batch(S, {V,X}) | Same signer, different batches | S |
| Batch(S, {V,W}) | Batch(S, {V,W}) | True duplicate | — |
| Single(V) | Single(V) | True duplicate | — |
Note: Single attestations are never slashed for overlap because they can be freely aggregated by anyone on the network per existing rules.
### State transition changes
Add `process_overlap_slashing` to `process_operations`:
```python
def process_overlap_slashing(state: BeaconState, slashing: OverlapSlashing) -> None:
assert is_valid_overlap_evidence(state, slashing.evidence)
signers = set()
for att in [slashing.evidence.attestation_a, slashing.evidence.attestation_b]:
if is_batch(att):
signers.add(att.value.batch_signer)
for signer in signers:
if is_slashable_validator(state.validators[signer], get_current_epoch(state)):
slash_validator(state, signer)
```
### P2P changes
Modify `beacon_attestation_{subnet_id}` topic:
- Message type changes from `SingleAttestation` to `WireAttestation`
- Validation dispatches on union selector:
```python
def validate_beacon_attestation(att: WireAttestation, subnet_id: uint64) -> Result:
match att.selector:
case 0x00:
return validate_single_attestation(att.value, subnet_id)
case 0x01:
return validate_batch_attestation(att.value, subnet_id)
case _:
return REJECT
```
Batch-specific validation:
- One batch per (batch_signer, slot, committee, data_root)
- Batch signer must be in attester set
- Batch signer's bit must be set
- Batch signature must be valid
- Aggregate attestation signature must be valid
- IGNORE if any attester already seen for same (slot, committee, data\_root)
Modify `compute_subnet_for_attestation` to use reduced subnet count:
```python
def compute_subnet_for_attestation(
committees_per_slot: uint64,
slot: Slot,
committee_index: CommitteeIndex
) -> uint64:
slots_since_epoch_start = uint64(slot % SLOTS_PER_EPOCH)
committees_since_epoch_start = committees_per_slot * slots_since_epoch_start
reduced_subnet_count = ATTESTATION_SUBNET_COUNT // BATCH_SUBNET_REDUCTION_FACTOR
return uint64((committees_since_epoch_start + committee_index) % reduced_subnet_count)
```
### Beacon block changes
Add to `BeaconBlockBody`:
```python
class BeaconBlockBody(Container):
# ... existing fields ...
overlap_slashings: List[OverlapSlashing, MAX_OVERLAP_SLASHINGS]
```
Rationale
---------
### Why SSZ union?
Clean type separation. `SingleAttestation` remains unchanged. No migration needed for existing attestation logic, other than the wrapper type. `BatchAttestation` adds new fields only where needed. Future attestation variants can be added via new selectors.
### Why explicit batch signer vs. defaulting to the lowest/highest index validator?
Operational flexibility. Operators may want to designate specific validators as batch signers based on infrastructure, redundancy, or risk management considerations.
### Why slash all batch signers uniformly?
BLS signature aggregates cannot be disaggregated. If two batches overlap, both batch signers must have independently obtained the overlapping validator's signature. Both failed the coordination requirement.
### Why not slash single attestations?
Once a validator publishes a single attestation, it enters the gossip network where aggregators can freely include it in aggregates. The original attester has no control over subsequent aggregation (per Electra/Gloas rules). Batch signers, in contrast, make an explicit claim: "I take responsibility for this batch."
### Why reduce subnets?
Batch efficiency scales with validators-per-committee. Reducing subnets increases density, making batching more attractive for medium-sized operators.
## Backwards compatibility
The `beacon_attestation_{subnet_id}` topic message type changes from `SingleAttestation` to `WireAttestation`. Nodes must upgrade simultaneously at fork boundary.
`SingleAttestation` serialization with selector `0x00`:
```
0x00 || ssz(SingleAttestation)
```
Pre-fork nodes expect raw `SingleAttestation` bytes. Post-fork nodes expect the selector prefix. Clean separation at fork boundary.
Subnet reduction requires coordinated upgrade with transition period for dual subscription.
## Security considerations
### Operational discipline required
Operators must ensure batched validators do not also publish single attestations for the same slot/committee/data. Redundant infrastructure or misconfigured clients could trigger slashing.
Recommended practices:
- Single attestation code path per validator per slot
- Deterministic batch signer selection within operator's validator set
- Disable individual attestation for validators designated as batch members
- Never share validator signing keys across operational boundaries
### Griefing resistance
Attackers cannot include honest validators in malicious batches because they cannot obtain the required individual signatures. BLS signatures cannot be forged without the private key.
### Race conditions
If validator V's single attestation propagates and batch signer S's batch containing V also propagates, overlap evidence exists and S is slashable. V is not penalized. Operators should:
- Publish batches promptly
- Only batch validators under tight operational control
- Ensure validators in a batch never produce independent attestations for the same duty
### Validator colocation leakage
Batches reveal which validators are co-located under the same operator. This is an inherent privacy/efficiency trade-off. Participation is optional.
### Subnet reduction trade-offs
Fewer subnets means higher message volume per subnet. Requires simulation to determine optimal `BATCH_SUBNET_REDUCTION_FACTOR`.
### DVT compatibility
Distributed validator setups need careful coordination to avoid accidental single attestations from individual nodes.
## Test cases
### Test vectors
1. **Single vs Batch (overlap)**: Single(V) + Batch(S, {V,W}) → slash S only
2. **Batch superset**: Batch(S1, {V,W}) + Batch(S2, {V,W,X}) → slash S1, S2
3. **Batch partial overlap**: Batch(S1, {V,W}) + Batch(S2, {V,X}) → slash S1, S2
4. **Identical bits, different signers**: Batch(S1, {V,W}) + Batch(S2, {V,W}) → slash S1, S2
5. **Same signer, partial overlap**: Batch(S, {V,W}) + Batch(S, {V,X}) → slash S (once)
6. **Disjoint batches**: Batch(S1, {V,W}) + Batch(S2, {X,Y}) → no slashing
7. **True duplicate batch**: Batch(S, {V,W}) + Batch(S, {V,W}) → no slashing
8. **True duplicate single**: Single(V) + Single(V) → no slashing
9. **Different attestation data**: overlapping bits but different `AttestationData` → no slashing (existing double-vote rules)
10. **Batch size 1 rejected**: Batch(S, {V}) with single bit → invalid, rejected at validation
## Reference implementation
See `consensus-specs/_features/eipXXXX/` in the consensus-specs repository.
## Open questions
1. **Subnet reduction factor.** Simulation needed. Candidates: 2 (64→32) or 4 (64→16).
2. **Gossip seen-set scope.** Should nodes track seen validators per (slot, committee, data) to reject potential overlaps at gossip layer, or rely purely on post-hoc slashing?
3. **DVT compatibility.** Distributed validator setups need careful coordination. Should the spec include explicit guidance?
4. **Slasher rewards.** Should overlap slashing evidence submission be incentivized similarly to proposer/attester slashings?
## Copyright
Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/).