ledger_ops.rs (34446B)
1 use std::collections::{BTreeMap, BTreeSet}; 2 3 use anyhow::{Context, Result, bail}; 4 5 use crate::compact::{ 6 CompactBlockContext, CompactBlockSizeBreakdown, compact_snapshot_fixed_prefix_size, 7 compact_varint_size, 8 }; 9 10 use super::hex::hex_hash; 11 use super::reveal::{BurnBundleSection, canonical_burn_bundle_hashes}; 12 use super::selection::{TransactionKind, fee_rate_key}; 13 use super::ticket::ticket_is_eligible_for_height; 14 use super::transaction::Transaction; 15 use super::vdf::vdf_solution_placeholder; 16 use super::{ 17 Amount, BURN_COMMITTEE_SIZE, Block, BlockSelection, BurnCommitteeMember, BurnTicket, 18 FinalizerMode, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, LeaderProof, LeaderProofPayload, Ledger, 19 MINE_REWARD, OutPoint, PUBLIC_KEY_BYTES, RECOVERY_BLOCK_DELAY_MS, SIGNATURE_BYTES, TxInput, 20 TxOutput, decode_hex_array, validate_address, validate_hash, validate_protocol_id, 21 validate_signature, 22 }; 23 24 pub(super) fn compact_block_context(ledger: &Ledger) -> &CompactBlockContext { 25 &ledger.compact_block_context 26 } 27 28 impl Ledger { 29 pub(crate) fn consensus_block_size_bytes(&self, block: &Block) -> Result<usize> { 30 self.compact_block_context.block_size_bytes(block) 31 } 32 33 pub(crate) fn storage_size_breakdowns( 34 &self, 35 requested_blocks: &[Block], 36 ) -> BTreeMap<String, CompactBlockSizeBreakdown> { 37 requested_blocks 38 .iter() 39 .filter_map(|block| { 40 self.compact_block_context 41 .stored_block_size_breakdown(&block.hash) 42 .cloned() 43 .or_else(|| self.compact_block_context.block_size_breakdown(block).ok()) 44 .map(|breakdown| (block.hash.clone(), breakdown)) 45 }) 46 .collect() 47 } 48 49 pub(crate) fn chain_storage_bytes_by_hash(&self) -> Result<BTreeMap<String, u64>> { 50 let mut cumulative_block_bytes = 0_u64; 51 let mut sizes = BTreeMap::new(); 52 let fixed_prefix_bytes = compact_snapshot_fixed_prefix_size( 53 &self.genesis_allocations, 54 self.initial_vdf_rounds, 55 &self.launch_profile, 56 )?; 57 for (index, block) in self.chain.iter().enumerate() { 58 let block_bytes = self 59 .compact_block_context 60 .stored_block_size_breakdown(&block.hash) 61 .with_context(|| { 62 format!( 63 "missing compact storage size for block {} at height {}", 64 block.hash, block.height 65 ) 66 })? 67 .total_bytes; 68 cumulative_block_bytes = cumulative_block_bytes 69 .checked_add(u64::try_from(block_bytes).context("block storage size overflows")?) 70 .context("cumulative block storage size overflows")?; 71 let prefix_bytes = fixed_prefix_bytes 72 .checked_add(compact_varint_size(index + 1)) 73 .context("snapshot prefix size overflows")?; 74 let chain_bytes = cumulative_block_bytes 75 .checked_add(u64::try_from(prefix_bytes).context("snapshot prefix size overflows")?) 76 .context("chain storage size overflows")?; 77 sizes.insert(block.hash.clone(), chain_bytes); 78 } 79 Ok(sizes) 80 } 81 } 82 83 pub(super) fn validate_genesis_allocations( 84 genesis_allocations: &BTreeMap<String, Amount>, 85 ) -> Result<()> { 86 for address in genesis_allocations.keys() { 87 validate_address(address, "genesis allocation")?; 88 } 89 Ok(()) 90 } 91 92 pub(super) fn validate_transaction_inputs(inputs: &[TxInput]) -> Result<()> { 93 for input in inputs { 94 validate_protocol_id(&input.outpoint.txid, "input outpoint txid")?; 95 validate_address(&input.owner, "input owner")?; 96 validate_signature(&input.signature, "input signature")?; 97 } 98 Ok(()) 99 } 100 101 pub(super) fn validate_transaction_outputs(outputs: &[TxOutput]) -> Result<()> { 102 for output in outputs { 103 validate_address(&output.address, "output recipient")?; 104 } 105 Ok(()) 106 } 107 108 pub(super) fn validate_genesis_burn_transaction(transaction: &Transaction) -> Result<()> { 109 let Transaction::Burn { 110 inputs, 111 change, 112 fee, 113 signature, 114 .. 115 } = transaction 116 else { 117 bail!("genesis only supports burn transactions"); 118 }; 119 if *fee != 0 { 120 bail!("genesis burn fee must be zero"); 121 } 122 validate_hash(signature, "genesis burn signature")?; 123 validate_transaction_outputs(change)?; 124 for input in inputs { 125 validate_hash(&input.outpoint.txid, "genesis burn input outpoint txid")?; 126 validate_address(&input.owner, "genesis burn input owner")?; 127 if input.signature != "genesis" { 128 bail!("genesis burn input signature is invalid"); 129 } 130 } 131 Ok(()) 132 } 133 134 pub(super) fn estimated_block_selection_size_bytes( 135 context: &CompactBlockContext, 136 selection: &BlockSelection, 137 finalizer_mode: FinalizerMode, 138 burn_bundle_section: &BurnBundleSection, 139 ) -> Result<usize> { 140 let has_leader_proof = finalizer_mode == FinalizerMode::Ticket; 141 let block = Block { 142 height: u64::MAX, 143 prev_hash: "f".repeat(64), 144 timestamp_ms: u64::MAX, 145 miner: "f".repeat(64), 146 reward_address: None, 147 reward_address_signature: None, 148 finalizer_mode, 149 finalizer_rank: 0, 150 reward: u64::MAX, 151 vdf_rounds: u64::MAX, 152 vdf_output: vdf_solution_placeholder(), 153 leader_proof: has_leader_proof.then(|| LeaderProof { 154 ticket_id: "f".repeat(64), 155 public_key: "f".repeat(64), 156 signature: "f".repeat(128), 157 }), 158 burn_bundle_section: burn_bundle_section.clone(), 159 transactions: selection.transactions.clone(), 160 transactions_v2: selection.transactions_v2.clone(), 161 hash: "f".repeat(64), 162 }; 163 context.block_size_bytes(&block) 164 } 165 166 pub(super) fn ensure_transaction_fits_empty_block( 167 context: &CompactBlockContext, 168 transaction: &Transaction, 169 max_block_bytes: usize, 170 ) -> Result<()> { 171 let selection = BlockSelection { 172 transactions: vec![transaction.clone()], 173 transactions_v2: Vec::new(), 174 }; 175 if estimated_block_selection_size_bytes( 176 context, 177 &selection, 178 FinalizerMode::Ticket, 179 &BurnBundleSection::default(), 180 )? > max_block_bytes 181 { 182 bail!("transaction exceeds max block size"); 183 } 184 Ok(()) 185 } 186 187 pub(super) fn ensure_transaction_v2_fits_empty_block( 188 context: &CompactBlockContext, 189 envelope: &str, 190 max_block_bytes: usize, 191 ) -> Result<()> { 192 let selection = BlockSelection { 193 transactions: Vec::new(), 194 transactions_v2: vec![envelope.to_string()], 195 }; 196 if estimated_block_selection_size_bytes( 197 context, 198 &selection, 199 FinalizerMode::Ticket, 200 &BurnBundleSection::default(), 201 )? > max_block_bytes 202 { 203 bail!("transaction v2 exceeds max block size"); 204 } 205 Ok(()) 206 } 207 208 pub(super) fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> { 209 let Some(proof) = &block.leader_proof else { 210 bail!("block is missing leader proof"); 211 }; 212 if proof.public_key != block.miner { 213 bail!("leader proof public key does not match block finalizer"); 214 } 215 let ticket = tickets 216 .iter() 217 .find(|ticket| { 218 ticket.id == proof.ticket_id && ticket_is_eligible_for_height(ticket, block.height) 219 }) 220 .context("leader ticket is not pending for this height")?; 221 if ticket.owner != block.miner { 222 bail!("leader ticket owner does not match block finalizer"); 223 } 224 if ticket.eligible_from_height > block.height { 225 bail!("leader ticket is not mature"); 226 } 227 228 let payload = LeaderProofPayload { 229 height: block.height, 230 prev_hash: block.prev_hash.clone(), 231 finalizer_rank: block.finalizer_rank, 232 vdf_output: block.vdf_output.clone(), 233 ticket_id: ticket.id.clone(), 234 ticket_amount: ticket.amount, 235 ticket_owner: ticket.owner.clone(), 236 }; 237 verify_leader_signature(proof, &payload)?; 238 Ok(()) 239 } 240 241 pub(super) fn verify_leader_signature( 242 proof: &LeaderProof, 243 payload: &LeaderProofPayload, 244 ) -> Result<()> { 245 verify_address_signature( 246 &proof.public_key, 247 &payload.canonical(), 248 &proof.signature, 249 "leader", 250 ) 251 } 252 253 pub(super) fn verify_address_signature( 254 address: &str, 255 payload: &str, 256 signature: &str, 257 label: &str, 258 ) -> Result<()> { 259 let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(address) 260 .with_context(|| format!("invalid {label} public key {address}"))?; 261 let signature = decode_hex_array::<SIGNATURE_BYTES>(signature) 262 .with_context(|| format!("invalid {label} signature hex"))?; 263 super::verify_ed25519(&public_key, payload.as_bytes(), &signature, label) 264 } 265 266 pub(super) fn vdf_seed_for_child( 267 prev_hash: &str, 268 height: u64, 269 bundle_hashes: &[String; super::BURN_COMMITTEE_SIZE], 270 content_commitment: &str, 271 ) -> String { 272 if height < GRINDING_RESISTANCE_ACTIVATION_HEIGHT { 273 return hex_hash(format!( 274 "iuna-vdf-child:{prev_hash}:{height}:{}", 275 canonical_burn_bundle_hashes(bundle_hashes) 276 )); 277 } 278 hex_hash(format!( 279 "iuna-vdf-child-v2:{prev_hash}:{height}:{content_commitment}:{}", 280 canonical_burn_bundle_hashes(bundle_hashes) 281 )) 282 } 283 284 pub(super) fn recovery_vdf_seed_for_child( 285 prev_hash: &str, 286 height: u64, 287 timestamp_ms: u64, 288 bundle_hashes: &[String; super::BURN_COMMITTEE_SIZE], 289 content_commitment: &str, 290 ) -> String { 291 if height < GRINDING_RESISTANCE_ACTIVATION_HEIGHT { 292 return hex_hash(format!( 293 "iuna-recovery-vdf-child:{prev_hash}:{height}:{timestamp_ms}:{}", 294 canonical_burn_bundle_hashes(bundle_hashes) 295 )); 296 } 297 hex_hash(format!( 298 "iuna-recovery-vdf-child-v2:{prev_hash}:{height}:{timestamp_ms}:{content_commitment}:{}", 299 canonical_burn_bundle_hashes(bundle_hashes) 300 )) 301 } 302 303 #[allow(clippy::too_many_arguments)] 304 pub(super) fn vdf_content_commitment( 305 height: u64, 306 prev_hash: &str, 307 miner: &str, 308 reward_address: Option<&str>, 309 finalizer_mode: FinalizerMode, 310 finalizer_rank: u32, 311 reward: Amount, 312 vdf_rounds: u64, 313 leader_ticket_id: Option<&str>, 314 burn_bundle_section: &BurnBundleSection, 315 transactions: &[Transaction], 316 transactions_v2: &[String], 317 ) -> String { 318 let mode = match finalizer_mode { 319 FinalizerMode::Ticket => "ticket", 320 FinalizerMode::Recovery => "recovery", 321 }; 322 let ticket_id = leader_ticket_id.unwrap_or("none"); 323 let transaction_hash = hex_hash(format!( 324 "iuna-vdf-transactions-v1:{}", 325 transactions 326 .iter() 327 .map(Transaction::canonical) 328 .collect::<Vec<_>>() 329 .join("|") 330 )); 331 let burn_section_hash = hex_hash(format!( 332 "iuna-vdf-burn-section-v1:{}", 333 burn_bundle_section.canonical() 334 )); 335 if let Some(reward_address) = reward_address { 336 let transaction_v2_hash = hex_hash(format!( 337 "iuna-vdf-transactions-v2:{}", 338 transactions_v2.join("|") 339 )); 340 return hex_hash(format!( 341 "iuna-vdf-content-v3:{height}:{prev_hash}:{miner}:{reward_address}:{mode}:{finalizer_rank}:{reward}:{vdf_rounds}:{ticket_id}:{transaction_hash}:{transaction_v2_hash}:{burn_section_hash}" 342 )); 343 } 344 if transactions_v2.is_empty() { 345 return hex_hash(format!( 346 "iuna-vdf-content-v1:{height}:{prev_hash}:{miner}:{mode}:{finalizer_rank}:{reward}:{vdf_rounds}:{ticket_id}:{transaction_hash}:{burn_section_hash}" 347 )); 348 } 349 let transaction_v2_hash = hex_hash(format!( 350 "iuna-vdf-transactions-v2:{}", 351 transactions_v2.join("|") 352 )); 353 hex_hash(format!( 354 "iuna-vdf-content-v2:{height}:{prev_hash}:{miner}:{mode}:{finalizer_rank}:{reward}:{vdf_rounds}:{ticket_id}:{transaction_hash}:{transaction_v2_hash}:{burn_section_hash}" 355 )) 356 } 357 358 pub(super) fn apply_transaction( 359 transaction: &Transaction, 360 utxos: &mut BTreeMap<OutPoint, TxOutput>, 361 signing_domain: &super::TransactionSigningDomain, 362 ) -> Result<()> { 363 transaction.verify_signature(signing_domain)?; 364 match transaction { 365 Transaction::Mine { recipient, .. } => { 366 let output = TxOutput { 367 address: recipient.clone(), 368 amount: MINE_REWARD, 369 }; 370 ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; 371 utxos.insert( 372 OutPoint { 373 txid: transaction.signature().to_string(), 374 index: 0, 375 }, 376 output, 377 ); 378 return Ok(()); 379 } 380 Transaction::Transfer { .. } | Transaction::Burn { .. } => {} 381 } 382 ensure_single_input_owner(transaction)?; 383 let input_total = spend_inputs(transaction, utxos)?; 384 let outputs = transaction.outputs(); 385 let output_total = outputs.iter().try_fold(0_u64, |total, output| { 386 total 387 .checked_add(output.amount) 388 .context("transaction outputs overflow") 389 })?; 390 let required = output_total 391 .checked_add(transaction.fee()) 392 .context("transaction outputs plus fee overflow")? 393 .checked_add(match transaction { 394 Transaction::Burn { amount, .. } => *amount, 395 Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, 396 }) 397 .context("transaction outputs plus burn overflow")?; 398 if input_total != required { 399 bail!("transaction inputs do not balance outputs, burn, and fee"); 400 } 401 ensure_outputs_do_not_overflow(utxos, &outputs)?; 402 for (index, output) in outputs.iter().enumerate() { 403 utxos.insert( 404 OutPoint { 405 txid: transaction.signature().to_string(), 406 index: index as u32, 407 }, 408 output.clone(), 409 ); 410 } 411 Ok(()) 412 } 413 414 pub(super) fn validate_block_fee_policy(block: &Block) -> Result<()> { 415 for transaction in &block.transactions { 416 if transaction.fee() == 0 { 417 bail!("block transaction fee must be greater than zero"); 418 } 419 } 420 Ok(()) 421 } 422 423 pub(super) fn fee_reward(transactions: &[Transaction]) -> Result<Amount> { 424 transactions.iter().try_fold(0_u64, |total, tx| { 425 total.checked_add(tx.fee()).context("block fees overflow") 426 }) 427 } 428 429 pub(super) fn block_reward( 430 transactions: &[Transaction], 431 additional_finalizer_fees: Amount, 432 ) -> Result<Amount> { 433 fee_reward(transactions)? 434 .checked_add(additional_finalizer_fees) 435 .context("block reward overflow") 436 } 437 438 pub(super) fn spend_inputs( 439 transaction: &Transaction, 440 utxos: &mut BTreeMap<OutPoint, TxOutput>, 441 ) -> Result<Amount> { 442 let mut seen = BTreeSet::new(); 443 let mut total = 0_u64; 444 for input in transaction.inputs() { 445 if !seen.insert(input.outpoint.clone()) { 446 bail!("duplicate input in transaction"); 447 } 448 let output = utxos.remove(&input.outpoint).with_context(|| { 449 format!("transaction spends missing output {}", input.outpoint.id()) 450 })?; 451 if output.address != input.owner { 452 bail!("transaction input owner does not match spent output"); 453 } 454 total = total 455 .checked_add(output.amount) 456 .context("transaction input total overflows")?; 457 } 458 Ok(total) 459 } 460 461 pub(super) fn apply_spendable_pending_transaction( 462 transaction: &Transaction, 463 utxos: &mut BTreeMap<OutPoint, TxOutput>, 464 signing_domain: &super::TransactionSigningDomain, 465 ) -> Result<()> { 466 if matches!(transaction, Transaction::Mine { .. }) { 467 bail!("pending mine outputs are not spendable"); 468 } 469 transaction.verify_signature(signing_domain)?; 470 ensure_single_input_owner(transaction)?; 471 let input_total = transaction_input_total(transaction, utxos)?; 472 let outputs = transaction.outputs(); 473 let output_total = outputs.iter().try_fold(0_u64, |total, output| { 474 total 475 .checked_add(output.amount) 476 .context("transaction outputs overflow") 477 })?; 478 let required = output_total 479 .checked_add(transaction.fee()) 480 .context("transaction outputs plus fee overflow")? 481 .checked_add(match transaction { 482 Transaction::Burn { amount, .. } => *amount, 483 Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, 484 }) 485 .context("transaction outputs plus burn overflow")?; 486 if input_total != required { 487 bail!("transaction inputs do not balance outputs, burn, and fee"); 488 } 489 ensure_outputs_do_not_overflow(utxos, &outputs)?; 490 for input in transaction.inputs() { 491 utxos.remove(&input.outpoint); 492 } 493 for (index, output) in outputs.iter().enumerate() { 494 utxos.insert( 495 OutPoint { 496 txid: transaction.signature().to_string(), 497 index: index as u32, 498 }, 499 output.clone(), 500 ); 501 } 502 Ok(()) 503 } 504 505 pub(super) fn transaction_input_total( 506 transaction: &Transaction, 507 utxos: &BTreeMap<OutPoint, TxOutput>, 508 ) -> Result<Amount> { 509 let mut seen = BTreeSet::new(); 510 let mut total = 0_u64; 511 for input in transaction.inputs() { 512 if !seen.insert(input.outpoint.clone()) { 513 bail!("duplicate input in transaction"); 514 } 515 let output = utxos.get(&input.outpoint).with_context(|| { 516 format!("transaction spends missing output {}", input.outpoint.id()) 517 })?; 518 if output.address != input.owner { 519 bail!("transaction input owner does not match spent output"); 520 } 521 total = total 522 .checked_add(output.amount) 523 .context("transaction input total overflows")?; 524 } 525 Ok(total) 526 } 527 528 pub(super) fn transaction_has_missing_inputs( 529 transaction: &Transaction, 530 utxos: &BTreeMap<OutPoint, TxOutput>, 531 ) -> bool { 532 transaction 533 .inputs() 534 .iter() 535 .any(|input| !utxos.contains_key(&input.outpoint)) 536 } 537 538 pub(super) fn ensure_single_input_owner(transaction: &Transaction) -> Result<()> { 539 if matches!(transaction, Transaction::Mine { .. }) { 540 return Ok(()); 541 } 542 ensure_single_input_owner_for_inputs(transaction.inputs()) 543 } 544 545 pub(super) fn ensure_single_input_owner_for_inputs(inputs: &[TxInput]) -> Result<()> { 546 let Some(first) = inputs.first() else { 547 bail!("transaction has no inputs"); 548 }; 549 if inputs.iter().any(|input| input.owner != first.owner) { 550 bail!("transaction inputs must have one owner"); 551 } 552 Ok(()) 553 } 554 555 pub(super) fn credit_reward_outputs( 556 utxos: &mut BTreeMap<OutPoint, TxOutput>, 557 block: &Block, 558 committee: &[BurnCommitteeMember], 559 ) -> Result<()> { 560 let outputs = reward_outputs_for_block(block, committee); 561 let tx_outputs = outputs 562 .iter() 563 .map(|(_, output)| output.clone()) 564 .collect::<Vec<_>>(); 565 ensure_outputs_do_not_overflow(utxos, &tx_outputs)?; 566 for (outpoint, output) in outputs { 567 utxos.insert(outpoint, output); 568 } 569 Ok(()) 570 } 571 572 pub(super) fn credit_reward_output( 573 utxos: &mut BTreeMap<OutPoint, TxOutput>, 574 block: &Block, 575 ) -> Result<()> { 576 credit_reward_outputs(utxos, block, &[]) 577 } 578 579 pub fn reward_outputs_for_block( 580 block: &Block, 581 committee: &[BurnCommitteeMember], 582 ) -> Vec<(OutPoint, TxOutput)> { 583 if block.reward == 0 { 584 return Vec::new(); 585 } 586 587 let mut outputs = Vec::new(); 588 let committee_slots = reward_committee_slots(block); 589 let committee_members = committee_slots 590 .into_iter() 591 .filter_map(|slot| { 592 committee 593 .iter() 594 .find(|member| member.slot == slot && member.owner != block.miner) 595 }) 596 .collect::<Vec<_>>(); 597 let committee_pool = if committee_members.is_empty() { 598 0 599 } else { 600 block.reward / 2 601 }; 602 let finalizer_amount = block.reward.saturating_sub(committee_pool); 603 604 if finalizer_amount > 0 { 605 outputs.push(( 606 reward_outpoint(&block.hash), 607 TxOutput { 608 address: block 609 .reward_address 610 .clone() 611 .unwrap_or_else(|| block.miner.clone()), 612 amount: finalizer_amount, 613 }, 614 )); 615 } 616 617 let mut remaining = committee_pool; 618 for (index, member) in committee_members.iter().enumerate() { 619 let members_left = committee_members.len() - index; 620 let amount = if members_left == 1 { 621 remaining 622 } else { 623 remaining / members_left as u64 624 }; 625 remaining = remaining.saturating_sub(amount); 626 if amount == 0 { 627 continue; 628 } 629 outputs.push(( 630 committee_reward_outpoint(&block.hash, member.slot), 631 TxOutput { 632 address: block 633 .burn_bundle_section 634 .signatures 635 .iter() 636 .find(|signature| signature.slot == member.slot) 637 .and_then(|signature| signature.reward_address.clone()) 638 .unwrap_or_else(|| member.owner.clone()), 639 amount, 640 }, 641 )); 642 } 643 644 outputs 645 } 646 647 fn reward_committee_slots(block: &Block) -> Vec<u8> { 648 match block.finalizer_mode { 649 FinalizerMode::Ticket if block.finalizer_rank <= 1 => block 650 .burn_bundle_section 651 .signatures 652 .iter() 653 .map(|signature| signature.slot) 654 .collect(), 655 FinalizerMode::Ticket | FinalizerMode::Recovery => Vec::new(), 656 } 657 } 658 659 pub(super) fn ensure_outputs_do_not_overflow( 660 utxos: &BTreeMap<OutPoint, TxOutput>, 661 outputs: &[TxOutput], 662 ) -> Result<()> { 663 let mut balances = BTreeMap::new(); 664 for output in utxos.values() { 665 let balance = balances.entry(output.address.clone()).or_insert(0_u64); 666 *balance = balance 667 .checked_add(output.amount) 668 .with_context(|| format!("balance overflow for {}", output.address))?; 669 } 670 for output in outputs { 671 let balance = balances.entry(output.address.clone()).or_insert(0_u64); 672 *balance = balance 673 .checked_add(output.amount) 674 .with_context(|| format!("balance overflow for {}", output.address))?; 675 } 676 Ok(()) 677 } 678 679 pub(super) fn ensure_block_has_burn( 680 transactions: &[Transaction], 681 transactions_v2: &[String], 682 ) -> Result<()> { 683 let has_v2_burn = transactions_v2.iter().any(|envelope| { 684 super::decode_hex(envelope) 685 .ok() 686 .and_then(|encoded| super::TransactionV2::decode(&encoded).ok()) 687 .is_some_and(|(_, transaction)| transaction.is_burn()) 688 }); 689 if !transactions.iter().any(Transaction::is_burn) && !has_v2_burn { 690 bail!("block must include at least one burn transaction"); 691 } 692 Ok(()) 693 } 694 695 pub(super) fn ensure_block_has_burn_from( 696 transactions: &[Transaction], 697 transactions_v2: &[String], 698 miner: &str, 699 ) -> Result<()> { 700 let has_legacy_burn = transactions 701 .iter() 702 .any(|transaction| transaction.is_burn() && transaction.sender() == miner); 703 let has_v2_burn = transactions_v2.iter().any(|envelope| { 704 super::decode_hex(envelope) 705 .ok() 706 .and_then(|encoded| super::TransactionV2::decode(&encoded).ok()) 707 .and_then(|(_, transaction)| transaction.burn_legacy_owner().ok().flatten()) 708 .is_some_and(|owner| owner == miner) 709 }); 710 if !has_legacy_burn && !has_v2_burn { 711 bail!("recovery block must include a burn from the finalizer"); 712 } 713 Ok(()) 714 } 715 716 pub(super) fn ensure_valid_recovery_block(block: &Block, parent: &Block) -> Result<()> { 717 if block.finalizer_rank != 0 { 718 bail!("recovery block finalizer rank must be 0"); 719 } 720 if block.leader_proof.is_some() { 721 bail!("recovery block must not carry a leader proof"); 722 } 723 let min_timestamp = parent.timestamp_ms.saturating_add(RECOVERY_BLOCK_DELAY_MS); 724 if block.timestamp_ms < min_timestamp { 725 bail!("recovery block is not available before timestamp {min_timestamp}"); 726 } 727 ensure_block_has_burn_from(&block.transactions, &block.transactions_v2, &block.miner) 728 } 729 730 pub(super) fn best_selectable_transaction_index( 731 transactions: &[Transaction], 732 utxos: &BTreeMap<OutPoint, TxOutput>, 733 required_kind: Option<TransactionKind>, 734 signing_domain: &super::TransactionSigningDomain, 735 ) -> Option<usize> { 736 transactions 737 .iter() 738 .enumerate() 739 .filter(|(_, tx)| match required_kind { 740 Some(TransactionKind::Burn) => tx.is_burn(), 741 None => true, 742 }) 743 .filter(|(_, tx)| { 744 let mut utxos = utxos.clone(); 745 apply_transaction(tx, &mut utxos, signing_domain).is_ok() 746 }) 747 .max_by(|(_, left), (_, right)| { 748 fee_rate_key(left) 749 .cmp(&fee_rate_key(right)) 750 .then_with(|| left.fee().cmp(&right.fee())) 751 .then_with(|| left.is_burn().cmp(&right.is_burn())) 752 .then_with(|| right.signature().cmp(left.signature())) 753 }) 754 .map(|(index, _)| index) 755 } 756 757 pub(super) fn best_selectable_burn_from_index( 758 transactions: &[Transaction], 759 utxos: &BTreeMap<OutPoint, TxOutput>, 760 owner: &str, 761 signing_domain: &super::TransactionSigningDomain, 762 ) -> Option<usize> { 763 transactions 764 .iter() 765 .enumerate() 766 .filter(|(_, tx)| tx.is_burn() && tx.sender() == owner) 767 .filter(|(_, tx)| { 768 let mut utxos = utxos.clone(); 769 apply_transaction(tx, &mut utxos, signing_domain).is_ok() 770 }) 771 .max_by(|(_, left), (_, right)| { 772 fee_rate_key(left) 773 .cmp(&fee_rate_key(right)) 774 .then_with(|| left.fee().cmp(&right.fee())) 775 .then_with(|| right.signature().cmp(left.signature())) 776 }) 777 .map(|(index, _)| index) 778 } 779 780 pub(super) fn reward_outpoint(block_hash: &str) -> OutPoint { 781 OutPoint { 782 txid: block_hash.to_string(), 783 index: u32::MAX, 784 } 785 } 786 787 fn committee_reward_outpoint(block_hash: &str, slot: u8) -> OutPoint { 788 let slot = usize::from(slot).min(BURN_COMMITTEE_SIZE.saturating_sub(1)); 789 OutPoint { 790 txid: block_hash.to_string(), 791 index: u32::MAX.saturating_sub(slot as u32), 792 } 793 } 794 795 #[cfg(test)] 796 mod tests { 797 use super::*; 798 use crate::domain::{BurnBundleSection, BurnBundleSignature}; 799 800 fn reward_block(finalizer_mode: FinalizerMode, finalizer_rank: u32, reward: Amount) -> Block { 801 let mut block = Block { 802 height: 1, 803 prev_hash: "p".repeat(64), 804 timestamp_ms: 1, 805 miner: "finalizer".to_string(), 806 reward_address: None, 807 reward_address_signature: None, 808 finalizer_mode, 809 finalizer_rank, 810 reward, 811 vdf_rounds: 1, 812 vdf_output: "out".to_string(), 813 leader_proof: None, 814 burn_bundle_section: BurnBundleSection::default(), 815 transactions: Vec::new(), 816 transactions_v2: Vec::new(), 817 hash: "h".repeat(64), 818 }; 819 block.hash = block.compute_hash(); 820 block 821 } 822 823 fn committee_member(slot: u8, owner: &str) -> BurnCommitteeMember { 824 BurnCommitteeMember { 825 slot, 826 root: format!("root-{slot}"), 827 owner: owner.to_string(), 828 weight: 1, 829 } 830 } 831 832 fn attest(block: &mut Block, slots: &[u8]) { 833 block.burn_bundle_section.signatures = slots 834 .iter() 835 .map(|slot| BurnBundleSignature { 836 slot: *slot, 837 member: format!("committee-{slot}"), 838 reward_address: None, 839 signature: format!("signature-{slot}"), 840 }) 841 .collect(); 842 } 843 844 fn output_amount(outputs: &[(OutPoint, TxOutput)], owner: &str) -> Amount { 845 outputs 846 .iter() 847 .filter(|(_, output)| output.address == owner) 848 .map(|(_, output)| output.amount) 849 .sum() 850 } 851 852 #[test] 853 fn activated_rewards_use_explicit_hybrid_payout_addresses() { 854 let mut block = reward_block(FinalizerMode::Ticket, 0, 100); 855 block.height = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT; 856 block.reward_address = Some("finalizer-hybrid".to_string()); 857 block.burn_bundle_section.signatures = vec![BurnBundleSignature { 858 slot: 1, 859 member: "committee".to_string(), 860 reward_address: Some("committee-hybrid".to_string()), 861 signature: "signature".to_string(), 862 }]; 863 let committee = vec![ 864 committee_member(0, "finalizer"), 865 committee_member(1, "committee"), 866 ]; 867 868 let outputs = reward_outputs_for_block(&block, &committee); 869 870 assert_eq!(output_amount(&outputs, "finalizer-hybrid"), 50); 871 assert_eq!(output_amount(&outputs, "committee-hybrid"), 50); 872 assert_eq!(output_amount(&outputs, "finalizer"), 0); 873 assert_eq!(output_amount(&outputs, "committee"), 0); 874 } 875 876 #[test] 877 fn rank_zero_splits_half_to_two_extra_committee_members() { 878 let mut block = reward_block(FinalizerMode::Ticket, 0, 100); 879 attest(&mut block, &[1, 2]); 880 let committee = vec![ 881 committee_member(0, "finalizer"), 882 committee_member(1, "committee-2"), 883 committee_member(2, "committee-3"), 884 ]; 885 886 let outputs = reward_outputs_for_block(&block, &committee); 887 888 assert_eq!(output_amount(&outputs, "finalizer"), 50); 889 assert_eq!(output_amount(&outputs, "committee-2"), 25); 890 assert_eq!(output_amount(&outputs, "committee-3"), 25); 891 assert!( 892 outputs 893 .iter() 894 .any(|(outpoint, _)| outpoint.index == u32::MAX) 895 ); 896 assert!( 897 outputs 898 .iter() 899 .any(|(outpoint, _)| outpoint.index == u32::MAX - 1) 900 ); 901 assert!( 902 outputs 903 .iter() 904 .any(|(outpoint, _)| outpoint.index == u32::MAX - 2) 905 ); 906 } 907 908 #[test] 909 fn rank_zero_gives_committee_half_to_the_only_available_extra_member() { 910 let mut block = reward_block(FinalizerMode::Ticket, 0, 101); 911 attest(&mut block, &[1]); 912 let committee = vec![ 913 committee_member(0, "finalizer"), 914 committee_member(1, "committee-2"), 915 ]; 916 917 let outputs = reward_outputs_for_block(&block, &committee); 918 919 assert_eq!(output_amount(&outputs, "finalizer"), 51); 920 assert_eq!(output_amount(&outputs, "committee-2"), 50); 921 } 922 923 #[test] 924 fn rank_one_splits_with_the_member_that_attested() { 925 let mut block = reward_block(FinalizerMode::Ticket, 1, 100); 926 attest(&mut block, &[2]); 927 let committee = vec![ 928 committee_member(0, "finalizer"), 929 committee_member(1, "committee-1"), 930 committee_member(2, "committee-2"), 931 ]; 932 933 let outputs = reward_outputs_for_block(&block, &committee); 934 935 assert_eq!(output_amount(&outputs, "finalizer"), 50); 936 assert_eq!(output_amount(&outputs, "committee-2"), 50); 937 assert_eq!(output_amount(&outputs, "committee-1"), 0); 938 } 939 940 #[test] 941 fn rank_two_and_recovery_pay_the_finalizer_only() { 942 let committee = vec![ 943 committee_member(1, "committee-2"), 944 committee_member(2, "committee-3"), 945 ]; 946 947 let rank_two = reward_block(FinalizerMode::Ticket, 2, 100); 948 let recovery = reward_block(FinalizerMode::Recovery, 0, 100); 949 950 assert_eq!( 951 output_amount( 952 &reward_outputs_for_block(&rank_two, &committee), 953 "finalizer" 954 ), 955 100 956 ); 957 assert_eq!(reward_outputs_for_block(&rank_two, &committee).len(), 1); 958 assert_eq!( 959 output_amount( 960 &reward_outputs_for_block(&recovery, &committee), 961 "finalizer" 962 ), 963 100 964 ); 965 assert_eq!(reward_outputs_for_block(&recovery, &committee).len(), 1); 966 } 967 968 #[test] 969 fn finalizer_and_committee_reward_outputs_do_not_create_lineage() { 970 let mut block = reward_block(FinalizerMode::Ticket, 0, 100); 971 attest(&mut block, &[1]); 972 let committee = vec![ 973 committee_member(0, "finalizer"), 974 committee_member(1, "committee-1"), 975 ]; 976 let mut utxos = BTreeMap::new(); 977 let utxo_lineage = BTreeMap::<OutPoint, super::super::UtxoLineageRoot>::new(); 978 let lineage_values = BTreeMap::<super::super::UtxoLineageRoot, Amount>::new(); 979 980 credit_reward_outputs(&mut utxos, &block, &committee).unwrap(); 981 982 assert_eq!(utxos.len(), 2); 983 assert!( 984 utxos 985 .keys() 986 .all(|outpoint| !utxo_lineage.contains_key(outpoint)) 987 ); 988 assert!(lineage_values.is_empty()); 989 } 990 991 #[test] 992 fn recovery_vdf_seed_binds_timestamp_while_ticket_seed_does_not() { 993 let hashes = std::array::from_fn(super::super::default_burn_bundle_hash); 994 let ticket_seed = vdf_seed_for_child(&"a".repeat(64), 42, &hashes, "content"); 995 let recovery_at_one = 996 recovery_vdf_seed_for_child(&"a".repeat(64), 42, 1, &hashes, "content"); 997 let recovery_at_two = 998 recovery_vdf_seed_for_child(&"a".repeat(64), 42, 2, &hashes, "content"); 999 1000 assert_ne!(ticket_seed, recovery_at_one); 1001 assert_ne!(recovery_at_one, recovery_at_two); 1002 assert_eq!( 1003 ticket_seed, 1004 vdf_seed_for_child(&"a".repeat(64), 42, &hashes, "content") 1005 ); 1006 assert_eq!( 1007 ticket_seed, 1008 vdf_seed_for_child(&"a".repeat(64), 42, &hashes, "different-content") 1009 ); 1010 1011 let activated = GRINDING_RESISTANCE_ACTIVATION_HEIGHT; 1012 assert_ne!( 1013 vdf_seed_for_child(&"a".repeat(64), activated, &hashes, "content"), 1014 vdf_seed_for_child(&"a".repeat(64), activated, &hashes, "different-content") 1015 ); 1016 } 1017 }