ledger_queries.rs (35031B)
1 use std::collections::{BTreeMap, BTreeSet}; 2 3 use anyhow::{Context, Result}; 4 use sha2::{Digest, Sha256}; 5 6 use super::genesis::balances_from_utxos; 7 use super::mine_policy::mine_anchor; 8 use super::ticket::{ 9 BurnTicket, apply_finalizer_ticket_effects, draw_parent_randomness, genesis_tickets, 10 ranked_tickets_for_height, tickets_created_by_block, 11 }; 12 use super::{ 13 Amount, Block, BurnCommitteeMember, BurnLeaderRank, ChainSnapshot, ChainStatus, LaunchProfile, 14 Ledger, OutPoint, Transaction, TxOutput, UtxoLineageRoot, 15 }; 16 17 fn apply_historical_ticket_block( 18 parent: &Block, 19 block: &Block, 20 launch_profile: &super::LaunchProfile, 21 tickets: &mut Vec<BurnTicket>, 22 ) -> Result<()> { 23 apply_finalizer_ticket_effects(parent, block, tickets)?; 24 tickets.extend(tickets_created_by_block(block, launch_profile)?); 25 Ok(()) 26 } 27 28 #[derive(Clone, Debug, Eq, PartialEq)] 29 pub(super) struct LineageCommitteeCandidate { 30 pub(super) root: UtxoLineageRoot, 31 pub(super) value: Amount, 32 pub(super) weight: u64, 33 pub(super) owner: String, 34 } 35 36 pub(super) fn lineage_committee_weight(value: Amount) -> u64 { 37 let one_plus_value = u128::from(value) + 1; 38 u128::BITS as u64 - one_plus_value.leading_zeros() as u64 - 1 39 } 40 41 pub(super) fn select_weighted_lineage_index( 42 parent: &Block, 43 target_height: u64, 44 slot: u8, 45 candidates: &[LineageCommitteeCandidate], 46 ) -> Option<usize> { 47 let total_weight = candidates.iter().try_fold(0_u128, |total, candidate| { 48 total.checked_add(u128::from(candidate.weight)) 49 })?; 50 if total_weight == 0 { 51 return None; 52 } 53 let seed = lineage_committee_draw_seed(parent, target_height, slot); 54 let digest = Sha256::digest(seed.as_bytes()); 55 let mut bytes = [0_u8; 16]; 56 bytes.copy_from_slice(&digest[..16]); 57 let draw = u128::from_be_bytes(bytes) % total_weight; 58 let mut cumulative = 0_u128; 59 for (index, candidate) in candidates.iter().enumerate() { 60 cumulative = cumulative.checked_add(u128::from(candidate.weight))?; 61 if draw < cumulative { 62 return Some(index); 63 } 64 } 65 None 66 } 67 68 fn lineage_committee_draw_seed(parent: &Block, target_height: u64, slot: u8) -> String { 69 let parent_randomness = draw_parent_randomness(parent, target_height); 70 format!("iuna-burn-lineage-draw-v1:{target_height}:{parent_randomness}:{slot}") 71 } 72 73 impl Ledger { 74 #[cfg(test)] 75 pub(crate) fn set_tip_height_for_test(&mut self, height: u64) { 76 self.chain 77 .last_mut() 78 .expect("ledger is always initialized with genesis") 79 .height = height; 80 } 81 82 pub fn snapshot(&self) -> ChainSnapshot { 83 ChainSnapshot { 84 genesis_allocations: self.genesis_allocations.clone(), 85 vdf_rounds: self.initial_vdf_rounds, 86 launch_profile: self.launch_profile.clone(), 87 blocks: self.chain.clone(), 88 } 89 } 90 91 pub(crate) fn genesis_snapshot(&self) -> ChainSnapshot { 92 ChainSnapshot { 93 genesis_allocations: self.genesis_allocations.clone(), 94 vdf_rounds: self.initial_vdf_rounds, 95 launch_profile: self.launch_profile.clone(), 96 blocks: vec![self.chain[0].clone()], 97 } 98 } 99 100 pub fn status(&self) -> ChainStatus { 101 self.status_with_balances(true) 102 } 103 104 pub fn light_status(&self) -> ChainStatus { 105 self.status_with_balances(false) 106 } 107 108 fn status_with_balances(&self, include_balances: bool) -> ChainStatus { 109 ChainStatus { 110 height: self.tip().height, 111 tip_hash: self.tip().hash.clone(), 112 finalized_height: self 113 .objective_finality_checkpoint 114 .as_ref() 115 .map(|checkpoint| checkpoint.height), 116 finalized_hash: self 117 .objective_finality_checkpoint 118 .as_ref() 119 .map(|checkpoint| checkpoint.hash.clone()), 120 next_leader: self.expected_leader_for_next_block(), 121 launch_profile_hash: self.launch_profile.hash(), 122 mine_reward: self.mine_reward, 123 current_mine_difficulty_bits: self.current_mine_difficulty_bits(), 124 balances: if include_balances { 125 balances_from_utxos(&self.utxos) 126 } else { 127 Default::default() 128 }, 129 pending_transactions: self.pending.len().saturating_add(self.pending_v2.len()), 130 } 131 } 132 133 pub fn tip_hash(&self) -> &str { 134 &self.tip().hash 135 } 136 137 pub fn objective_finality_checkpoint(&self) -> Option<(u64, &str)> { 138 self.objective_finality_checkpoint 139 .as_ref() 140 .map(|checkpoint| (checkpoint.height, checkpoint.hash.as_str())) 141 } 142 143 pub fn chain(&self) -> &[Block] { 144 &self.chain 145 } 146 147 pub fn burn_leader_ranks_for_block(&self, height: u64) -> Result<Vec<BurnLeaderRank>> { 148 Ok(self 149 .burn_leader_ranks_for_blocks([height])? 150 .remove(&height) 151 .unwrap_or_default()) 152 } 153 154 pub fn burn_leader_ranks_for_blocks<I>( 155 &self, 156 heights: I, 157 ) -> Result<BTreeMap<u64, Vec<BurnLeaderRank>>> 158 where 159 I: IntoIterator<Item = u64>, 160 { 161 let mut requested = heights.into_iter().collect::<BTreeSet<_>>(); 162 let mut ranks_by_height = BTreeMap::new(); 163 if requested.remove(&0) { 164 ranks_by_height.insert(0, Vec::new()); 165 } 166 if requested.is_empty() { 167 return Ok(ranks_by_height); 168 } 169 170 let mut tickets = genesis_tickets( 171 &self.genesis_allocations, 172 &self.chain[0], 173 &self.launch_profile, 174 )?; 175 let mut next_block_index = 1; 176 177 for height in requested { 178 let parent_index = height.checked_sub(1).context("block height underflows")? as usize; 179 let parent = self 180 .chain 181 .get(parent_index) 182 .with_context(|| format!("missing parent block for height {height}"))?; 183 while let Some(block) = self.chain.get(next_block_index) { 184 if block.height >= height { 185 break; 186 } 187 let block_parent = self 188 .chain 189 .get(next_block_index - 1) 190 .with_context(|| format!("missing parent block for height {}", block.height))?; 191 apply_historical_ticket_block( 192 block_parent, 193 block, 194 &self.launch_profile, 195 &mut tickets, 196 )?; 197 next_block_index += 1; 198 } 199 200 ranks_by_height.insert( 201 height, 202 ranked_tickets_for_height(parent, height, &tickets) 203 .into_iter() 204 .enumerate() 205 .map(|(rank, ticket)| BurnLeaderRank { 206 rank: rank as u32, 207 ticket_id: ticket.id, 208 owner: ticket.owner, 209 amount: ticket.amount, 210 eligible_from_height: ticket.eligible_from_height, 211 eligible_until_height: ticket.eligible_until_height, 212 }) 213 .collect(), 214 ); 215 } 216 217 Ok(ranks_by_height) 218 } 219 220 pub fn burn_committee_for_next_block(&self) -> Vec<BurnCommitteeMember> { 221 self.burn_committee_for_next_ticket_block(0) 222 } 223 224 pub fn burn_committee_for_next_ticket_block( 225 &self, 226 finalizer_rank: u32, 227 ) -> Vec<BurnCommitteeMember> { 228 self.burn_committee_for_ticket_block(self.tip().height + 1, finalizer_rank) 229 } 230 231 pub fn burn_committee_for_height(&self, height: u64) -> Vec<BurnCommitteeMember> { 232 self.burn_committee_for_ticket_block(height, 0) 233 } 234 235 pub fn burn_committee_for_block(&self, block: &Block) -> Vec<BurnCommitteeMember> { 236 match block.finalizer_mode { 237 super::FinalizerMode::Ticket => { 238 self.burn_committee_for_ticket_block(block.height, block.finalizer_rank) 239 } 240 super::FinalizerMode::Recovery => vec![BurnCommitteeMember { 241 slot: 0, 242 root: block.hash.clone(), 243 owner: block.miner.clone(), 244 weight: 0, 245 }], 246 } 247 } 248 249 fn burn_committee_for_ticket_block( 250 &self, 251 height: u64, 252 finalizer_rank: u32, 253 ) -> Vec<BurnCommitteeMember> { 254 let ranked = ranked_tickets_for_height(self.tip(), height, &self.tickets); 255 self.lineage_burn_committee_for_height(height, ranked, finalizer_rank) 256 } 257 258 pub fn burn_committee_memberships_for_next_block( 259 &self, 260 owner: &str, 261 ) -> Vec<BurnCommitteeMember> { 262 let max_rank = self 263 .finalizer_rank_count_for_next_block() 264 .min(super::BURN_COMMITTEE_SIZE); 265 let mut memberships = Vec::new(); 266 let mut seen_slots = BTreeSet::new(); 267 for rank in 0..max_rank { 268 for member in self.burn_committee_for_next_ticket_block(rank as u32) { 269 if member.owner != owner || !seen_slots.insert(member.slot) { 270 continue; 271 } 272 memberships.push(member); 273 } 274 } 275 memberships.sort_by_key(|member| member.slot); 276 memberships 277 } 278 279 fn lineage_burn_committee_for_height( 280 &self, 281 height: u64, 282 ranked: Vec<BurnTicket>, 283 finalizer_rank: u32, 284 ) -> Vec<BurnCommitteeMember> { 285 let Some(finalizer) = ranked.get(finalizer_rank as usize) else { 286 return Vec::new(); 287 }; 288 let mut committee = vec![BurnCommitteeMember { 289 slot: 0, 290 root: finalizer.id.clone(), 291 owner: finalizer.owner.clone(), 292 weight: finalizer.amount, 293 }]; 294 let max_committee_size = super::BURN_COMMITTEE_SIZE; 295 let eligible_ticket_owners = ranked 296 .iter() 297 .map(|ticket| ticket.owner.clone()) 298 .collect::<BTreeSet<_>>(); 299 let mut skipped_owners = ranked 300 .iter() 301 .take(finalizer_rank as usize) 302 .map(|ticket| ticket.owner.clone()) 303 .collect::<BTreeSet<_>>(); 304 skipped_owners.insert(finalizer.owner.clone()); 305 let mut remaining = self 306 .eligible_lineage_candidates(&skipped_owners) 307 .into_iter() 308 .filter_map(|candidate| { 309 let owner = self.representative_owner_for_lineage_root( 310 &candidate.root, 311 &skipped_owners, 312 &eligible_ticket_owners, 313 )?; 314 Some(LineageCommitteeCandidate { 315 root: candidate.root, 316 value: candidate.value, 317 weight: candidate.weight, 318 owner, 319 }) 320 }) 321 .collect::<Vec<_>>(); 322 323 for slot in 1..max_committee_size { 324 let Some(index) = 325 select_weighted_lineage_index(self.tip(), height, slot as u8, &remaining) 326 else { 327 break; 328 }; 329 let selected = remaining.remove(index); 330 skipped_owners.insert(selected.owner.clone()); 331 committee.push(BurnCommitteeMember { 332 slot: slot as u8, 333 root: selected.root.outpoint.id(), 334 owner: selected.owner, 335 weight: selected.value, 336 }); 337 remaining.retain(|candidate| { 338 candidate.root != selected.root 339 && self 340 .representative_owner_for_lineage_root( 341 &candidate.root, 342 &skipped_owners, 343 &eligible_ticket_owners, 344 ) 345 .is_some() 346 }); 347 for candidate in &mut remaining { 348 candidate.owner = self 349 .representative_owner_for_lineage_root( 350 &candidate.root, 351 &skipped_owners, 352 &eligible_ticket_owners, 353 ) 354 .expect("retained lineage candidate has representative owner"); 355 } 356 } 357 358 committee 359 } 360 361 fn eligible_lineage_candidates( 362 &self, 363 skipped_owners: &BTreeSet<String>, 364 ) -> Vec<LineageCommitteeCandidate> { 365 let parent_height = self.tip().height; 366 self.lineage_values 367 .iter() 368 .filter(|(root, value)| { 369 **value > 0 370 && root 371 .height 372 .saturating_add(self.launch_profile.burn_lineage_maturity_heights) 373 <= parent_height 374 && !skipped_owners 375 .iter() 376 .any(|owner| self.lineage_root_has_owner(root, owner)) 377 }) 378 .filter_map(|(root, value)| { 379 let weight = lineage_committee_weight(*value); 380 (weight > 0).then(|| LineageCommitteeCandidate { 381 root: root.clone(), 382 value: *value, 383 weight, 384 owner: String::new(), 385 }) 386 }) 387 .collect() 388 } 389 390 fn lineage_root_has_owner(&self, root: &UtxoLineageRoot, owner: &str) -> bool { 391 self.lineage_owners 392 .get(root) 393 .and_then(|owners| owners.get(owner)) 394 .is_some_and(|outputs| !outputs.is_empty()) 395 } 396 397 fn representative_owner_for_lineage_root( 398 &self, 399 root: &UtxoLineageRoot, 400 skipped_owners: &BTreeSet<String>, 401 eligible_ticket_owners: &BTreeSet<String>, 402 ) -> Option<String> { 403 self.lineage_owners.get(root).and_then(|owners| { 404 owners 405 .iter() 406 .filter(|(owner, outputs)| { 407 eligible_ticket_owners.contains(*owner) 408 && !skipped_owners.contains(*owner) 409 && !outputs.is_empty() 410 }) 411 .filter_map(|(owner, outputs)| { 412 let (outpoint, amount) = outputs.iter().max_by(|left, right| { 413 left.1.cmp(right.1).then_with(|| right.0.cmp(left.0)) 414 })?; 415 Some((owner.clone(), *amount, outpoint.clone())) 416 }) 417 .max_by(|left, right| { 418 left.1 419 .cmp(&right.1) 420 .then_with(|| right.2.cmp(&left.2)) 421 .then_with(|| right.0.cmp(&left.0)) 422 }) 423 .map(|(owner, _, _)| owner) 424 }) 425 } 426 427 pub fn genesis_hash(&self) -> &str { 428 &self.chain[0].hash 429 } 430 431 pub(super) fn transaction_signing_domain(&self) -> super::TransactionSigningDomain { 432 self.transaction_signing_domain_at(self.height().saturating_add(1)) 433 } 434 435 pub(super) fn transaction_signing_domain_at( 436 &self, 437 height: u64, 438 ) -> super::TransactionSigningDomain { 439 super::TransactionSigningDomain::for_height( 440 self.launch_profile.profile_id.clone(), 441 self.genesis_hash().to_string(), 442 height, 443 ) 444 } 445 446 pub fn is_setup_placeholder(&self) -> bool { 447 self.height() == 0 448 && self.genesis_allocations.is_empty() 449 && self.chain[0].transactions.is_empty() 450 && self.pending.is_empty() 451 } 452 453 pub fn height(&self) -> u64 { 454 self.tip().height 455 } 456 457 pub fn recent_blocks(&self, limit: usize) -> Vec<Block> { 458 self.chain.iter().rev().take(limit).cloned().collect() 459 } 460 461 pub fn blocks_before(&self, before_height: u64, limit: usize) -> Vec<Block> { 462 self.chain 463 .iter() 464 .rev() 465 .filter(|block| block.height < before_height) 466 .take(limit) 467 .cloned() 468 .collect() 469 } 470 471 pub fn blocks_from(&self, from_height: u64, limit: usize) -> Vec<Block> { 472 if limit == 0 { 473 return Vec::new(); 474 } 475 let Ok(start) = usize::try_from(from_height) else { 476 return Vec::new(); 477 }; 478 self.chain 479 .get(start..) 480 .unwrap_or_default() 481 .iter() 482 .take(limit) 483 .cloned() 484 .collect() 485 } 486 487 pub(crate) fn contains_block_sequence(&self, blocks: &[Block]) -> bool { 488 blocks.iter().all(|block| { 489 usize::try_from(block.height) 490 .ok() 491 .and_then(|height| self.chain.get(height)) 492 .is_some_and(|known| known.hash == block.hash) 493 }) 494 } 495 496 pub(crate) fn block_locator(&self) -> Vec<String> { 497 let mut locator = Vec::new(); 498 let mut index = self.chain.len().saturating_sub(1); 499 let mut step = 1_usize; 500 loop { 501 locator.push(self.chain[index].hash.clone()); 502 if index == 0 { 503 break; 504 } 505 index = index.saturating_sub(step); 506 if locator.len() > 10 { 507 step = step.saturating_mul(2); 508 } 509 } 510 locator 511 } 512 513 pub(crate) fn blocks_after_locator(&self, locator: &[String], limit: usize) -> Vec<Block> { 514 let common_height = locator.iter().find_map(|hash| { 515 self.chain 516 .iter() 517 .find(|block| block.hash == *hash) 518 .map(|block| block.height) 519 }); 520 common_height 521 .map(|height| self.blocks_from(height.saturating_add(1), limit)) 522 .unwrap_or_default() 523 } 524 525 pub fn block_by_hash(&self, hash: &str) -> Option<Block> { 526 self.chain.iter().find(|block| block.hash == hash).cloned() 527 } 528 529 pub fn has_block(&self, hash: &str) -> bool { 530 self.chain.iter().any(|block| block.hash == hash) 531 } 532 533 pub fn pending(&self) -> &[Transaction] { 534 &self.pending 535 } 536 537 pub fn pending_v2(&self) -> &[super::TransactionV2] { 538 &self.pending_v2 539 } 540 541 pub(crate) fn clear_pending_transactions(&mut self) { 542 self.pending.clear(); 543 self.pending_bytes = 0; 544 self.pending_v2.clear(); 545 self.pending_v2_bytes = 0; 546 } 547 548 pub fn orphan_transactions(&self) -> &[Transaction] { 549 &self.orphans 550 } 551 552 pub fn transaction_by_signature(&self, signature: &str) -> Option<Transaction> { 553 self.pending 554 .iter() 555 .chain(self.orphans.iter()) 556 .chain( 557 self.chain 558 .iter() 559 .flat_map(|block| block.transactions.iter()), 560 ) 561 .find(|tx| tx.signature() == signature) 562 .cloned() 563 } 564 565 pub fn has_transaction(&self, signature: &str) -> bool { 566 self.mined_transaction_ids.contains(signature) 567 || self 568 .pending 569 .iter() 570 .chain(self.orphans.iter()) 571 .any(|transaction| transaction.signature() == signature) 572 } 573 574 pub fn pending_mine_count_for_anchor(&self, anchor: &str) -> usize { 575 self.pending 576 .iter() 577 .filter(|tx| mine_anchor(tx) == Some(anchor)) 578 .count() 579 } 580 581 pub fn vdf_rounds(&self) -> u64 { 582 self.vdf_rounds 583 } 584 585 pub fn launch_profile(&self) -> &LaunchProfile { 586 &self.launch_profile 587 } 588 589 pub fn current_mine_difficulty_bits(&self) -> u32 { 590 self.mine_difficulty_bits_for_anchor_height(self.tip().height) 591 } 592 593 pub fn mine_difficulty_bits_at_height(&self, height: u64) -> u32 { 594 self.mine_difficulty_bits_for_anchor_height(height.min(self.tip().height)) 595 } 596 597 pub fn balance_of(&self, address: &str) -> Amount { 598 self.utxos 599 .values() 600 .filter(|output| output.address == address) 601 .map(|output| output.amount) 602 .sum() 603 } 604 605 pub fn utxos_for_address(&self, address: &str) -> Vec<(OutPoint, TxOutput)> { 606 self.utxos 607 .iter() 608 .filter(|(_, output)| output.address == address) 609 .map(|(outpoint, output)| (outpoint.clone(), output.clone())) 610 .collect() 611 } 612 613 pub fn utxos_for_addresses(&self, addresses: &BTreeSet<String>) -> Vec<(OutPoint, TxOutput)> { 614 self.utxos 615 .iter() 616 .filter(|(_, output)| addresses.contains(&output.address)) 617 .map(|(outpoint, output)| (outpoint.clone(), output.clone())) 618 .collect() 619 } 620 621 pub fn all_utxos(&self) -> Vec<(OutPoint, TxOutput)> { 622 self.utxos 623 .iter() 624 .map(|(outpoint, output)| (outpoint.clone(), output.clone())) 625 .collect() 626 } 627 628 pub fn output_for_outpoint(&self, outpoint: &OutPoint) -> Option<TxOutput> { 629 self.utxos.get(outpoint).cloned() 630 } 631 632 /// Consolidation keeps mining roots separate unless explicitly requested. 633 pub(crate) fn consolidation_root(&self, outpoint: &OutPoint) -> Option<OutPoint> { 634 self.utxo_lineage 635 .get(outpoint) 636 .map(|root| root.outpoint.clone()) 637 } 638 639 pub fn available_utxos_for_address(&self, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> { 640 Ok(self 641 .utxos_after_spendable_pending()? 642 .into_iter() 643 .filter(|(_, output)| output.address == address) 644 .collect()) 645 } 646 647 pub fn available_utxos_for_addresses( 648 &self, 649 addresses: &BTreeSet<String>, 650 ) -> Result<Vec<(OutPoint, TxOutput)>> { 651 Ok(self 652 .utxos_after_spendable_pending()? 653 .into_iter() 654 .filter(|(_, output)| addresses.contains(&output.address)) 655 .collect()) 656 } 657 658 pub fn next_nonce(&self, address: &str) -> u64 { 659 self.utxos 660 .keys() 661 .chain( 662 self.pending 663 .iter() 664 .flat_map(|tx| tx.inputs().iter().map(|input| &input.outpoint)), 665 ) 666 .filter(|outpoint| outpoint.txid.contains(address)) 667 .count() as u64 668 + 1 669 } 670 } 671 672 #[cfg(test)] 673 mod tests { 674 use super::*; 675 use crate::domain::{ 676 BURN_COMMITTEE_SIZE, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, GenesisBurn, MICRO_IUNA, Wallet, 677 }; 678 use proptest::prelude::*; 679 use proptest::test_runner::Config; 680 681 fn synthetic_committee_ledger(seed: u64) -> Ledger { 682 let mut ledger = Ledger::new(BTreeMap::new(), 1); 683 ledger.launch_profile.burn_lineage_maturity_heights = 0; 684 let owners = (0..10) 685 .map(|index| format!("owner-{seed}-{index}")) 686 .collect::<Vec<_>>(); 687 688 ledger.tickets = owners 689 .iter() 690 .enumerate() 691 .map(|(index, owner)| { 692 let valid = index == 0 || seed.rotate_right(index as u32) & 1 == 1; 693 let (eligible_from_height, eligible_until_height) = if valid { 694 (1, 1) 695 } else if index % 2 == 0 { 696 (0, 0) 697 } else { 698 (2, 3) 699 }; 700 BurnTicket { 701 id: format!("ticket-{seed}-{index}"), 702 owner: owner.clone(), 703 amount: 1 + seed.rotate_left(index as u32) % 100, 704 eligible_from_height, 705 eligible_until_height, 706 } 707 }) 708 .collect(); 709 710 for root_index in 0_u32..8 { 711 let root = UtxoLineageRoot { 712 outpoint: OutPoint { 713 txid: format!("{:064x}", u128::from(seed) << 8 | u128::from(root_index)), 714 index: root_index, 715 }, 716 height: u64::from((seed.rotate_right(root_index) & 0b11) == 0), 717 }; 718 let first_owner = (root_index as usize + seed as usize) % owners.len(); 719 let second_owner = (first_owner + 1 + root_index as usize) % owners.len(); 720 let first_value = 1 + seed.rotate_left(root_index) % 1_000; 721 let second_value = 1 + seed.rotate_right(root_index) % 1_000; 722 ledger 723 .lineage_values 724 .insert(root.clone(), first_value + second_value); 725 ledger.lineage_owners.insert( 726 root, 727 BTreeMap::from([ 728 ( 729 owners[first_owner].clone(), 730 BTreeMap::from([( 731 OutPoint { 732 txid: format!("{:064x}", 0x100_u128 + root_index as u128), 733 index: 0, 734 }, 735 first_value, 736 )]), 737 ), 738 ( 739 owners[second_owner].clone(), 740 BTreeMap::from([( 741 OutPoint { 742 txid: format!("{:064x}", 0x200_u128 + root_index as u128), 743 index: 0, 744 }, 745 second_value, 746 )]), 747 ), 748 ]), 749 ); 750 } 751 ledger 752 } 753 754 #[test] 755 fn lineage_committee_selects_a_ticket_owner_within_the_selected_root() { 756 let finalizer = Wallet::from_seed("lineage-ticket-finalizer"); 757 let ticket_owner = Wallet::from_seed("lineage-ticket-owner"); 758 let non_ticket_owner = Wallet::from_seed("lineage-non-ticket-owner"); 759 let mut ledger = Ledger::new_with_genesis_burns( 760 BTreeMap::from([(finalizer.address().to_string(), 10 * MICRO_IUNA)]), 761 vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], 762 1, 763 ) 764 .unwrap(); 765 ledger.launch_profile.burn_lineage_maturity_heights = 0; 766 767 let ticket_root = UtxoLineageRoot { 768 outpoint: OutPoint { 769 txid: "1".repeat(64), 770 index: 0, 771 }, 772 height: 0, 773 }; 774 ledger.lineage_values.insert(ticket_root.clone(), 110); 775 ledger.lineage_owners.insert( 776 ticket_root.clone(), 777 BTreeMap::from([ 778 ( 779 non_ticket_owner.address().to_string(), 780 BTreeMap::from([( 781 OutPoint { 782 txid: "2".repeat(64), 783 index: 0, 784 }, 785 100, 786 )]), 787 ), 788 ( 789 ticket_owner.address().to_string(), 790 BTreeMap::from([( 791 OutPoint { 792 txid: "3".repeat(64), 793 index: 0, 794 }, 795 10, 796 )]), 797 ), 798 ]), 799 ); 800 801 let non_ticket_root = UtxoLineageRoot { 802 outpoint: OutPoint { 803 txid: "4".repeat(64), 804 index: 0, 805 }, 806 height: 0, 807 }; 808 ledger.lineage_values.insert(non_ticket_root.clone(), 1_000); 809 ledger.lineage_owners.insert( 810 non_ticket_root, 811 BTreeMap::from([( 812 non_ticket_owner.address().to_string(), 813 BTreeMap::from([( 814 OutPoint { 815 txid: "5".repeat(64), 816 index: 0, 817 }, 818 1_000, 819 )]), 820 )]), 821 ); 822 823 let ranked = vec![ 824 BurnTicket { 825 id: "finalizer-ticket".to_string(), 826 owner: finalizer.address().to_string(), 827 amount: 1, 828 eligible_from_height: 1, 829 eligible_until_height: 1, 830 }, 831 BurnTicket { 832 id: "committee-ticket".to_string(), 833 owner: ticket_owner.address().to_string(), 834 amount: 1, 835 eligible_from_height: 1, 836 eligible_until_height: 1, 837 }, 838 ]; 839 840 let committee = ledger.lineage_burn_committee_for_height(1, ranked, 0); 841 842 assert_eq!(committee.len(), 2); 843 assert_eq!(committee[1].root, ticket_root.outpoint.id()); 844 assert_eq!(committee[1].owner, ticket_owner.address()); 845 assert_ne!(committee[1].owner, non_ticket_owner.address()); 846 } 847 848 #[test] 849 fn lineage_weight_is_floor_log2_of_one_plus_root_value() { 850 assert_eq!(lineage_committee_weight(0), 0); 851 assert_eq!(lineage_committee_weight(1), 1); 852 assert_eq!(lineage_committee_weight(2), 1); 853 assert_eq!(lineage_committee_weight(3), 2); 854 assert_eq!(lineage_committee_weight(7), 3); 855 assert_eq!(lineage_committee_weight(8), 3); 856 assert_eq!(lineage_committee_weight(u64::MAX), 64); 857 } 858 859 #[test] 860 fn committee_draw_seed_has_a_fixed_legacy_parent_hash_height_and_slot_vector() { 861 let mut parent = Ledger::new(BTreeMap::new(), 1).tip().clone(); 862 parent.hash = "a".repeat(64); 863 parent.vdf_output = "parent-vdf".to_string(); 864 let candidates = [1_u64, 2, 4] 865 .into_iter() 866 .enumerate() 867 .map(|(index, weight)| LineageCommitteeCandidate { 868 root: UtxoLineageRoot { 869 outpoint: OutPoint { 870 txid: format!("{:064x}", index + 1), 871 index: 0, 872 }, 873 height: 1, 874 }, 875 value: weight, 876 weight, 877 owner: format!("owner-{index}"), 878 }) 879 .collect::<Vec<_>>(); 880 881 assert_eq!( 882 select_weighted_lineage_index(&parent, 42, 1, &candidates), 883 Some(2) 884 ); 885 } 886 887 #[test] 888 fn committee_draw_stops_using_grindable_parent_hash_at_height_1000() { 889 let mut parent = Ledger::new(BTreeMap::new(), 1).tip().clone(); 890 parent.height = GRINDING_RESISTANCE_ACTIVATION_HEIGHT - 1; 891 parent.hash = "1".repeat(64); 892 parent.vdf_output = "parent-vdf".to_string(); 893 let mut alternate_hash = parent.clone(); 894 alternate_hash.hash = "2".repeat(64); 895 896 assert_ne!( 897 lineage_committee_draw_seed(&parent, GRINDING_RESISTANCE_ACTIVATION_HEIGHT - 1, 1,), 898 lineage_committee_draw_seed( 899 &alternate_hash, 900 GRINDING_RESISTANCE_ACTIVATION_HEIGHT - 1, 901 1, 902 ) 903 ); 904 assert_eq!( 905 lineage_committee_draw_seed(&parent, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 1), 906 lineage_committee_draw_seed(&alternate_hash, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 1,) 907 ); 908 909 let mut alternate_output = parent; 910 alternate_output.vdf_output = "different-vdf".to_string(); 911 assert_ne!( 912 lineage_committee_draw_seed( 913 &alternate_output, 914 GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 915 1, 916 ), 917 lineage_committee_draw_seed(&alternate_hash, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 1,) 918 ); 919 } 920 921 proptest! { 922 #![proptest_config(Config { cases: 128, .. Config::default() })] 923 924 #[test] 925 fn committee_members_are_eligible_ticket_owners_in_distinct_mature_roots( 926 seed in any::<u64>(), 927 requested_rank in 0usize..10, 928 ) { 929 let ledger = synthetic_committee_ledger(seed); 930 let target_height = ledger.height() + 1; 931 let ranked = ranked_tickets_for_height(ledger.tip(), target_height, &ledger.tickets); 932 let rank = requested_rank % ranked.len(); 933 let committee = ledger.burn_committee_for_next_ticket_block(rank as u32); 934 let repeated = ledger.burn_committee_for_next_ticket_block(rank as u32); 935 let excluded_owners = ranked 936 .iter() 937 .take(rank + 1) 938 .map(|ticket| ticket.owner.as_str()) 939 .collect::<BTreeSet<_>>(); 940 941 prop_assert_eq!(&committee, &repeated, "committee selection must be deterministic"); 942 prop_assert!(!committee.is_empty()); 943 prop_assert!(committee.len() <= BURN_COMMITTEE_SIZE); 944 945 let mut owners = BTreeSet::new(); 946 let mut roots = BTreeSet::new(); 947 for (index, member) in committee.iter().enumerate() { 948 prop_assert_eq!(usize::from(member.slot), index, "committee slots must be contiguous"); 949 prop_assert!(owners.insert(member.owner.as_str()), "committee owners must be unique"); 950 prop_assert!( 951 ledger.tickets.iter().any(|ticket| { 952 ticket.owner == member.owner 953 && ticket.eligible_from_height <= target_height 954 && target_height <= ticket.eligible_until_height 955 }), 956 "committee member {} has no ticket for height {target_height}", 957 member.owner, 958 ); 959 960 if member.slot == 0 { 961 prop_assert_eq!(&member.owner, &ranked[rank].owner); 962 prop_assert_eq!(&member.root, &ranked[rank].id); 963 prop_assert_eq!(member.weight, ranked[rank].amount); 964 continue; 965 } 966 967 prop_assert!(!excluded_owners.contains(member.owner.as_str())); 968 let (root, root_owners) = ledger 969 .lineage_owners 970 .iter() 971 .find(|(root, _)| root.outpoint.id() == member.root) 972 .expect("selected committee root must exist"); 973 prop_assert!(roots.insert(root.clone()), "lineage roots must be unique"); 974 prop_assert_eq!(member.weight, ledger.lineage_values[root]); 975 prop_assert!( 976 root.height 977 .saturating_add(ledger.launch_profile.burn_lineage_maturity_heights) 978 <= ledger.tip().height, 979 "committee root must be mature", 980 ); 981 prop_assert!( 982 root_owners 983 .get(&member.owner) 984 .is_some_and(|outputs| !outputs.is_empty()), 985 "committee member must currently belong to the selected lineage root", 986 ); 987 prop_assert!( 988 root_owners 989 .keys() 990 .all(|owner| !excluded_owners.contains(owner.as_str())), 991 "a root containing a missed owner or finalizer must be excluded", 992 ); 993 } 994 } 995 } 996 }