diff --git a/crates/espresso/node/api/light-client.toml b/crates/espresso/node/api/light-client.toml index 734a40ba3da..544dee57c81 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,14 @@ 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. 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 { @@ -47,6 +53,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..d7a1bae2042 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,73 @@ use versions::NEW_PROTOCOL_VERSION; use crate::api::data_source::{NodeStateDataSource, StakeTableDataSource}; +/// Construct a proof that the requested leaf is finalized. +/// +/// 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, + 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; + let new_protocol = requested_leaf.header().version() >= NEW_PROTOCOL_VERSION; + + let mut hint = finalized.filter(|finalized| finalized.saturating_sub(requested) <= chain_limit); + + // 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 + && 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 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, + 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, fetch_timeout: Duration, + chain_limit: usize, ) -> Result where State: AvailabilityDataSource + VersionedDataSource, @@ -53,14 +119,34 @@ 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))?; + + // 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, + &mut proof, + leaf, + requested_leaf_qc.clone(), + fetch_timeout, + remaining, + ) + .await?; + return Ok(proof); + } + if proof.push(leaf) { return Ok(proof); } @@ -88,47 +174,84 @@ 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) +} + +/// 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, + 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 begun 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 +266,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 +284,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 +301,16 @@ where status: StatusCode::BAD_REQUEST, }); } + 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 +570,10 @@ 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, bounding the memory needed + /// to construct and serialize it. + pub leaf_proof_chain_limit: usize, } impl Default for Options { @@ -440,6 +581,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 +606,7 @@ where let Options { fetch_timeout, large_object_range_limit, + leaf_proof_chain_limit, } = opt; api.get("leaf", move |req, state| { @@ -473,19 +616,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 +958,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 +976,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 +996,23 @@ mod test { sql::DataSource, }; + const CHAIN_LIMIT: usize = 500; + + 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 +1035,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 +1068,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 +1104,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 +1136,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 +1146,50 @@ 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 { .. })); + assert_eq!( + proof + .verify(LeafProofHint::Quorum(&AlwaysTrueQuorum)) + .await + .unwrap(), + leaves[0] + ); + } + + #[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(); + + // 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); + + { + 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 err = get_leaf_proof_with_cert2(&ds, leaves[0].clone(), Duration::MAX, 2) + .await + .unwrap_err(); + assert_eq!(err.status(), StatusCode::NOT_FOUND); + + let proof = get_leaf_proof_with_cert2(&ds, leaves[0].clone(), Duration::MAX, 3) .await .unwrap(); assert!(matches!(proof.proof(), FinalityProof::NewProtocol { .. })); @@ -996,6 +1202,258 @@ 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 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(); + assert!(matches!(proof.proof(), FinalityProof::NewProtocol { .. })); + assert_eq!( + proof + .verify(LeafProofHint::Quorum(&AlwaysTrueQuorum)) + .await + .unwrap(), + leaves[0] + ); + + // 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(); + 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_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; + let ds = DataSource::create( + DataSource::persistence_options(&storage), + Default::default(), + false, + ) + .await + .unwrap(); + + // 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(); + } + + 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); + + 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(); + } + + 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 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, + 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 +1474,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 +1508,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 +1526,7 @@ mod test { leaves[0].clone(), 4, Duration::from_secs(1), + CHAIN_LIMIT, ) .await .unwrap_err(); @@ -1089,9 +1558,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 +1601,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..7d40421a75b 100644 --- a/light-client/src/state.rs +++ b/light-client/src/state.rs @@ -59,6 +59,10 @@ pub struct Genesis { pub chain_id: ChainId, } +/// 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)] #[cfg_attr(feature = "clap", derive(clap::Parser))] pub struct LightClientOptions { @@ -200,6 +204,13 @@ where } else { None }; + 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 only ever return the requested leaf itself, 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 +965,28 @@ mod test { ); } + #[tokio::test] + #[test_log::test] + async fn test_fetch_leaf_distant_upper_bound() { + let client = TestClient::default(); + client.reject_distant_finalized_hints(16).await; + + // 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 + .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..df4420510fd 100644 --- a/light-client/src/testing.rs +++ b/light-client/src/testing.rs @@ -391,6 +391,8 @@ 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 exceeds this distance. + max_finalized_hint_distance: Option, } impl InnerTestClient { @@ -792,6 +794,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. + 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 +832,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();