From d98ccfd159c028e3a04e617042b687a901f6337d Mon Sep 17 00:00:00 2001 From: shane-moore Date: Tue, 8 Sep 2026 13:24:32 -0400 Subject: [PATCH 1/3] fix: validate PTC duty assignments --- Cargo.lock | 7 + anchor/duties_tracker/Cargo.toml | 2 + anchor/duties_tracker/src/duties_tracker.rs | 83 +++- anchor/duties_tracker/src/lib.rs | 71 ++- anchor/duties_tracker/src/ptc_tests.rs | 431 ++++++++++++++++++ anchor/message_validator/Cargo.toml | 6 + anchor/message_validator/src/lib.rs | 21 + .../src/partial_signature.rs | 4 + anchor/message_validator/src/ptc_tests.rs | 300 ++++++++++++ 9 files changed, 917 insertions(+), 8 deletions(-) create mode 100644 anchor/duties_tracker/src/ptc_tests.rs create mode 100644 anchor/message_validator/src/ptc_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 36a320342..c9d8c3b5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2614,6 +2614,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" name = "duties_tracker" version = "0.1.0" dependencies = [ + "axum", "beacon_node_fallback", "bls", "dashmap", @@ -2623,6 +2624,7 @@ dependencies = [ "parking_lot", "safe_arith", "sensitive_url", + "serde_json", "slot_clock", "ssv_types", "task_executor", @@ -5200,10 +5202,13 @@ dependencies = [ name = "message_validator" version = "0.1.0" dependencies = [ + "axum", + "beacon_node_fallback", "bls", "dashmap", "database", "duties_tracker", + "eth2", "ethereum_ssz", "fork", "hex", @@ -5211,6 +5216,8 @@ dependencies = [ "openssl", "processor", "safe_arith", + "sensitive_url", + "serde_json", "sha2", "slot_clock", "ssv_types", diff --git a/anchor/duties_tracker/Cargo.toml b/anchor/duties_tracker/Cargo.toml index b66d19951..9cda2b466 100644 --- a/anchor/duties_tracker/Cargo.toml +++ b/anchor/duties_tracker/Cargo.toml @@ -21,6 +21,8 @@ tracing = { workspace = true } types = { workspace = true } [dev-dependencies] +axum = { workspace = true } +serde_json = { workspace = true } database = { workspace = true, features = ["test-utils"] } openssl = { workspace = true } sensitive_url = { workspace = true } diff --git a/anchor/duties_tracker/src/duties_tracker.rs b/anchor/duties_tracker/src/duties_tracker.rs index 7ae98fd8a..df026a3d1 100644 --- a/anchor/duties_tracker/src/duties_tracker.rs +++ b/anchor/duties_tracker/src/duties_tracker.rs @@ -15,7 +15,7 @@ use types::{ChainSpec, Epoch, Slot}; use crate::{ Duties, DutiesProvider, DutyAssignment, MembershipKey, ProposerSchedule, ProposerScheduleError, - voluntary_exit_tracker::VoluntaryExitTracker, + PtcSchedule, PtcScheduleError, voluntary_exit_tracker::VoluntaryExitTracker, }; /// Only retain `HISTORICAL_DUTIES_EPOCHS` duties prior to the current epoch. @@ -29,6 +29,10 @@ pub enum Error { Arith(ArithError), #[error("Failed to poll proposers: {0}")] FailedToPollProposers(String), + #[error("Failed to poll PTC duties: {0}")] + FailedToPollPtc(String), + #[error("Invalid PTC duties: {0}")] + InvalidPtcDuties(#[from] PtcScheduleError), } pub struct DutiesTracker { @@ -260,6 +264,56 @@ impl DutiesTracker { Ok(()) } + /// Replace the current PTC view using exactly the indices captured for this request. + async fn poll_ptc_duties(&self) -> Result<(), Error> { + let current_slot = self.slot_clock.now().ok_or(Error::UnableToReadSlotClock)?; + let current_epoch = current_slot.epoch(self.slots_per_epoch); + + // Retain one previous epoch for PTC messages arriving after an epoch boundary. + self.duties + .ptc + .write() + .retain(|&epoch, _| epoch >= current_epoch.saturating_sub(1u64)); + + if self + .spec + .gloas_fork_epoch + .is_none_or(|gloas_epoch| current_epoch < gloas_epoch) + { + return Ok(()); + } + + // Release the database watch borrow before HTTP. Later additions remain unknown + // until a subsequent request includes them. + let validator_indices = self.network_state_rx.borrow().validator_indices(); + if validator_indices.is_empty() { + self.duties.ptc.write().remove(¤t_epoch); + return Ok(()); + } + + let response = self + .beacon_nodes + .first_success(|beacon_node| { + let indices = &validator_indices; + async move { + beacon_node + .post_validator_duties_ptc(current_epoch, indices) + .await + } + }) + .await + .map_err(|error| Error::FailedToPollPtc(error.to_string()))?; + + let schedule = PtcSchedule::from_response( + current_epoch, + self.slots_per_epoch, + &validator_indices, + response, + )?; + self.duties.ptc.write().insert(current_epoch, schedule); + Ok(()) + } + pub fn start(self: Arc, executor: TaskExecutor) { let self_clone = self.clone(); self_clone.spawn_polling_task( @@ -272,6 +326,15 @@ impl DutiesTracker { executor.clone(), ); + if self.spec.gloas_fork_epoch.is_some() { + self.clone().spawn_polling_task( + |tracker| async move { tracker.poll_ptc_duties().await }, + "Failed to poll PTC duties", + "ptc_tracker", + executor.clone(), + ); + } + self.spawn_polling_task( |tracker| { let tracker = tracker.clone(); @@ -381,6 +444,20 @@ impl DutiesProvider for DutiesTracker { None => DutyAssignment::Unknown, } } + + fn ptc_assignment_at_slot( + &self, + slot: Slot, + validator_index: ValidatorIndex, + ) -> DutyAssignment { + self.duties + .ptc + .read() + .get(&slot.epoch(self.slots_per_epoch)) + .map_or(DutyAssignment::Unknown, |schedule| { + schedule.assignment_at_slot(slot, validator_index.into()) + }) + } } /// Number of epochs to wait from the start of the period before actually fetching duties. @@ -1042,3 +1119,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "ptc_tests.rs"] +mod ptc_tests; diff --git a/anchor/duties_tracker/src/lib.rs b/anchor/duties_tracker/src/lib.rs index 0bcc1572f..d2a37b747 100644 --- a/anchor/duties_tracker/src/lib.rs +++ b/anchor/duties_tracker/src/lib.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use bls::PublicKeyBytes; use dashmap::DashMap; -use eth2::types::{DutiesResponse, ProposerData}; +use eth2::types::{DutiesResponse, ProposerData, PtcDuty}; use parking_lot::RwLock; use ssv_types::ValidatorIndex; use thiserror::Error; @@ -181,6 +181,8 @@ pub struct Duties { pub proposers: RwLock, /// Map from validator index to sync committee duties. pub sync_duties: SyncCommitteePerPeriod, + /// PTC snapshots include every queried index, including validators with no duty. + pub(crate) ptc: RwLock>, } impl Duties { @@ -188,6 +190,7 @@ impl Duties { Self { proposers: RwLock::new(HashMap::new()), sync_duties: SyncCommitteePerPeriod::new(), + ptc: RwLock::new(HashMap::new()), } } } @@ -199,19 +202,66 @@ impl Default for Duties { } /// Whether a validator holds a duty at a slot, as one atomic verdict over the stored duty view. -/// -/// A retained view is never revoked by a failed or malformed refresh, a local registry change, -/// `execution_optimistic`, or a reorg. It changes only when a complete schedule replaces it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DutyAssignment { - /// A complete fetched view assigns the validator at this slot. + /// A fetched view covering this validator assigns it at this slot. Assigned, - /// A complete fetched view proves the validator is not assigned at this slot. + /// A fetched view covering this validator proves it is not assigned at this slot. NotAssigned, - /// The view cannot answer: no completed fetch for the slot's epoch. + /// No fetched view covers this validator for the slot's epoch. Unknown, } +/// A sparse PTC response together with its exact requested-index coverage. +#[derive(Debug)] +pub(crate) struct PtcSchedule { + assignments: HashMap>, +} + +#[derive(Debug, Error, PartialEq)] +pub enum PtcScheduleError { + #[error("PTC duty slot {0} is outside epoch {1}")] + SlotOutOfEpoch(Slot, Epoch), + #[error("PTC response includes unrequested validator {0}")] + UnrequestedValidator(u64), + #[error("PTC response includes multiple duties for validator {0}")] + DuplicateValidator(u64), +} + +impl PtcSchedule { + fn from_response( + epoch: Epoch, + slots_per_epoch: u64, + requested_indices: &[u64], + response: DutiesResponse>, + ) -> Result { + let mut assignments: HashMap<_, _> = requested_indices + .iter() + .map(|&index| (index, None)) + .collect(); + for duty in response.data { + if duty.slot.epoch(slots_per_epoch) != epoch { + return Err(PtcScheduleError::SlotOutOfEpoch(duty.slot, epoch)); + } + let assignment = assignments + .get_mut(&duty.validator_index) + .ok_or(PtcScheduleError::UnrequestedValidator(duty.validator_index))?; + if assignment.replace(duty.slot).is_some() { + return Err(PtcScheduleError::DuplicateValidator(duty.validator_index)); + } + } + Ok(Self { assignments }) + } + + fn assignment_at_slot(&self, slot: Slot, validator_index: u64) -> DutyAssignment { + match self.assignments.get(&validator_index) { + Some(Some(assigned_slot)) if *assigned_slot == slot => DutyAssignment::Assigned, + Some(_) => DutyAssignment::NotAssigned, + None => DutyAssignment::Unknown, + } + } +} + pub trait DutiesProvider: Sync + Send + 'static { fn is_validator_in_sync_committee( &self, @@ -226,9 +276,16 @@ pub trait DutiesProvider: Sync + Send + 'static { fn get_voluntary_exit_duty_count(&self, slot: Slot, pubkey: &PublicKeyBytes) -> u64; + /// A retained complete proposer view is not revoked by a failed or malformed refresh, + /// local registry changes, `execution_optimistic`, or a reorg. Only a complete replacement + /// changes its assignments. fn proposer_assignment_at_slot( &self, slot: Slot, validator_pubkey: &PublicKeyBytes, ) -> DutyAssignment; + + /// Unknown unless a completed PTC fetch covered this validator in the slot's epoch. + fn ptc_assignment_at_slot(&self, slot: Slot, validator_index: ValidatorIndex) + -> DutyAssignment; } diff --git a/anchor/duties_tracker/src/ptc_tests.rs b/anchor/duties_tracker/src/ptc_tests.rs new file mode 100644 index 000000000..26d1f435e --- /dev/null +++ b/anchor/duties_tracker/src/ptc_tests.rs @@ -0,0 +1,431 @@ +//! Exercise the PTC HTTP client, request coverage and installed assignment snapshot together. + +use std::{sync::Mutex, time::Duration}; + +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + routing::post, +}; +use beacon_node_fallback::{ApiTopic, CandidateBeaconNode, Config}; +use database::{ + NetworkDatabase, PendingStateUpdates, + test_utils::{InMemoryTestFixture, generators}, +}; +use eth2::{BeaconNodeHttpClient, Timeouts, types::PtcDuty}; +use sensitive_url::SensitiveUrl; +use serde_json::{Value, json}; +use slot_clock::ManualSlotClock; +use tokio::{ + net::TcpListener, + sync::{mpsc, oneshot}, + task::JoinHandle, +}; +use types::Hash256; + +use super::*; + +const SLOTS_PER_EPOCH: u64 = 32; +const TEST_EPOCH: u64 = 3; +const TEST_INDEX: u64 = 10; +const SLOT_DURATION: Duration = Duration::from_secs(12); +const TEST_TIMEOUT: Duration = Duration::from_secs(5); + +type Request = (u64, Vec); +type Response = (StatusCode, Value); + +struct HttpState { + response: Mutex, + gate: Mutex>>, + requests: mpsc::UnboundedSender, +} + +struct PtcServer { + state: Arc, + requests: mpsc::UnboundedReceiver, + task: JoinHandle<()>, + url: SensitiveUrl, +} + +impl PtcServer { + async fn new() -> Self { + let (tx, requests) = mpsc::unbounded_channel(); + let state = Arc::new(HttpState { + response: Mutex::new((StatusCode::OK, response(vec![]))), + gate: Mutex::new(None), + requests: tx, + }); + let app = Router::new() + .route("/eth/v1/validator/duties/ptc/{epoch}", post(serve_ptc)) + .with_state(state.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = + SensitiveUrl::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + Self { + state, + requests, + task, + url, + } + } + + fn respond(&self, status: StatusCode, body: Value) { + *self.state.response.lock().unwrap() = (status, body); + } + + fn pause_response(&self) -> oneshot::Sender<()> { + let (tx, rx) = oneshot::channel(); + *self.state.gate.lock().unwrap() = Some(rx); + tx + } + + fn take_requests(&mut self) -> Vec { + let mut requests = vec![]; + while let Ok(request) = self.requests.try_recv() { + requests.push(request); + } + requests + } +} + +impl Drop for PtcServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn serve_ptc( + State(state): State>, + Path(epoch): Path, + Json(indices): Json>, +) -> (StatusCode, Json) { + let response = state.response.lock().unwrap().clone(); + let gate = state.gate.lock().unwrap().take(); + state.requests.send((epoch, indices)).unwrap(); + if let Some(gate) = gate { + gate.await.unwrap(); + } + (response.0, Json(response.1)) +} + +fn tracker( + server: &PtcServer, + db: &NetworkDatabase, + gloas_epoch: Option, +) -> DutiesTracker { + let mut spec = ChainSpec::mainnet(); + spec.gloas_fork_epoch = gloas_epoch.map(Epoch::new); + let spec = Arc::new(spec); + let client = BeaconNodeHttpClient::new(server.url.clone(), Timeouts::set_all(TEST_TIMEOUT)); + let beacon_nodes = Arc::new(BeaconNodeFallback::new( + vec![CandidateBeaconNode::new(client, 0)], + Config::default(), + ApiTopic::all(), + spec.clone(), + )); + let clock = ManualSlotClock::new(Slot::new(0), Duration::ZERO, SLOT_DURATION); + clock.set_slot(TEST_EPOCH * SLOTS_PER_EPOCH); + DutiesTracker::new( + Arc::new(VoluntaryExitTracker::new()), + beacon_nodes, + spec, + SLOTS_PER_EPOCH, + clock, + db.watch(), + ) +} + +/// Metadata without our own shares models validators whose messages Anchor only relays. +fn register_index(db: &NetworkDatabase, index: u64) -> PublicKeyBytes { + let cluster = generators::cluster::random(0); + let mut validator = generators::validator::random_metadata(cluster.cluster_id); + validator.index = Some(validator_index(index)); + let mut conn = db.connection().unwrap(); + let tx = conn.transaction().unwrap(); + let mut pending = PendingStateUpdates::default(); + db.insert_validator_tx(cluster, &validator, vec![], &tx, &mut pending) + .unwrap(); + tx.commit().unwrap(); + db.publish_pending_state_updates(pending); + validator.public_key +} + +fn duty(index: u64, slot: Slot) -> PtcDuty { + PtcDuty { + pubkey: generators::pubkey::random(), + validator_index: index, + slot, + } +} + +fn response(data: Vec) -> Value { + serde_json::to_value(DutiesResponse { + dependent_root: Hash256::from([0; 32]), + execution_optimistic: Some(false), + data, + }) + .unwrap() +} + +fn validator_index(index: u64) -> ValidatorIndex { + ValidatorIndex(usize::try_from(index).unwrap()) +} + +fn test_slot() -> Slot { + Epoch::new(TEST_EPOCH).start_slot(SLOTS_PER_EPOCH) +} + +#[tokio::test] +async fn test_ptc_poll_covers_relayed_validators_and_replaces_whole_snapshot() { + // Arrange: all three indexed validators are known, and none has a locally owned share. + let fixture = InMemoryTestFixture::new_empty(); + for index in TEST_INDEX..TEST_INDEX + 3 { + register_index(&fixture.db, index); + } + assert!(fixture.db.state().shares().values().next().is_none()); + let mut server = PtcServer::new().await; + let tracker = tracker(&server, &fixture.db, Some(TEST_EPOCH)); + let slot = test_slot(); + server.respond( + StatusCode::OK, + response(vec![duty(TEST_INDEX, slot), duty(TEST_INDEX + 1, slot + 1)]), + ); + + // Act: the actual sparse HTTP response supplies assignments for only two requested indices. + tracker.poll_ptc_duties().await.unwrap(); + + // Assert: wrong-slot and omitted queried indices are negatives, unqueried indices are unknown. + assert_eq!( + tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX)), + DutyAssignment::Assigned + ); + assert_eq!( + tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX + 1)), + DutyAssignment::NotAssigned + ); + assert_eq!( + tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX + 2)), + DutyAssignment::NotAssigned + ); + assert_eq!( + tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX + 3)), + DutyAssignment::Unknown + ); + assert_eq!( + tracker.ptc_assignment_at_slot(slot + SLOTS_PER_EPOCH, validator_index(TEST_INDEX)), + DutyAssignment::Unknown + ); + let mut requests = server.take_requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].0, TEST_EPOCH); + requests[0].1.sort(); + assert_eq!( + requests[0].1, + (TEST_INDEX..TEST_INDEX + 3) + .map(|i| i.to_string()) + .collect::>() + ); + + // Act: a complete refresh changes the roster without retaining old positive rows. + server.respond(StatusCode::OK, response(vec![duty(TEST_INDEX + 2, slot)])); + tracker.poll_ptc_duties().await.unwrap(); + + // Assert: the old assigned validator is now known absent, and the new assignment is installed. + assert_eq!( + tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX)), + DutyAssignment::NotAssigned + ); + assert_eq!( + tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX + 2)), + DutyAssignment::Assigned + ); +} + +#[tokio::test] +async fn test_ptc_poll_registration_during_request_stays_unknown_until_queried() { + // Arrange: pause the HTTP response after its request has reached the local server. + let fixture = InMemoryTestFixture::new_empty(); + register_index(&fixture.db, TEST_INDEX); + let mut server = PtcServer::new().await; + let release = server.pause_response(); + let tracker = Arc::new(tracker(&server, &fixture.db, Some(TEST_EPOCH))); + let polling = tokio::spawn({ + let tracker = tracker.clone(); + async move { tracker.poll_ptc_duties().await } + }); + let request = tokio::time::timeout(TEST_TIMEOUT, server.requests.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(request, (TEST_EPOCH, vec![TEST_INDEX.to_string()])); + + // Act: publish another validator while the old request remains pending, then finish it. + register_index(&fixture.db, TEST_INDEX + 1); + release.send(()).unwrap(); + polling.await.unwrap().unwrap(); + + // Assert: a successful empty response cannot establish absence for the new index. + assert_eq!( + tracker.ptc_assignment_at_slot(test_slot(), validator_index(TEST_INDEX)), + DutyAssignment::NotAssigned + ); + assert_eq!( + tracker.ptc_assignment_at_slot(test_slot(), validator_index(TEST_INDEX + 1)), + DutyAssignment::Unknown + ); + + // Act: the following complete poll includes the newly registered index. + tracker.poll_ptc_duties().await.unwrap(); + + // Assert: only this response can establish its absence. + assert_eq!( + tracker.ptc_assignment_at_slot(test_slot(), validator_index(TEST_INDEX + 1)), + DutyAssignment::NotAssigned + ); +} + +#[tokio::test] +async fn test_ptc_poll_failed_or_malformed_response_preserves_prior_knowledge() { + // Arrange: cover transport/API errors and responses that cannot define a coherent snapshot. + let fixture = InMemoryTestFixture::new_empty(); + register_index(&fixture.db, TEST_INDEX); + let server = PtcServer::new().await; + let tracker = tracker(&server, &fixture.db, Some(TEST_EPOCH)); + let slot = test_slot(); + let invalid_responses = [ + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"code": 500, "message": "unavailable"}), + ), + (StatusCode::OK, json!({"invalid": "duties response"})), + ( + StatusCode::OK, + response(vec![duty(TEST_INDEX, slot + SLOTS_PER_EPOCH)]), + ), + (StatusCode::OK, response(vec![duty(TEST_INDEX + 1, slot)])), + ( + StatusCode::OK, + response(vec![duty(TEST_INDEX, slot), duty(TEST_INDEX, slot + 1)]), + ), + ( + StatusCode::OK, + response(vec![duty(TEST_INDEX, slot), duty(TEST_INDEX, slot)]), + ), + ]; + + for expected in [DutyAssignment::Unknown, DutyAssignment::Assigned] { + if expected == DutyAssignment::Assigned { + server.respond(StatusCode::OK, response(vec![duty(TEST_INDEX, slot)])); + tracker.poll_ptc_duties().await.unwrap(); + } + for (status, body) in &invalid_responses { + // Act: neither failed initialization nor failed refresh is a valid negative. + server.respond(*status, body.clone()); + assert!(tracker.poll_ptc_duties().await.is_err()); + + // Assert: no malformed row or omitted response revokes prior knowledge. + assert_eq!( + tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX)), + expected + ); + } + } +} + +#[tokio::test] +async fn test_ptc_poll_empty_index_set_clears_current_snapshot_without_http() { + // Arrange: first fetch a valid current-epoch snapshot. + let fixture = InMemoryTestFixture::new_empty(); + let pubkey = register_index(&fixture.db, TEST_INDEX); + let mut server = PtcServer::new().await; + let tracker = tracker(&server, &fixture.db, Some(TEST_EPOCH)); + tracker.poll_ptc_duties().await.unwrap(); + assert_eq!( + tracker.ptc_assignment_at_slot(test_slot(), validator_index(TEST_INDEX)), + DutyAssignment::NotAssigned + ); + server.take_requests(); + + // Act: remove the last registered index and poll again. + let mut conn = fixture.db.connection().unwrap(); + let tx = conn.transaction().unwrap(); + let mut pending = PendingStateUpdates::default(); + fixture + .db + .delete_validator_tx(&pubkey, &tx, &mut pending) + .unwrap(); + tx.commit().unwrap(); + fixture.db.publish_pending_state_updates(pending); + tracker.poll_ptc_duties().await.unwrap(); + + // Assert: empty coverage is unknown and the API is never called with an empty index list. + assert_eq!( + tracker.ptc_assignment_at_slot(test_slot(), validator_index(TEST_INDEX)), + DutyAssignment::Unknown + ); + assert!(server.take_requests().is_empty()); +} + +#[tokio::test] +async fn test_ptc_poll_gloas_gate_and_previous_epoch_retention_on_failure() { + // Arrange: a known index must not trigger requests before Gloas or if it is unscheduled. + let fixture = InMemoryTestFixture::new_empty(); + register_index(&fixture.db, TEST_INDEX); + let mut server = PtcServer::new().await; + for gloas_epoch in [None, Some(TEST_EPOCH + 1)] { + let tracker = tracker(&server, &fixture.db, gloas_epoch); + tracker.poll_ptc_duties().await.unwrap(); + assert_eq!( + tracker.ptc_assignment_at_slot(test_slot(), validator_index(TEST_INDEX)), + DutyAssignment::Unknown + ); + } + assert!(server.take_requests().is_empty()); + let tracker = tracker(&server, &fixture.db, Some(TEST_EPOCH)); + + // Act: fetch current epochs at activation and one epoch later, then fail the next refresh. + for epoch in [TEST_EPOCH, TEST_EPOCH + 1] { + let slot = Epoch::new(epoch).start_slot(SLOTS_PER_EPOCH); + tracker.slot_clock.set_slot(slot.as_u64()); + server.respond(StatusCode::OK, response(vec![duty(TEST_INDEX, slot)])); + tracker.poll_ptc_duties().await.unwrap(); + } + assert_eq!( + tracker.ptc_assignment_at_slot(test_slot(), validator_index(TEST_INDEX)), + DutyAssignment::Assigned + ); + tracker + .slot_clock + .set_slot((TEST_EPOCH + 2) * SLOTS_PER_EPOCH); + server.respond( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"code": 500, "message": "unavailable"}), + ); + assert!(tracker.poll_ptc_duties().await.is_err()); + + // Assert: pruning still runs on failure, keeps the previous epoch, and never queries lookahead. + assert_eq!( + tracker.ptc_assignment_at_slot(test_slot(), validator_index(TEST_INDEX)), + DutyAssignment::Unknown + ); + assert_eq!( + tracker.ptc_assignment_at_slot(test_slot() + SLOTS_PER_EPOCH, validator_index(TEST_INDEX)), + DutyAssignment::Assigned + ); + assert_eq!( + tracker.ptc_assignment_at_slot( + test_slot() + 2 * SLOTS_PER_EPOCH, + validator_index(TEST_INDEX) + ), + DutyAssignment::Unknown + ); + let requests = server.take_requests(); + let mut queried_epochs = requests.iter().map(|(epoch, _)| *epoch).collect::>(); + queried_epochs.dedup(); + assert_eq!( + queried_epochs, + vec![TEST_EPOCH, TEST_EPOCH + 1, TEST_EPOCH + 2] + ); +} diff --git a/anchor/message_validator/Cargo.toml b/anchor/message_validator/Cargo.toml index 5f593006e..8c11e49d8 100644 --- a/anchor/message_validator/Cargo.toml +++ b/anchor/message_validator/Cargo.toml @@ -28,4 +28,10 @@ typenum = { workspace = true } types = { workspace = true } [dev-dependencies] +axum = { workspace = true } +beacon_node_fallback = { workspace = true } +database = { workspace = true, features = ["test-utils"] } +eth2 = { workspace = true } +serde_json = { workspace = true } +sensitive_url = { workspace = true } bls = { workspace = true } diff --git a/anchor/message_validator/src/lib.rs b/anchor/message_validator/src/lib.rs index f762bc584..96b61df31 100644 --- a/anchor/message_validator/src/lib.rs +++ b/anchor/message_validator/src/lib.rs @@ -969,6 +969,16 @@ pub(crate) fn validate_beacon_duty( } } + // Only a fetched PTC view covering this validator can establish that it has no duty. + // Missing local index metadata is also an unknown assignment, not a peer fault. + if role == Role::PTCAttester + && let Some(&validator_index) = validation_context.committee_info.validator_indices.first() + && duty_provider.ptc_assignment_at_slot(slot, validator_index) + == DutyAssignment::NotAssigned + { + return Err(ValidationFailure::NoDuty); + } + // Rule: For a sync committee duty message, check if the validator is assigned if role == Role::SyncCommittee { let period = @@ -1754,6 +1764,8 @@ mod tests { /// so pre-existing tests keep the "assigned proposer" behavior; new tests /// set it explicitly to drive the three cases. pub(crate) proposer_assignment: DutyAssignment, + /// PTC membership known by the receive-side tracker. + pub(crate) ptc_assignment: DutyAssignment, } // Manual `Default` (not derived) so the proposer flags default to their @@ -1767,11 +1779,20 @@ mod tests { epoch_known_for_proposers: true, validator_is_proposer: true, proposer_assignment: DutyAssignment::Assigned, + ptc_assignment: DutyAssignment::Assigned, } } } impl DutiesProvider for MockDutiesProvider { + fn ptc_assignment_at_slot( + &self, + _slot: Slot, + _validator_index: ValidatorIndex, + ) -> DutyAssignment { + self.ptc_assignment + } + fn is_validator_in_sync_committee( &self, _committee_period: u64, diff --git a/anchor/message_validator/src/partial_signature.rs b/anchor/message_validator/src/partial_signature.rs index 96109b7b7..c3450c5df 100644 --- a/anchor/message_validator/src/partial_signature.rs +++ b/anchor/message_validator/src/partial_signature.rs @@ -4763,3 +4763,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "ptc_tests.rs"] +mod ptc_tests; diff --git a/anchor/message_validator/src/ptc_tests.rs b/anchor/message_validator/src/ptc_tests.rs new file mode 100644 index 000000000..d2d2a936e --- /dev/null +++ b/anchor/message_validator/src/ptc_tests.rs @@ -0,0 +1,300 @@ +//! PTC assignment validation through the complete partial-signature admission pipeline. + +use std::{ + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use axum::{Json, Router, routing::post}; +use beacon_node_fallback::{ApiTopic, BeaconNodeFallback, CandidateBeaconNode, Config}; +use database::{ + PendingStateUpdates, + test_utils::{InMemoryTestFixture, generators}, +}; +use duties_tracker::{ + DutiesProvider, DutyAssignment, duties_tracker::DutiesTracker, + voluntary_exit_tracker::VoluntaryExitTracker, +}; +use eth2::{BeaconNodeHttpClient, Timeouts, types::DutiesResponse}; +use fork::Fork; +use sensitive_url::SensitiveUrl; +use slot_clock::{ManualSlotClock, SlotClock}; +use ssv_types::{ + CommitteeInfo, OperatorId, ValidatorIndex, message::SignedSSVMessage, msgid::Role, +}; +use task_executor::test_utils::TestRuntime; +use types::{Epoch, Hash256, Slot}; + +use super::{ + tests::{PartialSigTestOptions, create_test_partial_signature}, + *, +}; +use crate::{ + MessageAcceptance, + tests::{ + MockDutiesProvider, four_node_committee_and_keypair, generate_fork_schedule, + spec_with_gloas, + }, +}; + +const SLOTS_PER_EPOCH: u64 = 32; +const SLOT_DURATION: Duration = Duration::from_secs(12); +const TEST_TIMEOUT: Duration = Duration::from_secs(5); +const SIGNER: OperatorId = OperatorId(1); + +fn context<'a>( + message: &'a SignedSSVMessage, + committee: &'a CommitteeInfo, + keys: &'a HashMap>, +) -> ValidationContext<'a, ManualSlotClock> { + let now = SystemTime::now(); + ValidationContext { + signed_ssv_message: message, + committee_info: committee, + role: Role::PTCAttester, + received_at: now, + slots_per_epoch: SLOTS_PER_EPOCH, + epochs_per_sync_committee_period: 256, + sync_committee_size: 512, + slot_clock: ManualSlotClock::new( + Slot::new(0), + now.duration_since(UNIX_EPOCH).unwrap(), + SLOT_DURATION, + ), + operator_pub_keys: keys, + fork_schedule: generate_fork_schedule(Fork::Boole), + spec: spec_with_gloas(Some(0)), + } +} + +#[test] +fn test_ptc_not_assigned_ignored_without_consuming_accepted_message_state() { + // Arrange: a correctly signed PTC message and a fetched view proving no assignment. + let (mut committee, private_key, keys) = four_node_committee_and_keypair(); + committee.validator_indices = vec![ValidatorIndex(0)]; + let (_, message) = create_test_partial_signature( + Role::PTCAttester, + PartialSignatureKind::PTCAttester, + SIGNER, + PartialSigTestOptions::default(), + Some(private_key), + ); + let mut state = DutyState::new(2 * SLOTS_PER_EPOCH as usize); + + // Act: exercise role, index, assignment, signature and state handling in the real pipeline. + let failure = validate_partial_signature_message( + context(&message, &committee, &keys), + &mut state, + Arc::new(MockDutiesProvider { + ptc_assignment: DutyAssignment::NotAssigned, + ..Default::default() + }), + ) + .unwrap_err(); + + // Assert: operator allocation is allowed, but no accepted slot or message budget is recorded. + assert!(matches!(failure, ValidationFailure::NoDuty)); + assert!(matches!( + MessageAcceptance::from(&failure), + MessageAcceptance::Ignore + )); + let operator = state.get_or_create_operator(&SIGNER); + assert!(operator.get_signer_state(&Slot::new(0)).is_none()); + assert_eq!(operator.get_duty_count(Epoch::new(0), SLOTS_PER_EPOCH), 0); + + // Act: accept the identical message after a refreshed view confirms its assignment. + let accepted = validate_partial_signature_message( + context(&message, &committee, &keys), + &mut state, + Arc::new(MockDutiesProvider { + ptc_assignment: DutyAssignment::Assigned, + ..Default::default() + }), + ); + + // Assert: the ignored attempt did not consume the slot's message-count allowance. + assert!( + accepted.is_ok(), + "assignment rejection consumed state: {accepted:?}" + ); + assert_eq!( + state + .get_or_create_operator(&SIGNER) + .get_duty_count(Epoch::new(0), SLOTS_PER_EPOCH), + 1 + ); +} + +#[test] +fn test_ptc_unknown_view_and_unresolved_local_index_continue_other_validation() { + // Arrange: locally missing metadata must not trust a purported known-negative index lookup. + let (mut committee, private_key, keys) = four_node_committee_and_keypair(); + let (_, message) = create_test_partial_signature( + Role::PTCAttester, + PartialSignatureKind::PTCAttester, + SIGNER, + PartialSigTestOptions::default(), + Some(private_key), + ); + for (indices, assignment) in [ + (vec![ValidatorIndex(0)], DutyAssignment::Unknown), + (vec![], DutyAssignment::NotAssigned), + ] { + committee.validator_indices = indices; + + // Act: both unknown duty coverage and an unresolved local index bypass only assignment. + let result = validate_partial_signature_message( + context(&message, &committee, &keys), + &mut DutyState::new(2 * SLOTS_PER_EPOCH as usize), + Arc::new(MockDutiesProvider { + ptc_assignment: assignment, + ..Default::default() + }), + ); + + // Assert: correctly signed messages remain admissible in both cases. + assert!( + result.is_ok(), + "unknown assignment was treated as no duty: {result:?}" + ); + } + + // Act: unknown assignment still passes through RSA verification. + committee.validator_indices = vec![ValidatorIndex(0)]; + let (_, unsigned_message) = create_test_partial_signature( + Role::PTCAttester, + PartialSignatureKind::PTCAttester, + SIGNER, + PartialSigTestOptions::default(), + None, + ); + let result = validate_partial_signature_message( + context(&unsigned_message, &committee, &keys), + &mut DutyState::new(2 * SLOTS_PER_EPOCH as usize), + Arc::new(MockDutiesProvider { + ptc_assignment: DutyAssignment::Unknown, + ..Default::default() + }), + ); + + // Assert: assignment uncertainty does not turn off signature validation. + assert!(matches!( + result, + Err(ValidationFailure::SignatureVerificationFailed { .. }) + )); +} + +/// Starts the real polling task against a local Beacon API fixture, then supplies that exact +/// tracker to partial-signature validation. This pins the fetch-to-admission connection that +/// independent provider mocks cannot establish. +#[tokio::test] +async fn test_ptc_started_tracker_http_snapshot_controls_partial_signature_admission() { + // Arrange: indices 0, 1 and 2 are registered; index 3 has no fetched coverage. + let fixture = InMemoryTestFixture::new_empty(); + let mut duties = vec![]; + for index in 0..3 { + let cluster = generators::cluster::random(0); + let mut validator = generators::validator::random_metadata(cluster.cluster_id); + validator.index = Some(ValidatorIndex(index)); + let mut conn = fixture.db.connection().unwrap(); + let tx = conn.transaction().unwrap(); + let mut pending = PendingStateUpdates::default(); + fixture + .db + .insert_validator_tx(cluster, &validator, vec![], &tx, &mut pending) + .unwrap(); + tx.commit().unwrap(); + fixture.db.publish_pending_state_updates(pending); + if index < 2 { + duties.push(eth2::types::PtcDuty { + pubkey: validator.public_key, + validator_index: u64::try_from(index).unwrap(), + slot: Slot::new(u64::try_from(index).unwrap()), + }); + } + } + let response = serde_json::to_value(DutiesResponse { + dependent_root: Hash256::from([0; 32]), + execution_optimistic: Some(false), + data: duties, + }) + .unwrap(); + let app = Router::new().route( + "/eth/v1/validator/duties/ptc/0", + post(move |Json(mut indices): Json>| { + let response = response.clone(); + async move { + indices.sort(); + assert_eq!(indices, vec!["0", "1", "2"]); + Json(response) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = SensitiveUrl::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let spec = spec_with_gloas(Some(0)); + let client = BeaconNodeHttpClient::new(url, Timeouts::set_all(TEST_TIMEOUT)); + let beacon_nodes = Arc::new(BeaconNodeFallback::new( + vec![CandidateBeaconNode::new(client, 0)], + Config::default(), + ApiTopic::all(), + spec.clone(), + )); + let tracker = Arc::new(DutiesTracker::new( + Arc::new(VoluntaryExitTracker::new()), + beacon_nodes, + spec, + SLOTS_PER_EPOCH, + ManualSlotClock::new(Slot::new(0), Duration::ZERO, SLOT_DURATION), + fixture.db.watch(), + )); + let runtime = TestRuntime::default(); + + // Act: start production polling, waiting on its public assignment query rather than a delay. + tracker.clone().start(runtime.task_executor.clone()); + tokio::time::timeout(TEST_TIMEOUT, async { + while tracker.ptc_assignment_at_slot(Slot::new(0), ValidatorIndex(0)) + != DutyAssignment::Assigned + { + tokio::task::yield_now().await; + } + }) + .await + .expect("started tracker must install the HTTP PTC snapshot"); + let (mut committee, private_key, keys) = four_node_committee_and_keypair(); + for (index, should_accept) in [(0, true), (1, false), (2, false), (3, true)] { + committee.validator_indices = vec![ValidatorIndex(index)]; + let (_, message) = create_test_partial_signature( + Role::PTCAttester, + PartialSignatureKind::PTCAttester, + SIGNER, + PartialSigTestOptions { + validator_index: Some(ValidatorIndex(index)), + ..Default::default() + }, + Some(private_key.clone()), + ); + let result = validate_partial_signature_message( + context(&message, &committee, &keys), + &mut DutyState::new(2 * SLOTS_PER_EPOCH as usize), + tracker.clone(), + ); + + // Assert: assigned/unqueried pass; wrong-slot/queried-absent are ignored. + if should_accept { + assert!(result.is_ok(), "index {index}: {result:?}"); + } else { + let failure = result.unwrap_err(); + assert!( + matches!(failure, ValidationFailure::NoDuty), + "index {index}: {failure:?}" + ); + assert!(matches!( + MessageAcceptance::from(&failure), + MessageAcceptance::Ignore + )); + } + } + server.abort(); +} From a91022021c1be4d5d1449904edec4ea0c58829c6 Mon Sep 17 00:00:00 2001 From: shane-moore Date: Tue, 8 Sep 2026 18:37:21 -0400 Subject: [PATCH 2/3] test: simplify PTC assignment fixtures --- Cargo.lock | 1 - anchor/duties_tracker/src/ptc_tests.rs | 67 +++++++++-------------- anchor/message_validator/Cargo.toml | 1 - anchor/message_validator/src/ptc_tests.rs | 14 ++--- 4 files changed, 33 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9d8c3b5f..c1701191e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5217,7 +5217,6 @@ dependencies = [ "processor", "safe_arith", "sensitive_url", - "serde_json", "sha2", "slot_clock", "ssv_types", diff --git a/anchor/duties_tracker/src/ptc_tests.rs b/anchor/duties_tracker/src/ptc_tests.rs index 26d1f435e..b83735e7e 100644 --- a/anchor/duties_tracker/src/ptc_tests.rs +++ b/anchor/duties_tracker/src/ptc_tests.rs @@ -11,7 +11,7 @@ use axum::{ use beacon_node_fallback::{ApiTopic, CandidateBeaconNode, Config}; use database::{ NetworkDatabase, PendingStateUpdates, - test_utils::{InMemoryTestFixture, generators}, + test_utils::{InMemoryTestFixture, commit_and_publish, generators}, }; use eth2::{BeaconNodeHttpClient, Timeouts, types::PtcDuty}; use sensitive_url::SensitiveUrl; @@ -147,8 +147,7 @@ fn register_index(db: &NetworkDatabase, index: u64) -> PublicKeyBytes { let mut pending = PendingStateUpdates::default(); db.insert_validator_tx(cluster, &validator, vec![], &tx, &mut pending) .unwrap(); - tx.commit().unwrap(); - db.publish_pending_state_updates(pending); + commit_and_publish(db, tx, pending); validator.public_key } @@ -197,26 +196,19 @@ async fn test_ptc_poll_covers_relayed_validators_and_replaces_whole_snapshot() { tracker.poll_ptc_duties().await.unwrap(); // Assert: wrong-slot and omitted queried indices are negatives, unqueried indices are unknown. - assert_eq!( - tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX)), - DutyAssignment::Assigned - ); - assert_eq!( - tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX + 1)), - DutyAssignment::NotAssigned - ); - assert_eq!( - tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX + 2)), - DutyAssignment::NotAssigned - ); - assert_eq!( - tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX + 3)), - DutyAssignment::Unknown - ); - assert_eq!( - tracker.ptc_assignment_at_slot(slot + SLOTS_PER_EPOCH, validator_index(TEST_INDEX)), - DutyAssignment::Unknown - ); + for (query_slot, index, expected) in [ + (slot, TEST_INDEX, DutyAssignment::Assigned), + (slot, TEST_INDEX + 1, DutyAssignment::NotAssigned), + (slot, TEST_INDEX + 2, DutyAssignment::NotAssigned), + (slot, TEST_INDEX + 3, DutyAssignment::Unknown), + (slot + SLOTS_PER_EPOCH, TEST_INDEX, DutyAssignment::Unknown), + ] { + assert_eq!( + tracker.ptc_assignment_at_slot(query_slot, validator_index(index)), + expected, + "slot {query_slot}, validator {index}" + ); + } let mut requests = server.take_requests(); assert_eq!(requests.len(), 1); assert_eq!(requests[0].0, TEST_EPOCH); @@ -356,8 +348,7 @@ async fn test_ptc_poll_empty_index_set_clears_current_snapshot_without_http() { .db .delete_validator_tx(&pubkey, &tx, &mut pending) .unwrap(); - tx.commit().unwrap(); - fixture.db.publish_pending_state_updates(pending); + commit_and_publish(&fixture.db, tx, pending); tracker.poll_ptc_duties().await.unwrap(); // Assert: empty coverage is unknown and the API is never called with an empty index list. @@ -406,21 +397,17 @@ async fn test_ptc_poll_gloas_gate_and_previous_epoch_retention_on_failure() { assert!(tracker.poll_ptc_duties().await.is_err()); // Assert: pruning still runs on failure, keeps the previous epoch, and never queries lookahead. - assert_eq!( - tracker.ptc_assignment_at_slot(test_slot(), validator_index(TEST_INDEX)), - DutyAssignment::Unknown - ); - assert_eq!( - tracker.ptc_assignment_at_slot(test_slot() + SLOTS_PER_EPOCH, validator_index(TEST_INDEX)), - DutyAssignment::Assigned - ); - assert_eq!( - tracker.ptc_assignment_at_slot( - test_slot() + 2 * SLOTS_PER_EPOCH, - validator_index(TEST_INDEX) - ), - DutyAssignment::Unknown - ); + for (slot, expected) in [ + (test_slot(), DutyAssignment::Unknown), + (test_slot() + SLOTS_PER_EPOCH, DutyAssignment::Assigned), + (test_slot() + 2 * SLOTS_PER_EPOCH, DutyAssignment::Unknown), + ] { + assert_eq!( + tracker.ptc_assignment_at_slot(slot, validator_index(TEST_INDEX)), + expected, + "slot {slot}" + ); + } let requests = server.take_requests(); let mut queried_epochs = requests.iter().map(|(epoch, _)| *epoch).collect::>(); queried_epochs.dedup(); diff --git a/anchor/message_validator/Cargo.toml b/anchor/message_validator/Cargo.toml index 8c11e49d8..63cfcb697 100644 --- a/anchor/message_validator/Cargo.toml +++ b/anchor/message_validator/Cargo.toml @@ -32,6 +32,5 @@ axum = { workspace = true } beacon_node_fallback = { workspace = true } database = { workspace = true, features = ["test-utils"] } eth2 = { workspace = true } -serde_json = { workspace = true } sensitive_url = { workspace = true } bls = { workspace = true } diff --git a/anchor/message_validator/src/ptc_tests.rs b/anchor/message_validator/src/ptc_tests.rs index d2d2a936e..1e7f8596c 100644 --- a/anchor/message_validator/src/ptc_tests.rs +++ b/anchor/message_validator/src/ptc_tests.rs @@ -9,7 +9,7 @@ use axum::{Json, Router, routing::post}; use beacon_node_fallback::{ApiTopic, BeaconNodeFallback, CandidateBeaconNode, Config}; use database::{ PendingStateUpdates, - test_utils::{InMemoryTestFixture, generators}, + test_utils::{InMemoryTestFixture, commit_and_publish, generators}, }; use duties_tracker::{ DutiesProvider, DutyAssignment, duties_tracker::DutiesTracker, @@ -203,8 +203,7 @@ async fn test_ptc_started_tracker_http_snapshot_controls_partial_signature_admis .db .insert_validator_tx(cluster, &validator, vec![], &tx, &mut pending) .unwrap(); - tx.commit().unwrap(); - fixture.db.publish_pending_state_updates(pending); + commit_and_publish(&fixture.db, tx, pending); if index < 2 { duties.push(eth2::types::PtcDuty { pubkey: validator.public_key, @@ -213,12 +212,11 @@ async fn test_ptc_started_tracker_http_snapshot_controls_partial_signature_admis }); } } - let response = serde_json::to_value(DutiesResponse { + let response = DutiesResponse { dependent_root: Hash256::from([0; 32]), execution_optimistic: Some(false), data: duties, - }) - .unwrap(); + }; let app = Router::new().route( "/eth/v1/validator/duties/ptc/0", post(move |Json(mut indices): Json>| { @@ -251,13 +249,13 @@ async fn test_ptc_started_tracker_http_snapshot_controls_partial_signature_admis )); let runtime = TestRuntime::default(); - // Act: start production polling, waiting on its public assignment query rather than a delay. + // Act: start production polling and check public readiness with timer-paced retries. tracker.clone().start(runtime.task_executor.clone()); tokio::time::timeout(TEST_TIMEOUT, async { while tracker.ptc_assignment_at_slot(Slot::new(0), ValidatorIndex(0)) != DutyAssignment::Assigned { - tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(1)).await; } }) .await From b98808343988fc5d9762196e2467e7a5a1fc8699 Mon Sep 17 00:00:00 2001 From: shane-moore Date: Tue, 8 Sep 2026 18:39:30 -0400 Subject: [PATCH 3/3] chore: sort PTC test dependencies --- anchor/duties_tracker/Cargo.toml | 2 +- anchor/message_validator/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/anchor/duties_tracker/Cargo.toml b/anchor/duties_tracker/Cargo.toml index 9cda2b466..a95b9ee38 100644 --- a/anchor/duties_tracker/Cargo.toml +++ b/anchor/duties_tracker/Cargo.toml @@ -22,7 +22,7 @@ types = { workspace = true } [dev-dependencies] axum = { workspace = true } -serde_json = { workspace = true } database = { workspace = true, features = ["test-utils"] } openssl = { workspace = true } sensitive_url = { workspace = true } +serde_json = { workspace = true } diff --git a/anchor/message_validator/Cargo.toml b/anchor/message_validator/Cargo.toml index 63cfcb697..8d3a51220 100644 --- a/anchor/message_validator/Cargo.toml +++ b/anchor/message_validator/Cargo.toml @@ -30,7 +30,7 @@ types = { workspace = true } [dev-dependencies] axum = { workspace = true } beacon_node_fallback = { workspace = true } +bls = { workspace = true } database = { workspace = true, features = ["test-utils"] } eth2 = { workspace = true } sensitive_url = { workspace = true } -bls = { workspace = true }