Skip to main content

zebra_rpc/indexer/
methods.rs

1//! Implements `Indexer` methods on the `IndexerRPC` type
2
3use std::{collections::HashSet, pin::Pin, time::Duration};
4
5use futures::Stream;
6use tokio_stream::wrappers::ReceiverStream;
7use tonic::{Response, Status};
8use tower::util::ServiceExt;
9
10use tracing::Span;
11use zebra_chain::{block, chain_tip::ChainTip, serialization::BytesInDisplayOrder};
12use zebra_node_services::mempool::MempoolChangeKind;
13use zebra_state::{ReadRequest, ReadResponse, ReadState, MAX_NON_FINALIZED_CHAIN_FORKS};
14
15use super::{
16    indexer_server::Indexer, server::IndexerRPC, BlockAndHash, BlockHashAndHeight, BlockRequest,
17    Empty, MempoolChangeMessage, NonFinalizedStateChangeRequest,
18};
19
20/// The maximum number of messages that can be queued to be streamed to a client.
21const RESPONSE_BUFFER_SIZE: usize = 64;
22
23/// How long to wait for a backpressured send before treating the consumer as hung and dropping
24/// the subscription.
25///
26/// All three indexer streams apply backpressure so a slow consumer doesn't miss notifications,
27/// but without a bound a consumer whose connection is half-open (dead TCP not yet detected) would
28/// block the listener task indefinitely.
29const SEND_TIMEOUT: Duration = Duration::from_secs(60);
30
31#[tonic::async_trait]
32impl<ReadStateService, Tip> Indexer for IndexerRPC<ReadStateService, Tip>
33where
34    ReadStateService: ReadState,
35    Tip: ChainTip + Clone + Send + Sync + 'static,
36{
37    type ChainTipChangeStream =
38        Pin<Box<dyn Stream<Item = Result<BlockHashAndHeight, Status>> + Send>>;
39    type NonFinalizedStateChangeStream =
40        Pin<Box<dyn Stream<Item = Result<BlockAndHash, Status>> + Send>>;
41    type MempoolChangeStream =
42        Pin<Box<dyn Stream<Item = Result<MempoolChangeMessage, Status>> + Send>>;
43
44    async fn chain_tip_change(
45        &self,
46        _: tonic::Request<Empty>,
47    ) -> Result<Response<Self::ChainTipChangeStream>, Status> {
48        let span = Span::current();
49        let (response_sender, response_receiver) = tokio::sync::mpsc::channel(RESPONSE_BUFFER_SIZE);
50        let response_stream = ReceiverStream::new(response_receiver);
51        let mut chain_tip_change = self.chain_tip_change.clone();
52
53        tokio::spawn(async move {
54            // Notify the client of chain tip changes until the channel is closed
55            while let Ok(()) = chain_tip_change.best_tip_changed().await {
56                let Some((tip_height, tip_hash)) = chain_tip_change.best_tip_height_and_hash()
57                else {
58                    continue;
59                };
60
61                let send = response_sender.send(Ok(BlockHashAndHeight::new(tip_hash, tip_height)));
62                match tokio::time::timeout(SEND_TIMEOUT, send).await {
63                    Ok(Ok(())) => {}
64                    Ok(Err(_)) => {
65                        span.in_scope(|| {
66                            tracing::info!("client disconnected, dropping chain_tip_change task");
67                        });
68                        return;
69                    }
70                    Err(_) => {
71                        span.in_scope(|| {
72                            tracing::warn!(
73                                "slow consumer, dropping chain_tip_change stream after \
74                                 send timed out"
75                            );
76                        });
77                        return;
78                    }
79                }
80            }
81
82            span.in_scope(|| {
83                tracing::warn!("chain_tip_change channel has closed");
84            });
85
86            let _ = response_sender
87                .send(Err(Status::unavailable(
88                    "chain_tip_change channel has closed",
89                )))
90                .await;
91        });
92
93        Ok(Response::new(Box::pin(response_stream)))
94    }
95
96    async fn non_finalized_state_change(
97        &self,
98        request: tonic::Request<NonFinalizedStateChangeRequest>,
99    ) -> Result<Response<Self::NonFinalizedStateChangeStream>, Status> {
100        let span = Span::current();
101        let read_state = self.read_state.clone();
102        let (response_sender, response_receiver) = tokio::sync::mpsc::channel(RESPONSE_BUFFER_SIZE);
103        let response_stream = ReceiverStream::new(response_receiver);
104
105        // The caller may provide the hashes of the chain tips it already has so the server only
106        // streams blocks after those tips. Malformed hashes (wrong length) are rejected up front.
107        let known_chain_tips = decode_known_chain_tips(request.into_inner().chain_tip_hashes)?;
108
109        tokio::spawn(async move {
110            let mut non_finalized_state_change = match read_state
111                .oneshot(ReadRequest::NonFinalizedBlocksListener { known_chain_tips })
112                .await
113            {
114                Ok(ReadResponse::NonFinalizedBlocksListener(listener)) => listener.unwrap(),
115                Ok(_) => unreachable!("unexpected response type from ReadStateService"),
116                Err(error) => {
117                    span.in_scope(|| {
118                        tracing::error!(
119                            ?error,
120                            "failed to subscribe to non-finalized state changes"
121                        );
122                    });
123
124                    let _ = response_sender
125                        .send(Err(Status::unavailable(
126                            "failed to subscribe to non-finalized state changes",
127                        )))
128                        .await;
129                    return;
130                }
131            };
132
133            loop {
134                // A full listener buffer means the state-side task is blocked sending into it,
135                // so it may already have missed non-finalized state updates. The stream can no
136                // longer guarantee completeness, so drop the subscription instead of silently
137                // missing blocks.
138                if non_finalized_state_change.capacity() == 0 {
139                    span.in_scope(|| {
140                        tracing::warn!(
141                            "slow consumer, dropping non_finalized_state_change stream after \
142                             buffer filled"
143                        );
144                    });
145                    return;
146                }
147
148                let Some((hash, block)) = non_finalized_state_change.recv().await else {
149                    break;
150                };
151
152                let send = response_sender.send(Ok(BlockAndHash::new(hash, block)));
153                match tokio::time::timeout(SEND_TIMEOUT, send).await {
154                    Ok(Ok(())) => {}
155                    Ok(Err(_)) => {
156                        span.in_scope(|| {
157                            tracing::info!(
158                                "client disconnected, dropping non_finalized_state_change task"
159                            );
160                        });
161                        return;
162                    }
163                    Err(_) => {
164                        span.in_scope(|| {
165                            tracing::warn!(
166                                "slow consumer, dropping non_finalized_state_change stream after \
167                                 send timed out"
168                            );
169                        });
170                        return;
171                    }
172                }
173            }
174
175            span.in_scope(|| {
176                tracing::warn!("non-finalized state change channel has closed");
177            });
178
179            let _ = response_sender
180                .send(Err(Status::unavailable(
181                    "non-finalized state change channel has closed",
182                )))
183                .await;
184        });
185
186        Ok(Response::new(Box::pin(response_stream)))
187    }
188
189    async fn mempool_change(
190        &self,
191        _: tonic::Request<Empty>,
192    ) -> Result<Response<Self::MempoolChangeStream>, Status> {
193        let span = Span::current();
194        let (response_sender, response_receiver) = tokio::sync::mpsc::channel(RESPONSE_BUFFER_SIZE);
195        let response_stream = ReceiverStream::new(response_receiver);
196        let mut mempool_change = self.mempool_change.subscribe();
197
198        tokio::spawn(async move {
199            // Notify the client of chain tip changes until the channel is closed
200            while let Ok(change) = mempool_change.recv().await {
201                for tx_id in change.tx_ids() {
202                    span.in_scope(|| {
203                        tracing::debug!("mempool change: {:?}", change);
204                    });
205
206                    let msg = Ok(MempoolChangeMessage {
207                        change_type: match change.kind() {
208                            MempoolChangeKind::Added => 0,
209                            MempoolChangeKind::Invalidated => 1,
210                            MempoolChangeKind::Mined => 2,
211                        },
212                        tx_hash: tx_id.mined_id().bytes_in_display_order().to_vec(),
213                        auth_digest: tx_id
214                            .auth_digest()
215                            .map(|d| d.bytes_in_display_order().to_vec())
216                            .unwrap_or_default(),
217                    });
218
219                    let send = response_sender.send(msg);
220                    match tokio::time::timeout(SEND_TIMEOUT, send).await {
221                        Ok(Ok(())) => {}
222                        Ok(Err(_)) => {
223                            span.in_scope(|| {
224                                tracing::info!("client disconnected, dropping mempool_change task");
225                            });
226                            return;
227                        }
228                        Err(_) => {
229                            span.in_scope(|| {
230                                tracing::warn!(
231                                    "slow consumer, dropping mempool_change stream after \
232                                     send timed out"
233                                );
234                            });
235                            return;
236                        }
237                    }
238                }
239            }
240
241            span.in_scope(|| {
242                tracing::warn!("mempool_change channel has closed");
243            });
244
245            let _ = response_sender
246                .send(Err(Status::unavailable(
247                    "mempool_change channel has closed",
248                )))
249                .await;
250        });
251
252        Ok(Response::new(Box::pin(response_stream)))
253    }
254
255    async fn get_block(
256        &self,
257        request: tonic::Request<BlockRequest>,
258    ) -> Result<Response<BlockAndHash>, Status> {
259        // The request carries a single `hash_or_height` byte string: a 32-byte block hash in
260        // display order, or a 4-byte big-endian block height. The length tells the two apart.
261        let hash_or_height = request.into_inner().hash_or_height;
262        let hash_or_height = match hash_or_height.len() {
263            32 => zebra_state::HashOrHeight::Hash(hash_from_display_bytes(hash_or_height)?),
264            4 => {
265                let height = u32::from_be_bytes(
266                    hash_or_height
267                        .try_into()
268                        .expect("a 4-byte vec always converts to a [u8; 4]"),
269                );
270                let height = block::Height::try_from(height).map_err(|_| {
271                    Status::invalid_argument(format!("block height out of range: {height}"))
272                })?;
273                zebra_state::HashOrHeight::Height(height)
274            }
275            len => {
276                return Err(Status::invalid_argument(format!(
277                    "block request must be a 32-byte hash or a 4-byte height, got {len} bytes"
278                )));
279            }
280        };
281
282        match self
283            .read_state
284            .clone()
285            .oneshot(ReadRequest::Block(hash_or_height))
286            .await
287        {
288            Ok(ReadResponse::Block(Some(block))) => {
289                Ok(Response::new(BlockAndHash::new(block.hash(), block)))
290            }
291            Ok(ReadResponse::Block(None)) => Err(Status::not_found("block not found")),
292            Ok(_) => unreachable!("unexpected response type from ReadStateService"),
293            Err(error) => Err(Status::unavailable(format!(
294                "failed to read block: {error}"
295            ))),
296        }
297    }
298}
299
300/// Decodes the chain tip hashes from a [`NonFinalizedStateChangeRequest`] into a set of
301/// [`block::Hash`]es.
302///
303/// Each hash is expected to be 32 bytes in display order, matching the encoding used when the
304/// server streams [`BlockAndHash`] messages back to the caller.
305///
306/// # Errors
307///
308/// Returns an [`invalid_argument`](Status::invalid_argument) status if there are more hashes than
309/// the non-finalized state can hold chains ([`MAX_NON_FINALIZED_CHAIN_FORKS`]), or if any hash is
310/// not exactly 32 bytes long.
311fn decode_known_chain_tips(chain_tip_hashes: Vec<Vec<u8>>) -> Result<HashSet<block::Hash>, Status> {
312    // The non-finalized state holds at most `MAX_NON_FINALIZED_CHAIN_FORKS` chains, so a caller can
313    // never legitimately have more chain tips than that. Bound the untrusted input up front rather
314    // than allocating a set sized by the request.
315    if chain_tip_hashes.len() > MAX_NON_FINALIZED_CHAIN_FORKS {
316        return Err(Status::invalid_argument(format!(
317            "too many chain tip hashes: got {}, expected at most {MAX_NON_FINALIZED_CHAIN_FORKS}",
318            chain_tip_hashes.len(),
319        )));
320    }
321
322    chain_tip_hashes
323        .into_iter()
324        .map(hash_from_display_bytes)
325        .collect()
326}
327
328/// Decodes a 32-byte block hash in display order, rejecting wrong-length input.
329fn hash_from_display_bytes(hash: Vec<u8>) -> Result<block::Hash, Status> {
330    let bytes: [u8; 32] = hash.try_into().map_err(|hash: Vec<u8>| {
331        Status::invalid_argument(format!(
332            "invalid block hash length: expected 32 bytes, got {}",
333            hash.len()
334        ))
335    })?;
336
337    Ok(block::Hash::from_bytes_in_display_order(&bytes))
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use tonic::Code;
344
345    fn hash(byte: u8) -> block::Hash {
346        block::Hash::from_bytes_in_display_order(&[byte; 32])
347    }
348
349    #[test]
350    fn decode_known_chain_tips_round_trips_display_order() {
351        let hashes = [hash(1), hash(2), hash(3)];
352        let encoded = hashes
353            .iter()
354            .map(|h| h.bytes_in_display_order().to_vec())
355            .collect();
356
357        let decoded = decode_known_chain_tips(encoded).expect("valid hashes should decode");
358
359        assert_eq!(decoded, hashes.into_iter().collect());
360    }
361
362    #[test]
363    fn decode_known_chain_tips_accepts_empty() {
364        assert!(decode_known_chain_tips(Vec::new())
365            .expect("empty input should decode")
366            .is_empty());
367    }
368
369    #[test]
370    fn decode_known_chain_tips_dedups() {
371        let encoded = vec![
372            hash(7).bytes_in_display_order().to_vec(),
373            hash(7).bytes_in_display_order().to_vec(),
374        ];
375
376        let decoded = decode_known_chain_tips(encoded).expect("duplicate hashes should decode");
377
378        assert_eq!(decoded, std::iter::once(hash(7)).collect());
379    }
380
381    #[test]
382    fn decode_known_chain_tips_rejects_wrong_length() {
383        let status = decode_known_chain_tips(vec![vec![0; 31]])
384            .expect_err("a 31-byte hash should be rejected");
385
386        assert_eq!(status.code(), Code::InvalidArgument);
387    }
388
389    #[test]
390    fn decode_known_chain_tips_rejects_too_many() {
391        let encoded = (0..=MAX_NON_FINALIZED_CHAIN_FORKS as u8)
392            .map(|b| hash(b).bytes_in_display_order().to_vec())
393            .collect();
394
395        let status = decode_known_chain_tips(encoded)
396            .expect_err("more than MAX_NON_FINALIZED_CHAIN_FORKS hashes should be rejected");
397
398        assert_eq!(status.code(), Code::InvalidArgument);
399    }
400}