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