zebrad/components/inbound/downloads.rs
1//! A download stream that handles gossiped blocks from peers.
2
3#[cfg(test)]
4mod tests;
5
6use std::{
7 collections::{HashMap, HashSet},
8 net::IpAddr,
9 pin::Pin,
10 task::{Context, Poll},
11 time::Duration,
12};
13
14use futures::{
15 future::TryFutureExt,
16 ready,
17 stream::{FuturesUnordered, Stream},
18};
19use pin_project::pin_project;
20use thiserror::Error;
21use tokio::{sync::oneshot, task::JoinHandle, time::timeout};
22use tower::{Service, ServiceExt};
23use tracing_futures::Instrument;
24
25use zebra_chain::{
26 block::{self, HeightDiff},
27 chain_tip::ChainTip,
28};
29use zebra_network::{self as zn, PeerSocketAddr};
30use zebra_state as zs;
31
32use crate::components::sync::{BLOCK_DOWNLOAD_TIMEOUT, MIN_CONCURRENCY_LIMIT};
33
34type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
35
36/// How long to wait for the parent-height lookup that decides whether a behind-tip gossiped block
37/// was forged.
38///
39/// Bounds a drop path that also fires for honest old blocks. Timing out is treated as "no proof",
40/// so a slow state read costs the supplying peer nothing.
41const PARENT_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
42
43/// A gossiped block was dropped before verification because its coinbase height was outside the
44/// accepted range around the chain tip.
45///
46/// Peers legitimately serve blocks that are genuinely far ahead of the tip while Zebra is catching
47/// up, and blocks that are genuinely older than the finalized tip, so this error is returned for
48/// every such drop. But a peer can also answer a [`BlockDownload`](zn::Request::BlockDownload) request
49/// with a canonical header and a rewritten coinbase height, because the coinbase scriptSig is
50/// excluded from the V5 transaction ID and therefore from the block hash (ZIP-244). The initial hash
51/// check still passes, so the forged height reaches these drops before consensus validation, and the
52/// verifier never scores the peer (GHSA-4f6v-mj46-gxg3).
53///
54/// The downloader attaches the supplying peer's address to the drop only when the parent header
55/// Zebra already holds proves the claimed height wrong (see [`advertiser_if_parent_contradicts`]),
56/// so the inbound handler scores a proven rewrite, and never an authentic block.
57///
58/// This is the inbound-gossip sibling of the sync path's height limit errors (GHSA-g95h-hw6g-pvgv).
59#[derive(Copy, Clone, Debug, Error)]
60pub enum HeightLimitError {
61 /// The block's coinbase height is above the lookahead limit.
62 #[error("gossiped block height {height:?} too far ahead of the tip: {hash:?}")]
63 AboveLookahead {
64 height: block::Height,
65 hash: block::Hash,
66 },
67
68 /// The block's coinbase height is behind the finalized tip.
69 #[error("gossiped block height {height:?} behind the finalized tip: {hash:?}")]
70 BehindTip {
71 height: block::Height,
72 hash: block::Hash,
73 },
74}
75
76impl HeightLimitError {
77 /// The misbehavior score for a gossiped block whose parent proves its height was rewritten.
78 ///
79 /// A rewritten coinbase height is unambiguous misbehavior whichever limit it crossed, so score it
80 /// at the ban threshold, matching the sync path (GHSA-g95h-hw6g-pvgv).
81 pub fn misbehavior_score(&self) -> u32 {
82 zn::constants::MAX_PEER_MISBEHAVIOR_SCORE
83 }
84}
85
86/// Returns `advertiser_addr` if the parent header Zebra already holds proves that a gossiped block's
87/// claimed `block_height` was rewritten, and `None` if there is no such proof.
88///
89/// # Security
90///
91/// A peer can answer a `BlockDownload` request with a canonical header and a rewritten coinbase
92/// height, because the coinbase scriptSig is excluded from the V5 transaction ID and therefore from
93/// the block hash (ZIP-244). The hash check passes, so the forged height reaches the height limit
94/// drops before consensus validation. Peers also legitimately serve blocks that are genuinely far
95/// ahead of the tip or genuinely older than the finalized tip, so the drop is only attributed to the
96/// supplying peer when the parent header Zebra holds contradicts the claimed height: a block's height
97/// is one more than its parent's, so a held parent whose height disagrees proves the body was
98/// rewritten, whichever limit the claimed height crossed.
99///
100/// A parent Zebra does not hold, a height consistent with the parent, and a failed or timed-out
101/// lookup are all treated as no proof, so honest peers are never scored and a slow state read costs
102/// the peer nothing. (GHSA-4f6v-mj46-gxg3, the inbound-gossip sibling of GHSA-g95h-hw6g-pvgv.)
103async fn advertiser_if_parent_contradicts<ZS>(
104 state: ZS,
105 parent_hash: block::Hash,
106 block_height: block::Height,
107 advertiser_addr: Option<PeerSocketAddr>,
108) -> Option<PeerSocketAddr>
109where
110 ZS: Service<zs::Request, Response = zs::Response, Error = BoxError> + Send + Clone + 'static,
111 ZS::Future: Send,
112{
113 // There is no peer to score, so skip the state lookup.
114 let advertiser_addr = advertiser_addr?;
115
116 match timeout(
117 PARENT_LOOKUP_TIMEOUT,
118 state.oneshot(zs::Request::BlockHeader(parent_hash.into())),
119 )
120 .await
121 {
122 Ok(Ok(zs::Response::BlockHeader {
123 height: parent_height,
124 ..
125 })) if (parent_height + 1) != Some(block_height) => Some(advertiser_addr),
126 // Parent unknown, height consistent with it, or the lookup failed or timed out: there is no
127 // proof of misbehavior, so the peer is not scored.
128 _ => None,
129 }
130}
131
132/// The maximum number of concurrent inbound download and verify tasks.
133/// Also used as the maximum lookahead limit, before block verification.
134///
135/// We expect the syncer to download and verify checkpoints, so this bound
136/// can be small.
137///
138/// ## Security
139///
140/// The maximum block size is 2 million bytes. A deserialized malicious
141/// block with ~225_000 transparent outputs can take up 9MB of RAM.
142/// The total queue bound is `MAX_INBOUND_CONCURRENCY * 9 MB`. Each peer IP
143/// is limited to one in-flight download (9 MB) by the per-IP cap enforced
144/// in [`Downloads::download_and_verify`], so a sybil or IPv6-range attacker
145/// still needs many distinct source IPs to approach the total bound.
146/// (See #1880 for more details.)
147///
148/// Malicious blocks will eventually timeout or fail contextual validation.
149/// Once validation fails, the block is dropped, and its memory is deallocated.
150pub const MAX_INBOUND_CONCURRENCY: usize = 200;
151
152/// The action taken in response to a peer's gossiped block hash.
153pub enum DownloadAction {
154 /// The block hash was successfully queued for download and verification.
155 AddedToQueue,
156
157 /// The block hash is already queued, so this request was ignored.
158 ///
159 /// Another peer has already gossiped the same hash to us.
160 AlreadyQueued,
161
162 /// The queue is at capacity, so this request was ignored.
163 ///
164 /// The sync service should discover this block later, when we are closer
165 /// to the tip. The queue's capacity is [`Downloads::full_verify_concurrency_limit`].
166 FullQueue,
167
168 /// The advertising peer's IP already has an in-flight download, so
169 /// this request was ignored. Zcash's post-Blossom target block spacing
170 /// is 75 seconds, so honest peers rarely gossip more than one block
171 /// before the first is verified; during reorgs or recovery the same
172 /// hash also arrives from other peers or via the syncer.
173 TooManyFromPeer,
174}
175
176/// Manages download and verification of blocks gossiped to this peer.
177#[pin_project]
178#[derive(Debug)]
179pub struct Downloads<ZN, ZV, ZS, ZSTip>
180where
181 ZN: Service<zn::Request, Response = zn::Response, Error = BoxError> + Send + Clone + 'static,
182 ZN::Future: Send,
183 ZV: Service<zebra_consensus::Request, Response = block::Hash, Error = BoxError>
184 + Send
185 + Clone
186 + 'static,
187 ZV::Future: Send,
188 ZS: Service<zs::Request, Response = zs::Response, Error = BoxError> + Send + Clone + 'static,
189 ZS::Future: Send,
190 ZSTip: ChainTip + Clone + Send + 'static,
191{
192 // Configuration
193 //
194 /// The configured full verification concurrency limit, after applying the minimum limit.
195 full_verify_concurrency_limit: usize,
196
197 // Services
198 //
199 /// A service that forwards requests to connected peers, and returns their
200 /// responses.
201 network: ZN,
202
203 /// A service that verifies downloaded blocks.
204 verifier: ZV,
205
206 /// A service that manages cached blockchain state.
207 state: ZS,
208
209 /// Allows efficient access to the best tip of the blockchain.
210 latest_chain_tip: ZSTip,
211
212 // Internal downloads state
213 //
214 /// A list of pending block download and verify tasks.
215 #[pin]
216 pending: FuturesUnordered<
217 JoinHandle<Result<block::Hash, (BoxError, block::Hash, Option<PeerSocketAddr>)>>,
218 >,
219
220 /// Cancellation handles for tasks in [`Self::pending`], keyed by block
221 /// hash. The `Option<IpAddr>` is the advertiser IP recorded in
222 /// [`Self::in_flight_ips`], so completion can remove it by hash lookup.
223 cancel_handles: HashMap<block::Hash, (oneshot::Sender<()>, Option<IpAddr>)>,
224
225 /// Advertiser IPs with an in-flight download and verify task.
226 ///
227 /// Invariant: an IP is present iff some entry in [`Self::cancel_handles`]
228 /// has value `(_, Some(ip))`. Enforces the one-download-per-IP cap.
229 ///
230 /// Size-bounded by `full_verify_concurrency_limit` (≤ [`MAX_INBOUND_CONCURRENCY`]),
231 /// inherited from the [`DownloadAction::FullQueue`] check on
232 /// [`Self::pending`].
233 in_flight_ips: HashSet<IpAddr>,
234}
235
236impl<ZN, ZV, ZS, ZSTip> Stream for Downloads<ZN, ZV, ZS, ZSTip>
237where
238 ZN: Service<zn::Request, Response = zn::Response, Error = BoxError> + Send + Clone + 'static,
239 ZN::Future: Send,
240 ZV: Service<zebra_consensus::Request, Response = block::Hash, Error = BoxError>
241 + Send
242 + Clone
243 + 'static,
244 ZV::Future: Send,
245 ZS: Service<zs::Request, Response = zs::Response, Error = BoxError> + Send + Clone + 'static,
246 ZS::Future: Send,
247 ZSTip: ChainTip + Clone + Send + 'static,
248{
249 type Item = Result<block::Hash, (BoxError, Option<PeerSocketAddr>)>;
250
251 fn poll_next(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
252 let this = self.project();
253 // CORRECTNESS
254 //
255 // The current task must be scheduled for wakeup every time we return
256 // `Poll::Pending`.
257 //
258 // If no download and verify tasks have exited since the last poll, this
259 // task is scheduled for wakeup when the next task becomes ready.
260 //
261 // TODO: this would be cleaner with poll_map (#2693)
262 if let Some(join_result) = ready!(this.pending.poll_next(cx)) {
263 let (result, hash) =
264 match join_result.expect("block download and verify tasks must not panic") {
265 Ok(hash) => (Ok(hash), hash),
266 Err((e, hash, advertiser_addr)) => (Err((e, advertiser_addr)), hash),
267 };
268 if let Some((_, Some(ip))) = this.cancel_handles.remove(&hash) {
269 assert!(
270 this.in_flight_ips.remove(&ip),
271 "every tracked IP was inserted when its download was queued",
272 );
273 }
274 Poll::Ready(Some(result))
275 } else {
276 Poll::Ready(None)
277 }
278 }
279
280 fn size_hint(&self) -> (usize, Option<usize>) {
281 self.pending.size_hint()
282 }
283}
284
285impl<ZN, ZV, ZS, ZSTip> Downloads<ZN, ZV, ZS, ZSTip>
286where
287 ZN: Service<zn::Request, Response = zn::Response, Error = BoxError> + Send + Clone + 'static,
288 ZN::Future: Send,
289 ZV: Service<zebra_consensus::Request, Response = block::Hash, Error = BoxError>
290 + Send
291 + Clone
292 + 'static,
293 ZV::Future: Send,
294 ZS: Service<zs::Request, Response = zs::Response, Error = BoxError> + Send + Clone + 'static,
295 ZS::Future: Send,
296 ZSTip: ChainTip + Clone + Send + 'static,
297{
298 /// Initialize a new download stream with the provided `network`, `verifier`, and `state` services.
299 /// The `latest_chain_tip` must be linked to the provided `state` service.
300 ///
301 /// The download deadline covers network readiness and the response. Any retry policy
302 /// should be applied to `network` without a separate timeout that includes queued requests.
303 pub fn new(
304 full_verify_concurrency_limit: usize,
305 network: ZN,
306 verifier: ZV,
307 state: ZS,
308 latest_chain_tip: ZSTip,
309 ) -> Self {
310 // The syncer already warns about the minimum.
311 let full_verify_concurrency_limit =
312 full_verify_concurrency_limit.clamp(MIN_CONCURRENCY_LIMIT, MAX_INBOUND_CONCURRENCY);
313
314 Self {
315 full_verify_concurrency_limit,
316 network,
317 verifier,
318 state,
319 latest_chain_tip,
320 pending: FuturesUnordered::new(),
321 cancel_handles: HashMap::new(),
322 in_flight_ips: HashSet::new(),
323 }
324 }
325
326 /// Queue a block for download and verification.
327 ///
328 /// When `advertiser` is `Some`, it is passed to the network as a known block source,
329 /// and its IP is tracked in [`Self::in_flight_ips`] to enforce the one-download-per-IP cap.
330 /// `None` bypasses per-IP accounting (for example when Zebra triggers the download internally).
331 #[instrument(skip(self, hash), fields(hash = %hash))]
332 pub fn download_and_verify(
333 &mut self,
334 hash: block::Hash,
335 advertiser: Option<PeerSocketAddr>,
336 ) -> DownloadAction {
337 if self.cancel_handles.contains_key(&hash) {
338 debug!(
339 ?hash,
340 queue_len = self.pending.len(),
341 concurrency_limit = self.full_verify_concurrency_limit,
342 "block hash already queued for inbound download: ignored block",
343 );
344
345 metrics::gauge!("gossip.queued.block.count").set(self.pending.len() as f64);
346 metrics::counter!("gossip.already.queued.dropped.block.hash.count").increment(1);
347
348 return DownloadAction::AlreadyQueued;
349 }
350
351 if self.pending.len() >= self.full_verify_concurrency_limit {
352 debug!(
353 ?hash,
354 queue_len = self.pending.len(),
355 concurrency_limit = self.full_verify_concurrency_limit,
356 "too many blocks queued for inbound download: ignored block",
357 );
358
359 metrics::gauge!("gossip.queued.block.count").set(self.pending.len() as f64);
360 metrics::counter!("gossip.full.queue.dropped.block.hash.count").increment(1);
361
362 return DownloadAction::FullQueue;
363 }
364
365 let advertiser_ip = advertiser.map(|addr| addr.ip());
366 if let Some(ip) = advertiser_ip {
367 if self.in_flight_ips.contains(&ip) {
368 debug!(
369 ?hash,
370 ?advertiser,
371 "already have an in-flight inbound download from peer IP: ignored block",
372 );
373
374 metrics::counter!("gossip.peer.limit.dropped.block.hash.count").increment(1);
375
376 return DownloadAction::TooManyFromPeer;
377 }
378 }
379
380 // This oneshot is used to signal cancellation to the download task.
381 let (cancel_tx, mut cancel_rx) = oneshot::channel::<()>();
382
383 let state = self.state.clone();
384 let network = self.network.clone();
385 let verifier = self.verifier.clone();
386 let latest_chain_tip = self.latest_chain_tip.clone();
387 let full_verify_concurrency_limit = self.full_verify_concurrency_limit;
388
389 let fut = async move {
390 // Check if the block is already in the state.
391 match state.clone().oneshot(zs::Request::KnownBlock(hash)).await {
392 Ok(zs::Response::KnownBlock(None)) => Ok(()),
393 Ok(zs::Response::KnownBlock(Some(_))) => Err("already present".into()),
394 Ok(_) => unreachable!("wrong response"),
395 Err(e) => Err(e),
396 }
397 .map_err(|e| (e, None))?;
398
399 let response = timeout(
400 zn::constants::REQUEST_TIMEOUT + BLOCK_DOWNLOAD_TIMEOUT,
401 network.oneshot(zn::Request::BlockDownload { hash, advertiser }),
402 )
403 .await
404 .map_err(|e| (BoxError::from(e), None))?
405 .map_err(|e| (e, None))?;
406 let (block, advertiser_addr) = if let zn::Response::Blocks(blocks) = response {
407 assert_eq!(
408 blocks.len(),
409 1,
410 "wrong number of blocks in response to a single hash",
411 );
412
413 blocks
414 .first()
415 .expect("just checked length")
416 .available()
417 .expect(
418 "unexpected missing block status: single block failures should be errors",
419 )
420 } else {
421 unreachable!("wrong response to block request");
422 };
423 metrics::counter!("gossip.downloaded.block.count").increment(1);
424
425 // # Security & Performance
426 //
427 // Reject blocks that are too far ahead of our tip,
428 // and blocks that are behind the finalized tip.
429 //
430 // Avoids denial of service attacks. Also reduces wasted work on high blocks
431 // that will timeout before being verified, and low blocks that can never be finalized.
432 let tip_height = latest_chain_tip.best_tip_height();
433
434 let max_lookahead_height = if let Some(tip_height) = tip_height {
435 let lookahead = HeightDiff::try_from(full_verify_concurrency_limit)
436 .expect("fits in HeightDiff");
437 (tip_height + lookahead).expect("tip is much lower than Height::MAX")
438 } else {
439 let genesis_lookahead =
440 u32::try_from(full_verify_concurrency_limit - 1).expect("fits in u32");
441 block::Height(genesis_lookahead)
442 };
443
444 // Get the finalized tip height, assuming we're using the non-finalized state.
445 //
446 // It doesn't matter if we're a few blocks off here, because blocks this low
447 // are part of a fork with much less work. So they would be rejected anyway.
448 //
449 // And if we're still checkpointing, the checkpointer will reject blocks behind
450 // the finalized tip anyway.
451 //
452 // TODO: get the actual finalized tip height
453 let min_accepted_height = tip_height
454 .map(|tip_height| {
455 block::Height(tip_height.0.saturating_sub(zs::MAX_BLOCK_REORG_HEIGHT))
456 })
457 .unwrap_or(block::Height(0));
458
459 let block_height = block
460 .coinbase_height()
461 .ok_or_else(|| {
462 debug!(
463 ?hash,
464 "gossiped block with no height: dropped downloaded block"
465 );
466 metrics::counter!("gossip.no.height.dropped.block.count").increment(1);
467
468 BoxError::from("gossiped block with no height")
469 })
470 .map_err(|e| (e, None))?;
471
472 if block_height > max_lookahead_height {
473 debug!(
474 ?hash,
475 ?block_height,
476 ?tip_height,
477 ?max_lookahead_height,
478 lookahead_limit = full_verify_concurrency_limit,
479 "gossiped block height too far ahead of the tip: dropped downloaded block",
480 );
481 metrics::counter!("gossip.max.height.limit.dropped.block.count").increment(1);
482
483 // # Security
484 //
485 // Attribute the drop to the supplying peer only if the parent Zebra holds proves the
486 // claimed height was rewritten (GHSA-4f6v-mj46-gxg3). A genuinely far-ahead block
487 // has a parent Zebra does not hold yet, so it is dropped anonymously.
488 let advertiser_addr = advertiser_if_parent_contradicts(
489 state,
490 block.header.previous_block_hash,
491 block_height,
492 advertiser_addr,
493 )
494 .await;
495
496 return Err((
497 BoxError::from(HeightLimitError::AboveLookahead {
498 height: block_height,
499 hash,
500 }),
501 advertiser_addr,
502 ));
503 } else if block_height < min_accepted_height {
504 debug!(
505 ?hash,
506 ?block_height,
507 ?tip_height,
508 ?min_accepted_height,
509 behind_tip_limit = ?zs::MAX_BLOCK_REORG_HEIGHT,
510 "gossiped block height behind the finalized tip: dropped downloaded block",
511 );
512 metrics::counter!("gossip.min.height.limit.dropped.block.count").increment(1);
513
514 // # Security
515 //
516 // Attribute the drop to the supplying peer only if the parent Zebra holds proves the
517 // claimed height was rewritten (GHSA-4f6v-mj46-gxg3). A genuinely old block is
518 // consistent with its parent, or has a parent Zebra does not hold, so it is dropped
519 // anonymously.
520 let advertiser_addr = advertiser_if_parent_contradicts(
521 state,
522 block.header.previous_block_hash,
523 block_height,
524 advertiser_addr,
525 )
526 .await;
527
528 return Err((
529 BoxError::from(HeightLimitError::BehindTip {
530 height: block_height,
531 hash,
532 }),
533 advertiser_addr,
534 ));
535 }
536
537 verifier
538 .oneshot(zebra_consensus::Request::Commit(block))
539 .await
540 .map(|hash| (hash, block_height))
541 .map_err(|e| (e, advertiser_addr))
542 }
543 .map_ok(|(hash, height)| {
544 info!(?height, "downloaded and verified gossiped block");
545 metrics::counter!("gossip.verified.block.count").increment(1);
546 hash
547 })
548 // Tack the hash onto the error so poll_next can look up the cancel
549 // handle and advertising IP on failure as well as success.
550 .map_err(move |(e, advertiser_addr)| (e, hash, advertiser_addr))
551 .in_current_span();
552
553 let task = tokio::spawn(async move {
554 // Prefer the cancel handle if both are ready.
555 tokio::select! {
556 biased;
557 _ = &mut cancel_rx => {
558 trace!("task cancelled prior to completion");
559 metrics::counter!("gossip.cancelled.count").increment(1);
560 Err(("canceled".into(), hash, None))
561 }
562 verification = fut => verification,
563 }
564 });
565
566 self.pending.push(task);
567 assert!(
568 self.cancel_handles
569 .insert(hash, (cancel_tx, advertiser_ip))
570 .is_none(),
571 "blocks are only queued once"
572 );
573 if let Some(ip) = advertiser_ip {
574 assert!(
575 self.in_flight_ips.insert(ip),
576 "the per-IP cap check above rejects any IP already in flight",
577 );
578 }
579
580 debug!(
581 ?hash,
582 queue_len = self.pending.len(),
583 concurrency_limit = self.full_verify_concurrency_limit,
584 "queued hash for download",
585 );
586 metrics::gauge!("gossip.queued.block.count").set(self.pending.len() as f64);
587
588 DownloadAction::AddedToQueue
589 }
590}