Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
dfaf1b4
feat: add CLI config for operator doppelgänger protection
diegomrsantos Oct 15, 2025
5e2b95e
feat: implement operator doppelgänger detection service
diegomrsantos Oct 15, 2025
fd01236
feat: integrate operator doppelgänger detection with message receiver
diegomrsantos Oct 15, 2025
4237308
Merge branch 'unstable' into feat/operator-doppelganger-protection
diegomrsantos Oct 15, 2025
be56784
Merge branch 'unstable' into feat/operator-doppelganger-protection
diegomrsantos Oct 16, 2025
f776d11
Merge branch 'unstable' into feat/operator-doppelganger-protection
diegomrsantos Oct 17, 2025
eb99a99
fix: pass current epoch explicitly instead of using unwrap_or_else
diegomrsantos Oct 17, 2025
3117374
fix: handle slot clock read failure in doppelgänger check
diegomrsantos Oct 17, 2025
67b7126
refactor: simplify doppelgänger state management with Mutex
diegomrsantos Oct 17, 2025
4834057
chore: change stale message log from warn to debug
diegomrsantos Oct 17, 2025
8f98ea0
refactor: remove redundant enabled field from doppelgänger service
diegomrsantos Oct 20, 2025
91836e3
test: add comprehensive tests for doppelgänger service
diegomrsantos Oct 20, 2025
d93ef67
Merge branch 'unstable' into feat/operator-doppelganger-protection
diegomrsantos Oct 20, 2025
64146a6
refactor: extract operator doppelgänger initialization and add monito…
diegomrsantos Oct 20, 2025
e268715
refactor: apply best practices to operator doppelgänger feature
diegomrsantos Oct 20, 2025
982db2e
refactor: parameterize slot duration in operator doppelganger service
diegomrsantos Oct 20, 2025
892fcf6
refactor: remove update_and_check_freshness in favor of separate oper…
diegomrsantos Oct 20, 2025
8006a91
test: remove redundant operator doppelganger tests
diegomrsantos Oct 21, 2025
741c8ad
test: remove redundant initial state test
diegomrsantos Oct 21, 2025
58170c6
lint
diegomrsantos Oct 21, 2025
9276f25
refactor: extract operator_doppelganger to separate crate
diegomrsantos Oct 21, 2025
632f5d4
refactor: remove blocking wait for operator doppelganger monitoring
diegomrsantos Oct 21, 2025
db1d9b2
refactor: simplify operator doppelganger by removing intermediary cha…
diegomrsantos Oct 21, 2025
ffc12a2
Merge branch 'unstable' into feat/operator-doppelganger-protection
diegomrsantos Oct 22, 2025
90b58b1
docs: add architectural principles to CLAUDE.md
diegomrsantos Oct 22, 2025
e0bd8b2
refactor: replace height-based with grace period approach for operato…
diegomrsantos Oct 22, 2025
2d1b48c
style: apply cargo fmt
diegomrsantos Oct 22, 2025
fc962b0
feat: expand doppelgänger detection to all operator-signed messages
diegomrsantos Oct 22, 2025
00c7546
chore: simplify redundant grace period comments
diegomrsantos Oct 22, 2025
c06e7ce
refactor: simplify operator doppelgänger state management
diegomrsantos Oct 22, 2025
571c3f8
refactor: replace epoch-based monitoring with single sleep timer
diegomrsantos Oct 22, 2025
5a8ba76
refactor: remove unnecessary create_operator_doppelganger wrapper
diegomrsantos Oct 22, 2025
433feba
refactor: remove unnecessary generics from OperatorDoppelgangerService
diegomrsantos Oct 22, 2025
b4212b1
test: add async timer tests for operator doppelgänger monitoring
diegomrsantos Oct 22, 2025
ba86842
refactor: convert all detection logic tests to use async timers
diegomrsantos Oct 22, 2025
de1a339
refactor: simplify async timer tests by using single yield
diegomrsantos Oct 22, 2025
26ff439
refactor: replace boolean flags with explicit state enum
diegomrsantos Oct 22, 2025
2aa1ffb
refactor: move DoppelgangerState to private implementation
diegomrsantos Oct 22, 2025
56ef9a8
chore: remove unused dependencies from operator_doppelganger
diegomrsantos Oct 22, 2025
7122d72
perf: use RwLock for read-optimized state access
diegomrsantos Oct 22, 2025
885ab36
feat: block all outgoing messages during doppelgänger monitoring
diegomrsantos Oct 22, 2025
072924a
Merge branch 'unstable' into feat/operator-doppelganger-protection
diegomrsantos Oct 23, 2025
716b85c
docs: update CLAUDE.md with session learnings
diegomrsantos Oct 24, 2025
cd826ae
Merge branch 'unstable' into feat/operator-doppelganger-protection
diegomrsantos Oct 24, 2025
c56dd46
Merge branch 'unstable' into feat/operator-doppelganger-protection
diegomrsantos Oct 24, 2025
550aea5
Merge branch 'unstable' into feat/operator-doppelganger-protection
diegomrsantos Oct 25, 2025
ff3c4a9
refactor: align operator doppelgänger grace period with message TTL w…
diegomrsantos Oct 25, 2025
0fda8d4
fix: block outgoing messages during entire doppelgänger protection wi…
diegomrsantos Oct 27, 2025
a1a59b3
docs: update operator doppelgänger CLI help text
diegomrsantos Oct 27, 2025
3ea5c20
refactor: replace grace period with slot-based operator doppelgänger …
diegomrsantos Oct 29, 2025
ba750d4
refactor: pass ValidatedSSVMessage to eliminate redundant SSZ decoding
diegomrsantos Oct 30, 2025
42a1756
refactor: remove unnecessary start_operator_doppelganger wrapper
diegomrsantos Oct 30, 2025
03f56a0
refactor: simplify doppelgänger protection with blocking monitoring
diegomrsantos Oct 30, 2025
9ca3edb
Merge branch 'unstable' into feat/operator-doppelganger-protection
diegomrsantos Nov 3, 2025
fccefd0
remove unused dep
diegomrsantos Nov 3, 2025
d1499db
refactor: notifier with layered state architecture
diegomrsantos Nov 4, 2025
0d9948a
Merge branch 'unstable' into feat/operator-doppelganger-protection
diegomrsantos Nov 5, 2025
b2172a6
refactor: store operator_id in OperatorState enum to eliminate unsafe…
diegomrsantos Nov 5, 2025
9b53677
refactor: simplify startup_slot initialization
diegomrsantos Nov 5, 2025
1269e0c
feat: disable operator doppelgänger protection by default
diegomrsantos Nov 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions anchor/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,7 @@ impl Client {
duties_service.clone(),
database.watch(),
is_synced.clone(),
doppelganger_service.clone(),
executor.clone(),
&spec,
);
Expand Down
123 changes: 106 additions & 17 deletions anchor/client/src/notifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::sync::Arc;

use anchor_validator_store::AnchorValidatorStore;
use database::NetworkState;
use operator_doppelganger::OperatorDoppelgangerService;
use slot_clock::SlotClock;
use task_executor::TaskExecutor;
use tokio::{
Expand All @@ -13,15 +14,65 @@ use types::{ChainSpec, EthSpec};

use crate::duties_service::DutiesService;

/// Spawns a notifier service which periodically logs information about the node. It reuses
/// Lighthouse's `notifier_service` for most functionality but adds some Anchor-specific information
/// and logic.
///
/// Executes [`notify`] once per slot, halfway into it.
/// Represents whether the client is synced with the execution layer
#[derive(Debug, Clone, Copy)]
enum SyncState {
Syncing,
Synced,
}

impl SyncState {
fn from_bool(is_synced: bool) -> Self {
if is_synced {
Self::Synced
} else {
Self::Syncing
}
}
}

/// Represents the operator's presence on chain
#[derive(Debug, Clone, Copy)]
enum OperatorState {
NoOperator,
OperatorPresent { cluster_count: usize },
}

impl OperatorState {
fn from_option(operator_id: Option<ssv_types::OperatorId>, cluster_count: usize) -> Self {
if operator_id.is_some() {
Self::OperatorPresent { cluster_count }
} else {
Self::NoOperator
}
}
}

/// Represents the doppelgänger monitoring state
#[derive(Debug, Clone, Copy)]
enum DoppelgangerState {
NotMonitoring,
MonitoringForDoppelganger,
}

impl DoppelgangerState {
fn from_service(doppelganger_service: Option<&Arc<OperatorDoppelgangerService>>) -> Self {
if doppelganger_service
.map(|service| service.is_monitoring())
.unwrap_or(false)
{
Self::MonitoringForDoppelganger
} else {
Self::NotMonitoring
}
}
}

pub fn spawn_notifier<E: EthSpec, T: SlotClock + 'static>(
duties_service: Arc<DutiesService<AnchorValidatorStore<T, E>, T>>,
network_state: watch::Receiver<NetworkState>,
synced: watch::Receiver<bool>,
doppelganger_service: Option<Arc<OperatorDoppelgangerService>>,
executor: TaskExecutor,
spec: &ChainSpec,
) {
Expand All @@ -32,7 +83,13 @@ pub fn spawn_notifier<E: EthSpec, T: SlotClock + 'static>(
if let Some(duration_to_next_slot) = duties_service.slot_clock.duration_to_next_slot() {
// Sleep until the middle of the next slot
sleep(duration_to_next_slot + slot_duration / 2).await;
notify(&duties_service, &network_state, &synced).await;
notify(
&duties_service,
&network_state,
&synced,
doppelganger_service.as_ref(),
)
.await;
} else {
error!("Failed to read slot clock");
// If we can't read the slot clock, just wait another slot.
Expand All @@ -45,16 +102,13 @@ pub fn spawn_notifier<E: EthSpec, T: SlotClock + 'static>(
executor.spawn(interval_fut, "validator_notifier");
}

/// Performs a single notification routine.
///
/// This serves to notify the user of the current application status via `info` logging.
/// Additionally, some metrics are recorded by the `validator_services`.
async fn notify<E: EthSpec, T: SlotClock + 'static>(
duties_service: &DutiesService<AnchorValidatorStore<T, E>, T>,
network_state: &watch::Receiver<NetworkState>,
synced: &watch::Receiver<bool>,
doppelganger_service: Option<&Arc<OperatorDoppelgangerService>>,
) {
// Scope needed as Rust complains about `state` being held across `await` if using `drop`
// Gather state information
let (operator_id, cluster_count) = {
let state = network_state.borrow();
let operator_id = state.get_own_id();
Expand All @@ -71,16 +125,51 @@ async fn notify<E: EthSpec, T: SlotClock + 'static>(

let is_synced = *synced.borrow();

match (operator_id, is_synced) {
(None, false) => info!("Syncing"),
(None, true) => info!("Synced, waiting for operator key to appear on chain"),
(Some(operator_id), false) => {
// Build layered state
let sync = SyncState::from_bool(is_synced);
let operator = OperatorState::from_option(operator_id, cluster_count);
let doppelganger = DoppelgangerState::from_service(doppelganger_service);

// Match on compositional state layers
match (&sync, &operator, &doppelganger, validator_count) {
(SyncState::Syncing, OperatorState::NoOperator, _, _) => {
info!("Syncing")
}
(SyncState::Syncing, OperatorState::OperatorPresent { .. }, _, _) => {
let operator_id = operator_id.expect("operator_id is Some in this branch");
info!(%operator_id, "Operator present on chain, waiting for sync")
}
(Some(operator_id), true) if validator_count > 0 => {
(SyncState::Synced, OperatorState::NoOperator, _, _) => {
info!("Synced, waiting for operator key to appear on chain")
}
(
SyncState::Synced,
OperatorState::OperatorPresent { cluster_count },
DoppelgangerState::MonitoringForDoppelganger,
count,
) if count > 0 => {
let operator_id = operator_id.expect("operator_id is Some in this branch");
info!(
%operator_id,
cluster_count,
validator_count = count,
"Monitoring for operator doppelgänger (duties paused)"
)
}
(
SyncState::Synced,
OperatorState::OperatorPresent { cluster_count },
DoppelgangerState::NotMonitoring,
count,
) if count > 0 => {
let operator_id = operator_id.expect("operator_id is Some in this branch");
info!(%operator_id, cluster_count, "Operator active");
// Only call Lighthouse's notifier when we're actually performing duties
validator_services::notifier_service::notify(duties_service).await;
}
(Some(operator_id), true) => info!(%operator_id, "Operator ready, no validators assigned"),
(SyncState::Synced, OperatorState::OperatorPresent { .. }, _, _) => {
let operator_id = operator_id.expect("operator_id is Some in this branch");
info!(%operator_id, "Operator ready, no validators assigned")
}
}
}
Loading