1use 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#[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
53pub 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 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 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 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 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 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 const SIGNED_AUTH_ROOT: [u8; 32] = [9u8; 32];
181 const UNSIGNED_AUTH_ROOT: [u8; 32] = [0xAAu8; 32];
183
184 const NUM_SIGNERS: u64 = 4;
185 const STAKE_PER_SIGNER: u64 = 100;
186
187 const EPOCH_HEIGHT: u64 = 105;
189 const ROOT_BLOCK: u64 = 100;
191 const FIXTURE_EPOCH: u64 = 1;
193
194 fn upgrade_lock_at(major: u16, minor: u16) -> UpgradeLock<SeqTypes> {
196 UpgradeLock::new(Upgrade::trivial(version(major, minor)))
197 }
198
199 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 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 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 #[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 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 #[test]
336 fn test_cert_with_mislabelled_epoch_is_rejected() {
337 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 #[test]
362 fn test_cert_for_a_non_epoch_root_block_is_rejected() {
363 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 #[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 #[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 #[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 #[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}