From 6f41e7b5675f5e5e97376d6ff4fbeeab29d770cc Mon Sep 17 00:00:00 2001 From: Brendon Fish Date: Fri, 17 Jul 2026 23:47:50 -0400 Subject: [PATCH 1/4] limit range of leaf requests --- crates/espresso/node/api/light-client.toml | 13 +- crates/espresso/node/src/api/light_client.rs | 477 ++++++++++++++++--- crates/espresso/node/src/api/state.rs | 33 +- light-client/src/state.rs | 47 ++ light-client/src/testing.rs | 20 + 5 files changed, 498 insertions(+), 92 deletions(-) diff --git a/crates/espresso/node/api/light-client.toml b/crates/espresso/node/api/light-client.toml index 734a40ba3da..6dea7474dc8 100644 --- a/crates/espresso/node/api/light-client.toml +++ b/crates/espresso/node/api/light-client.toml @@ -16,7 +16,7 @@ PATH = [ ":payload-hash" = "TaggedBase64" DOC = """ Fetch a leaf plus a proof of it's finality (a chain of leaves leading from that leaf to either a -chain of consecutive valid QCs or a known-finalized leaf). +chain of consecutive valid QCs, a finality certificate, or a known-finalized leaf). `:height` and `:finalized`, if provided, must both be block numbers. If provided, `:finalized` must be greater than `:height`. @@ -27,8 +27,13 @@ to be finalized). This consists of a chain of valid leaves from the requested le either * a 2-chain of QCs (if the last leaf in the leaf chain is from HotShot >= 0.3) * a 3-chain of QCs (if the last leaf in the leaf chain is from HotShot < 0.3) +* a certificate directly committing the last leaf in the chain (HotShot >= 0.6) * the assumed `:finalized` leaf +The server bounds the number of leaves it will include in a single proof. If `:finalized` is too +far past the requested leaf for a bounded proof, the hint is ignored and the proof instead ends in +a QC chain or certificate, which the client must verify against the appropriate quorum. + Returns ```json { @@ -47,6 +52,12 @@ Returns "deciding_qc": QC } | undefined, + // Finality proof for HotShot >= 0.6 + "NewProtocol": { + "cert2": Certificate2, + "leaf_qc": QC + } | undefined, + // Leaf chain ends in assumed-finalized leaf. "Assumption": {} } diff --git a/crates/espresso/node/src/api/light_client.rs b/crates/espresso/node/src/api/light_client.rs index 1ae0ae09419..0eafd755ec6 100644 --- a/crates/espresso/node/src/api/light_client.rs +++ b/crates/espresso/node/src/api/light_client.rs @@ -18,7 +18,10 @@ use hotshot_query_service::{ node::BlockId, types::HeightIndexed, }; -use hotshot_types::utils::{epoch_from_block_number, root_block_in_epoch}; +use hotshot_types::{ + simple_certificate::QuorumCertificate2, + utils::{epoch_from_block_number, root_block_in_epoch}, +}; use itertools::izip; use jf_merkle_tree_compat::MerkleTreeScheme; use light_client::{ @@ -33,10 +36,47 @@ use versions::NEW_PROTOCOL_VERSION; use crate::api::data_source::{NodeStateDataSource, StakeTableDataSource}; +/// Construct a proof that the requested leaf is finalized. +/// +/// A nearby `finalized` hint yields the cheapest proof: a short leaf chain the client can verify +/// against its known-finalized leaf without any signature checks. A distant hint (or none) would +/// require an unboundedly long chain, so instead finality is proven directly, in a way appropriate +/// to the leaf's protocol version, and the client verifies the proof against a quorum. +pub(crate) async fn get_leaf_proof( + state: &State, + requested_leaf: LeafQueryData, + finalized: Option, + fetch_timeout: Duration, + chain_limit: usize, +) -> Result +where + State: AvailabilityDataSource + VersionedDataSource, + for<'a> State::ReadOnly<'a>: NodeStorage, +{ + let requested = requested_leaf.height() as usize; + match finalized { + Some(finalized) if finalized.saturating_sub(requested) <= chain_limit => { + get_leaf_proof_with_finalized_assumption( + state, + requested_leaf, + finalized, + fetch_timeout, + chain_limit, + ) + .await + }, + _ if requested_leaf.header().version() >= NEW_PROTOCOL_VERSION => { + get_leaf_proof_with_cert2(state, requested_leaf, fetch_timeout, chain_limit).await + }, + _ => get_leaf_proof_with_qc_chain(state, requested_leaf, fetch_timeout, chain_limit).await, + } +} + pub(crate) async fn get_leaf_proof_with_qc_chain( state: &State, requested_leaf: LeafQueryData, fetch_timeout: Duration, + chain_limit: usize, ) -> Result where State: AvailabilityDataSource + VersionedDataSource, @@ -53,14 +93,35 @@ where let mut leaves = state.get_leaf_range(requested + 1..latest_height).await; let mut proof = LeafProof::default(); + let requested_leaf_qc = requested_leaf.qc().clone(); proof.push(requested_leaf); + let mut remaining = chain_limit; while let Some(leaf) = leaves.next().await { let leaf = leaf .with_timeout(fetch_timeout) .await .ok_or_else(|| not_found("missing leaves"))?; + remaining = remaining + .checked_sub(1) + .ok_or_else(|| chain_too_long(requested, chain_limit))?; + + // Once the chain crosses into the new protocol, the HotStuff commit rules can no longer + // terminate it; cert2 is the finality mechanism from there on. + if leaf.header().version() >= NEW_PROTOCOL_VERSION { + complete_proof_with_cert2( + state, + &mut proof, + leaf, + requested_leaf_qc.clone(), + fetch_timeout, + remaining, + ) + .await?; + return Ok(proof); + } + if proof.push(leaf) { return Ok(proof); } @@ -88,47 +149,90 @@ pub(crate) async fn get_leaf_proof_with_cert2( state: &State, requested_leaf: LeafQueryData, fetch_timeout: Duration, + chain_limit: usize, ) -> Result where State: AvailabilityDataSource + VersionedDataSource, for<'a> State::ReadOnly<'a>: NodeStorage, { - let requested_height = requested_leaf.height(); + let mut proof = LeafProof::default(); + let requested_leaf_qc = requested_leaf.qc().clone(); + complete_proof_with_cert2( + state, + &mut proof, + requested_leaf, + requested_leaf_qc, + fetch_timeout, + chain_limit, + ) + .await?; + Ok(proof) +} + +/// Complete a leaf proof using new protocol certificate2 finality. +/// +/// `leaf` is the next leaf to add to the proof's chain: either the requested leaf itself (when the +/// chain is empty) or the first new-protocol leaf encountered while extending a chain that started +/// before the protocol cutover. The chain is extended from `leaf` to the leaf directly committed by +/// the earliest stored cert2, which is then attached to complete the proof. At most `chain_limit` +/// leaves are added past `leaf`, bounding the memory needed to construct the proof. +async fn complete_proof_with_cert2( + state: &State, + proof: &mut LeafProof, + leaf: LeafQueryData, + requested_leaf_qc: QuorumCertificate2, + fetch_timeout: Duration, + chain_limit: usize, +) -> Result<(), Error> +where + State: AvailabilityDataSource + VersionedDataSource, + for<'a> State::ReadOnly<'a>: NodeStorage, +{ + let start_height = leaf.height(); + let start_commit = leaf.leaf().commit(); + + // The new leaf may already complete a HotStuff chain among the leaves in the proof (possible + // when the chain started shortly before the protocol cutover). + if proof.push(leaf) { + return Ok(()); + } + let cert2 = state .read() .await .map_err(internal)? - .load_earliest_cert2(requested_height) + .load_earliest_cert2(start_height) .await .map_err(internal)? .ok_or_else(|| { not_found(format!( - "no cert2 finality proof available at or after height {requested_height}" + "no cert2 finality proof available at or after height {start_height}" )) })?; let cert2_height = cert2.data.block_number; - if cert2_height < requested_height { + if cert2_height < start_height { return Err(not_found( "cert2 finality proof is older than requested leaf", )); } + if cert2_height - start_height > chain_limit as u64 { + return Err(not_found(format!( + "earliest cert2 finality proof (height {cert2_height}) is more than {chain_limit} \ + leaves past height {start_height}" + ))); + } - let mut proof = LeafProof::default(); - let requested_leaf_qc = requested_leaf.qc().clone(); - - if cert2_height == requested_height { - if requested_leaf.leaf().commit() != cert2.data.leaf_commit { + if cert2_height == start_height { + if start_commit != cert2.data.leaf_commit { return Err(internal("stored cert2 does not finalize the expected leaf")); } - proof.push(requested_leaf); proof.add_certificate(Arc::new(cert2), requested_leaf_qc); - return Ok(proof); + return Ok(()); } - proof.push(requested_leaf); let mut leaves = state - .get_leaf_range(requested_height as usize + 1..cert2_height as usize + 1) + .get_leaf_range(start_height as usize + 1..cert2_height as usize + 1) .await; // Extend the proof chain until we reach the leaf directly committed by cert2. Once that leaf is // present and matches cert2's commitment, the requested leaf is finalized by the indirect @@ -143,11 +247,14 @@ where if leaf.leaf().commit() != cert2.data.leaf_commit { return Err(internal("stored cert2 does not finalize the expected leaf")); } - proof.push(leaf); - proof.add_certificate(Arc::new(cert2), requested_leaf_qc); - return Ok(proof); + if !proof.push(leaf) { + proof.add_certificate(Arc::new(cert2), requested_leaf_qc); + } + return Ok(()); + } + if proof.push(leaf) { + return Ok(()); } - proof.push(leaf); } Err(not_found("missing cert2 leaf")) @@ -158,6 +265,7 @@ pub(crate) async fn get_leaf_proof_with_finalized_assumption( requested_leaf: LeafQueryData, finalized: usize, fetch_timeout: Duration, + chain_limit: usize, ) -> Result where State: AvailabilityDataSource, @@ -174,6 +282,18 @@ where status: StatusCode::BAD_REQUEST, }); } + // The proof may need to span every leaf from the requested one to the finalized one, so a + // distant hint would require materializing an arbitrarily long chain. + if finalized - requested > chain_limit { + return Err(Error::Custom { + message: format!( + "finalized leaf height ({finalized}) is more than {chain_limit} blocks past the \ + requested leaf ({requested}); request a proof without the finalized parameter \ + instead" + ), + status: StatusCode::BAD_REQUEST, + }); + } let mut leaves = state.get_leaf_range(requested + 1..finalized).await; let mut proof = LeafProof::default(); @@ -433,6 +553,14 @@ pub(super) struct Options { /// belongs to a class which might contain a large payload, the large object limit always /// applies. pub large_object_range_limit: usize, + + /// The maximum number of leaves included in a single leaf proof. + /// + /// Proving an old leaf finalized requires constructing a chain of leaves from the requested + /// leaf to one whose finality can be established directly. This limit bounds the length of + /// that chain -- and thus the memory needed to construct and serialize it -- regardless of + /// what the client requests. + pub leaf_proof_chain_limit: usize, } impl Default for Options { @@ -440,6 +568,7 @@ impl Default for Options { Self { fetch_timeout: Duration::from_millis(500), large_object_range_limit: availability::Options::default().large_object_range_limit, + leaf_proof_chain_limit: availability::Options::default().small_object_range_limit, } } } @@ -464,6 +593,7 @@ where let Options { fetch_timeout, large_object_range_limit, + leaf_proof_chain_limit, } = opt; api.get("leaf", move |req, state| { @@ -473,19 +603,14 @@ where .opt_integer_param("finalized") .map_err(bad_param("finalized"))?; - if let Some(finalized) = finalized { - get_leaf_proof_with_finalized_assumption( - state, - requested_leaf, - finalized, - fetch_timeout, - ) - .await - } else if requested_leaf.header().version() >= NEW_PROTOCOL_VERSION { - get_leaf_proof_with_cert2(state, requested_leaf, fetch_timeout).await - } else { - get_leaf_proof_with_qc_chain(state, requested_leaf, fetch_timeout).await - } + get_leaf_proof( + state, + requested_leaf, + finalized, + fetch_timeout, + leaf_proof_chain_limit, + ) + .await } .boxed() })? @@ -820,6 +945,12 @@ fn not_found(msg: impl Into) -> Error { } } +fn chain_too_long(requested: usize, chain_limit: usize) -> Error { + not_found(format!( + "no finality proof found within {chain_limit} leaves of requested leaf {requested}" + )) +} + #[cfg(test)] mod test { use std::marker::PhantomData; @@ -832,17 +963,19 @@ mod test { data_source::{Transaction, storage::UpdateAvailabilityStorage}, merklized_state::UpdateStateData, }; - use hotshot_types::{simple_certificate::CertificatePair, simple_vote::Vote2Data}; + use hotshot_types::{ + data::ViewNumber, simple_certificate::CertificatePair, simple_vote::Vote2Data, + }; use jf_merkle_tree_compat::{AppendableMerkleTreeScheme, ToTraversalPath}; use light_client::{ consensus::leaf::{FinalityProof, LeafProofHint}, testing::{ AlwaysTrueQuorum, ENABLE_EPOCHS, LEGACY_VERSION, TestClient, VersionCheckQuorum, - leaf_chain, leaf_chain_with_upgrade, + custom_leaf_chain_with_upgrade, leaf_chain, leaf_chain_with_upgrade, }, }; use tide_disco::Error; - use versions::{DRB_AND_HEADER_UPGRADE_VERSION, EPOCH_VERSION, NEW_PROTOCOL_VERSION}; + use versions::{DRB_AND_HEADER_UPGRADE_VERSION, EPOCH_VERSION, NEW_PROTOCOL_VERSION, Upgrade}; use super::*; use crate::api::{ @@ -850,6 +983,24 @@ mod test { sql::DataSource, }; + const CHAIN_LIMIT: usize = 500; + + /// Construct a cert2 which directly commits `leaf`. + fn cert2_for_leaf(leaf: &LeafQueryData) -> espresso_types::Certificate2 { + let data = Vote2Data { + leaf_commit: leaf.leaf().commit(), + epoch: leaf.qc().data.epoch.unwrap(), + block_number: leaf.height(), + }; + espresso_types::Certificate2::new( + data.clone(), + data.commit(), + leaf.leaf().view_number(), + None, + PhantomData, + ) + } + #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn test_two_chain() { let storage = ::create_storage().await; @@ -872,9 +1023,10 @@ mod test { } // Ask for the first leaf; it is proved finalized by the chain formed along with the second. - let proof = get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::MAX) - .await - .unwrap(); + let proof = + get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::MAX, CHAIN_LIMIT) + .await + .unwrap(); assert_eq!( proof .verify(LeafProofHint::Quorum(&AlwaysTrueQuorum)) @@ -904,10 +1056,15 @@ mod test { tx.commit().await.unwrap(); } - let proof = - get_leaf_proof_with_finalized_assumption(&ds, leaves[0].clone(), 2, Duration::MAX) - .await - .unwrap(); + let proof = get_leaf_proof_with_finalized_assumption( + &ds, + leaves[0].clone(), + 2, + Duration::MAX, + CHAIN_LIMIT, + ) + .await + .unwrap(); assert_eq!( proof .verify(LeafProofHint::assumption(leaves[1].leaf())) @@ -935,10 +1092,15 @@ mod test { tx.commit().await.unwrap(); } - let proof = - get_leaf_proof_with_finalized_assumption(&ds, leaves[0].clone(), 2, Duration::MAX) - .await - .unwrap(); + let proof = get_leaf_proof_with_finalized_assumption( + &ds, + leaves[0].clone(), + 2, + Duration::MAX, + CHAIN_LIMIT, + ) + .await + .unwrap(); assert!(matches!(proof.proof(), FinalityProof::Assumption)); assert_eq!( proof @@ -962,18 +1124,7 @@ mod test { let leaves = leaf_chain(1..=2, NEW_PROTOCOL_VERSION).await; let cert2_leaf = &leaves[1]; - let cert2_data = Vote2Data { - leaf_commit: cert2_leaf.leaf().commit(), - epoch: cert2_leaf.qc().data.epoch.unwrap(), - block_number: cert2_leaf.height(), - }; - let cert2 = espresso_types::Certificate2::new( - cert2_data.clone(), - cert2_data.commit(), - cert2_leaf.leaf().view_number(), - None, - PhantomData, - ); + let cert2 = cert2_for_leaf(cert2_leaf); { let mut tx = ds.write().await.unwrap(); @@ -983,7 +1134,7 @@ mod test { tx.commit().await.unwrap(); } - let proof = get_leaf_proof_with_cert2(&ds, leaves[0].clone(), Duration::MAX) + let proof = get_leaf_proof_with_cert2(&ds, leaves[0].clone(), Duration::MAX, CHAIN_LIMIT) .await .unwrap(); assert!(matches!(proof.proof(), FinalityProof::NewProtocol { .. })); @@ -996,6 +1147,177 @@ mod test { ); } + #[test_log::test(tokio::test(flavor = "multi_thread"))] + async fn test_new_protocol_cert2_chain_limit() { + let storage = ::create_storage().await; + let ds = DataSource::create( + DataSource::persistence_options(&storage), + Default::default(), + false, + ) + .await + .unwrap(); + + // New protocol leaves never form a HotStuff QC chain, so a proof for the first leaf must + // extend all the way to the nearest cert2. + let leaves = leaf_chain(1..=4, NEW_PROTOCOL_VERSION).await; + let cert2_leaf = &leaves[3]; + let cert2 = cert2_for_leaf(cert2_leaf); + + { + let mut tx = ds.write().await.unwrap(); + for leaf in &leaves { + tx.insert_leaf(leaf).await.unwrap(); + } + tx.insert_cert2(cert2_leaf.height(), cert2).await.unwrap(); + tx.commit().await.unwrap(); + } + + // If the nearest cert2 is further away than the chain limit, the proof is refused rather + // than materializing an unbounded chain. + let err = get_leaf_proof_with_cert2(&ds, leaves[0].clone(), Duration::MAX, 2) + .await + .unwrap_err(); + assert_eq!(err.status(), StatusCode::NOT_FOUND); + + // Within the limit, the proof includes the full chain up to the cert2 leaf. + let proof = get_leaf_proof_with_cert2(&ds, leaves[0].clone(), Duration::MAX, 3) + .await + .unwrap(); + assert!(matches!(proof.proof(), FinalityProof::NewProtocol { .. })); + assert_eq!( + proof + .verify(LeafProofHint::Quorum(&AlwaysTrueQuorum)) + .await + .unwrap(), + leaves[0] + ); + } + + #[test_log::test(tokio::test(flavor = "multi_thread"))] + async fn test_qc_chain_chain_limit() { + let storage = ::create_storage().await; + let ds = DataSource::create( + DataSource::persistence_options(&storage), + Default::default(), + false, + ) + .await + .unwrap(); + + // Insert some leaves, forming a chain. Proving the first leaf finalized requires walking + // the two subsequent leaves. + let leaves = leaf_chain(1..=3, EPOCH_VERSION).await; + { + let mut tx = ds.write().await.unwrap(); + for leaf in &leaves { + tx.insert_leaf(leaf).await.unwrap(); + } + tx.commit().await.unwrap(); + } + + // A limit too small to reach the QC chain fails rather than walking further. + let err = get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::MAX, 1) + .await + .unwrap_err(); + assert_eq!(err.status(), StatusCode::NOT_FOUND); + + // A sufficient limit succeeds. + let proof = get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::MAX, 2) + .await + .unwrap(); + assert_eq!( + proof + .verify(LeafProofHint::Quorum(&AlwaysTrueQuorum)) + .await + .unwrap(), + leaves[0] + ); + } + + #[test_log::test(tokio::test(flavor = "multi_thread"))] + async fn test_finalized_hint_too_far() { + let storage = ::create_storage().await; + let ds = DataSource::create( + DataSource::persistence_options(&storage), + Default::default(), + false, + ) + .await + .unwrap(); + + let leaves = leaf_chain(1..=2, EPOCH_VERSION).await; + { + let mut tx = ds.write().await.unwrap(); + tx.insert_leaf(&leaves[0]).await.unwrap(); + tx.commit().await.unwrap(); + } + + // A finalized hint further from the requested leaf than the chain limit is rejected: the + // proof chain could span the entire distance. + let err = get_leaf_proof_with_finalized_assumption( + &ds, + leaves[0].clone(), + 1000, + Duration::MAX, + 10, + ) + .await + .unwrap_err(); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + } + + #[test_log::test(tokio::test(flavor = "multi_thread"))] + async fn test_qc_chain_new_protocol_cutover() { + let storage = ::create_storage().await; + let ds = DataSource::create( + DataSource::persistence_options(&storage), + Default::default(), + false, + ) + .await + .unwrap(); + + // Upgrade to the new protocol in the middle of the chain, and skew the leaf view numbers + // so that no HotStuff 2-chain ever forms. Proving the first (pre-upgrade) leaf finalized + // must then fall through to cert2 finality instead of walking the chain indefinitely. + let leaves = custom_leaf_chain_with_upgrade( + 1..=4, + 2, + Upgrade::new(DRB_AND_HEADER_UPGRADE_VERSION, NEW_PROTOCOL_VERSION), + |proposal| { + proposal.view_number = ViewNumber::new(proposal.block_header.height() * 2); + }, + ) + .await; + assert_eq!(leaves[0].header().version(), DRB_AND_HEADER_UPGRADE_VERSION); + assert_eq!(leaves[1].header().version(), NEW_PROTOCOL_VERSION); + let cert2_leaf = &leaves[3]; + let cert2 = cert2_for_leaf(cert2_leaf); + + { + let mut tx = ds.write().await.unwrap(); + for leaf in &leaves { + tx.insert_leaf(leaf).await.unwrap(); + } + tx.insert_cert2(cert2_leaf.height(), cert2).await.unwrap(); + tx.commit().await.unwrap(); + } + + let proof = + get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::MAX, CHAIN_LIMIT) + .await + .unwrap(); + assert!(matches!(proof.proof(), FinalityProof::NewProtocol { .. })); + assert_eq!( + proof + .verify(LeafProofHint::Quorum(&AlwaysTrueQuorum)) + .await + .unwrap(), + leaves[0] + ); + } + #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn test_bad_finalized() { let storage = ::create_storage().await; @@ -1016,10 +1338,15 @@ mod test { tx.commit().await.unwrap(); } - let err = - get_leaf_proof_with_finalized_assumption(&ds, leaves[0].clone(), 0, Duration::MAX) - .await - .unwrap_err(); + let err = get_leaf_proof_with_finalized_assumption( + &ds, + leaves[0].clone(), + 0, + Duration::MAX, + CHAIN_LIMIT, + ) + .await + .unwrap_err(); assert_eq!(err.status(), StatusCode::BAD_REQUEST); } @@ -1045,9 +1372,14 @@ mod test { tx.commit().await.unwrap(); } - let err = get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::from_secs(1)) - .await - .unwrap_err(); + let err = get_leaf_proof_with_qc_chain( + &ds, + leaves[0].clone(), + Duration::from_secs(1), + CHAIN_LIMIT, + ) + .await + .unwrap_err(); assert_eq!(err.status(), StatusCode::NOT_FOUND); // Even if we start from a finalized leave that extends one of the leaves we do have (4, @@ -1058,6 +1390,7 @@ mod test { leaves[0].clone(), 4, Duration::from_secs(1), + CHAIN_LIMIT, ) .await .unwrap_err(); @@ -1089,9 +1422,10 @@ mod test { tx.commit().await.unwrap(); } - let proof = get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::MAX) - .await - .unwrap(); + let proof = + get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::MAX, CHAIN_LIMIT) + .await + .unwrap(); assert_eq!( proof .verify(LeafProofHint::Quorum(&AlwaysTrueQuorum)) @@ -1131,9 +1465,10 @@ mod test { tx.commit().await.unwrap(); } - let proof = get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::MAX) - .await - .unwrap(); + let proof = + get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::MAX, CHAIN_LIMIT) + .await + .unwrap(); assert_eq!( proof .verify(LeafProofHint::Quorum(&VersionCheckQuorum::new( diff --git a/crates/espresso/node/src/api/state.rs b/crates/espresso/node/src/api/state.rs index ca49348406f..c269d6ecb4f 100644 --- a/crates/espresso/node/src/api/state.rs +++ b/crates/espresso/node/src/api/state.rs @@ -2961,26 +2961,15 @@ where .await .ok_or_else(|| not_found(format!("unknown leaf {requested}")))?; - let proof_result = if let Some(finalized) = finalized { - crate::api::light_client::get_leaf_proof_with_finalized_assumption( - ds, - requested_leaf, - finalized as usize, - fetch_timeout, - ) - .await - } else if requested_leaf.header().version() >= versions::NEW_PROTOCOL_VERSION { - crate::api::light_client::get_leaf_proof_with_cert2(ds, requested_leaf, fetch_timeout) - .await - } else { - crate::api::light_client::get_leaf_proof_with_qc_chain( - ds, - requested_leaf, - fetch_timeout, - ) - .await - }; - proof_result.map_err(|err| anyhow::anyhow!("{err}")) + crate::api::light_client::get_leaf_proof( + ds, + requested_leaf, + finalized.map(|f| f as usize), + fetch_timeout, + lc_leaf_proof_chain_limit(), + ) + .await + .map_err(|err| anyhow::anyhow!("{err}")) } async fn get_header_proof( @@ -3178,6 +3167,10 @@ fn lc_large_object_range_limit() -> usize { hotshot_query_service::availability::Options::default().large_object_range_limit } +fn lc_leaf_proof_chain_limit() -> usize { + crate::api::light_client::Options::default().leaf_proof_chain_limit +} + // ============================================================================ // v1::HotShotEventsApi implementation // ============================================================================ diff --git a/light-client/src/state.rs b/light-client/src/state.rs index c75f587b494..033371a4220 100644 --- a/light-client/src/state.rs +++ b/light-client/src/state.rs @@ -59,6 +59,17 @@ pub struct Genesis { pub chain_id: ChainId, } +/// Maximum distance between a requested leaf and the known-finalized leaf sent to the server as a +/// `finalized` hint. +/// +/// A nearby hint gives the cheapest possible proof: a short leaf chain verified by parent +/// commitments alone. But the chain may span every leaf between the requested and finalized +/// heights, so a distant hint would ask the server to materialize an arbitrarily long chain +/// (servers bound the proofs they are willing to construct, and reject or ignore distant hints). +/// Beyond this distance we omit the hint; the server then chooses a bounded finality proof which +/// we verify against a quorum. +const MAX_FINALIZED_HINT_DISTANCE: u64 = 500; + #[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "clap", derive(clap::Parser))] pub struct LightClientOptions { @@ -200,6 +211,18 @@ where } else { None }; + // Only use the upper bound as a finalized hint if it is close to the requested leaf. The + // proof chain the server constructs for a finalized hint may span every leaf between the + // requested and finalized heights, so servers refuse distant hints; without the hint, the + // server picks a bounded finality proof which we verify against a quorum instead. + let known_finalized = known_finalized.filter(|anchor| match id { + LeafId::Number(n) => { + anchor.height().saturating_sub(n as u64) <= MAX_FINALIZED_HINT_DISTANCE + }, + // Hash lookups in the database only ever return the requested leaf itself, which is + // handled above. + LeafId::Hash(_) => true, + }); let known_finalized = known_finalized.as_ref().map(LeafQueryData::leaf); self.fetch_leaf_from_server(id, known_finalized, quorum) .await @@ -954,6 +977,30 @@ mod test { ); } + #[tokio::test] + #[test_log::test] + async fn test_fetch_leaf_distant_upper_bound() { + let client = TestClient::default(); + // Simulate a server which refuses to build long proof chains for distant finalized hints. + client.reject_distant_finalized_hints(16).await; + + // Cache a leaf far above the requested one (e.g. the chain tip, as after a fresh sync). It + // must not be sent to the server as the finalized hint; the fetch should instead use a + // quorum-verified proof. + let distant = MAX_FINALIZED_HINT_DISTANCE + 2; + let distant_leaf = leaf_chain(distant..=distant, DRB_AND_HEADER_UPGRADE_VERSION) + .await + .remove(0); + let db = SqliteStorage::default().await.unwrap(); + db.insert_leaf(distant_leaf).await.unwrap(); + + let lc = LightClient::from_genesis(db, client.clone(), client.genesis().await); + assert_eq!( + lc.fetch_leaf(LeafId::Number(1)).await.unwrap(), + client.leaf(1).await, + ); + } + #[tokio::test] #[test_log::test] async fn test_fetch_leaf_invalid_proof() { diff --git a/light-client/src/testing.rs b/light-client/src/testing.rs index 5baf6814906..21f3572a7ff 100644 --- a/light-client/src/testing.rs +++ b/light-client/src/testing.rs @@ -391,6 +391,9 @@ struct InnerTestClient { upgrade: Option<(u64, Version)>, /// `Certificate2` finality proofs for new-protocol leaves, by height. cert2s: HashMap>, + /// If set, fail leaf proof requests whose `finalized` hint is more than this many blocks past + /// the requested leaf. + max_finalized_hint_distance: Option, } impl InnerTestClient { @@ -792,6 +795,13 @@ impl TestClient { let mut inner = self.inner.lock().await; inner.mock_block_height = Some(height); } + + /// Fail leaf proof requests whose `finalized` hint is more than `max_distance` past the + /// requested leaf, like a real server which bounds the proofs it constructs. + pub async fn reject_distant_finalized_hints(&self, max_distance: u64) { + let mut inner = self.inner.lock().await; + inner.max_finalized_hint_distance = Some(max_distance); + } } impl Client for TestClient { @@ -823,6 +833,16 @@ impl Client for TestClient { height = *sub; }; + if let (Some(finalized), Some(max_distance)) = + (finalized, inner.max_finalized_hint_distance) + && finalized.saturating_sub(height as u64) > max_distance + { + bail!( + "finalized hint ({finalized}) is more than {max_distance} blocks past the \ + requested leaf ({height})" + ); + } + let leaf = inner.leaf(height, self.epoch_height).await; let mut proof = LeafProof::default(); From 689b5919c6f0fff0506fecec25f01d9c3f5d5251 Mon Sep 17 00:00:00 2001 From: Brendon Fish Date: Sun, 19 Jul 2026 21:54:27 -0400 Subject: [PATCH 2/4] send smallest proof possible --- crates/espresso/node/api/light-client.toml | 7 +- crates/espresso/node/src/api/light_client.rs | 157 +++++++++++++++---- light-client/src/state.rs | 12 +- 3 files changed, 129 insertions(+), 47 deletions(-) diff --git a/crates/espresso/node/api/light-client.toml b/crates/espresso/node/api/light-client.toml index 6dea7474dc8..544dee57c81 100644 --- a/crates/espresso/node/api/light-client.toml +++ b/crates/espresso/node/api/light-client.toml @@ -30,9 +30,10 @@ either * a certificate directly committing the last leaf in the chain (HotShot >= 0.6) * the assumed `:finalized` leaf -The server bounds the number of leaves it will include in a single proof. If `:finalized` is too -far past the requested leaf for a bounded proof, the hint is ignored and the proof instead ends in -a QC chain or certificate, which the client must verify against the appropriate quorum. +The server bounds the number of leaves it will include in a single proof. The `:finalized` hint is +ignored if it is too far past the requested leaf for a bounded proof, or if a finality certificate +nearer the requested leaf yields a shorter proof; the proof instead ends in a QC chain or +certificate, which the client must verify against the appropriate quorum. Returns ```json diff --git a/crates/espresso/node/src/api/light_client.rs b/crates/espresso/node/src/api/light_client.rs index 0eafd755ec6..deadc07a286 100644 --- a/crates/espresso/node/src/api/light_client.rs +++ b/crates/espresso/node/src/api/light_client.rs @@ -38,10 +38,10 @@ use crate::api::data_source::{NodeStateDataSource, StakeTableDataSource}; /// Construct a proof that the requested leaf is finalized. /// -/// A nearby `finalized` hint yields the cheapest proof: a short leaf chain the client can verify -/// against its known-finalized leaf without any signature checks. A distant hint (or none) would -/// require an unboundedly long chain, so instead finality is proven directly, in a way appropriate -/// to the leaf's protocol version, and the client verifies the proof against a quorum. +/// A usable `finalized` hint yields a leaf chain the client can verify against its known-finalized +/// leaf without signature checks. The hint is ignored if the chain connecting to it could exceed +/// `chain_limit`, or if a cert2 near the requested leaf yields a shorter proof; finality is then +/// proven directly, for the client to verify against a quorum. pub(crate) async fn get_leaf_proof( state: &State, requested_leaf: LeafQueryData, @@ -54,24 +54,55 @@ where for<'a> State::ReadOnly<'a>: NodeStorage, { let requested = requested_leaf.height() as usize; - match finalized { - Some(finalized) if finalized.saturating_sub(requested) <= chain_limit => { - get_leaf_proof_with_finalized_assumption( - state, - requested_leaf, - finalized, - fetch_timeout, - chain_limit, - ) - .await - }, - _ if requested_leaf.header().version() >= NEW_PROTOCOL_VERSION => { - get_leaf_proof_with_cert2(state, requested_leaf, fetch_timeout, chain_limit).await - }, - _ => get_leaf_proof_with_qc_chain(state, requested_leaf, fetch_timeout, chain_limit).await, + let new_protocol = requested_leaf.header().version() >= NEW_PROTOCOL_VERSION; + + let mut hint = finalized.filter(|finalized| finalized.saturating_sub(requested) <= chain_limit); + + // A chain of new-protocol leaves can only end at the hint itself, spanning every leaf in + // between, so drop the hint when a nearby cert2 yields a strictly shorter proof. + if let Some(finalized) = hint + && new_protocol + && let Some(cert2_height) = earliest_cert2_height(state, requested as u64).await + && cert2_height + 1 < finalized as u64 + { + hint = None; + } + + if let Some(finalized) = hint { + get_leaf_proof_with_finalized_assumption( + state, + requested_leaf, + finalized, + fetch_timeout, + chain_limit, + ) + .await + } else if new_protocol { + get_leaf_proof_with_cert2(state, requested_leaf, fetch_timeout, chain_limit).await + } else { + get_leaf_proof_with_qc_chain(state, requested_leaf, fetch_timeout, chain_limit).await } } +/// The height of the earliest stored cert2 at or after `height`, if one is available. +/// +/// Storage errors are treated as no cert2 available: this only chooses between proof strategies, +/// and the chosen path will surface any persistent error. +async fn earliest_cert2_height(state: &State, height: u64) -> Option +where + State: VersionedDataSource, + for<'a> State::ReadOnly<'a>: NodeStorage, +{ + let cert2 = state + .read() + .await + .ok()? + .load_earliest_cert2(height) + .await + .ok()??; + Some(cert2.data.block_number) +} + pub(crate) async fn get_leaf_proof_with_qc_chain( state: &State, requested_leaf: LeafQueryData, @@ -107,8 +138,7 @@ where .checked_sub(1) .ok_or_else(|| chain_too_long(requested, chain_limit))?; - // Once the chain crosses into the new protocol, the HotStuff commit rules can no longer - // terminate it; cert2 is the finality mechanism from there on. + // HotStuff commit rules cannot terminate a chain of new-protocol leaves; switch to cert2. if leaf.header().version() >= NEW_PROTOCOL_VERSION { complete_proof_with_cert2( state, @@ -171,11 +201,9 @@ where /// Complete a leaf proof using new protocol certificate2 finality. /// -/// `leaf` is the next leaf to add to the proof's chain: either the requested leaf itself (when the -/// chain is empty) or the first new-protocol leaf encountered while extending a chain that started -/// before the protocol cutover. The chain is extended from `leaf` to the leaf directly committed by -/// the earliest stored cert2, which is then attached to complete the proof. At most `chain_limit` -/// leaves are added past `leaf`, bounding the memory needed to construct the proof. +/// Extends the proof's chain from `leaf` (the requested leaf, or the first new-protocol leaf in a +/// chain begun before the cutover) to the leaf directly committed by the earliest stored cert2, +/// adding at most `chain_limit` leaves. async fn complete_proof_with_cert2( state: &State, proof: &mut LeafProof, @@ -191,8 +219,7 @@ where let start_height = leaf.height(); let start_commit = leaf.leaf().commit(); - // The new leaf may already complete a HotStuff chain among the leaves in the proof (possible - // when the chain started shortly before the protocol cutover). + // The new leaf may already complete a HotStuff chain begun before the protocol cutover. if proof.push(leaf) { return Ok(()); } @@ -282,8 +309,6 @@ where status: StatusCode::BAD_REQUEST, }); } - // The proof may need to span every leaf from the requested one to the finalized one, so a - // distant hint would require materializing an arbitrarily long chain. if finalized - requested > chain_limit { return Err(Error::Custom { message: format!( @@ -556,10 +581,8 @@ pub(super) struct Options { /// The maximum number of leaves included in a single leaf proof. /// - /// Proving an old leaf finalized requires constructing a chain of leaves from the requested - /// leaf to one whose finality can be established directly. This limit bounds the length of - /// that chain -- and thus the memory needed to construct and serialize it -- regardless of - /// what the client requests. + /// Bounds the memory needed to construct and serialize a proof, regardless of what the client + /// requests. pub leaf_proof_chain_limit: usize, } @@ -1194,6 +1217,72 @@ mod test { ); } + #[test_log::test(tokio::test(flavor = "multi_thread"))] + async fn test_new_protocol_hint_vs_cert2() { + let storage = ::create_storage().await; + let ds = DataSource::create( + DataSource::persistence_options(&storage), + Default::default(), + false, + ) + .await + .unwrap(); + + let leaves = leaf_chain(1..=5, NEW_PROTOCOL_VERSION).await; + let cert2_leaf = &leaves[1]; + let cert2 = cert2_for_leaf(cert2_leaf); + + { + let mut tx = ds.write().await.unwrap(); + for leaf in &leaves { + tx.insert_leaf(leaf).await.unwrap(); + } + tx.insert_cert2(cert2_leaf.height(), cert2).await.unwrap(); + tx.commit().await.unwrap(); + } + + // The cert2 at height 2 yields a shorter proof than the chain to the finalized hint at + // height 5, so the hint is ignored. + let proof = get_leaf_proof(&ds, leaves[0].clone(), Some(5), Duration::MAX, CHAIN_LIMIT) + .await + .unwrap(); + assert!(matches!(proof.proof(), FinalityProof::NewProtocol { .. })); + assert_eq!( + proof + .verify(LeafProofHint::Quorum(&AlwaysTrueQuorum)) + .await + .unwrap(), + leaves[0] + ); + + // With the hint at height 3, the cert2 proof is no shorter, so the hint wins and the + // client can verify without signature checks. + let proof = get_leaf_proof(&ds, leaves[0].clone(), Some(3), Duration::MAX, CHAIN_LIMIT) + .await + .unwrap(); + assert!(matches!(proof.proof(), FinalityProof::Assumption)); + assert_eq!( + proof + .verify(LeafProofHint::assumption(leaves[2].leaf())) + .await + .unwrap(), + leaves[0] + ); + + // With no cert2 at or after the requested height, the hint is honored. + let proof = get_leaf_proof(&ds, leaves[2].clone(), Some(5), Duration::MAX, CHAIN_LIMIT) + .await + .unwrap(); + assert!(matches!(proof.proof(), FinalityProof::Assumption)); + assert_eq!( + proof + .verify(LeafProofHint::assumption(leaves[4].leaf())) + .await + .unwrap(), + leaves[2] + ); + } + #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn test_qc_chain_chain_limit() { let storage = ::create_storage().await; diff --git a/light-client/src/state.rs b/light-client/src/state.rs index 033371a4220..65f9c33dc85 100644 --- a/light-client/src/state.rs +++ b/light-client/src/state.rs @@ -62,12 +62,8 @@ pub struct Genesis { /// Maximum distance between a requested leaf and the known-finalized leaf sent to the server as a /// `finalized` hint. /// -/// A nearby hint gives the cheapest possible proof: a short leaf chain verified by parent -/// commitments alone. But the chain may span every leaf between the requested and finalized -/// heights, so a distant hint would ask the server to materialize an arbitrarily long chain -/// (servers bound the proofs they are willing to construct, and reject or ignore distant hints). -/// Beyond this distance we omit the hint; the server then chooses a bounded finality proof which -/// we verify against a quorum. +/// The proof for a hint may span every leaf between the two heights, so servers reject or ignore +/// distant hints. Beyond this distance we omit the hint and verify a quorum-based proof instead. const MAX_FINALIZED_HINT_DISTANCE: u64 = 500; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -211,10 +207,6 @@ where } else { None }; - // Only use the upper bound as a finalized hint if it is close to the requested leaf. The - // proof chain the server constructs for a finalized hint may span every leaf between the - // requested and finalized heights, so servers refuse distant hints; without the hint, the - // server picks a bounded finality proof which we verify against a quorum instead. let known_finalized = known_finalized.filter(|anchor| match id { LeafId::Number(n) => { anchor.height().saturating_sub(n as u64) <= MAX_FINALIZED_HINT_DISTANCE From 8686526daf4eeae70efa5531d308e561f7071dcb Mon Sep 17 00:00:00 2001 From: Brendon Fish Date: Sun, 19 Jul 2026 21:58:14 -0400 Subject: [PATCH 3/4] comments --- crates/espresso/node/src/api/light_client.rs | 53 ++++++-------------- light-client/src/state.rs | 16 ++---- light-client/src/testing.rs | 5 +- 3 files changed, 22 insertions(+), 52 deletions(-) diff --git a/crates/espresso/node/src/api/light_client.rs b/crates/espresso/node/src/api/light_client.rs index deadc07a286..3581a040f92 100644 --- a/crates/espresso/node/src/api/light_client.rs +++ b/crates/espresso/node/src/api/light_client.rs @@ -38,10 +38,8 @@ use crate::api::data_source::{NodeStateDataSource, StakeTableDataSource}; /// Construct a proof that the requested leaf is finalized. /// -/// A usable `finalized` hint yields a leaf chain the client can verify against its known-finalized -/// leaf without signature checks. The hint is ignored if the chain connecting to it could exceed -/// `chain_limit`, or if a cert2 near the requested leaf yields a shorter proof; finality is then -/// proven directly, for the client to verify against a quorum. +/// The `finalized` hint is honored when it yields a bounded proof no longer than a direct finality +/// proof, since the client can verify hint-based proofs without signature checks. pub(crate) async fn get_leaf_proof( state: &State, requested_leaf: LeafQueryData, @@ -58,8 +56,7 @@ where let mut hint = finalized.filter(|finalized| finalized.saturating_sub(requested) <= chain_limit); - // A chain of new-protocol leaves can only end at the hint itself, spanning every leaf in - // between, so drop the hint when a nearby cert2 yields a strictly shorter proof. + // New-protocol chains cannot terminate early, so a nearby cert2 may yield a shorter proof. if let Some(finalized) = hint && new_protocol && let Some(cert2_height) = earliest_cert2_height(state, requested as u64).await @@ -84,10 +81,8 @@ where } } -/// The height of the earliest stored cert2 at or after `height`, if one is available. -/// -/// Storage errors are treated as no cert2 available: this only chooses between proof strategies, -/// and the chosen path will surface any persistent error. +/// The earliest stored cert2 height at or after `height`. Errors are treated as no cert2: this +/// only chooses between proof strategies, and the chosen path will surface any persistent error. async fn earliest_cert2_height(state: &State, height: u64) -> Option where State: VersionedDataSource, @@ -199,11 +194,8 @@ where Ok(proof) } -/// Complete a leaf proof using new protocol certificate2 finality. -/// -/// Extends the proof's chain from `leaf` (the requested leaf, or the first new-protocol leaf in a -/// chain begun before the cutover) to the leaf directly committed by the earliest stored cert2, -/// adding at most `chain_limit` leaves. +/// Extend `proof` from `leaf` to the leaf directly committed by the earliest stored cert2, adding +/// at most `chain_limit` leaves. async fn complete_proof_with_cert2( state: &State, proof: &mut LeafProof, @@ -579,10 +571,8 @@ pub(super) struct Options { /// applies. pub large_object_range_limit: usize, - /// The maximum number of leaves included in a single leaf proof. - /// - /// Bounds the memory needed to construct and serialize a proof, regardless of what the client - /// requests. + /// The maximum number of leaves included in a single leaf proof, bounding the memory needed + /// to construct and serialize it. pub leaf_proof_chain_limit: usize, } @@ -1008,7 +998,6 @@ mod test { const CHAIN_LIMIT: usize = 500; - /// Construct a cert2 which directly commits `leaf`. fn cert2_for_leaf(leaf: &LeafQueryData) -> espresso_types::Certificate2 { let data = Vote2Data { leaf_commit: leaf.leaf().commit(), @@ -1181,8 +1170,7 @@ mod test { .await .unwrap(); - // New protocol leaves never form a HotStuff QC chain, so a proof for the first leaf must - // extend all the way to the nearest cert2. + // A proof for the first leaf must extend to the nearest cert2, three leaves away. let leaves = leaf_chain(1..=4, NEW_PROTOCOL_VERSION).await; let cert2_leaf = &leaves[3]; let cert2 = cert2_for_leaf(cert2_leaf); @@ -1196,14 +1184,11 @@ mod test { tx.commit().await.unwrap(); } - // If the nearest cert2 is further away than the chain limit, the proof is refused rather - // than materializing an unbounded chain. let err = get_leaf_proof_with_cert2(&ds, leaves[0].clone(), Duration::MAX, 2) .await .unwrap_err(); assert_eq!(err.status(), StatusCode::NOT_FOUND); - // Within the limit, the proof includes the full chain up to the cert2 leaf. let proof = get_leaf_proof_with_cert2(&ds, leaves[0].clone(), Duration::MAX, 3) .await .unwrap(); @@ -1241,8 +1226,7 @@ mod test { tx.commit().await.unwrap(); } - // The cert2 at height 2 yields a shorter proof than the chain to the finalized hint at - // height 5, so the hint is ignored. + // The cert2 at height 2 beats the hint at height 5, so the hint is ignored. let proof = get_leaf_proof(&ds, leaves[0].clone(), Some(5), Duration::MAX, CHAIN_LIMIT) .await .unwrap(); @@ -1255,8 +1239,7 @@ mod test { leaves[0] ); - // With the hint at height 3, the cert2 proof is no shorter, so the hint wins and the - // client can verify without signature checks. + // A hint at height 3 ties the cert2 proof length, so the hint wins. let proof = get_leaf_proof(&ds, leaves[0].clone(), Some(3), Duration::MAX, CHAIN_LIMIT) .await .unwrap(); @@ -1294,8 +1277,7 @@ mod test { .await .unwrap(); - // Insert some leaves, forming a chain. Proving the first leaf finalized requires walking - // the two subsequent leaves. + // Proving the first leaf finalized requires walking the two subsequent leaves. let leaves = leaf_chain(1..=3, EPOCH_VERSION).await; { let mut tx = ds.write().await.unwrap(); @@ -1305,13 +1287,11 @@ mod test { tx.commit().await.unwrap(); } - // A limit too small to reach the QC chain fails rather than walking further. let err = get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::MAX, 1) .await .unwrap_err(); assert_eq!(err.status(), StatusCode::NOT_FOUND); - // A sufficient limit succeeds. let proof = get_leaf_proof_with_qc_chain(&ds, leaves[0].clone(), Duration::MAX, 2) .await .unwrap(); @@ -1342,8 +1322,6 @@ mod test { tx.commit().await.unwrap(); } - // A finalized hint further from the requested leaf than the chain limit is rejected: the - // proof chain could span the entire distance. let err = get_leaf_proof_with_finalized_assumption( &ds, leaves[0].clone(), @@ -1367,9 +1345,8 @@ mod test { .await .unwrap(); - // Upgrade to the new protocol in the middle of the chain, and skew the leaf view numbers - // so that no HotStuff 2-chain ever forms. Proving the first (pre-upgrade) leaf finalized - // must then fall through to cert2 finality instead of walking the chain indefinitely. + // Upgrade mid-chain and skew view numbers so no HotStuff 2-chain ever forms; the proof + // for the first (pre-upgrade) leaf must fall through to cert2 finality. let leaves = custom_leaf_chain_with_upgrade( 1..=4, 2, diff --git a/light-client/src/state.rs b/light-client/src/state.rs index 65f9c33dc85..7d40421a75b 100644 --- a/light-client/src/state.rs +++ b/light-client/src/state.rs @@ -59,11 +59,8 @@ pub struct Genesis { pub chain_id: ChainId, } -/// Maximum distance between a requested leaf and the known-finalized leaf sent to the server as a -/// `finalized` hint. -/// -/// The proof for a hint may span every leaf between the two heights, so servers reject or ignore -/// distant hints. Beyond this distance we omit the hint and verify a quorum-based proof instead. +/// Maximum distance between a requested leaf and the `finalized` hint sent to the server. Servers +/// reject or ignore distant hints; beyond this we omit the hint and verify against a quorum. const MAX_FINALIZED_HINT_DISTANCE: u64 = 500; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -211,8 +208,7 @@ where LeafId::Number(n) => { anchor.height().saturating_sub(n as u64) <= MAX_FINALIZED_HINT_DISTANCE }, - // Hash lookups in the database only ever return the requested leaf itself, which is - // handled above. + // Hash lookups only ever return the requested leaf itself, handled above. LeafId::Hash(_) => true, }); let known_finalized = known_finalized.as_ref().map(LeafQueryData::leaf); @@ -973,12 +969,10 @@ mod test { #[test_log::test] async fn test_fetch_leaf_distant_upper_bound() { let client = TestClient::default(); - // Simulate a server which refuses to build long proof chains for distant finalized hints. client.reject_distant_finalized_hints(16).await; - // Cache a leaf far above the requested one (e.g. the chain tip, as after a fresh sync). It - // must not be sent to the server as the finalized hint; the fetch should instead use a - // quorum-verified proof. + // A cached leaf far above the requested one must not be sent as the finalized hint; the + // fetch should use a quorum-verified proof instead. let distant = MAX_FINALIZED_HINT_DISTANCE + 2; let distant_leaf = leaf_chain(distant..=distant, DRB_AND_HEADER_UPGRADE_VERSION) .await diff --git a/light-client/src/testing.rs b/light-client/src/testing.rs index 21f3572a7ff..df4420510fd 100644 --- a/light-client/src/testing.rs +++ b/light-client/src/testing.rs @@ -391,8 +391,7 @@ struct InnerTestClient { upgrade: Option<(u64, Version)>, /// `Certificate2` finality proofs for new-protocol leaves, by height. cert2s: HashMap>, - /// If set, fail leaf proof requests whose `finalized` hint is more than this many blocks past - /// the requested leaf. + /// If set, fail leaf proof requests whose `finalized` hint exceeds this distance. max_finalized_hint_distance: Option, } @@ -797,7 +796,7 @@ impl TestClient { } /// Fail leaf proof requests whose `finalized` hint is more than `max_distance` past the - /// requested leaf, like a real server which bounds the proofs it constructs. + /// requested leaf. pub async fn reject_distant_finalized_hints(&self, max_distance: u64) { let mut inner = self.inner.lock().await; inner.max_finalized_hint_distance = Some(max_distance); From 984b844cefb59aa0b6d84f748947caea36650391 Mon Sep 17 00:00:00 2001 From: Brendon Fish Date: Sun, 19 Jul 2026 22:11:43 -0400 Subject: [PATCH 4/4] test(light-client): distant hint falls through to bounded proof Co-Authored-By: Claude Fable 5 --- crates/espresso/node/src/api/light_client.rs | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/espresso/node/src/api/light_client.rs b/crates/espresso/node/src/api/light_client.rs index 3581a040f92..d7a1bae2042 100644 --- a/crates/espresso/node/src/api/light_client.rs +++ b/crates/espresso/node/src/api/light_client.rs @@ -1266,6 +1266,76 @@ mod test { ); } + #[test_log::test(tokio::test(flavor = "multi_thread"))] + async fn test_distant_hint_falls_through() { + let storage = ::create_storage().await; + let ds = DataSource::create( + DataSource::persistence_options(&storage), + Default::default(), + false, + ) + .await + .unwrap(); + + // Legacy leaves 1-3 upgrade to the new protocol at height 4, with a cert2 at height 5, so + // both fall-through paths have a bounded proof available. + let leaves = leaf_chain_with_upgrade( + 1..=5, + 4, + Upgrade::new(DRB_AND_HEADER_UPGRADE_VERSION, NEW_PROTOCOL_VERSION), + ) + .await; + let cert2_leaf = &leaves[4]; + let cert2 = cert2_for_leaf(cert2_leaf); + + { + let mut tx = ds.write().await.unwrap(); + for leaf in &leaves { + tx.insert_leaf(leaf).await.unwrap(); + } + tx.insert_cert2(cert2_leaf.height(), cert2).await.unwrap(); + tx.commit().await.unwrap(); + } + + // A hint more than `chain_limit` past a legacy leaf is ignored in favor of a QC chain. + let proof = get_leaf_proof( + &ds, + leaves[0].clone(), + Some(1000), + Duration::MAX, + CHAIN_LIMIT, + ) + .await + .unwrap(); + assert!(matches!(proof.proof(), FinalityProof::HotStuff2 { .. })); + assert_eq!( + proof + .verify(LeafProofHint::Quorum(&AlwaysTrueQuorum)) + .await + .unwrap(), + leaves[0] + ); + + // A hint more than `chain_limit` past a new-protocol leaf is ignored in favor of cert2. + let proof = get_leaf_proof( + &ds, + leaves[3].clone(), + Some(1000), + Duration::MAX, + CHAIN_LIMIT, + ) + .await + .unwrap(); + assert!(matches!(proof.proof(), FinalityProof::NewProtocol { .. })); + assert_eq!( + proof + .verify(LeafProofHint::Quorum(&AlwaysTrueQuorum)) + .await + .unwrap(), + leaves[3] + ); + } + #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn test_qc_chain_chain_limit() { let storage = ::create_storage().await;