Skip to main content

zebra_state/service/read/address/
tx_id.rs

1//! Reading address transaction IDs.
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, HashSet},
16    ops::RangeInclusive,
17};
18
19use zebra_chain::{block::Height, transaction, transparent};
20
21use crate::{
22    service::{
23        finalized_state::ZebraDb, non_finalized_state::Chain, read::FINALIZED_STATE_QUERY_RETRIES,
24    },
25    BoxError, TransactionLocation,
26};
27
28/// Returns the transaction IDs that sent or received funds from the supplied [`transparent::Address`]es,
29/// within `query_height_range`, in chain order, at most `max_entries` of them.
30///
31/// If the addresses do not exist in the non-finalized `chain` or finalized `db`,
32/// or the `query_height_range` is totally outside both the `chain` and `db` range,
33/// returns an empty list.
34pub fn transparent_tx_ids<C>(
35    chain: Option<C>,
36    db: &ZebraDb,
37    addresses: HashSet<transparent::Address>,
38    query_height_range: RangeInclusive<Height>,
39    max_entries: Option<usize>,
40) -> Result<BTreeMap<TransactionLocation, transaction::Hash>, BoxError>
41where
42    C: AsRef<Chain>,
43{
44    let mut tx_id_error = None;
45
46    // Retry the finalized tx ID query if it was interrupted by a finalizing block,
47    // and the non-finalized chain doesn't overlap the changed heights.
48    //
49    // TODO: refactor this into a generic retry(finalized_closure, process_and_check_closure) fn
50    for _ in 0..=FINALIZED_STATE_QUERY_RETRIES {
51        let (finalized_tx_ids, finalized_tip_range) =
52            finalized_transparent_tx_ids(db, &addresses, query_height_range.clone(), max_entries);
53
54        // Apply the non-finalized tx ID changes.
55        let chain_tx_id_changes = chain_transparent_tx_id_changes(
56            chain.as_ref(),
57            &addresses,
58            finalized_tip_range,
59            query_height_range.clone(),
60        );
61
62        // If the tx IDs are valid, return them, otherwise, retry or return an error.
63        match chain_tx_id_changes {
64            Ok(chain_tx_id_changes) => {
65                let tx_ids =
66                    apply_tx_id_changes(finalized_tx_ids, chain_tx_id_changes, max_entries);
67
68                return Ok(tx_ids);
69            }
70
71            Err(error) => tx_id_error = Some(Err(error)),
72        }
73    }
74
75    tx_id_error.expect("unexpected missing error: attempts should set error or return")
76}
77
78/// Returns the [`transaction::Hash`]es for `addresses` in the finalized chain `query_height_range`,
79/// and the finalized tip heights the transaction IDs were queried at.
80///
81/// If the addresses do not exist in the finalized `db`, returns an empty list.
82//
83// TODO: turn the return type into a struct?
84fn finalized_transparent_tx_ids(
85    db: &ZebraDb,
86    addresses: &HashSet<transparent::Address>,
87    query_height_range: RangeInclusive<Height>,
88    limit: Option<usize>,
89) -> (
90    BTreeMap<TransactionLocation, transaction::Hash>,
91    Option<RangeInclusive<Height>>,
92) {
93    // # Correctness
94    //
95    // The StateService can commit additional blocks while we are querying transaction IDs.
96
97    // Check if the finalized state changed while we were querying it
98    let start_finalized_tip = db.finalized_tip_height();
99
100    let finalized_tx_ids =
101        db.partial_finalized_transparent_tx_ids(addresses, query_height_range, limit);
102
103    let end_finalized_tip = db.finalized_tip_height();
104
105    let finalized_tip_range = if let (Some(start_finalized_tip), Some(end_finalized_tip)) =
106        (start_finalized_tip, end_finalized_tip)
107    {
108        Some(start_finalized_tip..=end_finalized_tip)
109    } else {
110        // State is empty
111        None
112    };
113
114    (finalized_tx_ids, finalized_tip_range)
115}
116
117/// Returns the extra transaction IDs for `addresses` in the non-finalized chain `query_height_range`,
118/// matching or overlapping the transaction IDs for the `finalized_tip_range`,
119///
120/// If the addresses do not exist in the non-finalized `chain`, returns an empty list.
121//
122// TODO: turn the return type into a struct?
123fn chain_transparent_tx_id_changes<C>(
124    chain: Option<C>,
125    addresses: &HashSet<transparent::Address>,
126    finalized_tip_range: Option<RangeInclusive<Height>>,
127    query_height_range: RangeInclusive<Height>,
128) -> Result<BTreeMap<TransactionLocation, transaction::Hash>, BoxError>
129where
130    C: AsRef<Chain>,
131{
132    let address_count = addresses.len();
133
134    let finalized_tip_range = match finalized_tip_range {
135        Some(finalized_tip_range) => finalized_tip_range,
136        None => {
137            assert!(
138                chain.is_none(),
139                "unexpected non-finalized chain when finalized state is empty"
140            );
141
142            debug!(
143                ?finalized_tip_range,
144                ?address_count,
145                "chain address tx ID query: state is empty, no tx IDs available",
146            );
147
148            return Ok(Default::default());
149        }
150    };
151
152    // # Correctness
153    //
154    // We can compensate for addresses with mismatching blocks,
155    // by adding the overlapping non-finalized transaction IDs.
156    //
157    // If there is only one address, mismatches aren't possible,
158    // because tx IDs are added to the finalized state in chain order (and never removed),
159    // and they are queried in chain order.
160
161    // Check if the finalized and non-finalized states match or overlap
162    let required_min_non_finalized_root = finalized_tip_range.start().0 + 1;
163
164    // Work out if we need to compensate for finalized query results from multiple heights:
165    // - Ok contains the finalized tip height (no need to compensate)
166    // - Err contains the required non-finalized chain overlap
167    let finalized_tip_status = required_min_non_finalized_root..=finalized_tip_range.end().0;
168    let finalized_tip_status = if finalized_tip_status.is_empty() {
169        let finalized_tip_height = *finalized_tip_range.end();
170        Ok(finalized_tip_height)
171    } else {
172        let required_non_finalized_overlap = finalized_tip_status;
173        Err(required_non_finalized_overlap)
174    };
175
176    if chain.is_none() {
177        if address_count <= 1 || finalized_tip_status.is_ok() {
178            debug!(
179                ?finalized_tip_status,
180                ?required_min_non_finalized_root,
181                ?finalized_tip_range,
182                ?address_count,
183                "chain address tx ID query: \
184                 finalized chain is consistent, and non-finalized chain is empty",
185            );
186
187            return Ok(Default::default());
188        } else {
189            // We can't compensate for inconsistent database queries,
190            // because the non-finalized chain is empty.
191            debug!(
192                ?finalized_tip_status,
193                ?required_min_non_finalized_root,
194                ?finalized_tip_range,
195                ?address_count,
196                "chain address tx ID query: \
197                 finalized tip query was inconsistent, but non-finalized chain is empty",
198            );
199
200            return Err("unable to get tx IDs: \
201                        state was committing a block, and non-finalized chain is empty"
202                .into());
203        }
204    }
205
206    let chain = chain.unwrap();
207    let chain = chain.as_ref();
208
209    let non_finalized_root = chain.non_finalized_root_height();
210    let non_finalized_tip = chain.non_finalized_tip_height();
211
212    assert!(
213        non_finalized_root.0 <= required_min_non_finalized_root,
214        "unexpected chain gap: the best chain is updated after its previous root is finalized",
215    );
216
217    match finalized_tip_status {
218        Ok(finalized_tip_height) => {
219            // If we've already committed this entire chain, ignore its UTXO changes.
220            // This is more likely if the non-finalized state is just getting started.
221            if finalized_tip_height >= non_finalized_tip {
222                debug!(
223                    ?non_finalized_root,
224                    ?non_finalized_tip,
225                    ?finalized_tip_status,
226                    ?finalized_tip_range,
227                    ?address_count,
228                    "chain address tx ID query: \
229                     non-finalized blocks have all been finalized, no new UTXO changes",
230                );
231
232                return Ok(Default::default());
233            }
234        }
235
236        Err(ref required_non_finalized_overlap) => {
237            // We can't compensate for inconsistent database queries,
238            // because the non-finalized chain is below the inconsistent query range.
239            if address_count > 1 && *required_non_finalized_overlap.end() > non_finalized_tip.0 {
240                debug!(
241                    ?non_finalized_root,
242                    ?non_finalized_tip,
243                    ?finalized_tip_status,
244                    ?finalized_tip_range,
245                    ?address_count,
246                    "chain address tx ID query: \
247                     finalized tip query was inconsistent, \
248                     some inconsistent blocks are missing from the non-finalized chain, \
249                     and the query has multiple addresses",
250                );
251
252                return Err("unable to get tx IDs: \
253                            state was committing a block, \
254                            that is missing from the non-finalized chain, \
255                            and the query has multiple addresses"
256                    .into());
257            }
258
259            // Correctness: some finalized UTXOs might have duplicate creates or spends,
260            // but we've just checked they can be corrected by applying the non-finalized UTXO changes.
261            assert!(
262                address_count <= 1
263                    || required_non_finalized_overlap
264                        .clone()
265                        .all(|height| chain.blocks.contains_key(&Height(height))),
266                "tx ID query inconsistency: \
267                 chain must contain required overlap blocks \
268                 or query must only have one address",
269            );
270        }
271    }
272
273    Ok(chain.partial_transparent_tx_ids(addresses, query_height_range))
274}
275
276/// Returns the combined finalized and non-finalized transaction IDs, truncated to `max_entries`.
277fn apply_tx_id_changes(
278    finalized_tx_ids: BTreeMap<TransactionLocation, transaction::Hash>,
279    chain_tx_ids: BTreeMap<TransactionLocation, transaction::Hash>,
280    max_entries: Option<usize>,
281) -> BTreeMap<TransactionLocation, transaction::Hash> {
282    // Correctness: compensate for inconsistent tx IDs finalized blocks across multiple addresses,
283    // by combining them with overlapping non-finalized block tx IDs.
284    let tx_ids: BTreeMap<_, _> = finalized_tx_ids.into_iter().chain(chain_tx_ids).collect();
285
286    // Truncate after merging, so the survivors are the first in chain order.
287    match max_entries {
288        Some(max_entries) => tx_ids.into_iter().take(max_entries).collect(),
289        None => tx_ids,
290    }
291}