Skip to main content

zebra_rpc/lightwalletd/
methods.rs

1//! Implements `CompactTxStreamer` methods on the `LightwalletdRPC` type.
2
3use std::{
4    collections::{HashMap, HashSet},
5    pin::Pin,
6};
7
8use futures::{future::OptionFuture, Stream};
9use tokio_stream::wrappers::ReceiverStream;
10use tonic::{Request, Response, Status, Streaming};
11use tower::util::ServiceExt;
12
13use zebra_chain::{
14    block,
15    chain_tip::ChainTip,
16    parameters::{Network, NetworkUpgrade},
17    serialization::{BytesInDisplayOrder, ZcashSerialize},
18    subtree::NoteCommitmentSubtreeIndex,
19    transaction, transparent,
20};
21use zebra_node_services::mempool::{self, MempoolService};
22use zebra_state::{HashOrHeight, ReadRequest, ReadResponse, ReadState};
23
24use crate::methods::{
25    trees::GetTreestateResponse, GetAddressBalanceRequest, GetAddressTxIdsRequest,
26    GetAddressUtxosRequest, GetAddressUtxosResponse, RpcServer as RpcMethods,
27    MAX_REQUEST_ADDRESSES,
28};
29
30use super::{
31    compact_tx_streamer_server::CompactTxStreamer, server::LightwalletdRPC, Address, AddressList,
32    Balance, BlockId, BlockRange, ChainSpec, CompactBlock, CompactTx, Duration, Empty, Exclude,
33    GetAddressUtxosArg, GetAddressUtxosReply, GetAddressUtxosReplyList, GetSubtreeRootsArg,
34    LightdInfo, PingResponse, RawTransaction, SendResponse, ShieldedProtocol, SubtreeRoot,
35    TransparentAddressBlockFilter, TreeState, TxFilter,
36};
37
38/// The maximum number of messages that can be queued to be streamed to a client.
39const RESPONSE_BUFFER_SIZE: usize = 64;
40
41/// The maximum number of `exclude` entries a `GetMempoolTx` client can send.
42///
43/// Matches `lightwalletd`. The list is only useful up to the size of the mempool,
44/// which holds at most `tx_cost_limit / MEMPOOL_TRANSACTION_COST_THRESHOLD`
45/// transactions (8,000 by default).
46const MAX_EXCLUDE_ENTRIES: usize = 20_000;
47
48/// The maximum size of a raw transaction submitted by a client.
49///
50/// A transaction larger than a block can never be mined.
51const MAX_RAW_TRANSACTION_BYTES: usize = block::MAX_BLOCK_BYTES as usize;
52
53/// How long to wait for a backpressured send to a response stream before treating
54/// the consumer as hung and dropping the stream.
55///
56/// Without a bound, a consumer whose connection is half-open (dead TCP not yet
57/// detected) would block the streaming task indefinitely.
58const STREAM_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
59
60/// A type alias for the boxed response streams returned by server-streaming methods.
61type ResponseStream<T> = Pin<Box<dyn Stream<Item = Result<T, Status>> + Send>>;
62
63#[tonic::async_trait]
64impl<Rpc, ReadStateService, Mempool, Tip> CompactTxStreamer
65    for LightwalletdRPC<Rpc, ReadStateService, Mempool, Tip>
66where
67    Rpc: RpcMethods + Clone,
68    ReadStateService: ReadState,
69    Mempool: MempoolService,
70    Tip: ChainTip + Clone + Send + Sync + 'static,
71{
72    type GetBlockRangeStream = ResponseStream<CompactBlock>;
73    type GetBlockRangeNullifiersStream = ResponseStream<CompactBlock>;
74    type GetTaddressTxidsStream = ResponseStream<RawTransaction>;
75    type GetTaddressTransactionsStream = ResponseStream<RawTransaction>;
76    type GetMempoolTxStream = ResponseStream<CompactTx>;
77    type GetMempoolStreamStream = ResponseStream<RawTransaction>;
78    type GetSubtreeRootsStream = ResponseStream<SubtreeRoot>;
79    type GetAddressUtxosStreamStream = ResponseStream<GetAddressUtxosReply>;
80
81    async fn get_latest_block(
82        &self,
83        _request: Request<ChainSpec>,
84    ) -> Result<Response<BlockId>, Status> {
85        let (height, hash) = self
86            .latest_chain_tip
87            .best_tip_height_and_hash()
88            .ok_or_else(|| Status::unavailable("no chain tip available yet"))?;
89
90        Ok(Response::new(BlockId {
91            height: height.0.into(),
92            hash: hash.0.to_vec(),
93        }))
94    }
95
96    async fn get_block(&self, request: Request<BlockId>) -> Result<Response<CompactBlock>, Status> {
97        let hash_or_height = block_id_to_hash_or_height(request.into_inner())?;
98        let block = compact_block(
99            self.read_state.clone(),
100            &self.network,
101            hash_or_height,
102            false,
103        )
104        .await?;
105
106        Ok(Response::new(block))
107    }
108
109    async fn get_block_nullifiers(
110        &self,
111        request: Request<BlockId>,
112    ) -> Result<Response<CompactBlock>, Status> {
113        let hash_or_height = block_id_to_hash_or_height(request.into_inner())?;
114        let block =
115            compact_block(self.read_state.clone(), &self.network, hash_or_height, true).await?;
116
117        Ok(Response::new(block))
118    }
119
120    async fn get_block_range(
121        &self,
122        request: Request<BlockRange>,
123    ) -> Result<Response<Self::GetBlockRangeStream>, Status> {
124        let stream = block_range_stream(
125            self.read_state.clone(),
126            self.network.clone(),
127            request.into_inner(),
128            false,
129        )?;
130
131        Ok(Response::new(stream))
132    }
133
134    async fn get_block_range_nullifiers(
135        &self,
136        request: Request<BlockRange>,
137    ) -> Result<Response<Self::GetBlockRangeNullifiersStream>, Status> {
138        let stream = block_range_stream(
139            self.read_state.clone(),
140            self.network.clone(),
141            request.into_inner(),
142            true,
143        )?;
144
145        Ok(Response::new(stream))
146    }
147
148    async fn get_transaction(
149        &self,
150        request: Request<TxFilter>,
151    ) -> Result<Response<RawTransaction>, Status> {
152        let tx_filter = request.into_inner();
153
154        // Only specification by transaction hash is supported, matching lightwalletd.
155        let hash: [u8; 32] = tx_filter.hash.try_into().map_err(|hash: Vec<u8>| {
156            Status::invalid_argument(format!(
157                "transaction hash must be 32 bytes, got {}",
158                hash.len()
159            ))
160        })?;
161        let hash = transaction::Hash::from(hash);
162
163        // Look for the transaction in any chain first, then in the mempool.
164        let side_chain_tx = match self
165            .read_state
166            .clone()
167            .oneshot(ReadRequest::AnyChainTransaction(hash))
168            .await
169        {
170            Ok(ReadResponse::AnyChainTransaction(Some(zebra_state::AnyTx::Mined(mined_tx)))) => {
171                return Ok(Response::new(RawTransaction {
172                    data: serialize_transaction(&mined_tx.tx)?,
173                    height: mined_tx.height.0.into(),
174                }));
175            }
176            Ok(ReadResponse::AnyChainTransaction(Some(zebra_state::AnyTx::Side((tx, _))))) => {
177                Some(tx)
178            }
179            Ok(ReadResponse::AnyChainTransaction(None)) => None,
180            Ok(_) => unreachable!("unexpected response type from ReadStateService"),
181            Err(error) => {
182                return Err(Status::internal(format!(
183                    "failed to read transaction: {error}"
184                )));
185            }
186        };
187
188        let mempool_response = self
189            .mempool
190            .clone()
191            .oneshot(mempool::Request::TransactionsByMinedId(
192                [hash].into_iter().collect(),
193            ))
194            .await
195            .map_err(|error| Status::internal(format!("failed to query mempool: {error}")))?;
196
197        let mempool_tx = match mempool_response {
198            mempool::Response::Transactions(transactions) => transactions.into_iter().next(),
199            _ => unreachable!("unexpected response type from mempool service"),
200        };
201
202        if let Some(tx) = mempool_tx {
203            Ok(Response::new(RawTransaction {
204                data: serialize_transaction(&tx.transaction)?,
205                // A height of 0 means the transaction is in the mempool.
206                height: 0,
207            }))
208        } else if let Some(tx) = side_chain_tx {
209            Ok(Response::new(RawTransaction {
210                data: serialize_transaction(&tx)?,
211                // The protocol's sentinel height for a transaction that has been
212                // mined on a fork that is not currently the main chain.
213                height: u64::MAX,
214            }))
215        } else {
216            Err(Status::not_found("transaction not found"))
217        }
218    }
219
220    async fn send_transaction(
221        &self,
222        request: Request<RawTransaction>,
223    ) -> Result<Response<SendResponse>, Status> {
224        let data = request.into_inner().data;
225
226        // Reject before hex-encoding doubles it: a transaction larger than a block
227        // can never be mined.
228        if data.len() > MAX_RAW_TRANSACTION_BYTES {
229            return Err(Status::invalid_argument(format!(
230                "transaction too large: the limit is {MAX_RAW_TRANSACTION_BYTES} bytes"
231            )));
232        }
233
234        let transaction_hex = hex::encode(data);
235
236        // Like lightwalletd, submission errors are returned in the `SendResponse`
237        // rather than as a gRPC error status.
238        let response = match self.rpc.send_raw_transaction(transaction_hex, None).await {
239            Ok(sent_tx) => SendResponse {
240                error_code: 0,
241                error_message: sent_tx.hash().to_string(),
242            },
243            Err(error) => SendResponse {
244                error_code: error.code(),
245                error_message: error.message().to_string(),
246            },
247        };
248
249        Ok(Response::new(response))
250    }
251
252    async fn get_taddress_txids(
253        &self,
254        request: Request<TransparentAddressBlockFilter>,
255    ) -> Result<Response<Self::GetTaddressTxidsStream>, Status> {
256        let stream =
257            taddress_transactions_stream(&self.rpc, self.read_state.clone(), request.into_inner())
258                .await?;
259
260        Ok(Response::new(stream))
261    }
262
263    async fn get_taddress_transactions(
264        &self,
265        request: Request<TransparentAddressBlockFilter>,
266    ) -> Result<Response<Self::GetTaddressTransactionsStream>, Status> {
267        let stream =
268            taddress_transactions_stream(&self.rpc, self.read_state.clone(), request.into_inner())
269                .await?;
270
271        Ok(Response::new(stream))
272    }
273
274    async fn get_taddress_balance(
275        &self,
276        request: Request<AddressList>,
277    ) -> Result<Response<Balance>, Status> {
278        let addresses = request.into_inner().addresses;
279
280        address_balance(&self.rpc, addresses)
281            .await
282            .map(Response::new)
283    }
284
285    async fn get_taddress_balance_stream(
286        &self,
287        request: Request<Streaming<Address>>,
288    ) -> Result<Response<Balance>, Status> {
289        let addresses = collect_streamed_addresses(request.into_inner()).await?;
290
291        address_balance(&self.rpc, addresses)
292            .await
293            .map(Response::new)
294    }
295
296    async fn get_mempool_tx(
297        &self,
298        request: Request<Exclude>,
299    ) -> Result<Response<Self::GetMempoolTxStream>, Status> {
300        let exclude = request.into_inner().txid;
301
302        if exclude.len() > MAX_EXCLUDE_ENTRIES {
303            return Err(Status::invalid_argument(format!(
304                "too many exclude entries: the limit is {MAX_EXCLUDE_ENTRIES}"
305            )));
306        }
307
308        let transactions = mempool_transactions(self.mempool.clone()).await?;
309
310        let txids: Vec<transaction::Hash> =
311            transactions.iter().map(|tx| tx.id.mined_id()).collect();
312        let excluded = excluded_txids(&txids, &exclude);
313
314        let compact_txs: Vec<Result<CompactTx, Status>> = transactions
315            .iter()
316            .filter(|tx| !excluded.contains(&tx.id.mined_id()))
317            .filter(|tx| super::has_compact_data(&tx.transaction))
318            .map(|tx| {
319                Ok(CompactTx::from_transaction(
320                    0,
321                    tx.id.mined_id(),
322                    &tx.transaction,
323                    false,
324                ))
325            })
326            .collect();
327
328        Ok(Response::new(Box::pin(futures::stream::iter(compact_txs))))
329    }
330
331    async fn get_mempool_stream(
332        &self,
333        _request: Request<Empty>,
334    ) -> Result<Response<Self::GetMempoolStreamStream>, Status> {
335        let (response_sender, response_receiver) = tokio::sync::mpsc::channel(RESPONSE_BUFFER_SIZE);
336        let response_stream = ReceiverStream::new(response_receiver);
337
338        // Subscribe to mempool changes before taking the initial snapshot, so no
339        // transactions are missed between the snapshot and the change stream.
340        let mut mempool_change = self.mempool_change.subscribe();
341        let mempool = self.mempool.clone();
342        let mut latest_chain_tip = self.latest_chain_tip.clone();
343
344        // The stream must only close when a block is added to the best chain after the
345        // stream starts. Mark the current tip here rather than in the spawned task: a
346        // block arriving before that task is first polled would otherwise be marked as
347        // seen, and the stream would stay open through a block change.
348        latest_chain_tip.mark_best_tip_seen();
349
350        tokio::spawn(async move {
351            let mut sent_txids: HashSet<transaction::Hash> = HashSet::new();
352
353            // Send the current mempool contents.
354            if !send_unseen_mempool_txs(&mempool, &response_sender, &mut sent_txids).await {
355                return;
356            }
357
358            // Stream newly added transactions, and close the stream when a new block
359            // is added to the best chain, matching lightwalletd's behaviour.
360            loop {
361                tokio::select! {
362                    _ = latest_chain_tip.best_tip_changed() => return,
363                    change = mempool_change.recv() => {
364                        let change = match change {
365                            Ok(change) => change,
366                            // Lagging means changes were dropped, so this stream would
367                            // silently omit the transactions they carried. Re-snapshot
368                            // the mempool and send whatever the client hasn't seen.
369                            Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
370                                if !send_unseen_mempool_txs(
371                                    &mempool,
372                                    &response_sender,
373                                    &mut sent_txids,
374                                )
375                                .await
376                                {
377                                    return;
378                                }
379
380                                continue;
381                            }
382                            Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
383                        };
384                        if !change.is_added() {
385                            continue;
386                        }
387
388                        let request = mempool::Request::TransactionsById(change.into_tx_ids());
389                        let Ok(mempool::Response::Transactions(transactions)) =
390                            mempool.clone().oneshot(request).await
391                        else {
392                            return;
393                        };
394
395                        for tx in transactions {
396                            if sent_txids.contains(&tx.id.mined_id()) {
397                                continue;
398                            }
399                            if !send_raw_mempool_tx(&response_sender, &tx).await {
400                                return;
401                            }
402                            sent_txids.insert(tx.id.mined_id());
403                        }
404                    }
405                }
406            }
407        });
408
409        Ok(Response::new(Box::pin(response_stream)))
410    }
411
412    async fn get_tree_state(
413        &self,
414        request: Request<BlockId>,
415    ) -> Result<Response<TreeState>, Status> {
416        let hash_or_height = block_id_to_hash_or_height(request.into_inner())?;
417
418        let treestate = self
419            .rpc
420            .z_get_treestate(hash_or_height.to_string())
421            .await
422            .map_err(rpc_error_to_status)?;
423
424        Ok(Response::new(treestate_to_proto(&self.network, treestate)))
425    }
426
427    async fn get_latest_tree_state(
428        &self,
429        _request: Request<Empty>,
430    ) -> Result<Response<TreeState>, Status> {
431        // Read the treestate by the tip's hash rather than its height, so a reorg
432        // between the two reads can't return the treestate of a different block.
433        let (_height, hash) = self
434            .latest_chain_tip
435            .best_tip_height_and_hash()
436            .ok_or_else(|| Status::unavailable("no chain tip available yet"))?;
437
438        let treestate = self
439            .rpc
440            .z_get_treestate(hash.to_string())
441            .await
442            .map_err(rpc_error_to_status)?;
443
444        Ok(Response::new(treestate_to_proto(&self.network, treestate)))
445    }
446
447    async fn get_subtree_roots(
448        &self,
449        request: Request<GetSubtreeRootsArg>,
450    ) -> Result<Response<Self::GetSubtreeRootsStream>, Status> {
451        let args = request.into_inner();
452
453        let pool = match ShieldedProtocol::try_from(args.shielded_protocol) {
454            Ok(ShieldedProtocol::Sapling) => "sapling",
455            Ok(ShieldedProtocol::Orchard) => "orchard",
456            Ok(ShieldedProtocol::Ironwood) => "ironwood",
457            Err(_) => return Err(Status::invalid_argument("unknown shielded protocol")),
458        };
459
460        let start_index = u16::try_from(args.start_index)
461            .map(NoteCommitmentSubtreeIndex)
462            .map_err(|_| Status::invalid_argument("start index must fit in 16 bits"))?;
463
464        let limit = if args.max_entries == 0 {
465            None
466        } else {
467            // Cast is safe due to the `min` with a 16-bit value.
468            Some(NoteCommitmentSubtreeIndex(
469                args.max_entries.min(u16::MAX.into()) as u16,
470            ))
471        };
472
473        let subtrees = self
474            .rpc
475            .z_get_subtrees_by_index(pool.to_string(), start_index, limit)
476            .await
477            .map_err(rpc_error_to_status)?;
478
479        let read_state = self.read_state.clone();
480        let (response_sender, response_receiver) = tokio::sync::mpsc::channel(RESPONSE_BUFFER_SIZE);
481        let response_stream = ReceiverStream::new(response_receiver);
482
483        tokio::spawn(async move {
484            for subtree in subtrees.subtrees() {
485                let root_hash = match hex::decode(&subtree.root) {
486                    Ok(root_hash) => root_hash,
487                    Err(_) => {
488                        let _ = send_bounded(
489                            &response_sender,
490                            Err(Status::internal("invalid subtree root hex")),
491                        )
492                        .await;
493                        return;
494                    }
495                };
496
497                let completing_block_hash = match read_state
498                    .clone()
499                    .oneshot(ReadRequest::BestChainBlockHash(subtree.end_height))
500                    .await
501                {
502                    Ok(ReadResponse::BlockHash(Some(hash))) => hash,
503                    Ok(ReadResponse::BlockHash(None)) => {
504                        let _ = send_bounded(
505                            &response_sender,
506                            Err(Status::not_found("completing block not found")),
507                        )
508                        .await;
509                        return;
510                    }
511                    Ok(_) => unreachable!("unexpected response type from ReadStateService"),
512                    Err(error) => {
513                        let _ = send_bounded(
514                            &response_sender,
515                            Err(Status::internal(format!(
516                                "failed to read completing block hash: {error}"
517                            ))),
518                        )
519                        .await;
520                        return;
521                    }
522                };
523
524                let subtree_root = SubtreeRoot {
525                    root_hash,
526                    // Display order, unlike the other hashes here: lightwalletd reverses
527                    // this one in `GetSubtreeRoots`.
528                    completing_block_hash: completing_block_hash.bytes_in_display_order().to_vec(),
529                    completing_block_height: subtree.end_height.0.into(),
530                };
531
532                if !send_bounded(&response_sender, Ok(subtree_root)).await {
533                    return;
534                }
535            }
536        });
537
538        Ok(Response::new(Box::pin(response_stream)))
539    }
540
541    async fn get_address_utxos(
542        &self,
543        request: Request<GetAddressUtxosArg>,
544    ) -> Result<Response<GetAddressUtxosReplyList>, Status> {
545        let address_utxos = address_utxos(&self.rpc, request.into_inner()).await?;
546
547        Ok(Response::new(GetAddressUtxosReplyList { address_utxos }))
548    }
549
550    async fn get_address_utxos_stream(
551        &self,
552        request: Request<GetAddressUtxosArg>,
553    ) -> Result<Response<Self::GetAddressUtxosStreamStream>, Status> {
554        let address_utxos = address_utxos(&self.rpc, request.into_inner()).await?;
555
556        Ok(Response::new(Box::pin(futures::stream::iter(
557            address_utxos.into_iter().map(Ok),
558        ))))
559    }
560
561    async fn get_lightd_info(
562        &self,
563        _request: Request<Empty>,
564    ) -> Result<Response<LightdInfo>, Status> {
565        let blockchain_info = self
566            .rpc
567            .get_blockchain_info()
568            .await
569            .map_err(rpc_error_to_status)?;
570        let info = self.rpc.get_info().await.map_err(rpc_error_to_status)?;
571
572        let (tip_branch_id, _next_branch_id) = blockchain_info.consensus().into_parts();
573
574        let lightd_info = LightdInfo {
575            // The node's version, not this crate's: `CARGO_PKG_VERSION` here is
576            // `zebra-rpc`, which clients would read as the node version.
577            version: info.build().clone(),
578            vendor: "ZcashFoundation/zebra".to_string(),
579            taddr_support: true,
580            chain_name: blockchain_info.chain().clone(),
581            sapling_activation_height: self.network.sapling_activation_height().0.into(),
582            consensus_branch_id: format!("{tip_branch_id:08x}"),
583            block_height: blockchain_info.blocks().0.into(),
584            git_commit: String::new(),
585            branch: String::new(),
586            build_date: String::new(),
587            build_user: String::new(),
588            estimated_height: blockchain_info.estimated_height().0.into(),
589            zcashd_build: info.build().clone(),
590            zcashd_subversion: info.subversion().clone(),
591            donation_address: String::new(),
592        };
593
594        Ok(Response::new(lightd_info))
595    }
596
597    async fn ping(&self, _request: Request<Duration>) -> Result<Response<PingResponse>, Status> {
598        // The `Ping` method is a testing-only method that can be used for denial of
599        // service, so it is disabled, like in production lightwalletd instances.
600        Err(Status::unimplemented("Ping is disabled"))
601    }
602}
603
604/// Converts a [`BlockId`] into a [`HashOrHeight`], using the hash if it is present
605/// and the height otherwise.
606fn block_id_to_hash_or_height(block_id: BlockId) -> Result<HashOrHeight, Status> {
607    if !block_id.hash.is_empty() {
608        // Hashes are sent in canonical little-endian (internal) byte order, per
609        // zcash/lightwalletd#512. `GetTreeState` in lightwalletd still reads
610        // big-endian here, which is an unfixed instance of the same bug.
611        let hash: [u8; 32] = block_id.hash.try_into().map_err(|hash: Vec<u8>| {
612            Status::invalid_argument(format!("block hash must be 32 bytes, got {}", hash.len()))
613        })?;
614
615        Ok(HashOrHeight::Hash(block::Hash(hash)))
616    } else if block_id.height == 0 {
617        // Neither field is set. Reading that as a request for genesis, as lightwalletd
618        // does not, would answer an uninitialised request with a block that has no
619        // shielded data — so the client's scan "succeeds" and finds nothing.
620        Err(Status::invalid_argument(
621            "block id must specify a hash or a height",
622        ))
623    } else {
624        let height = u32::try_from(block_id.height)
625            .ok()
626            .and_then(|height| block::Height::try_from(height).ok())
627            .ok_or_else(|| {
628                Status::invalid_argument(format!("block height out of range: {}", block_id.height))
629            })?;
630
631        Ok(HashOrHeight::Height(height))
632    }
633}
634
635/// Fetches a block and its note commitment tree sizes from the state, and converts
636/// them into a [`CompactBlock`].
637async fn compact_block<ReadStateService: ReadState>(
638    read_state: ReadStateService,
639    network: &Network,
640    hash_or_height: HashOrHeight,
641    nullifiers_only: bool,
642) -> Result<CompactBlock, Status> {
643    let block = match read_state
644        .clone()
645        .oneshot(ReadRequest::Block(hash_or_height))
646        .await
647    {
648        Ok(ReadResponse::Block(Some(block))) => block,
649        Ok(ReadResponse::Block(None)) => return Err(Status::not_found("block not found")),
650        Ok(_) => unreachable!("unexpected response type from ReadStateService"),
651        Err(error) => {
652            return Err(Status::internal(format!("failed to read block: {error}")));
653        }
654    };
655
656    let hash = block.hash();
657
658    let height = block
659        .coinbase_height()
660        .ok_or_else(|| Status::internal("block in the state must have a height"))?;
661
662    // The Ironwood pool only exists from NU6.3: before activation the tree is
663    // legitimately empty, so the read is skipped entirely.
664    let is_ironwood_active = NetworkUpgrade::current(network, height) >= NetworkUpgrade::Nu6_3;
665
666    // `nullifiers_only` responses carry zeroed tree sizes, like lightwalletd's
667    // `pruneCompactBlockToNullifiers`, so don't read the trees at all.
668    let (sapling_tree_size, orchard_tree_size, ironwood_tree_size) = if nullifiers_only {
669        (0, 0, 0)
670    } else {
671        // Fetch the trees by the block's hash rather than the caller's hash or height,
672        // so a concurrent reorg can't make the tree sizes inconsistent with the block.
673        let sapling_request = read_state
674            .clone()
675            .oneshot(ReadRequest::SaplingTree(hash.into()));
676        let orchard_request = read_state
677            .clone()
678            .oneshot(ReadRequest::OrchardTree(hash.into()));
679        let ironwood_request: OptionFuture<_> = is_ironwood_active
680            .then(|| read_state.oneshot(ReadRequest::IronwoodTree(hash.into())))
681            .into();
682
683        let (sapling_response, orchard_response, ironwood_response) =
684            futures::join!(sapling_request, orchard_request, ironwood_request);
685
686        // A missing tree is not an empty tree: serving zero would tell the client no
687        // notes exist as of this block, corrupting every position derived from it.
688        let sapling_tree_size = match sapling_response {
689            Ok(ReadResponse::SaplingTree(Some(tree))) => tree.count(),
690            Ok(ReadResponse::SaplingTree(None)) => {
691                return Err(Status::aborted(
692                    "sapling tree not found for this block, it may have been reorged",
693                ));
694            }
695            Ok(_) => unreachable!("unexpected response type from ReadStateService"),
696            Err(error) => {
697                return Err(Status::internal(format!(
698                    "failed to read sapling tree: {error}"
699                )));
700            }
701        };
702
703        let orchard_tree_size = match orchard_response {
704            Ok(ReadResponse::OrchardTree(Some(tree))) => tree.count(),
705            Ok(ReadResponse::OrchardTree(None)) => {
706                return Err(Status::aborted(
707                    "orchard tree not found for this block, it may have been reorged",
708                ));
709            }
710            Ok(_) => unreachable!("unexpected response type from ReadStateService"),
711            Err(error) => {
712                return Err(Status::internal(format!(
713                    "failed to read orchard tree: {error}"
714                )));
715            }
716        };
717
718        let ironwood_tree_size = match ironwood_response {
719            // The read is only skipped before NU6.3, where zero is the correct size.
720            None => 0,
721            Some(Ok(ReadResponse::IronwoodTree(Some(tree)))) => tree.count(),
722            Some(Ok(ReadResponse::IronwoodTree(None))) => {
723                return Err(Status::aborted(
724                    "ironwood tree not found for this block, it may have been reorged",
725                ));
726            }
727            Some(Ok(_)) => unreachable!("unexpected response type from ReadStateService"),
728            Some(Err(error)) => {
729                return Err(Status::internal(format!(
730                    "failed to read ironwood tree: {error}"
731                )));
732            }
733        };
734
735        (sapling_tree_size, orchard_tree_size, ironwood_tree_size)
736    };
737
738    Ok(CompactBlock::from_block(
739        &block,
740        hash,
741        height,
742        sapling_tree_size,
743        orchard_tree_size,
744        ironwood_tree_size,
745        nullifiers_only,
746    ))
747}
748
749/// Returns a stream of compact blocks for the given block range.
750///
751/// The range is inclusive, and the blocks are streamed in the order given by the
752/// range: ascending if `start <= end`, and descending otherwise.
753fn block_range_stream<ReadStateService: ReadState>(
754    read_state: ReadStateService,
755    network: Network,
756    block_range: BlockRange,
757    nullifiers_only: bool,
758) -> Result<ResponseStream<CompactBlock>, Status> {
759    let range_height = |block_id: Option<BlockId>, name: &str| {
760        let block_id =
761            block_id.ok_or_else(|| Status::invalid_argument(format!("{name} not specified")))?;
762        match block_id_to_hash_or_height(block_id)? {
763            HashOrHeight::Height(height) => Ok(height.0),
764            HashOrHeight::Hash(_) => Err(Status::invalid_argument(format!(
765                "{name} must be specified as a height"
766            ))),
767        }
768    };
769
770    let start = range_height(block_range.start, "start block")?;
771    let end = range_height(block_range.end, "end block")?;
772
773    let (response_sender, response_receiver) = tokio::sync::mpsc::channel(RESPONSE_BUFFER_SIZE);
774    let response_stream = ReceiverStream::new(response_receiver);
775
776    tokio::spawn(async move {
777        let heights: Box<dyn Iterator<Item = u32> + Send> = if start <= end {
778            Box::new(start..=end)
779        } else {
780            Box::new((end..=start).rev())
781        };
782
783        for height in heights {
784            let hash_or_height = HashOrHeight::Height(block::Height(height));
785            let response = compact_block(
786                read_state.clone(),
787                &network,
788                hash_or_height,
789                nullifiers_only,
790            )
791            .await;
792            let is_err = response.is_err();
793
794            if !send_bounded(&response_sender, response).await || is_err {
795                return;
796            }
797        }
798    });
799
800    Ok(Box::pin(response_stream))
801}
802
803/// Returns a stream of raw transactions for the given transparent address and block
804/// range, in block order.
805async fn taddress_transactions_stream<Rpc, ReadStateService>(
806    rpc: &Rpc,
807    read_state: ReadStateService,
808    request: TransparentAddressBlockFilter,
809) -> Result<ResponseStream<RawTransaction>, Status>
810where
811    Rpc: RpcMethods,
812    ReadStateService: ReadState,
813{
814    let range = request
815        .range
816        .ok_or_else(|| Status::invalid_argument("block range not specified"))?;
817
818    // A zero start or end height means the full range in the underlying RPC method.
819    let range_height = |block_id: Option<BlockId>| -> Result<Option<u32>, Status> {
820        let Some(block_id) = block_id else {
821            return Ok(None);
822        };
823        if !block_id.hash.is_empty() {
824            return Err(Status::invalid_argument(
825                "block range must be specified by height",
826            ));
827        }
828        let height = u32::try_from(block_id.height).map_err(|_| {
829            Status::invalid_argument(format!("block height out of range: {}", block_id.height))
830        })?;
831        Ok((height != 0).then_some(height))
832    };
833
834    let txids = rpc
835        .get_address_tx_ids(GetAddressTxIdsRequest::new(
836            vec![request.address],
837            range_height(range.start)?,
838            range_height(range.end)?,
839        ))
840        .await
841        .map_err(rpc_arg_error_to_status)?;
842
843    let (response_sender, response_receiver) = tokio::sync::mpsc::channel(RESPONSE_BUFFER_SIZE);
844    let response_stream = ReceiverStream::new(response_receiver);
845
846    tokio::spawn(async move {
847        for txid in txids {
848            let response = match txid.parse() {
849                Ok(txid) => raw_transaction_by_txid(&read_state, txid).await,
850                Err(_) => Err(Status::internal(
851                    "transaction ids must be valid hex strings",
852                )),
853            };
854            let is_err = response.is_err();
855
856            if !send_bounded(&response_sender, response).await || is_err {
857                return;
858            }
859        }
860    });
861
862    Ok(Box::pin(response_stream))
863}
864
865/// Fetches a mined transaction from the best chain by its transaction ID.
866async fn raw_transaction_by_txid<ReadStateService: ReadState>(
867    read_state: &ReadStateService,
868    txid: transaction::Hash,
869) -> Result<RawTransaction, Status> {
870    match read_state
871        .clone()
872        .oneshot(ReadRequest::Transaction(txid))
873        .await
874    {
875        Ok(ReadResponse::Transaction(Some(mined_tx))) => Ok(RawTransaction {
876            data: serialize_transaction(&mined_tx.tx)?,
877            height: mined_tx.height.0.into(),
878        }),
879        Ok(ReadResponse::Transaction(None)) => Err(Status::not_found("transaction not found")),
880        Ok(_) => unreachable!("unexpected response type from ReadStateService"),
881        Err(error) => Err(Status::internal(format!(
882            "failed to read transaction: {error}"
883        ))),
884    }
885}
886
887/// Serializes a transaction into raw bytes for a [`RawTransaction`].
888fn serialize_transaction(tx: &transaction::Transaction) -> Result<Vec<u8>, Status> {
889    tx.zcash_serialize_to_vec()
890        .map_err(|_| Status::internal("failed to serialize transaction"))
891}
892
893/// Returns an error if `addresses` exceeds [`MAX_REQUEST_ADDRESSES`].
894/// Collects the transparent addresses a client streams, validating each one as it
895/// arrives and stopping at the first invalid or excess entry.
896///
897/// Each message is only bounded by the gRPC message size, so buffering unvalidated
898/// input is unbounded in bytes even though the message count is capped.
899async fn collect_streamed_addresses<S>(mut address_stream: S) -> Result<Vec<String>, Status>
900where
901    S: futures::Stream<Item = Result<Address, Status>> + Unpin,
902{
903    use futures::StreamExt;
904
905    let mut addresses = Vec::new();
906
907    while let Some(address) = address_stream.next().await.transpose()? {
908        if addresses.len() >= MAX_REQUEST_ADDRESSES {
909            return Err(Status::invalid_argument(format!(
910                "too many addresses: the limit is {MAX_REQUEST_ADDRESSES}"
911            )));
912        }
913
914        if address.address.parse::<transparent::Address>().is_err() {
915            return Err(Status::invalid_argument("invalid transparent address"));
916        }
917
918        addresses.push(address.address);
919    }
920
921    Ok(addresses)
922}
923
924/// Returns an error if there are too many addresses in a single request.
925fn check_address_count(addresses: &[String]) -> Result<(), Status> {
926    if addresses.len() > MAX_REQUEST_ADDRESSES {
927        return Err(Status::invalid_argument(format!(
928            "too many addresses: the limit is {MAX_REQUEST_ADDRESSES}"
929        )));
930    }
931
932    Ok(())
933}
934
935/// Fetches the total balance of a list of transparent addresses.
936async fn address_balance<Rpc: RpcMethods>(
937    rpc: &Rpc,
938    addresses: Vec<String>,
939) -> Result<Balance, Status> {
940    check_address_count(&addresses)?;
941
942    let balance = rpc
943        .get_address_balance(GetAddressBalanceRequest::new(addresses))
944        .await
945        .map_err(rpc_arg_error_to_status)?;
946
947    Ok(Balance {
948        // Cast is safe: the total ZEC supply in zatoshis fits in an `i64`.
949        value_zat: balance.balance() as i64,
950    })
951}
952
953/// Fetches the UTXOs for a list of transparent addresses, at or above the given start height,
954/// at most `max_entries` of them.
955///
956/// Both limits are passed to the state query, so they bound the work the node does rather than
957/// just the size of the reply.
958async fn address_utxos<Rpc: RpcMethods>(
959    rpc: &Rpc,
960    args: GetAddressUtxosArg,
961) -> Result<Vec<GetAddressUtxosReply>, Status> {
962    check_address_count(&args.addresses)?;
963
964    let response = rpc
965        .get_address_utxos(
966            GetAddressUtxosRequest::new(args.addresses, false)
967                .with_limits(args.start_height, args.max_entries),
968        )
969        .await
970        .map_err(rpc_arg_error_to_status)?;
971
972    let GetAddressUtxosResponse::Utxos(utxos) = response else {
973        unreachable!("chain info is never requested");
974    };
975
976    Ok(utxos
977        .iter()
978        .map(|utxo| GetAddressUtxosReply {
979            address: utxo.address().to_string(),
980            txid: utxo.txid().0.to_vec(),
981            // Cast is safe: output indexes are 32-bit values, and valid indexes are
982            // bounded by the maximum block size.
983            index: utxo.output_index().index() as i32,
984            script: utxo.script().as_raw_bytes().to_vec(),
985            // Cast is safe: the total ZEC supply in zatoshis fits in an `i64`.
986            value_zat: utxo.satoshis() as i64,
987            height: utxo.height().0.into(),
988        })
989        .collect())
990}
991
992/// Sends every mempool transaction that isn't already in `sent_txids`, adding the ones
993/// it sends, and returns false if the client went away or the mempool query failed.
994///
995/// Used for the initial snapshot, and again whenever the change subscription lags and
996/// the stream would otherwise silently omit the dropped transactions.
997async fn send_unseen_mempool_txs<Mempool: MempoolService>(
998    mempool: &Mempool,
999    response_sender: &tokio::sync::mpsc::Sender<Result<RawTransaction, Status>>,
1000    sent_txids: &mut HashSet<transaction::Hash>,
1001) -> bool {
1002    let transactions = match mempool_transactions(mempool.clone()).await {
1003        Ok(transactions) => transactions,
1004        Err(status) => {
1005            let _ = send_bounded(response_sender, Err(status)).await;
1006            return false;
1007        }
1008    };
1009
1010    for tx in transactions {
1011        if sent_txids.contains(&tx.id.mined_id()) {
1012            continue;
1013        }
1014
1015        if !send_raw_mempool_tx(response_sender, &tx).await {
1016            return false;
1017        }
1018
1019        sent_txids.insert(tx.id.mined_id());
1020    }
1021
1022    true
1023}
1024
1025/// Fetches all transactions currently in the mempool.
1026async fn mempool_transactions<Mempool: MempoolService>(
1027    mempool: Mempool,
1028) -> Result<Vec<transaction::UnminedTx>, Status> {
1029    let response = mempool
1030        .oneshot(mempool::Request::FullTransactions)
1031        .await
1032        .map_err(|error| Status::internal(format!("failed to query mempool: {error}")))?;
1033
1034    match response {
1035        mempool::Response::FullTransactions { transactions, .. } => {
1036            Ok(transactions.into_iter().map(|tx| tx.transaction).collect())
1037        }
1038        _ => unreachable!("unexpected response type from mempool service"),
1039    }
1040}
1041
1042/// Returns the transaction IDs excluded by an `Exclude` list of shortened
1043/// transaction IDs.
1044///
1045/// Each entry is a reversed prefix of the big-endian (display order) transaction ID
1046/// hex — equivalently, a suffix of the little-endian transaction ID bytes sent in
1047/// `CompactTx.hash` — matching lightwalletd, which reverses each entry before
1048/// comparing it with big-endian transaction IDs.
1049///
1050/// If an entry matches more than one transaction, none of the matching transactions
1051/// are excluded, following the lightwalletd specification.
1052fn excluded_txids(txids: &[transaction::Hash], exclude: &[Vec<u8>]) -> HashSet<transaction::Hash> {
1053    // Bucket the suffixes by length, so each transaction ID costs one lookup per
1054    // length in use, rather than a scan per exclude entry.
1055    let mut buckets: HashMap<usize, HashMap<&[u8], (usize, Option<transaction::Hash>)>> =
1056        HashMap::new();
1057
1058    for suffix in exclude {
1059        // An empty entry doesn't identify a transaction, and would otherwise
1060        // exclude the only transaction in a single-transaction mempool. An entry
1061        // longer than a transaction ID can never match one.
1062        if suffix.is_empty() || suffix.len() > 32 {
1063            continue;
1064        }
1065
1066        buckets
1067            .entry(suffix.len())
1068            .or_default()
1069            .entry(suffix.as_slice())
1070            .or_insert((0, None));
1071    }
1072
1073    for txid in txids {
1074        for (suffix_len, entries) in buckets.iter_mut() {
1075            if let Some((matches, first_match)) = entries.get_mut(&txid.0[32 - suffix_len..]) {
1076                *matches += 1;
1077                first_match.get_or_insert(*txid);
1078            }
1079        }
1080    }
1081
1082    // An entry that matches more than one transaction doesn't identify one.
1083    buckets
1084        .values()
1085        .flat_map(|entries| entries.values())
1086        .filter_map(|&(matches, first_match)| (matches == 1).then_some(first_match).flatten())
1087        .collect()
1088}
1089
1090/// Sends `item` to a response stream, returning false if the client disconnected or
1091/// stopped reading.
1092///
1093/// The send is bounded by [`STREAM_SEND_TIMEOUT`] so a client that holds its
1094/// connection open without consuming the stream can't park the producer task, and
1095/// its buffered responses, for the lifetime of the process.
1096async fn send_bounded<T>(
1097    response_sender: &tokio::sync::mpsc::Sender<Result<T, Status>>,
1098    item: Result<T, Status>,
1099) -> bool {
1100    matches!(
1101        tokio::time::timeout(STREAM_SEND_TIMEOUT, response_sender.send(item)).await,
1102        Ok(Ok(()))
1103    )
1104}
1105
1106/// Sends a mempool transaction to a raw transaction stream, returning false if the
1107/// client disconnected or hung, or the transaction could not be serialized.
1108async fn send_raw_mempool_tx(
1109    response_sender: &tokio::sync::mpsc::Sender<Result<RawTransaction, Status>>,
1110    tx: &transaction::UnminedTx,
1111) -> bool {
1112    let Ok(data) = tx.transaction.zcash_serialize_to_vec() else {
1113        let _ = send_bounded(
1114            response_sender,
1115            Err(Status::internal("failed to serialize transaction")),
1116        )
1117        .await;
1118        return false;
1119    };
1120
1121    // A height of 0 means the transaction is in the mempool. The protocol comment
1122    // says `GetMempoolStream` sends the latest block height instead, but that
1123    // documentation is wrong and lightwalletd always sends 0:
1124    // <https://github.com/zcash/librustzcash/issues/1484>
1125    send_bounded(response_sender, Ok(RawTransaction { data, height: 0 })).await
1126}
1127
1128/// Converts a [`GetTreestateResponse`] into a lightwalletd [`TreeState`].
1129fn treestate_to_proto(network: &Network, treestate: GetTreestateResponse) -> TreeState {
1130    let final_state_hex =
1131        |commitments: &Option<Vec<u8>>| commitments.as_ref().map(hex::encode).unwrap_or_default();
1132
1133    TreeState {
1134        network: network.bip70_network_name(),
1135        height: treestate.height().0.into(),
1136        hash: treestate.hash().to_string(),
1137        time: treestate.time(),
1138        sapling_tree: final_state_hex(treestate.sapling().commitments().final_state()),
1139        orchard_tree: final_state_hex(treestate.orchard().commitments().final_state()),
1140        // Absent before NU6.3, where an empty tree is the correct answer.
1141        ironwood_tree: treestate
1142            .ironwood()
1143            .as_ref()
1144            .map(|ironwood| final_state_hex(ironwood.commitments().final_state()))
1145            .unwrap_or_default(),
1146    }
1147}
1148
1149/// Converts a JSON-RPC method error into a gRPC status, for a lookup of something the
1150/// client asked for by identifier.
1151fn rpc_error_to_status(error: jsonrpsee_types::ErrorObjectOwned) -> Status {
1152    // Missing blocks, transactions, and invalid addresses or keys are reported with
1153    // zcashd's `INVALID_ADDRESS_OR_KEY` (-5) and `INVALID_PARAMETER` (-8) error codes.
1154    match error.code() {
1155        -5 | -8 => Status::not_found(error.message().to_string()),
1156        _ => Status::internal(error.message().to_string()),
1157    }
1158}
1159
1160/// Converts a JSON-RPC method error into a gRPC status, for a call whose arguments the
1161/// client supplied and Zebra has not already validated.
1162///
1163/// zcashd reuses the same error codes for "no such thing" and "that argument is not
1164/// valid", so only the caller knows which it meant. Reporting a malformed address as
1165/// `NOT_FOUND`, or a bad range as `INTERNAL`, blames the server for the client's input.
1166fn rpc_arg_error_to_status(error: jsonrpsee_types::ErrorObjectOwned) -> Status {
1167    // -5 `INVALID_ADDRESS_OR_KEY`, -8 `INVALID_PARAMETER`, -32602 `InvalidParams`.
1168    match error.code() {
1169        -5 | -8 | -32602 => Status::invalid_argument(error.message().to_string()),
1170        _ => Status::internal(error.message().to_string()),
1171    }
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176    use super::*;
1177    use futures::StreamExt;
1178    use std::sync::Arc;
1179    use tonic::Code;
1180    use zebra_chain::serialization::BytesInDisplayOrder;
1181
1182    /// A streamed address list must be validated entry by entry, so an invalid
1183    /// address ends the stream instead of being buffered until it closes.
1184    #[tokio::test]
1185    async fn streamed_addresses_are_validated_before_the_stream_ends() {
1186        use std::sync::atomic::{AtomicUsize, Ordering};
1187
1188        let _init_guard = zebra_test::init();
1189
1190        let valid = "t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd".to_string();
1191        let yielded = Arc::new(AtomicUsize::new(0));
1192
1193        // A valid address, an invalid one, then more input the server must not read.
1194        let addresses = futures::stream::iter(
1195            [valid.clone(), "not an address".to_string(), valid.clone()]
1196                .map(|address| Ok(Address { address })),
1197        )
1198        .inspect({
1199            let yielded = yielded.clone();
1200            move |_| {
1201                yielded.fetch_add(1, Ordering::SeqCst);
1202            }
1203        });
1204
1205        let status = collect_streamed_addresses(Box::pin(addresses))
1206            .await
1207            .expect_err("an invalid address must be rejected");
1208
1209        assert_eq!(status.code(), Code::InvalidArgument);
1210        assert_eq!(
1211            yielded.load(Ordering::SeqCst),
1212            2,
1213            "the server must stop reading at the first invalid address",
1214        );
1215    }
1216
1217    /// The streamed address count is bounded, so a client can't buffer without limit.
1218    #[tokio::test]
1219    async fn streamed_addresses_are_bounded() {
1220        let _init_guard = zebra_test::init();
1221
1222        let valid = "t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd".to_string();
1223        let addresses = futures::stream::iter(
1224            std::iter::repeat_n(valid, MAX_REQUEST_ADDRESSES + 1)
1225                .map(|address| Ok(Address { address })),
1226        );
1227
1228        let status = collect_streamed_addresses(Box::pin(addresses))
1229            .await
1230            .expect_err("too many addresses must be rejected");
1231
1232        assert_eq!(status.code(), Code::InvalidArgument);
1233    }
1234
1235    fn txid(byte: u8) -> transaction::Hash {
1236        transaction::Hash::from_bytes_in_display_order(&[byte; 32])
1237    }
1238
1239    #[test]
1240    fn block_id_hash_round_trips_internal_byte_order() {
1241        let hash = block::Hash([7; 32]);
1242        let block_id = BlockId {
1243            height: 0,
1244            hash: hash.0.to_vec(),
1245        };
1246
1247        let hash_or_height =
1248            block_id_to_hash_or_height(block_id).expect("a 32-byte hash should convert");
1249
1250        assert_eq!(hash_or_height, HashOrHeight::Hash(hash));
1251    }
1252
1253    #[test]
1254    fn block_id_height_is_used_when_hash_is_empty() {
1255        let block_id = BlockId {
1256            height: 500_000,
1257            hash: Vec::new(),
1258        };
1259
1260        let hash_or_height =
1261            block_id_to_hash_or_height(block_id).expect("a valid height should convert");
1262
1263        assert_eq!(hash_or_height, HashOrHeight::Height(block::Height(500_000)));
1264    }
1265
1266    #[test]
1267    fn block_id_rejects_wrong_hash_length_and_out_of_range_height() {
1268        let status = block_id_to_hash_or_height(BlockId {
1269            height: 0,
1270            hash: vec![0; 31],
1271        })
1272        .expect_err("a 31-byte hash should be rejected");
1273        assert_eq!(status.code(), Code::InvalidArgument);
1274
1275        let status = block_id_to_hash_or_height(BlockId {
1276            height: u64::MAX,
1277            hash: Vec::new(),
1278        })
1279        .expect_err("an out-of-range height should be rejected");
1280        assert_eq!(status.code(), Code::InvalidArgument);
1281    }
1282
1283    #[test]
1284    fn exclude_list_matches_reversed_display_order_prefixes() {
1285        // A txid whose internal (little-endian) bytes are 0, 1, ..., 31,
1286        // so its display order is 31, 30, ..., 0.
1287        let asymmetric = transaction::Hash(core::array::from_fn(|i| i as u8));
1288        let txids = [txid(1), txid(2), asymmetric];
1289
1290        // A shortened txid is a reversed display-order prefix: the client shortens
1291        // the display hex "1f1e1d..." to "1f1e", and sends it reversed.
1292        let excluded = excluded_txids(&txids, &[vec![30, 31]]);
1293        assert_eq!(excluded, [asymmetric].into_iter().collect());
1294
1295        // A display-order (unreversed) prefix must not match.
1296        assert!(excluded_txids(&txids, &[vec![31, 30]]).is_empty());
1297
1298        // A full txid in `CompactTx.hash` (internal) order excludes its match.
1299        let excluded = excluded_txids(&txids, &[asymmetric.0.to_vec()]);
1300        assert_eq!(excluded, [asymmetric].into_iter().collect());
1301    }
1302
1303    #[test]
1304    fn exclude_list_keeps_transactions_with_ambiguous_or_unknown_entries() {
1305        let txids = [txid(1), txid(2)];
1306
1307        // An empty entry doesn't identify a transaction, so nothing is excluded,
1308        // even when it is the only transaction.
1309        assert!(excluded_txids(&txids[..1], &[Vec::new()]).is_empty());
1310
1311        // An entry that matches nothing excludes nothing.
1312        assert!(excluded_txids(&txids, &[vec![9, 9, 9]]).is_empty());
1313
1314        // An entry that matches more than one transaction excludes nothing.
1315        let txids = [txid(7), txid(7)];
1316        assert!(excluded_txids(&txids, &[vec![7, 7]]).is_empty());
1317    }
1318}