Skip to content

Commit 9dac850

Browse files
feat: report equivocation to finalizer
1 parent f6427a1 commit 9dac850

5 files changed

Lines changed: 178 additions & 5 deletions

File tree

finalizer/src/actor.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use std::marker::PhantomData;
3030
use std::num::NonZero;
3131
use std::time::{Duration, Instant};
3232
use summit_orchestrator::Message;
33-
use summit_syncer::Update;
33+
use summit_syncer::{FaultEvidence, Update};
3434
use summit_types::account::ValidatorStatus;
3535
use summit_types::checkpoint::Checkpoint;
3636
use summit_types::consensus_state_query::{
@@ -585,6 +585,9 @@ impl<
585585
break;
586586
}
587587
}
588+
Update::Fault(evidence) => {
589+
self.handle_fault(evidence);
590+
}
588591
}
589592
}
590593
mailbox_message = self.mailbox.next() => {
@@ -1369,6 +1372,45 @@ impl<
13691372
Ok(HandleOutcome::Applied)
13701373
}
13711374

1375+
/// Handles Byzantine fault evidence observed by the local consensus engine:
1376+
/// a committee member signed conflicting votes.
1377+
///
1378+
/// This is not deterministic. Only nodes that saw both votes observe it.
1379+
fn handle_fault(
1380+
&self,
1381+
evidence: FaultEvidence<Digest, bls12381_multisig::Scheme<PublicKey, V>>,
1382+
) {
1383+
// The signer index refers to the epoch committee sorted by node public
1384+
// key (the same order used to build the signing scheme). Resolution is
1385+
// only valid when the evidence epoch matches the current canonical
1386+
// state epoch.
1387+
let validator = (evidence.epoch.get() == self.canonical_state.get_epoch())
1388+
.then(|| {
1389+
self.canonical_state
1390+
.get_current_epoch_validators()
1391+
.into_iter()
1392+
.nth(evidence.signer.get() as usize)
1393+
.map(|(node_key, _)| node_key)
1394+
})
1395+
.flatten();
1396+
error!(
1397+
target: "critical",
1398+
epoch = evidence.epoch.get(),
1399+
view = evidence.view.get(),
1400+
signer_index = evidence.signer.get(),
1401+
?validator,
1402+
kind = ?evidence.kind(),
1403+
"validator equivocated (Byzantine fault detected)"
1404+
);
1405+
#[cfg(feature = "prom")]
1406+
counter!(
1407+
"critical_errors_total",
1408+
"reason" => evidence.kind().as_reason(),
1409+
"severity" => "critical"
1410+
)
1411+
.increment(1);
1412+
}
1413+
13721414
async fn handle_notarized_block(&mut self, block: Block) -> Result<HandleOutcome> {
13731415
let mut to_process = vec![block];
13741416
// If any iteration defers a block because the EL is SYNCING, signal

syncer/src/actor.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,19 @@ where
737737
.ignore();
738738
}
739739
}
740+
Message::Fault { evidence } => {
741+
// A committee member signed conflicting votes (Byzantine fault).
742+
// Forward to the application (finalizer), which owns critical
743+
// logging, metrics, and identity resolution against consensus state.
744+
debug!(
745+
epoch = evidence.epoch.get(),
746+
view = evidence.view.get(),
747+
signer_index = evidence.signer.get(),
748+
kind = ?evidence.kind(),
749+
"forwarding Byzantine fault evidence to application"
750+
);
751+
application.report(Update::Fault(evidence));
752+
}
740753
Message::GetBlock {
741754
identifier,
742755
response,

syncer/src/ingress/mailbox.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::FaultEvidence;
12
use crate::durability::Durable as _;
23
use commonware_actor::{
34
Feedback,
@@ -189,6 +190,11 @@ pub(crate) enum Message<S: Scheme<B::Digest>, B: Block> {
189190
/// The finalization.
190191
finalization: Finalization<S, B::Digest>,
191192
},
193+
/// Evidence of Byzantine behavior (equivocation) reported by the consensus engine.
194+
Fault {
195+
/// The fault evidence.
196+
evidence: FaultEvidence<B::Digest, S>,
197+
},
192198
/// Attempts to set the sync starting point from a finalized commitment.
193199
///
194200
/// If the verified finalization advances the current floor, the syncer
@@ -246,7 +252,8 @@ impl<S: Scheme<B::Digest>, B: Block> Message<S, B> {
246252
| Self::SetFloor { .. }
247253
| Self::Prune { .. }
248254
| Self::Notarization { .. }
249-
| Self::Finalization { .. } => false,
255+
| Self::Finalization { .. }
256+
| Self::Fault { .. } => false,
250257
}
251258
}
252259

@@ -268,7 +275,8 @@ impl<S: Scheme<B::Digest>, B: Block> Message<S, B> {
268275
| Self::SetFloor { .. }
269276
| Self::Prune { .. }
270277
| Self::Notarization { .. }
271-
| Self::Finalization { .. } => false,
278+
| Self::Finalization { .. }
279+
| Self::Fault { .. } => false,
272280
}
273281
}
274282
}
@@ -726,6 +734,15 @@ impl<S: Scheme<B::Digest>, B: Block> Reporter for Mailbox<S, B> {
726734
let message = match activity {
727735
Activity::Notarization(notarization) => Message::Notarization { notarization },
728736
Activity::Finalization(finalization) => Message::Finalization { finalization },
737+
Activity::ConflictingNotarize(evidence) => Message::Fault {
738+
evidence: FaultEvidence::conflicting_notarize(evidence),
739+
},
740+
Activity::ConflictingFinalize(evidence) => Message::Fault {
741+
evidence: FaultEvidence::conflicting_finalize(evidence),
742+
},
743+
Activity::NullifyFinalize(evidence) => Message::Fault {
744+
evidence: FaultEvidence::nullify_finalize(evidence),
745+
},
729746
_ => return Feedback::Ok,
730747
};
731748
self.sender.enqueue(message)

syncer/src/lib.rs

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,13 @@ mod stream;
8080
pub mod variant;
8181
pub use variant::{Buffer, Variant};
8282

83-
use commonware_consensus::Block;
8483
use commonware_consensus::simplex::scheme::Scheme;
85-
use commonware_consensus::simplex::types::Finalization;
84+
use commonware_consensus::simplex::types::{
85+
Attributable as _, ConflictingFinalize, ConflictingNotarize, Finalization, NullifyFinalize,
86+
};
87+
use commonware_consensus::types::{Epoch, Participant, View};
88+
use commonware_consensus::{Block, Epochable as _, Viewable as _};
89+
use commonware_cryptography::Digest;
8690
use commonware_utils::{Acknowledgement, acknowledgement::Exact};
8791

8892
/// An update reported to the application: finalized tips, finalized blocks, or notarized blocks.
@@ -111,6 +115,102 @@ pub enum Update<B: Block, S: Scheme<B::Digest>, A: Acknowledgement = Exact> {
111115
/// blocks without waiting for finalization. For a given block, this update is reported before
112116
/// its [`Self::FinalizedBlock`] update.
113117
NotarizedBlock(B),
118+
/// Locally observed evidence of Byzantine behavior (equivocation) by a validator.
119+
///
120+
/// Reported by the consensus batcher when a committee member signs conflicting votes.
121+
/// Delivery is best-effort and NOT deterministic: only nodes that received both
122+
/// conflicting votes observe the fault, and different nodes may observe it at
123+
/// different times (or not at all). Consumers must not apply state transitions
124+
/// based on this update alone.
125+
Fault(FaultEvidence<B::Digest, S>),
126+
}
127+
128+
/// The kind of Byzantine fault observed.
129+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130+
pub enum FaultKind {
131+
/// The validator signed notarize votes for two different proposals in the same view.
132+
ConflictingNotarize,
133+
/// The validator signed finalize votes for two different proposals in the same view.
134+
ConflictingFinalize,
135+
/// The validator signed both a nullify and a finalize for the same view.
136+
NullifyFinalize,
137+
}
138+
139+
impl FaultKind {
140+
/// Stable label for metrics.
141+
pub const fn as_reason(self) -> &'static str {
142+
match self {
143+
Self::ConflictingNotarize => "equivocation_notarize",
144+
Self::ConflictingFinalize => "equivocation_finalize",
145+
Self::NullifyFinalize => "nullify_finalize",
146+
}
147+
}
148+
}
149+
150+
/// Cryptographic evidence of a Byzantine fault, self-contained and verifiable
151+
/// against the epoch's committee.
152+
#[derive(Clone, Debug)]
153+
pub enum FaultProof<D: Digest, S: Scheme<D>> {
154+
/// Two conflicting signed notarize votes.
155+
ConflictingNotarize(ConflictingNotarize<S, D>),
156+
/// Two conflicting signed finalize votes.
157+
ConflictingFinalize(ConflictingFinalize<S, D>),
158+
/// A signed nullify and a signed finalize for the same view.
159+
NullifyFinalize(NullifyFinalize<S, D>),
160+
}
161+
162+
/// Locally observed Byzantine fault evidence with its consensus coordinates.
163+
#[derive(Clone, Debug)]
164+
pub struct FaultEvidence<D: Digest, S: Scheme<D>> {
165+
/// The epoch in which the fault occurred.
166+
pub epoch: Epoch,
167+
/// The view in which the fault occurred.
168+
pub view: View,
169+
/// The committee index of the faulting validator (per the epoch's committee order).
170+
pub signer: Participant,
171+
/// The signed evidence.
172+
pub proof: FaultProof<D, S>,
173+
}
174+
175+
impl<D: Digest, S: Scheme<D>> FaultEvidence<D, S> {
176+
/// Builds evidence from a [`ConflictingNotarize`] activity.
177+
pub fn conflicting_notarize(evidence: ConflictingNotarize<S, D>) -> Self {
178+
Self {
179+
epoch: evidence.epoch(),
180+
view: evidence.view(),
181+
signer: evidence.signer(),
182+
proof: FaultProof::ConflictingNotarize(evidence),
183+
}
184+
}
185+
186+
/// Builds evidence from a [`ConflictingFinalize`] activity.
187+
pub fn conflicting_finalize(evidence: ConflictingFinalize<S, D>) -> Self {
188+
Self {
189+
epoch: evidence.epoch(),
190+
view: evidence.view(),
191+
signer: evidence.signer(),
192+
proof: FaultProof::ConflictingFinalize(evidence),
193+
}
194+
}
195+
196+
/// Builds evidence from a [`NullifyFinalize`] activity.
197+
pub fn nullify_finalize(evidence: NullifyFinalize<S, D>) -> Self {
198+
Self {
199+
epoch: evidence.epoch(),
200+
view: evidence.view(),
201+
signer: evidence.signer(),
202+
proof: FaultProof::NullifyFinalize(evidence),
203+
}
204+
}
205+
206+
/// The kind of fault this evidence proves.
207+
pub const fn kind(&self) -> FaultKind {
208+
match self.proof {
209+
FaultProof::ConflictingNotarize(_) => FaultKind::ConflictingNotarize,
210+
FaultProof::ConflictingFinalize(_) => FaultKind::ConflictingFinalize,
211+
FaultProof::NullifyFinalize(_) => FaultKind::NullifyFinalize,
212+
}
213+
}
114214
}
115215

116216
#[cfg(test)]

syncer/src/mocks/application.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ impl<B: Block, S: Scheme<B::Digest>> Reporter for Application<B, S> {
7777
.unwrap()
7878
.push(RecordedUpdate::Notarized(block.digest()));
7979
}
80+
Update::Fault(_) => {}
8081
}
8182
Feedback::Ok
8283
}

0 commit comments

Comments
 (0)