ledger_reveal.rs (39212B)
1 use std::collections::{BTreeMap, BTreeSet}; 2 3 use anyhow::{Context, Result, bail}; 4 5 use super::ledger_ops::verify_address_signature; 6 use super::reveal::{burn_bundle_slot_mask, burn_committee_mask}; 7 use super::{ 8 Amount, BURN_COMMITTEE_SIZE, Block, BurnBundle, BurnBundlePayload, BurnBundleSection, 9 BurnBundleSignature, BurnCommitteeMember, FinalizerMode, Ledger, MAX_BURN_BUNDLE_BYTES, 10 MaskedBurn, MaskedBurnV2, OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, Transaction, Wallet, 11 hex_encode, 12 }; 13 14 impl Ledger { 15 pub fn burn_bundle_attestations_required_for_next_block(&self) -> bool { 16 self.pending.iter().any(|transaction| { 17 transaction.is_burn() && self.transaction_is_eligible_for_next_block(transaction) 18 }) || self.pending_v2.iter().any(|transaction| { 19 transaction.is_burn() && self.transaction_v2_is_eligible_for_next_block(transaction) 20 }) 21 } 22 23 pub fn explicit_burn_bundle_signatures_required_for_next_block( 24 &self, 25 finalizer_mode: FinalizerMode, 26 finalizer_rank: u32, 27 _finalizer: &str, 28 ) -> usize { 29 let committee_size = match finalizer_mode { 30 FinalizerMode::Ticket => self 31 .burn_committee_for_next_ticket_block(finalizer_rank) 32 .len(), 33 FinalizerMode::Recovery => 1, 34 }; 35 self.required_explicit_burn_signatures( 36 self.height() + 1, 37 finalizer_mode, 38 finalizer_rank, 39 committee_size, 40 ) 41 } 42 43 pub fn build_burn_bundle(&self, wallet: &Wallet) -> Result<Option<BurnBundle>> { 44 Ok(self.build_burn_bundles(wallet)?.into_iter().next()) 45 } 46 47 pub fn build_burn_bundles(&self, wallet: &Wallet) -> Result<Vec<BurnBundle>> { 48 let height = self.tip().height + 1; 49 let prev_hash = self.tip().hash.clone(); 50 let memberships = self.burn_committee_memberships_for_next_block(wallet.address()); 51 if memberships.is_empty() { 52 return Ok(Vec::new()); 53 } 54 let burns = self 55 .valid_pending_transactions() 56 .into_iter() 57 .filter(|transaction| { 58 transaction.is_burn() && self.transaction_is_eligible_for_next_block(transaction) 59 }) 60 .collect::<Vec<_>>(); 61 let domain = self.transaction_v2_domain()?; 62 let burns_v2 = self 63 .pending_v2 64 .iter() 65 .filter(|transaction| { 66 transaction.is_burn() && self.transaction_v2_is_eligible_for_next_block(transaction) 67 }) 68 .map(|transaction| { 69 Ok(( 70 transaction.fee(), 71 hex_encode(transaction.transaction_id(&domain)?), 72 hex_encode(transaction.encode(&domain)?), 73 )) 74 }) 75 .collect::<Result<Vec<_>>>()?; 76 let mut candidates = burns 77 .into_iter() 78 .map(|burn| (burn.fee(), burn.signature().to_string(), Some(burn), None)) 79 .chain( 80 burns_v2 81 .into_iter() 82 .map(|(fee, id, envelope)| (fee, id, None, Some(envelope))), 83 ) 84 .collect::<Vec<_>>(); 85 candidates.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); 86 87 let mut bundles = Vec::new(); 88 for member in memberships { 89 let mut selected = Vec::new(); 90 let mut selected_v2 = Vec::new(); 91 for (_, _, legacy_burn, v2_envelope) in &candidates { 92 let mut candidate = selected.clone(); 93 let mut candidate_v2 = selected_v2.clone(); 94 if let Some(burn) = legacy_burn { 95 candidate.push(burn.clone()); 96 } 97 if let Some(envelope) = v2_envelope { 98 candidate_v2.push(envelope.clone()); 99 } 100 let bundle = wallet.burn_bundle(BurnBundlePayload { 101 height, 102 prev_hash: prev_hash.clone(), 103 slot: member.slot, 104 member: wallet.address().to_string(), 105 reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) 106 .then(|| self.wallet_reward_address(wallet, height)), 107 burns: candidate.clone(), 108 burns_v2: candidate_v2.clone(), 109 }); 110 if bundle.serialized_size_bytes()? <= MAX_BURN_BUNDLE_BYTES { 111 selected = candidate; 112 selected_v2 = candidate_v2; 113 } 114 } 115 bundles.push( 116 wallet.burn_bundle(BurnBundlePayload { 117 height, 118 prev_hash: prev_hash.clone(), 119 slot: member.slot, 120 member: wallet.address().to_string(), 121 reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) 122 .then(|| self.wallet_reward_address(wallet, height)), 123 burns: selected, 124 burns_v2: selected_v2, 125 }), 126 ); 127 } 128 Ok(bundles) 129 } 130 131 pub fn validate_next_block_burn_bundles( 132 &self, 133 bundles: Vec<BurnBundle>, 134 ) -> Result<Vec<BurnBundle>> { 135 let expected_height = self.tip().height + 1; 136 let expected_prev_hash = self.tip().hash.clone(); 137 self.validate_burn_bundles_for_any_next_ticket_block( 138 expected_height, 139 &expected_prev_hash, 140 bundles, 141 ) 142 } 143 144 pub(super) fn validate_next_block_burn_bundles_for_finalizer_rank( 145 &self, 146 finalizer_rank: u32, 147 bundles: Vec<BurnBundle>, 148 ) -> Result<Vec<BurnBundle>> { 149 let expected_height = self.tip().height + 1; 150 let expected_prev_hash = self.tip().hash.clone(); 151 let committee = self 152 .burn_committee_for_next_ticket_block(finalizer_rank) 153 .into_iter() 154 .map(|member| (member.slot, member)) 155 .collect::<BTreeMap<_, _>>(); 156 let validated = self.validate_burn_bundles_for_committee( 157 expected_height, 158 &expected_prev_hash, 159 &committee, 160 bundles, 161 )?; 162 let required_signatures = self.required_explicit_burn_signatures( 163 expected_height, 164 FinalizerMode::Ticket, 165 finalizer_rank, 166 committee.len(), 167 ); 168 let explicit_signatures = validated.iter().filter(|bundle| bundle.slot != 0).count(); 169 if explicit_signatures < required_signatures { 170 bail!( 171 "not enough burn bundle signatures collected: got {explicit_signatures}, need {required_signatures}" 172 ); 173 } 174 Ok(validated) 175 } 176 177 pub(crate) fn precheck_next_block_burn_bundle(&self, bundle: &BurnBundle) -> Result<()> { 178 let expected_height = self.tip().height + 1; 179 let expected_prev_hash = self.tip().hash.clone(); 180 let max_rank = self 181 .finalizer_rank_count_for_next_block() 182 .min(BURN_COMMITTEE_SIZE); 183 let mut first_error = None; 184 for rank in 0..max_rank { 185 let committee = self 186 .burn_committee_for_next_ticket_block(rank as u32) 187 .into_iter() 188 .map(|member| (member.slot, member)) 189 .collect::<BTreeMap<_, _>>(); 190 match self.precheck_burn_bundle_for_block( 191 expected_height, 192 &expected_prev_hash, 193 &committee, 194 bundle, 195 ) { 196 Ok(()) => return Ok(()), 197 Err(error) => { 198 if first_error.is_none() { 199 first_error = Some(error); 200 } 201 } 202 } 203 } 204 Err(first_error.unwrap_or_else(|| anyhow::anyhow!("burn bundle slot is not assigned"))) 205 } 206 207 #[cfg(test)] 208 pub fn test_burn_bundle(&self, wallet: &Wallet, burns: Vec<Transaction>) -> BurnBundle { 209 let height = self.tip().height + 1; 210 let prev_hash = self.tip().hash.clone(); 211 let member = self 212 .burn_committee_for_next_block() 213 .into_iter() 214 .find(|member| member.owner == wallet.address()) 215 .expect("test wallet must be a burn committee member"); 216 wallet.burn_bundle(BurnBundlePayload { 217 height, 218 prev_hash, 219 slot: member.slot, 220 member: wallet.address().to_string(), 221 reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) 222 .then(|| self.wallet_reward_address(wallet, height)), 223 burns, 224 burns_v2: Vec::new(), 225 }) 226 } 227 228 pub(super) fn burn_bundle_section_from_bundles( 229 &self, 230 bundles: Vec<BurnBundle>, 231 ) -> Result<BurnBundleSection> { 232 let signatures = bundles 233 .iter() 234 .filter(|bundle| bundle.slot != 0) 235 .map(|bundle| BurnBundleSignature { 236 slot: bundle.slot, 237 member: bundle.member.clone(), 238 reward_address: bundle.reward_address.clone(), 239 signature: bundle.signature.clone(), 240 }) 241 .collect::<Vec<_>>(); 242 let mut by_signature: BTreeMap<String, MaskedBurn> = BTreeMap::new(); 243 let mut by_v2_id: BTreeMap<String, MaskedBurnV2> = BTreeMap::new(); 244 for bundle in bundles { 245 let slot_mask = if bundle.slot == 0 { 246 0 247 } else { 248 burn_bundle_slot_mask(bundle.slot).unwrap_or(0) 249 }; 250 for burn in bundle.burns { 251 by_signature 252 .entry(burn.signature().to_string()) 253 .and_modify(|masked| masked.bundle_mask |= slot_mask) 254 .or_insert(MaskedBurn { 255 burn, 256 bundle_mask: slot_mask, 257 }); 258 } 259 for envelope in bundle.burns_v2 { 260 by_v2_id 261 .entry(envelope.clone()) 262 .and_modify(|masked| masked.bundle_mask |= slot_mask) 263 .or_insert(MaskedBurnV2 { 264 envelope, 265 bundle_mask: slot_mask, 266 }); 267 } 268 } 269 let mut burns = by_signature.into_values().collect::<Vec<_>>(); 270 burns.sort_by(|left, right| { 271 right 272 .burn 273 .fee() 274 .cmp(&left.burn.fee()) 275 .then_with(|| left.burn.signature().cmp(right.burn.signature())) 276 }); 277 let domain = self.transaction_v2_domain()?; 278 let mut burns_v2 = by_v2_id 279 .into_values() 280 .map(|masked| { 281 let burn = super::ledger_v2::decode_canonical_transaction_v2_envelope( 282 &masked.envelope, 283 &domain, 284 )?; 285 Ok(( 286 burn.fee(), 287 hex_encode(burn.transaction_id(&domain)?), 288 masked, 289 )) 290 }) 291 .collect::<Result<Vec<_>>>()?; 292 burns_v2.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); 293 Ok(BurnBundleSection { 294 signatures, 295 burns, 296 burns_v2: burns_v2.into_iter().map(|(_, _, masked)| masked).collect(), 297 }) 298 } 299 300 pub(super) fn validate_burn_bundle_section_for_block(&self, block: &Block) -> Result<()> { 301 let section = &block.burn_bundle_section; 302 if section.signatures.len() > BURN_COMMITTEE_SIZE.saturating_sub(1) { 303 bail!("block has too many burn bundle signatures"); 304 } 305 if section 306 .signatures 307 .windows(2) 308 .any(|pair| pair[0].slot >= pair[1].slot) 309 { 310 bail!("burn bundle signatures are not in slot order"); 311 } 312 let committee = self 313 .burn_committee_for_block(block) 314 .into_iter() 315 .map(|member| (member.slot, member)) 316 .collect::<BTreeMap<_, _>>(); 317 let mut seen_slots = BTreeSet::new(); 318 let mut seen_members = BTreeSet::new(); 319 let mut included_mask = 0_u8; 320 for signature in §ion.signatures { 321 if usize::from(signature.slot) >= BURN_COMMITTEE_SIZE { 322 bail!("burn bundle slot is invalid"); 323 } 324 if signature.slot == 0 { 325 bail!("finalizer burn attestation is implicit"); 326 } 327 if !seen_slots.insert(signature.slot) { 328 bail!("duplicate burn bundle slot"); 329 } 330 if !seen_members.insert(signature.member.clone()) { 331 bail!("duplicate burn bundle member"); 332 } 333 let member = committee 334 .get(&signature.slot) 335 .context("burn bundle slot is not assigned")?; 336 if signature.member != member.owner { 337 bail!("burn bundle member is not assigned to slot"); 338 } 339 self.validate_reward_address( 340 block.height, 341 signature.reward_address.as_deref(), 342 "committee member", 343 )?; 344 included_mask |= burn_bundle_slot_mask(signature.slot)?; 345 } 346 let required_signatures = self.required_explicit_burn_signatures( 347 block.height, 348 block.finalizer_mode, 349 block.finalizer_rank, 350 committee.len(), 351 ); 352 if section.signatures.len() < required_signatures { 353 bail!( 354 "block has too few burn bundle signatures: got {}, need {required_signatures}", 355 section.signatures.len() 356 ); 357 } 358 359 let mut seen_burns = BTreeSet::new(); 360 let mut previous_key: Option<(Amount, String)> = None; 361 for masked in §ion.burns { 362 if masked.bundle_mask & !burn_committee_mask() != 0 { 363 bail!("masked burn references an invalid burn bundle slot"); 364 } 365 if masked.bundle_mask & !included_mask != 0 { 366 bail!("masked burn references a missing burn bundle signature"); 367 } 368 if !seen_burns.insert(masked.burn.signature().to_string()) { 369 bail!("duplicate burn in burn bundle section"); 370 } 371 if !masked.burn.is_burn() { 372 bail!("burn bundle section contains a non-burn transaction"); 373 } 374 if matching_burn_by_signature(&masked.burn, &block.transactions).is_none() { 375 bail!("attested burn is not included in the block"); 376 } 377 self.validate_transaction_terms(&masked.burn)?; 378 let key = (masked.burn.fee(), masked.burn.signature().to_string()); 379 if let Some((previous_fee, previous_signature)) = &previous_key { 380 if key.0 > *previous_fee || key.0 == *previous_fee && key.1 < *previous_signature { 381 bail!("burn bundle section is not fee ordered"); 382 } 383 } 384 previous_key = Some(key); 385 } 386 let domain = self.transaction_v2_domain()?; 387 let mut seen_burns_v2 = BTreeSet::new(); 388 for masked in §ion.burns_v2 { 389 if masked.bundle_mask & !burn_committee_mask() != 0 { 390 bail!("masked transaction v2 burn references an invalid burn bundle slot"); 391 } 392 if masked.bundle_mask & !included_mask != 0 { 393 bail!("masked transaction v2 burn references a missing burn bundle signature"); 394 } 395 let burn = super::ledger_v2::decode_canonical_transaction_v2_envelope( 396 &masked.envelope, 397 &domain, 398 )?; 399 if !burn.is_burn() { 400 bail!("burn bundle section contains a non-burn transaction v2"); 401 } 402 let id = hex_encode(burn.transaction_id(&domain)?); 403 if !seen_burns_v2.insert(id) { 404 bail!("duplicate transaction v2 burn in burn bundle section"); 405 } 406 if !block.transactions_v2.contains(&masked.envelope) { 407 bail!("attested transaction v2 burn is not included in the block"); 408 } 409 self.validate_transaction_v2_anchor_for_block(&burn, block.height)?; 410 } 411 412 for bundle in section.expand(block.height, &block.prev_hash) { 413 if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES { 414 bail!("burn bundle exceeds max size"); 415 } 416 verify_address_signature( 417 &bundle.member, 418 &bundle.canonical_payload(), 419 &bundle.signature, 420 "burn bundle", 421 )?; 422 } 423 Ok(()) 424 } 425 426 fn required_explicit_burn_signatures( 427 &self, 428 height: u64, 429 finalizer_mode: FinalizerMode, 430 finalizer_rank: u32, 431 committee_size: usize, 432 ) -> usize { 433 if committee_size == 0 { 434 return 0; 435 } 436 match finalizer_mode { 437 FinalizerMode::Ticket 438 if finalizer_rank == 0 && height >= OBJECTIVE_FINALITY_ACTIVATION_HEIGHT => 439 { 440 objective_finality_quorum(committee_size).saturating_sub(1) 441 } 442 FinalizerMode::Ticket if finalizer_rank == 0 => committee_size.min(3).saturating_sub(1), 443 FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.min(2).saturating_sub(1), 444 FinalizerMode::Ticket => 0, 445 FinalizerMode::Recovery => 0, 446 } 447 } 448 449 pub(super) fn block_certifies_parent(&self, block: &Block, committee_size: usize) -> bool { 450 block.height > OBJECTIVE_FINALITY_ACTIVATION_HEIGHT 451 && block.finalizer_mode == FinalizerMode::Ticket 452 && block.finalizer_rank == 0 453 && committee_size > 0 454 && block.burn_bundle_section.signatures.len() + 1 455 >= objective_finality_quorum(committee_size) 456 } 457 458 fn validate_burn_bundles_for_any_next_ticket_block( 459 &self, 460 expected_height: u64, 461 expected_prev_hash: &str, 462 bundles: Vec<BurnBundle>, 463 ) -> Result<Vec<BurnBundle>> { 464 let max_rank = self 465 .finalizer_rank_count_for_next_block() 466 .min(BURN_COMMITTEE_SIZE); 467 let mut first_error = None; 468 for rank in 0..max_rank { 469 let committee = self 470 .burn_committee_for_next_ticket_block(rank as u32) 471 .into_iter() 472 .map(|member| (member.slot, member)) 473 .collect::<BTreeMap<_, _>>(); 474 match self.validate_burn_bundles_for_committee( 475 expected_height, 476 expected_prev_hash, 477 &committee, 478 bundles.clone(), 479 ) { 480 Ok(validated) => return Ok(validated), 481 Err(error) => { 482 if first_error.is_none() { 483 first_error = Some(error); 484 } 485 } 486 } 487 } 488 Err(first_error.unwrap_or_else(|| anyhow::anyhow!("no ticket committee is available"))) 489 } 490 491 fn validate_burn_bundles_for_committee( 492 &self, 493 expected_height: u64, 494 expected_prev_hash: &str, 495 committee: &BTreeMap<u8, BurnCommitteeMember>, 496 mut bundles: Vec<BurnBundle>, 497 ) -> Result<Vec<BurnBundle>> { 498 if bundles.len() > BURN_COMMITTEE_SIZE { 499 bail!("block has too many burn bundles"); 500 } 501 bundles.sort_by_key(|bundle| bundle.slot); 502 if bundles.windows(2).any(|pair| pair[0].slot == pair[1].slot) { 503 bail!("duplicate burn bundle slot"); 504 } 505 let mut seen_members = BTreeSet::new(); 506 let domain = self.transaction_v2_domain()?; 507 for bundle in &bundles { 508 if !seen_members.insert(bundle.member.clone()) { 509 bail!("duplicate burn bundle member"); 510 } 511 self.precheck_burn_bundle_for_block( 512 expected_height, 513 expected_prev_hash, 514 committee, 515 bundle, 516 )?; 517 for burn in &bundle.burns { 518 if matching_burn_by_signature(burn, &self.pending).is_none() { 519 bail!("burn bundle references a burn that is not in the mempool"); 520 } 521 } 522 for envelope in &bundle.burns_v2 { 523 let burn = 524 super::ledger_v2::decode_canonical_transaction_v2_envelope(envelope, &domain)?; 525 let id = burn.transaction_id(&domain)?; 526 if !self 527 .pending_v2 528 .iter() 529 .any(|pending| pending.transaction_id(&domain).ok() == Some(id)) 530 { 531 bail!( 532 "burn bundle references a transaction v2 burn that is not in the mempool" 533 ); 534 } 535 } 536 } 537 Ok(bundles) 538 } 539 540 fn precheck_burn_bundle_for_block( 541 &self, 542 expected_height: u64, 543 expected_prev_hash: &str, 544 committee: &BTreeMap<u8, BurnCommitteeMember>, 545 bundle: &BurnBundle, 546 ) -> Result<()> { 547 if bundle.height != expected_height { 548 bail!("burn bundle height is invalid"); 549 } 550 if bundle.prev_hash != expected_prev_hash { 551 return Err(super::ValidationError::BurnBundleParentMismatch.into()); 552 } 553 if usize::from(bundle.slot) >= BURN_COMMITTEE_SIZE { 554 bail!("burn bundle slot is invalid"); 555 } 556 let member = committee 557 .get(&bundle.slot) 558 .context("burn bundle slot is not assigned")?; 559 if bundle.member != member.owner { 560 bail!("burn bundle member is not assigned to slot"); 561 } 562 self.validate_reward_address( 563 expected_height, 564 bundle.reward_address.as_deref(), 565 "committee member", 566 )?; 567 if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES { 568 bail!("burn bundle exceeds max size"); 569 } 570 verify_address_signature( 571 &bundle.member, 572 &bundle.canonical_payload(), 573 &bundle.signature, 574 "burn bundle", 575 )?; 576 let mut seen_bundle_burns = BTreeSet::new(); 577 let mut previous_key: Option<(Amount, String)> = None; 578 for burn in &bundle.burns { 579 if !seen_bundle_burns.insert(burn.signature().to_string()) { 580 bail!("duplicate burn in burn bundle"); 581 } 582 if !burn.is_burn() { 583 bail!("burn bundle contains a non-burn transaction"); 584 } 585 self.validate_transaction_terms(burn)?; 586 self.validate_transaction_anchor_for_block(burn, expected_height)?; 587 let key = (burn.fee(), burn.signature().to_string()); 588 if let Some((previous_fee, previous_signature)) = &previous_key { 589 if key.0 > *previous_fee || key.0 == *previous_fee && key.1 < *previous_signature { 590 bail!("burn bundle is not fee ordered"); 591 } 592 } 593 previous_key = Some(key); 594 } 595 let domain = self.transaction_v2_domain()?; 596 let mut seen_bundle_burns_v2 = BTreeSet::new(); 597 let mut previous_v2_key: Option<(Amount, String)> = None; 598 for envelope in &bundle.burns_v2 { 599 let burn = 600 super::ledger_v2::decode_canonical_transaction_v2_envelope(envelope, &domain)?; 601 if !burn.is_burn() { 602 bail!("burn bundle contains a non-burn transaction v2"); 603 } 604 let id = hex_encode(burn.transaction_id(&domain)?); 605 if !seen_bundle_burns_v2.insert(id.clone()) { 606 bail!("duplicate transaction v2 burn in burn bundle"); 607 } 608 self.validate_transaction_v2_anchor_for_block(&burn, expected_height)?; 609 let key = (burn.fee(), id); 610 if let Some((previous_fee, previous_id)) = &previous_v2_key { 611 if key.0 > *previous_fee || key.0 == *previous_fee && key.1 < *previous_id { 612 bail!("transaction v2 burn bundle is not fee ordered"); 613 } 614 } 615 previous_v2_key = Some(key); 616 } 617 Ok(()) 618 } 619 } 620 621 pub(super) fn objective_finality_quorum(committee_size: usize) -> usize { 622 if committee_size == 0 { 623 0 624 } else { 625 committee_size.saturating_mul(2) / 3 + 1 626 } 627 } 628 629 fn matching_burn_by_signature<'a>( 630 attested: &Transaction, 631 transactions: &'a [Transaction], 632 ) -> Option<&'a Transaction> { 633 transactions.iter().find(|transaction| { 634 transaction.is_burn() 635 && transaction.signature() == attested.signature() 636 && transaction.canonical() == attested.canonical() 637 }) 638 } 639 640 #[cfg(test)] 641 mod tests { 642 use std::collections::BTreeMap; 643 644 use super::*; 645 use crate::domain::{ 646 GenesisBurn, MICRO_IUNA, OutPoint, TxInput, TxOutput, UtxoLineageRoot, Wallet, 647 }; 648 649 fn ledger() -> Ledger { 650 Ledger::new(BTreeMap::new(), 1) 651 } 652 653 fn burn(signature: &str, fee: Amount) -> Transaction { 654 Transaction::Burn { 655 inputs: vec![TxInput { 656 outpoint: OutPoint { 657 txid: format!("{signature:0<64}"), 658 index: 0, 659 }, 660 owner: "owner".to_string(), 661 signature: signature.to_string(), 662 }], 663 change: vec![TxOutput { 664 address: "owner".to_string(), 665 amount: 1, 666 }], 667 amount: 1, 668 fee, 669 anchor: None, 670 signature: signature.to_string(), 671 } 672 } 673 674 fn funded_ledger(wallets: &[Wallet]) -> Ledger { 675 let allocations = wallets 676 .iter() 677 .map(|wallet| (wallet.address().to_string(), 10 * MICRO_IUNA)) 678 .collect::<BTreeMap<_, _>>(); 679 let genesis_burns = wallets 680 .iter() 681 .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) 682 .collect::<Vec<_>>(); 683 Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap() 684 } 685 686 #[test] 687 fn burn_quorum_depends_on_rank_and_available_committee() { 688 let ledger = ledger(); 689 690 assert_eq!( 691 ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 5), 692 2 693 ); 694 assert_eq!( 695 ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 4), 696 2 697 ); 698 assert_eq!( 699 ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 3), 700 2 701 ); 702 assert_eq!( 703 ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 2), 704 1 705 ); 706 assert_eq!( 707 ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 5), 708 3 709 ); 710 assert_eq!( 711 ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 4), 712 2 713 ); 714 assert_eq!( 715 ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 3), 716 2 717 ); 718 assert_eq!( 719 ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 1, 5), 720 1 721 ); 722 assert_eq!( 723 ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 1, 2), 724 1 725 ); 726 assert_eq!( 727 ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 2, 3), 728 0 729 ); 730 assert_eq!( 731 ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Recovery, 0, 3), 732 0 733 ); 734 assert_eq!( 735 ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 1), 736 0 737 ); 738 } 739 740 #[test] 741 fn finalizer_waits_until_the_required_burn_bundle_quorum_is_available() { 742 let wallets = (0..3) 743 .map(|index| Wallet::from_seed(&format!("bundle-quorum-wallet-{index}"))) 744 .collect::<Vec<_>>(); 745 let mut ledger = funded_ledger(&wallets); 746 ledger.launch_profile.burn_lineage_maturity_heights = 0; 747 for (index, wallet) in wallets.iter().enumerate() { 748 let outpoint = OutPoint { 749 txid: format!("{:064x}", index + 1), 750 index: 0, 751 }; 752 let root = UtxoLineageRoot { 753 outpoint: outpoint.clone(), 754 height: 0, 755 }; 756 ledger.lineage_values.insert(root.clone(), 10); 757 ledger.lineage_owners.insert( 758 root, 759 BTreeMap::from([( 760 wallet.address().to_string(), 761 BTreeMap::from([(outpoint, 10)]), 762 )]), 763 ); 764 } 765 let committee_size = ledger.burn_committee_for_next_ticket_block(0).len(); 766 assert!( 767 ledger.required_explicit_burn_signatures( 768 ledger.height() + 1, 769 FinalizerMode::Ticket, 770 0, 771 committee_size, 772 ) > 0 773 ); 774 775 let error = ledger 776 .validate_next_block_burn_bundles_for_finalizer_rank(0, Vec::new()) 777 .unwrap_err(); 778 779 assert!( 780 error 781 .to_string() 782 .contains("not enough burn bundle signatures collected") 783 ); 784 } 785 786 #[test] 787 fn only_post_activation_rank_zero_quorum_certifies_its_parent() { 788 let ledger = ledger(); 789 let mut block = ledger.tip().clone(); 790 block.height = 1_001; 791 block.finalizer_mode = FinalizerMode::Ticket; 792 block.finalizer_rank = 0; 793 block.burn_bundle_section.signatures = (1..=3) 794 .map(|slot| BurnBundleSignature { 795 slot, 796 member: format!("member-{slot}"), 797 reward_address: None, 798 signature: format!("signature-{slot}"), 799 }) 800 .collect(); 801 802 assert!(ledger.block_certifies_parent(&block, 5)); 803 804 block.height = OBJECTIVE_FINALITY_ACTIVATION_HEIGHT; 805 assert!(!ledger.block_certifies_parent(&block, 5)); 806 block.height += 1; 807 block.burn_bundle_section.signatures.pop(); 808 assert!(!ledger.block_certifies_parent(&block, 5)); 809 block 810 .burn_bundle_section 811 .signatures 812 .push(BurnBundleSignature { 813 slot: 3, 814 member: "member-3".to_string(), 815 reward_address: None, 816 signature: "signature-3".to_string(), 817 }); 818 block.finalizer_rank = 1; 819 assert!(!ledger.block_certifies_parent(&block, 5)); 820 } 821 822 #[test] 823 fn burn_bundle_section_deduplicates_burns_and_tracks_member_masks() { 824 let ledger = ledger(); 825 let high_fee_burn = burn("a", 10); 826 let low_fee_burn = burn("b", 1); 827 let bundles = vec![ 828 BurnBundle { 829 height: 1, 830 prev_hash: "parent".to_string(), 831 slot: 1, 832 member: "member-1".to_string(), 833 reward_address: None, 834 burns: vec![low_fee_burn.clone(), high_fee_burn.clone()], 835 burns_v2: Vec::new(), 836 signature: "sig-1".to_string(), 837 }, 838 BurnBundle { 839 height: 1, 840 prev_hash: "parent".to_string(), 841 slot: 2, 842 member: "member-2".to_string(), 843 reward_address: None, 844 burns: vec![high_fee_burn.clone()], 845 burns_v2: Vec::new(), 846 signature: "sig-2".to_string(), 847 }, 848 ]; 849 850 let section = ledger.burn_bundle_section_from_bundles(bundles).unwrap(); 851 852 assert_eq!(section.signatures.len(), 2); 853 assert_eq!(section.burns.len(), 2); 854 assert_eq!(section.burns[0].burn.signature(), high_fee_burn.signature()); 855 assert_eq!( 856 section.burns[0].bundle_mask, 857 burn_bundle_slot_mask(1).unwrap() | burn_bundle_slot_mask(2).unwrap() 858 ); 859 assert_eq!(section.burns[1].burn.signature(), low_fee_burn.signature()); 860 assert_eq!( 861 section.burns[1].bundle_mask, 862 burn_bundle_slot_mask(1).unwrap() 863 ); 864 865 let expanded = section.expand(1, "parent"); 866 assert_eq!(expanded[0].burns, vec![high_fee_burn.clone(), low_fee_burn]); 867 assert_eq!(expanded[1].burns, vec![high_fee_burn]); 868 } 869 870 #[test] 871 fn burn_bundle_rejects_different_burn_with_same_signature() { 872 let alice = Wallet::from_seed("bundle-match-alice"); 873 let bob = Wallet::from_seed("bundle-match-bob"); 874 let mut ledger = funded_ledger(&[alice.clone(), bob.clone()]); 875 let pending_burn = ledger.build_burn(&bob, 1, 1).unwrap(); 876 ledger.submit_transaction(pending_burn.clone()).unwrap(); 877 let member = ledger 878 .burn_committee_for_next_block() 879 .into_iter() 880 .find(|member| member.owner == alice.address()) 881 .expect("alice should be in the burn committee"); 882 let mut attested_burn = pending_burn.clone(); 883 if let Transaction::Burn { amount, .. } = &mut attested_burn { 884 *amount += 1; 885 } 886 let bundle = alice.burn_bundle(BurnBundlePayload { 887 height: ledger.height() + 1, 888 prev_hash: ledger.tip_hash().to_string(), 889 slot: member.slot, 890 member: alice.address().to_string(), 891 reward_address: None, 892 burns: vec![attested_burn], 893 burns_v2: Vec::new(), 894 }); 895 896 let error = ledger 897 .validate_next_block_burn_bundles(vec![bundle]) 898 .unwrap_err(); 899 900 assert!( 901 error 902 .to_string() 903 .contains("burn bundle references a burn that is not in the mempool") 904 ); 905 } 906 907 #[test] 908 fn burn_bundle_rejects_a_burn_queued_for_the_following_height() { 909 let member = Wallet::from_seed("future-burn-bundle-member"); 910 let mut ledger = funded_ledger(std::slice::from_ref(&member)); 911 let tip = ledger.chain.last_mut().unwrap(); 912 tip.height = super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1; 913 tip.prev_hash = "a".repeat(64); 914 tip.hash = "b".repeat(64); 915 let future_burn = ledger.build_burn(&member, 1, 1).unwrap(); 916 let bundle = member.burn_bundle(BurnBundlePayload { 917 height: ledger.height() + 1, 918 prev_hash: ledger.tip_hash().to_string(), 919 slot: 1, 920 member: member.address().to_string(), 921 reward_address: None, 922 burns: vec![future_burn], 923 burns_v2: Vec::new(), 924 }); 925 let committee = BTreeMap::from([( 926 1, 927 BurnCommitteeMember { 928 slot: 1, 929 root: "c".repeat(64), 930 owner: member.address().to_string(), 931 weight: 1, 932 }, 933 )]); 934 935 let error = ledger 936 .precheck_burn_bundle_for_block( 937 ledger.height() + 1, 938 ledger.tip_hash(), 939 &committee, 940 &bundle, 941 ) 942 .unwrap_err(); 943 944 assert!(error.to_string().contains("block grandparent")); 945 } 946 947 #[test] 948 fn invalid_committee_bundle_signature_is_rejected() { 949 let alice = Wallet::from_seed("bundle-signature-alice"); 950 let bob = Wallet::from_seed("bundle-signature-bob"); 951 let ledger = funded_ledger(&[alice.clone(), bob.clone()]); 952 let finalizer_address = ledger.expected_leader_for_next_block().unwrap(); 953 let finalizer = [&alice, &bob] 954 .into_iter() 955 .find(|wallet| wallet.address() == finalizer_address) 956 .unwrap(); 957 let mut bundle = ledger.test_burn_bundle(finalizer, Vec::new()); 958 let replacement = if bundle.signature.starts_with('0') { 959 "1" 960 } else { 961 "0" 962 }; 963 bundle.signature.replace_range(0..1, replacement); 964 965 assert!( 966 ledger 967 .validate_next_block_burn_bundles(vec![bundle]) 968 .unwrap_err() 969 .to_string() 970 .contains("signature") 971 ); 972 } 973 974 #[test] 975 fn block_bundle_validation_rejects_duplicate_slots() { 976 let alice = Wallet::from_seed("duplicate-slot-alice"); 977 let bob = Wallet::from_seed("duplicate-slot-bob"); 978 let ledger = funded_ledger(&[alice.clone(), bob.clone()]); 979 let finalizer_address = ledger.expected_leader_for_next_block().unwrap(); 980 let finalizer = [&alice, &bob] 981 .into_iter() 982 .find(|wallet| wallet.address() == finalizer_address) 983 .unwrap(); 984 let bundle = ledger.test_burn_bundle(finalizer, Vec::new()); 985 986 assert!( 987 ledger 988 .validate_next_block_burn_bundles(vec![bundle.clone(), bundle]) 989 .unwrap_err() 990 .to_string() 991 .contains("duplicate burn bundle slot") 992 ); 993 } 994 995 #[test] 996 fn attested_burn_omitted_from_block_is_rejected_without_consulting_mempool_policy() { 997 let alice = Wallet::from_seed("required-burn-alice"); 998 let bob = Wallet::from_seed("required-burn-bob"); 999 let mut ledger = funded_ledger(&[alice.clone(), bob.clone()]); 1000 let finalizer_address = ledger.expected_leader_for_next_block().unwrap(); 1001 let finalizer = [&alice, &bob] 1002 .into_iter() 1003 .find(|wallet| wallet.address() == finalizer_address) 1004 .unwrap(); 1005 let other = [&alice, &bob] 1006 .into_iter() 1007 .find(|wallet| wallet.address() != finalizer_address) 1008 .unwrap(); 1009 let pending_burn = ledger.build_burn(other, 1, 1).unwrap(); 1010 ledger.submit_transaction(pending_burn.clone()).unwrap(); 1011 let anchor = ledger.build_burn(finalizer, 1, 1).unwrap(); 1012 ledger.submit_transaction(anchor).unwrap(); 1013 let mut block = ledger 1014 .prepare_next_block(finalizer.address(), 1) 1015 .unwrap() 1016 .finish(finalizer, "unused-vdf".to_string()); 1017 let bundle = ledger.test_burn_bundle(finalizer, vec![pending_burn.clone()]); 1018 block.burn_bundle_section = ledger 1019 .burn_bundle_section_from_bundles(vec![bundle]) 1020 .unwrap(); 1021 block 1022 .transactions 1023 .retain(|transaction| transaction.signature() != pending_burn.signature()); 1024 1025 assert!( 1026 ledger 1027 .validate_burn_bundle_section_for_block(&block) 1028 .unwrap_err() 1029 .to_string() 1030 .contains("attested burn is not included") 1031 ); 1032 } 1033 }