Skip to main content

zebra_state/service/read/address/
utxo.rs

1//! Transparent address index UTXO queries.
2//!
3//! In the functions in this module:
4//!
5//! The block write task commits blocks to the finalized state before updating
6//! `chain` with a cached copy of the best non-finalized chain from
7//! `NonFinalizedState.chain_set`. Then the block commit task can commit additional blocks to
8//! the finalized state after we've cloned the `chain`.
9//!
10//! This means that some blocks can be in both:
11//! - the cached [`Chain`], and
12//! - the shared finalized [`ZebraDb`] reference.
13
14use std::{
15    collections::{BTreeMap, BTreeSet, HashSet},
16    ops::RangeInclusive,
17};
18
19use derive_getters::Getters;
20use zebra_chain::{
21    block::{self, Height},
22    parameters::Network,
23    transaction, transparent,
24};
25
26use crate::{
27    service::{
28        finalized_state::ZebraDb, non_finalized_state::Chain, read::FINALIZED_STATE_QUERY_RETRIES,
29    },
30    BoxError, OutputLocation, TransactionLocation,
31};
32
33/// The full range of address heights.
34///
35/// The genesis coinbase transactions are ignored by a consensus rule,
36/// so they are not included in any address indexes.
37pub const ADDRESS_HEIGHTS_FULL_RANGE: RangeInclusive<Height> = Height(1)..=Height::MAX;
38
39/// A convenience wrapper that efficiently stores unspent transparent outputs,
40/// and the corresponding transaction IDs.
41#[derive(Clone, Debug, Default, Eq, PartialEq, Getters)]
42pub struct AddressUtxos {
43    /// A set of unspent transparent outputs.
44    #[getter(skip)]
45    utxos: BTreeMap<OutputLocation, transparent::Output>,
46
47    /// The transaction IDs for each [`OutputLocation`] in `utxos`.
48    #[getter(skip)]
49    tx_ids: BTreeMap<TransactionLocation, transaction::Hash>,
50
51    /// The configured network for this state.
52    #[getter(skip)]
53    network: Network,
54
55    /// The last height and hash that was queried to produce these UTXOs, if any.
56    /// It will be None if the state is empty.
57    last_height_and_hash: Option<(block::Height, block::Hash)>,
58}
59
60impl AddressUtxos {
61    /// Creates a new set of address UTXOs.
62    pub fn new(
63        network: &Network,
64        utxos: BTreeMap<OutputLocation, transparent::Output>,
65        tx_ids: BTreeMap<TransactionLocation, transaction::Hash>,
66        last_height_and_hash: Option<(block::Height, block::Hash)>,
67    ) -> Self {
68        Self {
69            utxos,
70            tx_ids,
71            network: network.clone(),
72            last_height_and_hash,
73        }
74    }
75
76    /// Returns an iterator that provides the unspent output, its transaction hash,
77    /// its location in the chain, and the address it was sent to.
78    ///
79    /// The UTXOs are returned in chain order, across all addresses.
80    #[allow(dead_code)]
81    pub fn utxos(
82        &self,
83    ) -> impl Iterator<
84        Item = (
85            transparent::Address,
86            &transaction::Hash,
87            &OutputLocation,
88            &transparent::Output,
89        ),
90    > {
91        self.utxos.iter().map(|(out_loc, output)| {
92            (
93                output
94                    .address(&self.network)
95                    .expect("address indexes only contain outputs with addresses"),
96                self.tx_ids
97                    .get(&out_loc.transaction_location())
98                    .expect("address indexes are consistent"),
99                out_loc,
100                output,
101            )
102        })
103    }
104}
105
106/// Returns the unspent transparent outputs (UTXOs) for the supplied [`transparent::Address`]es
107/// in `query_height_range`, in chain order, at most `max_entries` of them; and the transaction
108/// IDs for the transactions containing those UTXOs.
109///
110/// If the addresses do not exist in the non-finalized `chain` or finalized `db`,
111/// returns an empty list.
112///
113/// Both limits are applied to the index scan, so the work this query does is bounded by what
114/// the caller asks for, rather than by the size of the addresses' UTXO sets.
115pub fn address_utxos<C>(
116    network: &Network,
117    chain: Option<C>,
118    db: &ZebraDb,
119    addresses: HashSet<transparent::Address>,
120    query_height_range: RangeInclusive<Height>,
121    max_entries: Option<usize>,
122) -> Result<AddressUtxos, BoxError>
123where
124    C: AsRef<Chain>,
125{
126    let mut utxo_error = None;
127    let address_count = addresses.len();
128
129    // The non-finalized chain can spend UTXOs that the finalized query returns, so a limited
130    // finalized query has to over-fetch by that many entries, or a full page of results can
131    // arrive short and look like the end of the address' UTXOs.
132    let finalized_limit = max_entries.map(|max_entries| {
133        let chain_spent_count = chain
134            .as_ref()
135            .map(|chain| {
136                chain
137                    .as_ref()
138                    .partial_transparent_spent_utxo_count(&addresses)
139            })
140            .unwrap_or(0);
141
142        finalized_scan_limit(max_entries, chain_spent_count)
143    });
144
145    // Retry the finalized UTXO query if it was interrupted by a finalizing block,
146    // and the non-finalized chain doesn't overlap the changed heights.
147    //
148    // TODO: refactor this into a generic retry(finalized_closure, process_and_check_closure) fn
149    for attempt in 0..=FINALIZED_STATE_QUERY_RETRIES {
150        debug!(?attempt, ?address_count, "starting address UTXO query");
151
152        let (finalized_utxos, finalized_tip_range) =
153            finalized_address_utxos(db, &addresses, query_height_range.clone(), finalized_limit);
154
155        debug!(
156            finalized_utxo_count = ?finalized_utxos.len(),
157            ?finalized_tip_range,
158            ?address_count,
159            ?attempt,
160            "finalized address UTXO response",
161        );
162
163        // Apply the non-finalized UTXO changes.
164        let chain_utxo_changes =
165            chain_transparent_utxo_changes(chain.as_ref(), &addresses, finalized_tip_range);
166
167        // If the UTXOs are valid, return them, otherwise, retry or return an error.
168        match chain_utxo_changes {
169            Ok((created_chain_utxos, spent_chain_utxos, last_height)) => {
170                debug!(
171                    chain_utxo_count = ?created_chain_utxos.len(),
172                    chain_utxo_spent = ?spent_chain_utxos.len(),
173                    ?address_count,
174                    ?attempt,
175                    "chain address UTXO response",
176                );
177
178                let utxos = apply_utxo_changes(
179                    finalized_utxos,
180                    created_chain_utxos,
181                    spent_chain_utxos,
182                    &query_height_range,
183                    max_entries,
184                );
185
186                let tx_ids = lookup_tx_ids_for_utxos(chain.as_ref(), db, &addresses, &utxos);
187
188                debug!(
189                    full_utxo_count = ?utxos.len(),
190                    tx_id_count = ?tx_ids.len(),
191                    ?address_count,
192                    ?attempt,
193                    "full address UTXO response",
194                );
195
196                // Get the matching hash for the given height, if any
197                let last_height_and_hash = last_height.and_then(|height| {
198                    Some(height).zip(
199                        chain
200                            .as_ref()
201                            .and_then(|c| c.as_ref().hash_by_height(height))
202                            .or_else(|| db.hash(height)),
203                    )
204                });
205
206                return Ok(AddressUtxos::new(
207                    network,
208                    utxos,
209                    tx_ids,
210                    last_height_and_hash,
211                ));
212            }
213
214            Err(chain_utxo_error) => {
215                debug!(
216                    ?chain_utxo_error,
217                    ?address_count,
218                    ?attempt,
219                    "chain address UTXO response",
220                );
221
222                utxo_error = Some(Err(chain_utxo_error))
223            }
224        }
225    }
226
227    utxo_error.expect("unexpected missing error: attempts should set error or return")
228}
229
230/// Returns the unspent transparent outputs (UTXOs) for `addresses` in the finalized chain,
231/// and the finalized tip heights the UTXOs were queried at.
232///
233/// If the addresses do not exist in the finalized `db`, returns an empty list.
234//
235// TODO: turn the return type into a struct?
236fn finalized_address_utxos(
237    db: &ZebraDb,
238    addresses: &HashSet<transparent::Address>,
239    query_height_range: RangeInclusive<Height>,
240    limit: Option<usize>,
241) -> (
242    BTreeMap<OutputLocation, transparent::Output>,
243    Option<RangeInclusive<Height>>,
244) {
245    // # Correctness
246    //
247    // The StateService can commit additional blocks while we are querying address UTXOs.
248
249    // Check if the finalized state changed while we were querying it
250    let start_finalized_tip = db.finalized_tip_height();
251
252    let finalized_utxos = db.partial_finalized_address_utxos(addresses, query_height_range, limit);
253
254    let end_finalized_tip = db.finalized_tip_height();
255
256    let finalized_tip_range = if let (Some(start_finalized_tip), Some(end_finalized_tip)) =
257        (start_finalized_tip, end_finalized_tip)
258    {
259        Some(start_finalized_tip..=end_finalized_tip)
260    } else {
261        // State is empty
262        None
263    };
264
265    (finalized_utxos, finalized_tip_range)
266}
267
268/// Returns the UTXO changes (created and spent) for `addresses` in the
269/// non-finalized chain, matching or overlapping the UTXOs for the
270/// `finalized_tip_range`. Also returns the height of the last block in which
271/// the changes were located, or None if the state is empty.
272///
273/// If the addresses do not exist in the non-finalized `chain`, returns an empty
274/// list.
275//
276// TODO: turn the return type into a struct?
277fn chain_transparent_utxo_changes<C>(
278    chain: Option<C>,
279    addresses: &HashSet<transparent::Address>,
280    finalized_tip_range: Option<RangeInclusive<Height>>,
281) -> Result<
282    (
283        BTreeMap<OutputLocation, transparent::Output>,
284        BTreeSet<OutputLocation>,
285        Option<Height>,
286    ),
287    BoxError,
288>
289where
290    C: AsRef<Chain>,
291{
292    let address_count = addresses.len();
293
294    let finalized_tip_range = match finalized_tip_range {
295        Some(finalized_tip_range) => finalized_tip_range,
296        None => {
297            assert!(
298                chain.is_none(),
299                "unexpected non-finalized chain when finalized state is empty"
300            );
301
302            debug!(
303                ?finalized_tip_range,
304                ?address_count,
305                "chain address UTXO query: state is empty, no UTXOs available",
306            );
307
308            return Ok(Default::default());
309        }
310    };
311
312    // # Correctness
313    //
314    // We can compensate for deleted UTXOs by applying the overlapping non-finalized UTXO changes.
315
316    // Check if the finalized and non-finalized states match or overlap
317    let required_min_non_finalized_root = finalized_tip_range.start().0 + 1;
318
319    // Work out if we need to compensate for finalized query results from multiple heights:
320    // - Ok contains the finalized tip height (no need to compensate)
321    // - Err contains the required non-finalized chain overlap
322    let finalized_tip_status = required_min_non_finalized_root..=finalized_tip_range.end().0;
323    let finalized_tip_status = if finalized_tip_status.is_empty() {
324        let finalized_tip_height = *finalized_tip_range.end();
325        Ok(finalized_tip_height)
326    } else {
327        let required_non_finalized_overlap = finalized_tip_status;
328        Err(required_non_finalized_overlap)
329    };
330
331    if chain.is_none() {
332        if let Ok(finalized_tip_height) = finalized_tip_status {
333            debug!(
334                ?finalized_tip_status,
335                ?required_min_non_finalized_root,
336                ?finalized_tip_range,
337                ?address_count,
338                "chain address UTXO query: \
339                 finalized chain is consistent, and non-finalized chain is empty",
340            );
341
342            return Ok((
343                Default::default(),
344                Default::default(),
345                Some(finalized_tip_height),
346            ));
347        } else {
348            // We can't compensate for inconsistent database queries,
349            // because the non-finalized chain is empty.
350            debug!(
351                ?finalized_tip_status,
352                ?required_min_non_finalized_root,
353                ?finalized_tip_range,
354                ?address_count,
355                "chain address UTXO query: \
356                 finalized tip query was inconsistent, but non-finalized chain is empty",
357            );
358
359            return Err("unable to get UTXOs: \
360                        state was committing a block, and non-finalized chain is empty"
361                .into());
362        }
363    }
364
365    let chain = chain.unwrap();
366    let chain = chain.as_ref();
367
368    let non_finalized_root = chain.non_finalized_root_height();
369    let non_finalized_tip = chain.non_finalized_tip_height();
370
371    assert!(
372        non_finalized_root.0 <= required_min_non_finalized_root,
373        "unexpected chain gap: the best chain is updated after its previous root is finalized",
374    );
375
376    match finalized_tip_status {
377        Ok(finalized_tip_height) => {
378            // If we've already committed this entire chain, ignore its UTXO changes.
379            // This is more likely if the non-finalized state is just getting started.
380            if finalized_tip_height >= non_finalized_tip {
381                debug!(
382                    ?non_finalized_root,
383                    ?non_finalized_tip,
384                    ?finalized_tip_status,
385                    ?finalized_tip_range,
386                    ?address_count,
387                    "chain address UTXO query: \
388                     non-finalized blocks have all been finalized, no new UTXO changes",
389                );
390
391                return Ok((
392                    Default::default(),
393                    Default::default(),
394                    Some(finalized_tip_height),
395                ));
396            }
397        }
398
399        Err(ref required_non_finalized_overlap) => {
400            // We can't compensate for inconsistent database queries,
401            // because the non-finalized chain is below the inconsistent query range.
402            if *required_non_finalized_overlap.end() > non_finalized_tip.0 {
403                debug!(
404                    ?non_finalized_root,
405                    ?non_finalized_tip,
406                    ?finalized_tip_status,
407                    ?finalized_tip_range,
408                    ?address_count,
409                    "chain address UTXO query: \
410                     finalized tip query was inconsistent, \
411                     and some inconsistent blocks are missing from the non-finalized chain",
412                );
413
414                return Err("unable to get UTXOs: \
415                            state was committing a block, \
416                            that is missing from the non-finalized chain"
417                    .into());
418            }
419
420            // Correctness: some finalized UTXOs might have duplicate creates or spends,
421            // but we've just checked they can be corrected by applying the non-finalized UTXO changes.
422            assert!(
423                required_non_finalized_overlap
424                    .clone()
425                    .all(|height| chain.blocks.contains_key(&Height(height))),
426                "UTXO query inconsistency: chain must contain required overlap blocks",
427            );
428        }
429    }
430    let (created, spent) = chain.partial_transparent_utxo_changes(addresses);
431    Ok((created, spent, Some(non_finalized_tip)))
432}
433
434/// The number of UTXOs a limited finalized query has to ask for, when the caller wants
435/// `max_entries` of them and the non-finalized chain spends `chain_spent_count` of the
436/// addresses' UTXOs.
437///
438/// Any of the UTXOs the finalized query returns can turn out to be spent by the chain, so the
439/// query has to over-fetch by as many entries as the chain spends. Otherwise a full page comes
440/// back short, and a caller paging by count reads that as the end of the addresses' UTXOs.
441///
442/// The result is an over-approximation: it can make the query read entries that are then
443/// discarded, but never too few to fill the page.
444fn finalized_scan_limit(max_entries: usize, chain_spent_count: usize) -> usize {
445    max_entries.saturating_add(chain_spent_count)
446}
447
448/// Combines the supplied finalized and non-finalized UTXOs, removes the spent UTXOs and the
449/// UTXOs outside `query_height_range`, truncates the result to `max_entries`, and returns it.
450fn apply_utxo_changes(
451    finalized_utxos: BTreeMap<OutputLocation, transparent::Output>,
452    created_chain_utxos: BTreeMap<OutputLocation, transparent::Output>,
453    spent_chain_utxos: BTreeSet<OutputLocation>,
454    query_height_range: &RangeInclusive<Height>,
455    max_entries: Option<usize>,
456) -> BTreeMap<OutputLocation, transparent::Output> {
457    // Correctness: combine the created UTXOs, then remove spent UTXOs,
458    // to compensate for overlapping finalized and non-finalized blocks.
459    //
460    // The finalized UTXOs are already limited to the query height range, but the
461    // non-finalized ones are not.
462    let utxos: BTreeMap<_, _> = finalized_utxos
463        .into_iter()
464        .chain(
465            created_chain_utxos
466                .into_iter()
467                .filter(|(utxo_location, _)| query_height_range.contains(&utxo_location.height())),
468        )
469        .filter(|(utxo_location, _output)| !spent_chain_utxos.contains(utxo_location))
470        .collect();
471
472    // Truncate after collecting, so the entries that survive are the first in chain order,
473    // rather than the first in the order the two sources happened to be chained in.
474    match max_entries {
475        Some(max_entries) => utxos.into_iter().take(max_entries).collect(),
476        None => utxos,
477    }
478}
479
480/// Returns the [`transaction::Hash`]es containing the supplied UTXOs,
481/// from the non-finalized `chain` and finalized `db`.
482///
483/// # Panics
484///
485/// If any UTXO is not in the supplied state.
486fn lookup_tx_ids_for_utxos<C>(
487    chain: Option<C>,
488    db: &ZebraDb,
489    addresses: &HashSet<transparent::Address>,
490    utxos: &BTreeMap<OutputLocation, transparent::Output>,
491) -> BTreeMap<TransactionLocation, transaction::Hash>
492where
493    C: AsRef<Chain>,
494{
495    // Get the unique set of transaction locations
496    let transaction_locations: BTreeSet<TransactionLocation> = utxos
497        .keys()
498        .map(|output_location| output_location.transaction_location())
499        .collect();
500
501    let chain_tx_ids = chain
502        .as_ref()
503        .map(|chain| {
504            chain
505                .as_ref()
506                .partial_transparent_tx_ids(addresses, ADDRESS_HEIGHTS_FULL_RANGE)
507        })
508        .unwrap_or_default();
509
510    // First try the in-memory chain, then the disk database
511    transaction_locations
512        .iter()
513        .map(|tx_loc| {
514            (
515                *tx_loc,
516                chain_tx_ids.get(tx_loc).cloned().unwrap_or_else(|| {
517                    db.transaction_hash(*tx_loc)
518                        .expect("unexpected inconsistent UTXO indexes")
519                }),
520            )
521        })
522        .collect()
523}
524
525#[cfg(test)]
526mod tests {
527    use zebra_chain::{amount::Amount, transparent::Script};
528
529    use super::*;
530
531    /// Returns a UTXO at `height`, and its location.
532    fn utxo(height: u32) -> (OutputLocation, transparent::Output) {
533        (
534            OutputLocation::from_usize(Height(height), 0, 0),
535            transparent::Output::new(Amount::zero(), Script::new(&[])),
536        )
537    }
538
539    /// Returns the heights of `utxos`, in chain order.
540    fn heights(utxos: &BTreeMap<OutputLocation, transparent::Output>) -> Vec<u32> {
541        utxos.keys().map(|location| location.height().0).collect()
542    }
543
544    /// The non-finalized chain creates UTXOs across the whole chain, not just the queried
545    /// range, so the ones outside it have to be dropped even though the finalized query
546    /// never returned them.
547    #[test]
548    fn created_chain_utxos_are_limited_to_the_query_height_range() {
549        let finalized = BTreeMap::from([utxo(30), utxo(40)]);
550        let created = BTreeMap::from([utxo(10), utxo(50)]);
551
552        let utxos = apply_utxo_changes(
553            finalized,
554            created,
555            BTreeSet::new(),
556            &(Height(20)..=Height(45)),
557            None,
558        );
559
560        assert_eq!(heights(&utxos), vec![30, 40]);
561    }
562
563    /// A truncated result keeps the first UTXOs in chain order, not the first in the order
564    /// the finalized and non-finalized sources are combined in.
565    #[test]
566    fn a_limited_result_keeps_the_lowest_utxos() {
567        let finalized = BTreeMap::from([utxo(30), utxo(40)]);
568        let created = BTreeMap::from([utxo(10), utxo(20)]);
569
570        let utxos = apply_utxo_changes(
571            finalized,
572            created,
573            BTreeSet::new(),
574            &ADDRESS_HEIGHTS_FULL_RANGE,
575            Some(3),
576        );
577
578        assert_eq!(heights(&utxos), vec![10, 20, 30]);
579    }
580
581    /// The over-fetch is what keeps a page full when the non-finalized chain spends some of
582    /// the UTXOs the finalized query returned.
583    #[test]
584    fn over_fetching_keeps_a_limited_page_full() {
585        let max_entries = 2;
586        let all_finalized = [utxo(10), utxo(20), utxo(30), utxo(40), utxo(50)];
587
588        // The chain spends the first UTXO the finalized query would return.
589        let spent = BTreeSet::from([all_finalized[0].0]);
590
591        // The finalized query returns the first `limit` UTXOs, whatever the limit is.
592        let query = |limit: usize| {
593            apply_utxo_changes(
594                all_finalized.iter().take(limit).cloned().collect(),
595                BTreeMap::new(),
596                spent.clone(),
597                &ADDRESS_HEIGHTS_FULL_RANGE,
598                Some(max_entries),
599            )
600        };
601
602        // Asking for exactly `max_entries` returns a short page, because one of them is spent.
603        assert_eq!(heights(&query(max_entries)), vec![20]);
604
605        // Over-fetching by the number of spends fills the page instead.
606        let limit = finalized_scan_limit(max_entries, spent.len());
607        assert_eq!(heights(&query(limit)), vec![20, 30]);
608    }
609}