Add gossip validation spec tests for proposer/attester slashings (#9323)

Addresses #9232 partially. This PR covers two topics only.
* #9232

Wires up networking test vectors for `gossip_proposer_slashing` and `gossip_attester_slashing` topics.

The tests also revealed minor spec non-compliance where invalid slashings were ignored rather than rejected.


  - Refactor `process_gossip_proposer_slashing` and `process_gossip_attester_slashing` to return `MessageAcceptance`, so it can be verified in the tests
- Add `GossipValidation` test case, handler, and test entries
- Spec compliance fix: distinguish between internal errors and validation error - return `Reject` when the slashing is invalid and only penalise on invalid messages


Co-Authored-By: Jimmy Chen <jchen.tc@gmail.com>
This commit is contained in:
Jimmy Chen
2026-05-28 10:27:16 +10:00
committed by GitHub
parent dfb259171a
commit 5636030b49
10 changed files with 374 additions and 63 deletions

View File

@@ -26,8 +26,10 @@ fork_choice = { workspace = true }
fs2 = { workspace = true }
hex = { workspace = true }
kzg = { workspace = true }
lighthouse_network = { workspace = true }
logging = { workspace = true }
milhouse = { workspace = true }
network = { workspace = true }
proto_array = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }

View File

@@ -75,8 +75,15 @@ excluded_paths = [
"tests/.*/compute_challenge/.*",
# We don't need these manifest files at the moment.
"tests/.*/manifest.yaml",
# TODO: gossip condition tests not implemented yet
"tests/.*/.*/networking/.*",
# TODO: Remaining gossip validation topics not yet implemented
"tests/.*/.*/networking/gossip_beacon_block/.*",
"tests/.*/.*/networking/gossip_beacon_attestation/.*",
"tests/.*/.*/networking/gossip_beacon_aggregate_and_proof/.*",
"tests/.*/.*/networking/gossip_voluntary_exit/.*",
"tests/.*/.*/networking/gossip_bls_to_execution_change/.*",
"tests/.*/.*/networking/gossip_sync_committee_message/.*",
"tests/.*/.*/networking/gossip_sync_committee_contribution_and_proof/.*",
"tests/.*/.*/networking/gossip_blob_sidecar/.*",
# TODO: fast confirmation rule not merged yet
"tests/.*/.*/fast_confirmation",
]

View File

@@ -20,6 +20,7 @@ mod fork_choice;
mod genesis_initialization;
mod genesis_validity;
mod get_custody_groups;
mod gossip_validation;
mod kzg_blob_to_kzg_commitment;
mod kzg_compute_blob_kzg_proof;
mod kzg_compute_cells;
@@ -57,6 +58,7 @@ pub use fork::ForkTest;
pub use genesis_initialization::*;
pub use genesis_validity::*;
pub use get_custody_groups::*;
pub use gossip_validation::*;
pub use kzg_blob_to_kzg_commitment::*;
pub use kzg_compute_blob_kzg_proof::*;
pub use kzg_compute_cells::*;

View File

@@ -0,0 +1,206 @@
use super::*;
use crate::bls_setting::BlsSetting;
use crate::decode::{ssz_decode_file, ssz_decode_state, yaml_decode_file};
use crate::type_name::TypeName;
use beacon_chain::test_utils::{BeaconChainHarness, EphemeralHarnessType};
use lighthouse_network::{MessageAcceptance, MessageId, PeerId};
use network::NetworkBeaconProcessor;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use types::{AttesterSlashing, BeaconState, EthSpec, ForkName, ProposerSlashing};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ExpectedOutcome {
Valid,
Ignore,
Reject,
}
impl PartialEq<MessageAcceptance> for ExpectedOutcome {
fn eq(&self, other: &MessageAcceptance) -> bool {
matches!(
(self, other),
(Self::Valid, MessageAcceptance::Accept)
| (Self::Ignore, MessageAcceptance::Ignore)
| (Self::Reject, MessageAcceptance::Reject)
)
}
}
#[derive(Debug, Clone, Deserialize)]
struct Meta {
topic: Topic,
#[serde(default)]
messages: Vec<MessageMeta>,
#[serde(default)]
bls_setting: Option<BlsSetting>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct MessageMeta {
message: String,
expected: ExpectedOutcome,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
#[allow(dead_code)]
subnet_id: Option<u64>,
#[serde(default)]
#[allow(dead_code)]
offset_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Topic {
ProposerSlashing,
AttesterSlashing,
// TODO: add support for these topics
// VoluntaryExit,
// BlsToExecutionChange,
// SyncCommittee,
// SyncCommitteeContributionAndProof,
// BeaconBlock,
// BeaconAttestation,
// BeaconAggregateAndProof,
}
#[derive(Debug)]
pub struct GossipValidation<E: EthSpec> {
path: PathBuf,
meta: Meta,
state: BeaconState<E>,
}
impl<E: EthSpec> LoadCase for GossipValidation<E> {
fn load_from_dir(path: &Path, fork_name: ForkName) -> Result<Self, Error> {
let meta: Meta = yaml_decode_file(&path.join("meta.yaml"))?;
let spec = &testing_spec::<E>(fork_name);
let state = ssz_decode_state(&path.join("state.ssz_snappy"), spec)?;
Ok(Self {
path: path.to_path_buf(),
meta,
state,
})
}
}
impl<E: EthSpec + TypeName> Case for GossipValidation<E> {
fn description(&self) -> String {
self.path
.iter()
.next_back()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default()
}
fn result(&self, _case_index: usize, fork_name: ForkName) -> Result<(), Error> {
if let Some(bls_setting) = self.meta.bls_setting {
bls_setting.check()?;
}
let spec = testing_spec::<E>(fork_name);
let tester = GossipTester::new(self, spec)?;
for message_meta in &self.meta.messages {
let actual =
tester.validate_message(&self.path, &self.meta.topic, message_meta, fork_name)?;
if message_meta.expected != actual {
return Err(Error::NotEqual(format!(
"{}: expected {:?}, got {:?}{}",
self.path.display(),
message_meta.expected,
actual,
message_meta
.reason
.as_ref()
.map(|r| format!(" ({r})"))
.unwrap_or_default()
)));
}
}
Ok(())
}
}
struct GossipTester<E: EthSpec> {
network_beacon_processor: Arc<NetworkBeaconProcessor<EphemeralHarnessType<E>>>,
}
impl<E: EthSpec> GossipTester<E> {
fn new(case: &GossipValidation<E>, spec: ChainSpec) -> Result<Self, Error> {
let genesis_time = case.state.genesis_time();
let spec = Arc::new(spec);
let harness = BeaconChainHarness::<EphemeralHarnessType<E>>::builder(E::default())
.spec(spec.clone())
.keypairs(vec![])
.genesis_state_ephemeral_store(case.state.clone())
.mock_execution_layer()
.recalculate_fork_times_with_genesis(genesis_time)
.mock_execution_layer_all_payloads_valid()
.build();
let network_beacon_processor = NetworkBeaconProcessor::null_from_harness(&harness);
Ok(Self {
network_beacon_processor: Arc::new(network_beacon_processor),
})
}
fn validate_message(
&self,
path: &Path,
topic: &Topic,
message_meta: &MessageMeta,
fork_name: ForkName,
) -> Result<MessageAcceptance, Error> {
match topic {
Topic::ProposerSlashing => self.validate_proposer_slashing(path, message_meta),
Topic::AttesterSlashing => {
self.validate_attester_slashing(path, message_meta, fork_name)
}
}
}
fn validate_proposer_slashing(
&self,
path: &Path,
message_meta: &MessageMeta,
) -> Result<MessageAcceptance, Error> {
let slashing: ProposerSlashing =
ssz_decode_file(&path.join(format!("{}.ssz_snappy", message_meta.message)))?;
let message_id = MessageId::new(&[]);
let peer_id = PeerId::random();
Ok(self
.network_beacon_processor
.process_gossip_proposer_slashing(message_id, peer_id, slashing))
}
fn validate_attester_slashing(
&self,
path: &Path,
message_meta: &MessageMeta,
fork_name: ForkName,
) -> Result<MessageAcceptance, Error> {
let ssz_path = path.join(format!("{}.ssz_snappy", message_meta.message));
let slashing: AttesterSlashing<E> = if fork_name.electra_enabled() {
ssz_decode_file(&ssz_path).map(AttesterSlashing::Electra)?
} else {
ssz_decode_file(&ssz_path).map(AttesterSlashing::Base)?
};
let message_id = MessageId::new(&[]);
let peer_id = PeerId::random();
Ok(self
.network_beacon_processor
.process_gossip_attester_slashing(message_id, peer_id, slashing))
}
}

View File

@@ -979,6 +979,36 @@ impl<E: EthSpec + TypeName> Handler for ComputeColumnsForCustodyGroupHandler<E>
}
}
pub struct GossipValidationHandler<E> {
handler_name: &'static str,
_phantom: PhantomData<E>,
}
impl<E> GossipValidationHandler<E> {
pub const fn new(handler_name: &'static str) -> Self {
Self {
handler_name,
_phantom: PhantomData,
}
}
}
impl<E: EthSpec + TypeName> Handler for GossipValidationHandler<E> {
type Case = cases::GossipValidation<E>;
fn config_name() -> &'static str {
E::name()
}
fn runner_name() -> &'static str {
"networking"
}
fn handler_name(&self) -> String {
self.handler_name.into()
}
}
#[derive(Educe)]
#[educe(Default)]
pub struct KZGComputeCellsHandler<E>(PhantomData<E>);

View File

@@ -1189,3 +1189,15 @@ fn compute_columns_for_custody_group() {
ComputeColumnsForCustodyGroupHandler::<MainnetEthSpec>::default().run();
ComputeColumnsForCustodyGroupHandler::<MinimalEthSpec>::default().run();
}
#[test]
fn gossip_proposer_slashing() {
GossipValidationHandler::<MinimalEthSpec>::new("gossip_proposer_slashing").run();
GossipValidationHandler::<MainnetEthSpec>::new("gossip_proposer_slashing").run();
}
#[test]
fn gossip_attester_slashing() {
GossipValidationHandler::<MinimalEthSpec>::new("gossip_attester_slashing").run();
GossipValidationHandler::<MainnetEthSpec>::new("gossip_attester_slashing").run();
}