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