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 chain order; and the transaction IDs for the transactions containing those UTXOs.
108///
109/// If the addresses do not exist in the non-finalized `chain` or finalized `db`,
110/// returns an empty list.
111pub fn address_utxos<C>(
112    network: &Network,
113    chain: Option<C>,
114    db: &ZebraDb,
115    addresses: HashSet<transparent::Address>,
116) -> Result<AddressUtxos, BoxError>
117where
118    C: AsRef<Chain>,
119{
120    let mut utxo_error = None;
121    let address_count = addresses.len();
122
123    // Retry the finalized UTXO query if it was interrupted by a finalizing block,
124    // and the non-finalized chain doesn't overlap the changed heights.
125    //
126    // TODO: refactor this into a generic retry(finalized_closure, process_and_check_closure) fn
127    for attempt in 0..=FINALIZED_STATE_QUERY_RETRIES {
128        debug!(?attempt, ?address_count, "starting address UTXO query");
129
130        let (finalized_utxos, finalized_tip_range) = finalized_address_utxos(db, &addresses);
131
132        debug!(
133            finalized_utxo_count = ?finalized_utxos.len(),
134            ?finalized_tip_range,
135            ?address_count,
136            ?attempt,
137            "finalized address UTXO response",
138        );
139
140        // Apply the non-finalized UTXO changes.
141        let chain_utxo_changes =
142            chain_transparent_utxo_changes(chain.as_ref(), &addresses, finalized_tip_range);
143
144        // If the UTXOs are valid, return them, otherwise, retry or return an error.
145        match chain_utxo_changes {
146            Ok((created_chain_utxos, spent_chain_utxos, last_height)) => {
147                debug!(
148                    chain_utxo_count = ?created_chain_utxos.len(),
149                    chain_utxo_spent = ?spent_chain_utxos.len(),
150                    ?address_count,
151                    ?attempt,
152                    "chain address UTXO response",
153                );
154
155                let utxos =
156                    apply_utxo_changes(finalized_utxos, created_chain_utxos, spent_chain_utxos);
157                let tx_ids = lookup_tx_ids_for_utxos(chain.as_ref(), db, &addresses, &utxos);
158
159                debug!(
160                    full_utxo_count = ?utxos.len(),
161                    tx_id_count = ?tx_ids.len(),
162                    ?address_count,
163                    ?attempt,
164                    "full address UTXO response",
165                );
166
167                // Get the matching hash for the given height, if any
168                let last_height_and_hash = last_height.and_then(|height| {
169                    Some(height).zip(
170                        chain
171                            .as_ref()
172                            .and_then(|c| c.as_ref().hash_by_height(height))
173                            .or_else(|| db.hash(height)),
174                    )
175                });
176
177                return Ok(AddressUtxos::new(
178                    network,
179                    utxos,
180                    tx_ids,
181                    last_height_and_hash,
182                ));
183            }
184
185            Err(chain_utxo_error) => {
186                debug!(
187                    ?chain_utxo_error,
188                    ?address_count,
189                    ?attempt,
190                    "chain address UTXO response",
191                );
192
193                utxo_error = Some(Err(chain_utxo_error))
194            }
195        }
196    }
197
198    utxo_error.expect("unexpected missing error: attempts should set error or return")
199}
200
201/// Returns the unspent transparent outputs (UTXOs) for `addresses` in the finalized chain,
202/// and the finalized tip heights the UTXOs were queried at.
203///
204/// If the addresses do not exist in the finalized `db`, returns an empty list.
205//
206// TODO: turn the return type into a struct?
207fn finalized_address_utxos(
208    db: &ZebraDb,
209    addresses: &HashSet<transparent::Address>,
210) -> (
211    BTreeMap<OutputLocation, transparent::Output>,
212    Option<RangeInclusive<Height>>,
213) {
214    // # Correctness
215    //
216    // The StateService can commit additional blocks while we are querying address UTXOs.
217
218    // Check if the finalized state changed while we were querying it
219    let start_finalized_tip = db.finalized_tip_height();
220
221    let finalized_utxos = db.partial_finalized_address_utxos(addresses);
222
223    let end_finalized_tip = db.finalized_tip_height();
224
225    let finalized_tip_range = if let (Some(start_finalized_tip), Some(end_finalized_tip)) =
226        (start_finalized_tip, end_finalized_tip)
227    {
228        Some(start_finalized_tip..=end_finalized_tip)
229    } else {
230        // State is empty
231        None
232    };
233
234    (finalized_utxos, finalized_tip_range)
235}
236
237/// Returns the UTXO changes (created and spent) for `addresses` in the
238/// non-finalized chain, matching or overlapping the UTXOs for the
239/// `finalized_tip_range`. Also returns the height of the last block in which
240/// the changes were located, or None if the state is empty.
241///
242/// If the addresses do not exist in the non-finalized `chain`, returns an empty
243/// list.
244//
245// TODO: turn the return type into a struct?
246fn chain_transparent_utxo_changes<C>(
247    chain: Option<C>,
248    addresses: &HashSet<transparent::Address>,
249    finalized_tip_range: Option<RangeInclusive<Height>>,
250) -> Result<
251    (
252        BTreeMap<OutputLocation, transparent::Output>,
253        BTreeSet<OutputLocation>,
254        Option<Height>,
255    ),
256    BoxError,
257>
258where
259    C: AsRef<Chain>,
260{
261    let address_count = addresses.len();
262
263    let finalized_tip_range = match finalized_tip_range {
264        Some(finalized_tip_range) => finalized_tip_range,
265        None => {
266            assert!(
267                chain.is_none(),
268                "unexpected non-finalized chain when finalized state is empty"
269            );
270
271            debug!(
272                ?finalized_tip_range,
273                ?address_count,
274                "chain address UTXO query: state is empty, no UTXOs available",
275            );
276
277            return Ok(Default::default());
278        }
279    };
280
281    // # Correctness
282    //
283    // We can compensate for deleted UTXOs by applying the overlapping non-finalized UTXO changes.
284
285    // Check if the finalized and non-finalized states match or overlap
286    let required_min_non_finalized_root = finalized_tip_range.start().0 + 1;
287
288    // Work out if we need to compensate for finalized query results from multiple heights:
289    // - Ok contains the finalized tip height (no need to compensate)
290    // - Err contains the required non-finalized chain overlap
291    let finalized_tip_status = required_min_non_finalized_root..=finalized_tip_range.end().0;
292    let finalized_tip_status = if finalized_tip_status.is_empty() {
293        let finalized_tip_height = *finalized_tip_range.end();
294        Ok(finalized_tip_height)
295    } else {
296        let required_non_finalized_overlap = finalized_tip_status;
297        Err(required_non_finalized_overlap)
298    };
299
300    if chain.is_none() {
301        if let Ok(finalized_tip_height) = finalized_tip_status {
302            debug!(
303                ?finalized_tip_status,
304                ?required_min_non_finalized_root,
305                ?finalized_tip_range,
306                ?address_count,
307                "chain address UTXO query: \
308                 finalized chain is consistent, and non-finalized chain is empty",
309            );
310
311            return Ok((
312                Default::default(),
313                Default::default(),
314                Some(finalized_tip_height),
315            ));
316        } else {
317            // We can't compensate for inconsistent database queries,
318            // because the non-finalized chain is empty.
319            debug!(
320                ?finalized_tip_status,
321                ?required_min_non_finalized_root,
322                ?finalized_tip_range,
323                ?address_count,
324                "chain address UTXO query: \
325                 finalized tip query was inconsistent, but non-finalized chain is empty",
326            );
327
328            return Err("unable to get UTXOs: \
329                        state was committing a block, and non-finalized chain is empty"
330                .into());
331        }
332    }
333
334    let chain = chain.unwrap();
335    let chain = chain.as_ref();
336
337    let non_finalized_root = chain.non_finalized_root_height();
338    let non_finalized_tip = chain.non_finalized_tip_height();
339
340    assert!(
341        non_finalized_root.0 <= required_min_non_finalized_root,
342        "unexpected chain gap: the best chain is updated after its previous root is finalized",
343    );
344
345    match finalized_tip_status {
346        Ok(finalized_tip_height) => {
347            // If we've already committed this entire chain, ignore its UTXO changes.
348            // This is more likely if the non-finalized state is just getting started.
349            if finalized_tip_height >= non_finalized_tip {
350                debug!(
351                    ?non_finalized_root,
352                    ?non_finalized_tip,
353                    ?finalized_tip_status,
354                    ?finalized_tip_range,
355                    ?address_count,
356                    "chain address UTXO query: \
357                     non-finalized blocks have all been finalized, no new UTXO changes",
358                );
359
360                return Ok((
361                    Default::default(),
362                    Default::default(),
363                    Some(finalized_tip_height),
364                ));
365            }
366        }
367
368        Err(ref required_non_finalized_overlap) => {
369            // We can't compensate for inconsistent database queries,
370            // because the non-finalized chain is below the inconsistent query range.
371            if *required_non_finalized_overlap.end() > non_finalized_tip.0 {
372                debug!(
373                    ?non_finalized_root,
374                    ?non_finalized_tip,
375                    ?finalized_tip_status,
376                    ?finalized_tip_range,
377                    ?address_count,
378                    "chain address UTXO query: \
379                     finalized tip query was inconsistent, \
380                     and some inconsistent blocks are missing from the non-finalized chain",
381                );
382
383                return Err("unable to get UTXOs: \
384                            state was committing a block, \
385                            that is missing from the non-finalized chain"
386                    .into());
387            }
388
389            // Correctness: some finalized UTXOs might have duplicate creates or spends,
390            // but we've just checked they can be corrected by applying the non-finalized UTXO changes.
391            assert!(
392                required_non_finalized_overlap
393                    .clone()
394                    .all(|height| chain.blocks.contains_key(&Height(height))),
395                "UTXO query inconsistency: chain must contain required overlap blocks",
396            );
397        }
398    }
399    let (created, spent) = chain.partial_transparent_utxo_changes(addresses);
400    Ok((created, spent, Some(non_finalized_tip)))
401}
402
403/// Combines the supplied finalized and non-finalized UTXOs,
404/// removes the spent UTXOs, and returns the result.
405fn apply_utxo_changes(
406    finalized_utxos: BTreeMap<OutputLocation, transparent::Output>,
407    created_chain_utxos: BTreeMap<OutputLocation, transparent::Output>,
408    spent_chain_utxos: BTreeSet<OutputLocation>,
409) -> BTreeMap<OutputLocation, transparent::Output> {
410    // Correctness: combine the created UTXOs, then remove spent UTXOs,
411    // to compensate for overlapping finalized and non-finalized blocks.
412    finalized_utxos
413        .into_iter()
414        .chain(created_chain_utxos)
415        .filter(|(utxo_location, _output)| !spent_chain_utxos.contains(utxo_location))
416        .collect()
417}
418
419/// Returns the [`transaction::Hash`]es containing the supplied UTXOs,
420/// from the non-finalized `chain` and finalized `db`.
421///
422/// # Panics
423///
424/// If any UTXO is not in the supplied state.
425fn lookup_tx_ids_for_utxos<C>(
426    chain: Option<C>,
427    db: &ZebraDb,
428    addresses: &HashSet<transparent::Address>,
429    utxos: &BTreeMap<OutputLocation, transparent::Output>,
430) -> BTreeMap<TransactionLocation, transaction::Hash>
431where
432    C: AsRef<Chain>,
433{
434    // Get the unique set of transaction locations
435    let transaction_locations: BTreeSet<TransactionLocation> = utxos
436        .keys()
437        .map(|output_location| output_location.transaction_location())
438        .collect();
439
440    let chain_tx_ids = chain
441        .as_ref()
442        .map(|chain| {
443            chain
444                .as_ref()
445                .partial_transparent_tx_ids(addresses, ADDRESS_HEIGHTS_FULL_RANGE)
446        })
447        .unwrap_or_default();
448
449    // First try the in-memory chain, then the disk database
450    transaction_locations
451        .iter()
452        .map(|tx_loc| {
453            (
454                *tx_loc,
455                chain_tx_ids.get(tx_loc).cloned().unwrap_or_else(|| {
456                    db.transaction_hash(*tx_loc)
457                        .expect("unexpected inconsistent UTXO indexes")
458                }),
459            )
460        })
461        .collect()
462}