1use 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
33pub const ADDRESS_HEIGHTS_FULL_RANGE: RangeInclusive<Height> = Height(1)..=Height::MAX;
38
39#[derive(Clone, Debug, Default, Eq, PartialEq, Getters)]
42pub struct AddressUtxos {
43 #[getter(skip)]
45 utxos: BTreeMap<OutputLocation, transparent::Output>,
46
47 #[getter(skip)]
49 tx_ids: BTreeMap<TransactionLocation, transaction::Hash>,
50
51 #[getter(skip)]
53 network: Network,
54
55 last_height_and_hash: Option<(block::Height, block::Hash)>,
58}
59
60impl AddressUtxos {
61 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 #[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
106pub 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 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 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 let chain_utxo_changes =
165 chain_transparent_utxo_changes(chain.as_ref(), &addresses, finalized_tip_range);
166
167 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 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
230fn 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 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 None
263 };
264
265 (finalized_utxos, finalized_tip_range)
266}
267
268fn 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 let required_min_non_finalized_root = finalized_tip_range.start().0 + 1;
318
319 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 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 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 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 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
434fn finalized_scan_limit(max_entries: usize, chain_spent_count: usize) -> usize {
445 max_entries.saturating_add(chain_spent_count)
446}
447
448fn 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 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 match max_entries {
475 Some(max_entries) => utxos.into_iter().take(max_entries).collect(),
476 None => utxos,
477 }
478}
479
480fn 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 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 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 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 fn heights(utxos: &BTreeMap<OutputLocation, transparent::Output>) -> Vec<u32> {
541 utxos.keys().map(|location| location.height().0).collect()
542 }
543
544 #[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 #[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 #[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 let spent = BTreeSet::from([all_finalized[0].0]);
590
591 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 assert_eq!(heights(&query(max_entries)), vec![20]);
604
605 let limit = finalized_scan_limit(max_entries, spent.len());
607 assert_eq!(heights(&query(limit)), vec![20, 30]);
608 }
609}