Skip to main content

espresso_node/
state_cert.rs

1//! State certificate validation and error handling
2
3use std::collections::HashMap;
4
5use alloy::primitives::U256;
6use anyhow::{bail, ensure};
7use espresso_types::SeqTypes;
8use hotshot_contract_adapter::light_client::derive_signed_state_digest;
9use hotshot_types::{
10    data::{EpochNumber, ViewNumber},
11    light_client::StateVerKey,
12    message::UpgradeLock,
13    simple_certificate::LightClientStateUpdateCertificateV2,
14    stake_table::HSStakeTable,
15    traits::signature_key::{LCV2StateSignatureKey, LCV3StateSignatureKey, StakeTableEntryType},
16    utils::{epoch_from_block_number, is_epoch_root, root_block_in_epoch},
17};
18
19/// Error type for state certificate fetching
20#[derive(Debug, thiserror::Error)]
21pub enum StateCertFetchError {
22    #[error("failed to fetch state certificate: {0:#}")]
23    FetchError(anyhow::Error),
24
25    #[error("state certificate validation failed: {0:#}")]
26    ValidationError(anyhow::Error),
27
28    #[error("state certificate error: {0:#}")]
29    Other(anyhow::Error),
30
31    #[error("no state certificate for epoch {epoch}: epochs have not started")]
32    EpochsNotStarted { epoch: u64 },
33
34    #[error("no state certificate for epoch {epoch}: the first epoch is {first_epoch}")]
35    BeforeFirstEpoch { epoch: u64, first_epoch: u64 },
36
37    #[error(
38        "no state certificate for epoch {epoch}: its root block is not decided, this node is at \
39         block {decided_height}"
40    )]
41    RootNotDecided { epoch: u64, decided_height: u64 },
42}
43
44/// Fails until this node decides the root block that a state certificate for `epoch` signs.
45///
46/// `decided_height` is `None` while this node's decided leaf predates epochs. `current_epoch` is
47/// the epoch this node's consensus is in, `None` if unknown: once epochs have started, a node whose
48/// decided leaf trails its consensus still fetches any epoch that consensus has already left.
49pub(crate) fn ensure_cert_can_exist(
50    epoch: EpochNumber,
51    first_epoch: EpochNumber,
52    epoch_height: u64,
53    decided_height: Option<u64>,
54    current_epoch: Option<EpochNumber>,
55) -> Result<(), StateCertFetchError> {
56    let Some(decided_height) = decided_height else {
57        return Err(StateCertFetchError::EpochsNotStarted { epoch: epoch.u64() });
58    };
59    if epoch < first_epoch {
60        return Err(StateCertFetchError::BeforeFirstEpoch {
61            epoch: epoch.u64(),
62            first_epoch: first_epoch.u64(),
63        });
64    }
65    if current_epoch.is_some_and(|current| current > epoch) {
66        return Ok(());
67    }
68    // The cert signs the root block inside `epoch` itself, not the `epoch - 2` root that sets its
69    // stake table. `epoch` comes from the URL, so epochs past the decided one are rejected before
70    // `root_block_in_epoch` multiplies it.
71    if epoch.u64() > epoch_from_block_number(decided_height, epoch_height)
72        || decided_height < root_block_in_epoch(epoch.u64(), epoch_height)
73    {
74        return Err(StateCertFetchError::RootNotDecided {
75            epoch: epoch.u64(),
76            decided_height,
77        });
78    }
79    Ok(())
80}
81
82/// Validates a state certificate: that it belongs to `expected_epoch`, and that its
83/// signatures reach the threshold under `stake_table`.
84pub fn validate_state_cert(
85    cert: &LightClientStateUpdateCertificateV2<SeqTypes>,
86    stake_table: &HSStakeTable<SeqTypes>,
87    expected_epoch: EpochNumber,
88    epoch_height: u64,
89    upgrade_lock: &UpgradeLock<SeqTypes>,
90) -> anyhow::Result<()> {
91    // Validators publish state signatures for ordinary blocks to a public relay, over the
92    // same digest verified below. Only epoch roots ever carry a genuine certificate, so
93    // without this a peer could assemble one from harvested relay signatures.
94    ensure!(
95        is_epoch_root(cert.light_client_state.block_height, epoch_height),
96        "state certificate is for block {}, which is not an epoch root",
97        cert.light_client_state.block_height
98    );
99
100    // `cert.epoch` is outside the signed digest, so a peer can set it to whatever was
101    // requested. `block_height` is inside it, so derive the epoch from that instead.
102    let derived_epoch = EpochNumber::new(epoch_from_block_number(
103        cert.light_client_state.block_height,
104        epoch_height,
105    ));
106    if derived_epoch != expected_epoch {
107        bail!(
108            "state certificate is for block {} in epoch {derived_epoch}, but epoch \
109             {expected_epoch} was requested",
110            cert.light_client_state.block_height
111        );
112    }
113
114    if cert.epoch != derived_epoch {
115        bail!(
116            "state certificate is labelled epoch {}, but its block {} belongs to epoch \
117             {derived_epoch}",
118            cert.epoch,
119            cert.light_client_state.block_height
120        );
121    }
122
123    let signed_state_digest = derive_signed_state_digest(
124        &cert.light_client_state,
125        &cert.next_stake_table_state,
126        &cert.auth_root,
127    );
128
129    // Take the version from our own upgrade lock, not the certificate: only LCV3 covers
130    // `auth_root`, so trusting it here would let a peer skip that check by zeroing it.
131    // `view_number` sits inside `light_client_state`, which LCV2 does cover.
132    // V4 is where `Header::auth_root()` stops returning zero, so that is the gate.
133    let require_lcv3 =
134        upgrade_lock.upgraded_drb_and_header(ViewNumber::new(cert.light_client_state.view_number));
135
136    let signature_map: HashMap<&StateVerKey, _> = cert
137        .signatures
138        .iter()
139        .map(|(key, lcv3_sig, lcv2_sig)| (key, (lcv3_sig, lcv2_sig)))
140        .collect();
141
142    // Verify signatures and accumulate weight
143    let mut accumulated_weight = U256::ZERO;
144
145    for peer in stake_table.iter() {
146        if let Some((lcv3_sig, lcv2_sig)) = signature_map.get(&peer.state_ver_key) {
147            let lcv2_valid = <StateVerKey as LCV2StateSignatureKey>::verify_state_sig(
148                &peer.state_ver_key,
149                lcv2_sig,
150                &cert.light_client_state,
151                &cert.next_stake_table_state,
152            );
153
154            let is_valid = if require_lcv3 {
155                let lcv3_valid = <StateVerKey as LCV3StateSignatureKey>::verify_state_sig(
156                    &peer.state_ver_key,
157                    lcv3_sig,
158                    signed_state_digest,
159                );
160
161                lcv2_valid && lcv3_valid
162            } else {
163                lcv2_valid
164            };
165
166            if is_valid {
167                accumulated_weight += peer.stake_table_entry.stake();
168            } else {
169                bail!(format!(
170                    "Invalid signature from key: {}",
171                    peer.state_ver_key
172                ))
173            }
174        }
175    }
176
177    // Check if accumulated weight meets the threshold
178    let total_stake = stake_table.total_stakes();
179    let threshold = hotshot_types::stake_table::one_honest_threshold(total_stake);
180    if accumulated_weight < threshold {
181        bail!("accumulated weight {accumulated_weight} is below threshold {threshold}");
182    }
183
184    Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189    use alloy::primitives::{FixedBytes, U256};
190    use espresso_types::PubKey;
191    use hotshot_contract_adapter::light_client::derive_signed_state_digest;
192    use hotshot_types::{
193        PeerConfig,
194        light_client::{
195            CircuitField, LightClientState, StakeTableState, StateKeyPair, StateVerKey,
196        },
197        simple_certificate::LightClientStateUpdateCertificateV2,
198        stake_table::HSStakeTable,
199        traits::signature_key::{LCV2StateSignatureKey, LCV3StateSignatureKey, SignatureKey},
200    };
201    use versions::{Upgrade, version};
202
203    use super::*;
204
205    /// Covered by the fixture's signatures.
206    const SIGNED_AUTH_ROOT: [u8; 32] = [9u8; 32];
207    /// Swapped in after signing, so no signature covers it.
208    const UNSIGNED_AUTH_ROOT: [u8; 32] = [0xAAu8; 32];
209
210    const NUM_SIGNERS: u64 = 4;
211    const STAKE_PER_SIGNER: u64 = 100;
212
213    /// Makes `ROOT_BLOCK` the epoch root of `FIXTURE_EPOCH`: (100 + 5) % 105 == 0.
214    const EPOCH_HEIGHT: u64 = 105;
215    /// The epoch root of `FIXTURE_EPOCH` under `EPOCH_HEIGHT`.
216    const ROOT_BLOCK: u64 = 100;
217    /// The epoch every fixture certificate is for.
218    const FIXTURE_EPOCH: u64 = 1;
219
220    /// With no decided upgrade certificate, the base version applies to every view.
221    fn upgrade_lock_at(major: u16, minor: u16) -> UpgradeLock<SeqTypes> {
222        UpgradeLock::new(Upgrade::trivial(version(major, minor)))
223    }
224
225    /// A V4-era certificate at the epoch root, and the stake table of its signers.
226    fn valid_cert_and_stake_table() -> (
227        LightClientStateUpdateCertificateV2<SeqTypes>,
228        HSStakeTable<SeqTypes>,
229    ) {
230        cert_and_stake_table(ROOT_BLOCK, FixedBytes::<32>::from(SIGNED_AUTH_ROOT))
231    }
232
233    /// Signs over both the block height and `auth_root`, so the certificate is
234    /// self-consistent for any pair: a caller can build a correctly signed certificate for
235    /// a block that is not an epoch root, or for any `auth_root`.
236    fn cert_and_stake_table(
237        block_height: u64,
238        auth_root: FixedBytes<32>,
239    ) -> (
240        LightClientStateUpdateCertificateV2<SeqTypes>,
241        HSStakeTable<SeqTypes>,
242    ) {
243        let light_client_state = LightClientState {
244            view_number: 42,
245            block_height,
246            block_comm_root: CircuitField::from(7u64),
247        };
248        let next_stake_table_state = StakeTableState {
249            bls_key_comm: CircuitField::from(1u64),
250            schnorr_key_comm: CircuitField::from(2u64),
251            amount_comm: CircuitField::from(3u64),
252            threshold: CircuitField::from(1u64),
253        };
254        let digest =
255            derive_signed_state_digest(&light_client_state, &next_stake_table_state, &auth_root);
256
257        let mut signatures = Vec::new();
258        let mut peers = Vec::new();
259        for i in 0..NUM_SIGNERS {
260            let state_key_pair = StateKeyPair::generate_from_seed_indexed([0u8; 32], i);
261            let sign_key = state_key_pair.sign_key_ref();
262
263            let lcv2_sig = <StateVerKey as LCV2StateSignatureKey>::sign_state(
264                sign_key,
265                &light_client_state,
266                &next_stake_table_state,
267            )
268            .expect("LCV2 sign");
269            let lcv3_sig = <StateVerKey as LCV3StateSignatureKey>::sign_state(sign_key, digest)
270                .expect("LCV3 sign");
271
272            signatures.push((state_key_pair.ver_key(), lcv3_sig, lcv2_sig));
273
274            let bls_key = PubKey::generated_from_seed_indexed([0u8; 32], i).0;
275            peers.push(PeerConfig::<SeqTypes> {
276                stake_table_entry: bls_key.stake_table_entry(U256::from(STAKE_PER_SIGNER)),
277                state_ver_key: state_key_pair.ver_key(),
278                connect_info: None,
279            });
280        }
281
282        let cert = LightClientStateUpdateCertificateV2::<SeqTypes> {
283            epoch: EpochNumber::new(FIXTURE_EPOCH),
284            light_client_state,
285            next_stake_table_state,
286            signatures,
287            auth_root,
288        };
289        (cert, HSStakeTable::from(peers))
290    }
291
292    /// Mirrors `From<LightClientStateUpdateCertificateV1>`, which clones the LCV2 signature
293    /// into the LCV3 slot: legacy certificates carry no real LCV3 signature. Both persistence
294    /// backends upcast V1 certificates on load, so this shape is live.
295    fn legacy_cert_and_stake_table() -> (
296        LightClientStateUpdateCertificateV2<SeqTypes>,
297        HSStakeTable<SeqTypes>,
298    ) {
299        let (mut cert, stake_table) = cert_and_stake_table(ROOT_BLOCK, FixedBytes::<32>::default());
300        for (_, lcv3_sig, lcv2_sig) in cert.signatures.iter_mut() {
301            *lcv3_sig = lcv2_sig.clone();
302        }
303        (cert, stake_table)
304    }
305
306    #[test]
307    fn test_valid_cert_is_accepted() {
308        let (cert, stake_table) = valid_cert_and_stake_table();
309        validate_state_cert(
310            &cert,
311            &stake_table,
312            EpochNumber::new(FIXTURE_EPOCH),
313            EPOCH_HEIGHT,
314            &upgrade_lock_at(0, 5),
315        )
316        .expect("well-formed certificate must validate");
317    }
318
319    /// `epoch` is a bare label: `derive_signed_state_digest` covers only the light client
320    /// state, the next stake table state, and the auth root. A peer can relabel a certificate
321    /// it holds for one epoch to match a request for another, and enough of the two epochs'
322    /// signers normally overlap to clear the one-honest threshold against the wrong stake
323    /// table. So the binding must be to `block_height`, which is signed.
324    #[test]
325    fn test_cert_from_another_epoch_is_rejected() {
326        let (mut cert, stake_table) = valid_cert_and_stake_table();
327
328        let digest_before = derive_signed_state_digest(
329            &cert.light_client_state,
330            &cert.next_stake_table_state,
331            &cert.auth_root,
332        );
333        cert.epoch = EpochNumber::new(2);
334        let digest_after = derive_signed_state_digest(
335            &cert.light_client_state,
336            &cert.next_stake_table_state,
337            &cert.auth_root,
338        );
339        assert_eq!(
340            digest_before, digest_after,
341            "relabelling changed the signed digest; if this ever fails the epoch became \
342             authenticated and the label check is redundant"
343        );
344
345        // Relabelling does not help: the signed block height still says epoch 1.
346        validate_state_cert(
347            &cert,
348            &stake_table,
349            EpochNumber::new(2),
350            EPOCH_HEIGHT,
351            &upgrade_lock_at(0, 5),
352        )
353        .expect_err(
354            "a cert whose signed block height belongs to epoch 1 must not satisfy a request for \
355             epoch 2, however it is labelled",
356        );
357    }
358
359    /// The label is what downstream consumers key on after validation, so it has to agree
360    /// with the block height even when the height itself satisfies the request.
361    #[test]
362    fn test_cert_with_mislabelled_epoch_is_rejected() {
363        // `ROOT_BLOCK` is an epoch root and derives to the requested epoch, so only the
364        // label check can reject this.
365        assert!(is_epoch_root(ROOT_BLOCK, EPOCH_HEIGHT));
366        assert_eq!(
367            epoch_from_block_number(ROOT_BLOCK, EPOCH_HEIGHT),
368            FIXTURE_EPOCH
369        );
370
371        let (mut cert, stake_table) = valid_cert_and_stake_table();
372        cert.epoch = EpochNumber::new(2);
373
374        validate_state_cert(
375            &cert,
376            &stake_table,
377            EpochNumber::new(FIXTURE_EPOCH),
378            EPOCH_HEIGHT,
379            &upgrade_lock_at(0, 5),
380        )
381        .expect_err("a cert whose label disagrees with its signed block height must be rejected");
382    }
383
384    /// Validators sign the light client state of ordinary blocks too, and publish those
385    /// signatures to a public relay over the same digest verified here. Only epoch roots
386    /// carry a genuine certificate, so a bundle assembled at any other height is a forgery.
387    #[test]
388    fn test_cert_for_a_non_epoch_root_block_is_rejected() {
389        // Block 99 is inside epoch 1 but is not its root, so only the epoch-root check
390        // can reject it: the signatures are valid and the derived epoch matches.
391        assert!(!is_epoch_root(99, EPOCH_HEIGHT));
392        assert_eq!(epoch_from_block_number(99, EPOCH_HEIGHT), FIXTURE_EPOCH);
393
394        let (cert, stake_table) =
395            cert_and_stake_table(99, FixedBytes::<32>::from(SIGNED_AUTH_ROOT));
396        validate_state_cert(
397            &cert,
398            &stake_table,
399            EpochNumber::new(FIXTURE_EPOCH),
400            EPOCH_HEIGHT,
401            &upgrade_lock_at(0, 5),
402        )
403        .expect_err("a cert for a block that is not an epoch root must be rejected");
404    }
405
406    /// Already rejected before the fix; kept so a refactor can't narrow the check to zero.
407    #[test]
408    fn test_unsigned_auth_root_is_rejected() {
409        let (mut cert, stake_table) = valid_cert_and_stake_table();
410        cert.auth_root = FixedBytes::<32>::from(UNSIGNED_AUTH_ROOT);
411
412        let err = validate_state_cert(
413            &cert,
414            &stake_table,
415            EpochNumber::new(FIXTURE_EPOCH),
416            EPOCH_HEIGHT,
417            &upgrade_lock_at(0, 5),
418        )
419        .expect_err("certificate with a mutated auth_root must be rejected");
420        assert!(
421            err.to_string().contains("Invalid signature"),
422            "expected signature failure, got: {err}"
423        );
424    }
425
426    /// Zeroing `auth_root` must not disable the LCV3 check.
427    /// The LCV2 signatures stay valid because they never covered `auth_root`, so before the
428    /// fix this certificate was accepted with full stake weight.
429    #[test]
430    fn test_zero_auth_root_is_rejected_on_v4() {
431        let (mut cert, stake_table) = valid_cert_and_stake_table();
432        cert.auth_root = FixedBytes::<32>::default();
433
434        validate_state_cert(
435            &cert,
436            &stake_table,
437            EpochNumber::new(FIXTURE_EPOCH),
438            EPOCH_HEIGHT,
439            &upgrade_lock_at(0, 5),
440        )
441        .expect_err("a zeroed auth_root must not disable the LCV3 check");
442    }
443
444    /// Regression guard for catchup: certificates from epochs predating V4 have a genuinely
445    /// zero `auth_root` and carry no meaningful LCV3 signature. Under a pre-V4 upgrade lock
446    /// they must still validate, so the fix cannot be "simplified" into rejecting all zeros.
447    #[test]
448    fn test_prev4_cert_with_zero_auth_root_is_accepted() {
449        let (cert, stake_table) = legacy_cert_and_stake_table();
450
451        validate_state_cert(
452            &cert,
453            &stake_table,
454            EpochNumber::new(FIXTURE_EPOCH),
455            EPOCH_HEIGHT,
456            &upgrade_lock_at(0, 3),
457        )
458        .expect("genuine pre-V4 certificates must still validate");
459    }
460
461    /// The other side of the gate: the same legacy shape must not satisfy a V4-era view.
462    /// Together with the test above this pins `require_lcv3` from both directions, so
463    /// hardcoding it either way fails.
464    #[test]
465    fn test_legacy_cert_is_rejected_on_v4() {
466        let (cert, stake_table) = legacy_cert_and_stake_table();
467
468        validate_state_cert(
469            &cert,
470            &stake_table,
471            EpochNumber::new(FIXTURE_EPOCH),
472            EPOCH_HEIGHT,
473            &upgrade_lock_at(0, 5),
474        )
475        .expect_err("legacy LCV3 slot must not satisfy the V4 check");
476    }
477
478    const DECAF_EPOCH_HEIGHT: u64 = 3000;
479    const DECAF_FIRST_EPOCH: u64 = 1054;
480    /// Read off decaf's live certificates for these epochs.
481    const FIRST_EPOCH_ROOT: u64 = 3_161_995;
482    const EPOCH_7361_ROOT: u64 = 22_082_995;
483
484    fn decaf_gate(
485        epoch: u64,
486        decided_height: u64,
487        current_epoch: Option<u64>,
488    ) -> Result<(), StateCertFetchError> {
489        ensure_cert_can_exist(
490            EpochNumber::new(epoch),
491            EpochNumber::new(DECAF_FIRST_EPOCH),
492            DECAF_EPOCH_HEIGHT,
493            Some(decided_height),
494            current_epoch.map(EpochNumber::new),
495        )
496    }
497
498    #[rstest::rstest]
499    #[case::root_just_decided(7361, EPOCH_7361_ROOT, Some(7361))]
500    #[case::epoch_last_block_decided(7361, 7361 * DECAF_EPOCH_HEIGHT, None)]
501    #[case::past_epoch(DECAF_FIRST_EPOCH, EPOCH_7361_ROOT, None)]
502    #[case::consensus_left_the_epoch(7361, EPOCH_7361_ROOT - 1000, Some(7362))]
503    #[case::first_epoch(DECAF_FIRST_EPOCH, FIRST_EPOCH_ROOT, None)]
504    fn cert_that_can_exist_is_fetched(
505        #[case] epoch: u64,
506        #[case] decided_height: u64,
507        #[case] current_epoch: Option<u64>,
508    ) {
509        decaf_gate(epoch, decided_height, current_epoch).expect("peers may hold this cert");
510    }
511
512    #[rstest::rstest]
513    #[case::root_one_block_ahead(7361, EPOCH_7361_ROOT - 1, Some(7361))]
514    #[case::current_epoch_unknown(7361, EPOCH_7361_ROOT - 1, None)]
515    #[case::previous_epoch_last_block_decided(7361, 7360 * DECAF_EPOCH_HEIGHT, None)]
516    #[case::far_future_epoch(u64::MAX, EPOCH_7361_ROOT, Some(7361))]
517    fn cert_whose_root_is_not_decided_is_not_fetched(
518        #[case] epoch: u64,
519        #[case] decided_height: u64,
520        #[case] current_epoch: Option<u64>,
521    ) {
522        let err = decaf_gate(epoch, decided_height, current_epoch).unwrap_err();
523        assert!(
524            matches!(err, StateCertFetchError::RootNotDecided { .. }),
525            "{err}"
526        );
527    }
528
529    #[rstest::rstest]
530    #[case::epoch_zero(0)]
531    #[case::before_first_epoch(DECAF_FIRST_EPOCH - 1)]
532    fn cert_before_the_first_epoch_is_not_fetched(#[case] epoch: u64) {
533        let err = decaf_gate(epoch, EPOCH_7361_ROOT, Some(7361)).unwrap_err();
534        assert!(
535            matches!(err, StateCertFetchError::BeforeFirstEpoch { .. }),
536            "{err}"
537        );
538    }
539
540    #[test]
541    fn cert_before_epochs_start_is_not_fetched() {
542        let err = ensure_cert_can_exist(
543            EpochNumber::new(DECAF_FIRST_EPOCH),
544            EpochNumber::new(DECAF_FIRST_EPOCH),
545            DECAF_EPOCH_HEIGHT,
546            None,
547            None,
548        )
549        .unwrap_err();
550        assert!(
551            matches!(err, StateCertFetchError::EpochsNotStarted { .. }),
552            "{err}"
553        );
554    }
555}