Skip to main content

nklave_core/
service.rs

1//! Signing service that orchestrates the complete signing flow
2//!
3//! This service ties together key management, slashing protection, and state integrity
4
5use crate::keys::bls::{BlsKeypair, BlsSignature};
6use crate::metrics;
7use crate::policy::ethereum::EthereumPolicy;
8use crate::policy::types::{PolicyDecision, RefusalCode, SigningType};
9use crate::state::integrity::{DecisionRecord, IntegrityError, SigningContext, StateIntegrity};
10use crate::state::validator::ValidatorState;
11use std::collections::HashMap;
12use std::sync::{Arc, RwLock};
13use std::time::Instant;
14use thiserror::Error;
15
16/// The signing service that coordinates all signing operations
17pub struct SigningService {
18    /// Loaded validator keypairs indexed by public key (RwLock for dynamic reloading)
19    keypairs: RwLock<HashMap<[u8; 48], BlsKeypair>>,
20
21    /// Per-validator safety state
22    validators: RwLock<HashMap<[u8; 48], ValidatorState>>,
23
24    /// State integrity tracker
25    integrity: RwLock<StateIntegrity>,
26
27    /// Ethereum slashing policy
28    policy: EthereumPolicy,
29
30    /// Genesis validators root (set on first request)
31    genesis_validators_root: RwLock<Option<[u8; 32]>>,
32}
33
34impl SigningService {
35    /// Create a new signing service with the given keypairs
36    pub fn new(keypairs: Vec<BlsKeypair>) -> Self {
37        let keypair_map: HashMap<[u8; 48], BlsKeypair> = keypairs
38            .into_iter()
39            .map(|kp| {
40                let pubkey = kp.public_key_bytes();
41                (pubkey, kp)
42            })
43            .collect();
44
45        Self {
46            keypairs: RwLock::new(keypair_map),
47            validators: RwLock::new(HashMap::new()),
48            integrity: RwLock::new(StateIntegrity::new()),
49            policy: EthereumPolicy::new(),
50            genesis_validators_root: RwLock::new(None),
51        }
52    }
53
54    /// Create a signing service with existing state (for recovery)
55    pub fn with_state(
56        keypairs: Vec<BlsKeypair>,
57        validators: HashMap<[u8; 48], ValidatorState>,
58        integrity: StateIntegrity,
59    ) -> Self {
60        let keypair_map: HashMap<[u8; 48], BlsKeypair> = keypairs
61            .into_iter()
62            .map(|kp| {
63                let pubkey = kp.public_key_bytes();
64                (pubkey, kp)
65            })
66            .collect();
67
68        let genesis_root = integrity.genesis_validators_root;
69
70        Self {
71            keypairs: RwLock::new(keypair_map),
72            validators: RwLock::new(validators),
73            integrity: RwLock::new(integrity),
74            policy: EthereumPolicy::new(),
75            genesis_validators_root: RwLock::new(genesis_root),
76        }
77    }
78
79    /// Get all public keys managed by this service
80    pub fn public_keys(&self) -> Vec<[u8; 48]> {
81        self.keypairs.read().unwrap().keys().copied().collect()
82    }
83
84    /// Get public keys as hex strings with 0x prefix
85    pub fn public_keys_hex(&self) -> Vec<String> {
86        self.keypairs
87            .read()
88            .unwrap()
89            .keys()
90            .map(|pk| format!("0x{}", hex::encode(pk)))
91            .collect()
92    }
93
94    /// Check if a validator is managed by this service
95    pub fn has_validator(&self, pubkey: &[u8; 48]) -> bool {
96        self.keypairs.read().unwrap().contains_key(pubkey)
97    }
98
99    /// Add new keypairs to the service (for dynamic key reloading)
100    ///
101    /// Returns the number of new keys added (excludes duplicates)
102    pub fn add_keys(&self, keypairs: Vec<BlsKeypair>) -> usize {
103        let mut keypair_map = self.keypairs.write().unwrap();
104        let initial_count = keypair_map.len();
105
106        for kp in keypairs {
107            let pubkey = kp.public_key_bytes();
108            keypair_map.entry(pubkey).or_insert(kp);
109        }
110
111        keypair_map.len() - initial_count
112    }
113
114    /// Get the total number of validators managed by this service
115    pub fn validator_count(&self) -> usize {
116        self.keypairs.read().unwrap().len()
117    }
118
119    /// Set or verify the genesis validators root
120    pub fn set_genesis_validators_root(
121        &self,
122        root: [u8; 32],
123    ) -> Result<(), SigningServiceError> {
124        let mut genesis_root = self.genesis_validators_root.write().unwrap();
125
126        match *genesis_root {
127            Some(existing) if existing != root => {
128                Err(SigningServiceError::GenesisRootMismatch {
129                    expected: existing,
130                    actual: root,
131                })
132            }
133            Some(_) => Ok(()), // Already set to same value
134            None => {
135                *genesis_root = Some(root);
136                // Also update integrity tracker
137                let mut integrity = self.integrity.write().unwrap();
138                integrity
139                    .set_genesis_validators_root(root)
140                    .map_err(SigningServiceError::Integrity)?;
141                Ok(())
142            }
143        }
144    }
145
146    /// Sign a block proposal
147    ///
148    /// Returns the BLS signature if allowed, or an error if refused
149    pub fn sign_block_proposal(
150        &self,
151        pubkey: &[u8; 48],
152        slot: u64,
153        signing_root: [u8; 32],
154    ) -> Result<SigningResult, SigningServiceError> {
155        let start = Instant::now();
156        let validator_hex = format!("0x{}...{}", &hex::encode(&pubkey[..4]), &hex::encode(&pubkey[44..]));
157
158        // Get the keypair
159        let keypairs = self.keypairs.read().unwrap();
160        let keypair = keypairs
161            .get(pubkey)
162            .ok_or(SigningServiceError::UnknownValidator(*pubkey))?;
163
164        // Get or create validator state
165        let mut validators = self.validators.write().unwrap();
166        let state = validators
167            .entry(*pubkey)
168            .or_insert_with(|| ValidatorState::new(*pubkey));
169
170        // Check slashing protection
171        let decision = self.policy.check_block_proposal(state, slot, &signing_root);
172
173        match decision {
174            PolicyDecision::Allow => {
175                // Sign the message
176                let signature = keypair.sign(&signing_root);
177
178                // Record the signing
179                state.record_block_signing(slot, signing_root);
180
181                // Record decision in integrity tracker with signing context
182                let mut integrity = self.integrity.write().unwrap();
183                let record = integrity.prepare_record_with_context(
184                    *pubkey,
185                    SigningType::BlockProposal,
186                    PolicyDecision::Allow,
187                    signing_root,
188                    SigningContext::BlockProposal { slot },
189                );
190                integrity
191                    .record_decision(&record)
192                    .map_err(SigningServiceError::Integrity)?;
193
194                // Record metrics
195                let elapsed = start.elapsed().as_secs_f64();
196                metrics::record_signing_success("block_proposal", &validator_hex);
197                metrics::record_signing_latency("block_proposal", elapsed);
198                metrics::record_block_signed(&validator_hex);
199                metrics::set_last_signed_slot(&validator_hex, slot);
200                metrics::set_state_sequence(integrity.sequence_number);
201
202                Ok(SigningResult {
203                    signature,
204                    decision_record: record,
205                })
206            }
207            PolicyDecision::Refuse(code) => {
208                // Record the refusal in integrity tracker with signing context
209                let mut integrity = self.integrity.write().unwrap();
210                let record = integrity.prepare_record_with_context(
211                    *pubkey,
212                    SigningType::BlockProposal,
213                    PolicyDecision::Refuse(code),
214                    signing_root,
215                    SigningContext::BlockProposal { slot },
216                );
217                integrity
218                    .record_decision(&record)
219                    .map_err(SigningServiceError::Integrity)?;
220
221                // Record metrics
222                let elapsed = start.elapsed().as_secs_f64();
223                metrics::record_signing_refusal("block_proposal", &code.to_string(), &validator_hex);
224                metrics::record_signing_latency("block_proposal", elapsed);
225
226                Err(SigningServiceError::SlashingProtection(code))
227            }
228        }
229    }
230
231    /// Sign an attestation
232    ///
233    /// Returns the BLS signature if allowed, or an error if refused
234    pub fn sign_attestation(
235        &self,
236        pubkey: &[u8; 48],
237        source_epoch: u64,
238        target_epoch: u64,
239        signing_root: [u8; 32],
240    ) -> Result<SigningResult, SigningServiceError> {
241        let start = Instant::now();
242        let validator_hex = format!("0x{}...{}", &hex::encode(&pubkey[..4]), &hex::encode(&pubkey[44..]));
243
244        // Get the keypair
245        let keypairs = self.keypairs.read().unwrap();
246        let keypair = keypairs
247            .get(pubkey)
248            .ok_or(SigningServiceError::UnknownValidator(*pubkey))?;
249
250        // Get or create validator state
251        let mut validators = self.validators.write().unwrap();
252        let state = validators
253            .entry(*pubkey)
254            .or_insert_with(|| ValidatorState::new(*pubkey));
255
256        // Check slashing protection
257        let decision = self
258            .policy
259            .check_attestation(state, source_epoch, target_epoch, &signing_root);
260
261        match decision {
262            PolicyDecision::Allow => {
263                // Sign the message
264                let signature = keypair.sign(&signing_root);
265
266                // Record the signing
267                state.record_attestation_signing(source_epoch, target_epoch, signing_root);
268
269                // Record decision in integrity tracker with signing context
270                let mut integrity = self.integrity.write().unwrap();
271                let record = integrity.prepare_record_with_context(
272                    *pubkey,
273                    SigningType::Attestation,
274                    PolicyDecision::Allow,
275                    signing_root,
276                    SigningContext::Attestation {
277                        source_epoch,
278                        target_epoch,
279                    },
280                );
281                integrity
282                    .record_decision(&record)
283                    .map_err(SigningServiceError::Integrity)?;
284
285                // Record metrics
286                let elapsed = start.elapsed().as_secs_f64();
287                metrics::record_signing_success("attestation", &validator_hex);
288                metrics::record_signing_latency("attestation", elapsed);
289                metrics::record_attestation_signed(&validator_hex);
290                metrics::set_last_signed_target_epoch(&validator_hex, target_epoch);
291                metrics::set_state_sequence(integrity.sequence_number);
292
293                Ok(SigningResult {
294                    signature,
295                    decision_record: record,
296                })
297            }
298            PolicyDecision::Refuse(code) => {
299                // Record the refusal in integrity tracker with signing context
300                let mut integrity = self.integrity.write().unwrap();
301                let record = integrity.prepare_record_with_context(
302                    *pubkey,
303                    SigningType::Attestation,
304                    PolicyDecision::Refuse(code),
305                    signing_root,
306                    SigningContext::Attestation {
307                        source_epoch,
308                        target_epoch,
309                    },
310                );
311                integrity
312                    .record_decision(&record)
313                    .map_err(SigningServiceError::Integrity)?;
314
315                // Record metrics
316                let elapsed = start.elapsed().as_secs_f64();
317                metrics::record_signing_refusal("attestation", &code.to_string(), &validator_hex);
318                metrics::record_signing_latency("attestation", elapsed);
319
320                Err(SigningServiceError::SlashingProtection(code))
321            }
322        }
323    }
324
325    /// Sign a generic message (for types without slashing protection)
326    ///
327    /// Used for: RANDAO reveal, aggregation slot, sync committee messages, etc.
328    pub fn sign_generic(
329        &self,
330        pubkey: &[u8; 48],
331        signing_type: SigningType,
332        signing_root: [u8; 32],
333    ) -> Result<SigningResult, SigningServiceError> {
334        let start = Instant::now();
335        let validator_hex = format!("0x{}...{}", &hex::encode(&pubkey[..4]), &hex::encode(&pubkey[44..]));
336        let type_name = signing_type.as_str();
337
338        // Get the keypair
339        let keypairs = self.keypairs.read().unwrap();
340        let keypair = keypairs
341            .get(pubkey)
342            .ok_or(SigningServiceError::UnknownValidator(*pubkey))?;
343
344        // Sign the message (no slashing protection needed for these types)
345        let signature = keypair.sign(&signing_root);
346
347        // Record decision in integrity tracker with signing context
348        let mut integrity = self.integrity.write().unwrap();
349        let record = integrity.prepare_record_with_context(
350            *pubkey,
351            signing_type,
352            PolicyDecision::Allow,
353            signing_root,
354            SigningContext::Other,
355        );
356        integrity
357            .record_decision(&record)
358            .map_err(SigningServiceError::Integrity)?;
359
360        // Record metrics
361        let elapsed = start.elapsed().as_secs_f64();
362        metrics::record_signing_success(type_name, &validator_hex);
363        metrics::record_signing_latency(type_name, elapsed);
364        metrics::set_state_sequence(integrity.sequence_number);
365
366        Ok(SigningResult {
367            signature,
368            decision_record: record,
369        })
370    }
371
372    /// Replay decision records (for crash recovery)
373    ///
374    /// This method is used during startup to replay decisions from the log
375    /// that occurred after the last checkpoint. It restores both the integrity
376    /// hash chain and the validator slashing protection state.
377    pub fn replay_records(&self, records: Vec<DecisionRecord>) -> Result<u64, SigningServiceError> {
378        let mut validators = self.validators.write().unwrap();
379        let mut integrity = self.integrity.write().unwrap();
380
381        let mut replayed = 0u64;
382        let mut state_restored = 0u64;
383
384        for record in records {
385            // Skip records we've already processed
386            if record.sequence <= integrity.sequence_number {
387                continue;
388            }
389
390            // Verify and apply the record to integrity
391            integrity
392                .record_decision(&record)
393                .map_err(SigningServiceError::Integrity)?;
394
395            // Only restore state for allowed decisions (refusals don't change state)
396            if record.decision.is_allowed() {
397                let state = validators
398                    .entry(record.validator_pubkey)
399                    .or_insert_with(|| ValidatorState::new(record.validator_pubkey));
400
401                // Use signing context to restore validator state
402                match &record.signing_context {
403                    Some(SigningContext::BlockProposal { slot }) => {
404                        state.record_block_signing(*slot, record.signing_root);
405                        tracing::debug!(
406                            sequence = record.sequence,
407                            slot = slot,
408                            "Replayed block proposal - restored slot state"
409                        );
410                        state_restored += 1;
411                    }
412                    Some(SigningContext::Attestation {
413                        source_epoch,
414                        target_epoch,
415                    }) => {
416                        state.record_attestation_signing(
417                            *source_epoch,
418                            *target_epoch,
419                            record.signing_root,
420                        );
421                        tracing::debug!(
422                            sequence = record.sequence,
423                            source_epoch = source_epoch,
424                            target_epoch = target_epoch,
425                            "Replayed attestation - restored epoch state"
426                        );
427                        state_restored += 1;
428                    }
429                    Some(SigningContext::CosmosVote {
430                        height,
431                        round,
432                        vote_type,
433                        block_hash,
434                    }) => {
435                        // Restore Cosmos vote state
436                        if let Some(cosmos_state) = state.cosmos_state_mut() {
437                            let msg_type = match vote_type {
438                                1 => crate::state::validator::CosmosSignedMsgType::Prevote,
439                                2 => crate::state::validator::CosmosSignedMsgType::Precommit,
440                                _ => crate::state::validator::CosmosSignedMsgType::Prevote,
441                            };
442                            cosmos_state.record_vote(*height, *round, msg_type, *block_hash);
443                            tracing::debug!(
444                                sequence = record.sequence,
445                                height = height,
446                                round = round,
447                                "Replayed Cosmos vote - restored state"
448                            );
449                            state_restored += 1;
450                        }
451                    }
452                    Some(SigningContext::CosmosProposal {
453                        height,
454                        round,
455                        block_hash,
456                    }) => {
457                        // Restore Cosmos proposal state
458                        if let Some(cosmos_state) = state.cosmos_state_mut() {
459                            cosmos_state.record_vote(
460                                *height,
461                                *round,
462                                crate::state::validator::CosmosSignedMsgType::Proposal,
463                                Some(*block_hash),
464                            );
465                            tracing::debug!(
466                                sequence = record.sequence,
467                                height = height,
468                                round = round,
469                                "Replayed Cosmos proposal - restored state"
470                            );
471                            state_restored += 1;
472                        }
473                    }
474                    Some(SigningContext::Other) | None => {
475                        // Generic signing types don't have slashing-relevant state
476                        tracing::debug!(
477                            sequence = record.sequence,
478                            request_type = ?record.request_type,
479                            "Replayed generic signing record (no state to restore)"
480                        );
481                    }
482                }
483            } else {
484                tracing::debug!(
485                    sequence = record.sequence,
486                    decision = ?record.decision,
487                    "Replayed refusal record"
488                );
489            }
490
491            replayed += 1;
492        }
493
494        tracing::info!(
495            replayed = replayed,
496            state_restored = state_restored,
497            "Replayed decision records"
498        );
499        Ok(replayed)
500    }
501
502    /// Get a copy of the current validator states
503    pub fn validator_states(&self) -> HashMap<[u8; 48], ValidatorState> {
504        self.validators.read().unwrap().clone()
505    }
506
507    /// Get the current state integrity
508    pub fn integrity(&self) -> StateIntegrity {
509        self.integrity.read().unwrap().clone()
510    }
511
512    /// Get the last decision sequence number
513    pub fn last_sequence(&self) -> u64 {
514        self.integrity.read().unwrap().sequence_number
515    }
516}
517
518/// Result of a successful signing operation
519#[derive(Debug, Clone)]
520pub struct SigningResult {
521    /// The BLS signature
522    pub signature: BlsSignature,
523
524    /// The decision record for logging
525    pub decision_record: DecisionRecord,
526}
527
528impl SigningResult {
529    /// Get the signature as a hex string with 0x prefix
530    pub fn signature_hex(&self) -> String {
531        self.signature.to_hex()
532    }
533
534    /// Get the signature as bytes
535    pub fn signature_bytes(&self) -> [u8; 96] {
536        self.signature.to_bytes()
537    }
538}
539
540/// Errors that can occur during signing operations
541#[derive(Debug, Error)]
542pub enum SigningServiceError {
543    #[error("Unknown validator: 0x{}", hex::encode(.0))]
544    UnknownValidator([u8; 48]),
545
546    #[error("Slashing protection: {0}")]
547    SlashingProtection(RefusalCode),
548
549    #[error("Integrity error: {0}")]
550    Integrity(#[from] IntegrityError),
551
552    #[error("Genesis validators root mismatch: expected 0x{}, got 0x{}", hex::encode(.expected), hex::encode(.actual))]
553    GenesisRootMismatch { expected: [u8; 32], actual: [u8; 32] },
554
555    #[error("Invalid signing root: {0}")]
556    InvalidSigningRoot(String),
557}
558
559/// Thread-safe wrapper for SigningService
560pub type SharedSigningService = Arc<SigningService>;
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    fn make_service() -> SigningService {
567        let keypair = BlsKeypair::generate();
568        SigningService::new(vec![keypair])
569    }
570
571    #[test]
572    fn test_sign_block_proposal() {
573        let service = make_service();
574        let pubkey = service.public_keys()[0];
575        let signing_root = [1u8; 32];
576
577        let result = service.sign_block_proposal(&pubkey, 100, signing_root);
578        assert!(result.is_ok());
579
580        let signing_result = result.unwrap();
581        assert!(!signing_result.signature_bytes().iter().all(|&b| b == 0));
582    }
583
584    #[test]
585    fn test_double_proposal_rejected() {
586        let service = make_service();
587        let pubkey = service.public_keys()[0];
588
589        // First proposal
590        let result1 = service.sign_block_proposal(&pubkey, 100, [1u8; 32]);
591        assert!(result1.is_ok());
592
593        // Different block at same slot
594        let result2 = service.sign_block_proposal(&pubkey, 100, [2u8; 32]);
595        assert!(matches!(
596            result2,
597            Err(SigningServiceError::SlashingProtection(RefusalCode::DoubleProposal))
598        ));
599    }
600
601    #[test]
602    fn test_sign_attestation() {
603        let service = make_service();
604        let pubkey = service.public_keys()[0];
605        let signing_root = [1u8; 32];
606
607        let result = service.sign_attestation(&pubkey, 10, 11, signing_root);
608        assert!(result.is_ok());
609    }
610
611    #[test]
612    fn test_double_vote_rejected() {
613        let service = make_service();
614        let pubkey = service.public_keys()[0];
615
616        // First attestation
617        let result1 = service.sign_attestation(&pubkey, 10, 11, [1u8; 32]);
618        assert!(result1.is_ok());
619
620        // Different attestation for same target
621        let result2 = service.sign_attestation(&pubkey, 10, 11, [2u8; 32]);
622        assert!(matches!(
623            result2,
624            Err(SigningServiceError::SlashingProtection(RefusalCode::DoubleVote))
625        ));
626    }
627
628    #[test]
629    fn test_surround_vote_rejected() {
630        let service = make_service();
631        let pubkey = service.public_keys()[0];
632
633        // First attestation (5, 10)
634        let result1 = service.sign_attestation(&pubkey, 5, 10, [1u8; 32]);
635        assert!(result1.is_ok());
636
637        // Surrounding attestation (3, 12) - should be rejected
638        let result2 = service.sign_attestation(&pubkey, 3, 12, [2u8; 32]);
639        assert!(matches!(
640            result2,
641            Err(SigningServiceError::SlashingProtection(RefusalCode::SurroundVote))
642        ));
643    }
644
645    #[test]
646    fn test_unknown_validator() {
647        let service = make_service();
648        let unknown_pubkey = [99u8; 48];
649
650        let result = service.sign_block_proposal(&unknown_pubkey, 100, [1u8; 32]);
651        assert!(matches!(
652            result,
653            Err(SigningServiceError::UnknownValidator(_))
654        ));
655    }
656
657    #[test]
658    fn test_sign_generic() {
659        let service = make_service();
660        let pubkey = service.public_keys()[0];
661
662        let result = service.sign_generic(&pubkey, SigningType::RandaoReveal, [1u8; 32]);
663        assert!(result.is_ok());
664    }
665
666    #[test]
667    fn test_genesis_root() {
668        let service = make_service();
669        let root1 = [1u8; 32];
670        let root2 = [2u8; 32];
671
672        // First set should succeed
673        assert!(service.set_genesis_validators_root(root1).is_ok());
674
675        // Same root should succeed
676        assert!(service.set_genesis_validators_root(root1).is_ok());
677
678        // Different root should fail
679        assert!(matches!(
680            service.set_genesis_validators_root(root2),
681            Err(SigningServiceError::GenesisRootMismatch { .. })
682        ));
683    }
684}