block.rs (22296B)
1 use std::collections::BTreeMap; 2 3 use anyhow::{Context, Result}; 4 use serde::{Deserialize, Serialize}; 5 6 use super::{ 7 Amount, BURN_COMMITTEE_SIZE, BurnBundleSection, BurnTicket, LaunchProfile, LeaderScore, 8 Transaction, Wallet, hex_hash, recovery_vdf_seed_for_child, vdf_content_commitment, 9 vdf_seed_for_child, 10 }; 11 12 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 13 pub struct Block { 14 pub height: u64, 15 pub prev_hash: String, 16 pub timestamp_ms: u64, 17 pub miner: String, 18 #[serde(default, skip_serializing_if = "Option::is_none")] 19 pub reward_address: Option<String>, 20 #[serde(default, skip_serializing_if = "Option::is_none")] 21 pub reward_address_signature: Option<String>, 22 #[serde(default)] 23 pub finalizer_mode: FinalizerMode, 24 #[serde(default)] 25 pub finalizer_rank: u32, 26 pub reward: Amount, 27 pub vdf_rounds: u64, 28 pub vdf_output: String, 29 pub leader_proof: Option<LeaderProof>, 30 #[serde(default)] 31 pub burn_bundle_section: BurnBundleSection, 32 pub transactions: Vec<Transaction>, 33 /// Canonical hex-encoded transaction-v2 wire envelopes. 34 #[serde(default, skip_serializing_if = "Vec::is_empty")] 35 pub transactions_v2: Vec<String>, 36 pub hash: String, 37 } 38 39 #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] 40 #[serde(rename_all = "snake_case")] 41 pub enum FinalizerMode { 42 #[default] 43 Ticket, 44 Recovery, 45 } 46 47 impl Block { 48 pub fn compute_hash(&self) -> String { 49 hex_hash(format!( 50 "block:{}:{}:{}", 51 self.content_hash(), 52 self.vdf_seed(), 53 self.vdf_output, 54 )) 55 } 56 57 pub fn vdf_seed(&self) -> String { 58 let bundle_hashes = self.burn_bundle_hashes(); 59 let content_commitment = self.vdf_content_commitment(); 60 match self.finalizer_mode { 61 FinalizerMode::Ticket => vdf_seed_for_child( 62 &self.prev_hash, 63 self.height, 64 &bundle_hashes, 65 &content_commitment, 66 ), 67 FinalizerMode::Recovery => recovery_vdf_seed_for_child( 68 &self.prev_hash, 69 self.height, 70 self.timestamp_ms, 71 &bundle_hashes, 72 &content_commitment, 73 ), 74 } 75 } 76 77 fn vdf_content_commitment(&self) -> String { 78 vdf_content_commitment( 79 self.height, 80 &self.prev_hash, 81 &self.miner, 82 self.reward_address.as_deref(), 83 self.finalizer_mode, 84 self.finalizer_rank, 85 self.reward, 86 self.vdf_rounds, 87 self.leader_proof 88 .as_ref() 89 .map(|proof| proof.ticket_id.as_str()), 90 &self.burn_bundle_section, 91 &self.transactions, 92 &self.transactions_v2, 93 ) 94 } 95 96 fn content_hash(&self) -> String { 97 let txs = self 98 .transactions 99 .iter() 100 .map(Transaction::canonical) 101 .collect::<Vec<_>>() 102 .join("|"); 103 let txs_v2 = self.transactions_v2.join("|"); 104 let burn_section = self.burn_bundle_section.canonical(); 105 let leader_proof = self 106 .leader_proof 107 .as_ref() 108 .map(|proof| { 109 format!( 110 "{}:{}:{}", 111 proof.ticket_id, proof.public_key, proof.signature 112 ) 113 }) 114 .unwrap_or_default(); 115 if let Some(reward_address) = &self.reward_address { 116 let reward_address_signature = self.reward_address_signature.as_deref().unwrap_or(""); 117 return hex_hash(format!( 118 "block-content-v6:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", 119 self.height, 120 self.prev_hash, 121 self.timestamp_ms, 122 self.miner, 123 reward_address, 124 reward_address_signature, 125 self.finalizer_rank, 126 self.reward, 127 self.vdf_rounds, 128 leader_proof, 129 txs, 130 txs_v2, 131 canonical_burn_block_items(&burn_section) 132 )); 133 } 134 if self.transactions_v2.is_empty() { 135 return hex_hash(format!( 136 "block-content-v4:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", 137 self.height, 138 self.prev_hash, 139 self.timestamp_ms, 140 self.miner, 141 self.finalizer_rank, 142 self.reward, 143 self.vdf_rounds, 144 leader_proof, 145 txs, 146 canonical_burn_block_items(&burn_section) 147 )); 148 } 149 hex_hash(format!( 150 "block-content-v5:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", 151 self.height, 152 self.prev_hash, 153 self.timestamp_ms, 154 self.miner, 155 self.finalizer_rank, 156 self.reward, 157 self.vdf_rounds, 158 leader_proof, 159 txs, 160 txs_v2, 161 canonical_burn_block_items(&burn_section) 162 )) 163 } 164 165 pub(super) fn leader_score(&self) -> LeaderScore { 166 LeaderScore { 167 finalizer_mode_rank: self.finalizer_mode.fork_choice_rank(), 168 finalizer_rank: self.finalizer_rank, 169 proof_rank: self 170 .leader_proof 171 .as_ref() 172 .map(LeaderProof::rank) 173 .unwrap_or_else(|| self.hash.clone()), 174 } 175 } 176 177 pub fn json_size_bytes(&self) -> Result<usize> { 178 serde_json::to_vec(self) 179 .map(|bytes| bytes.len()) 180 .context("failed to serialize block as JSON for size check") 181 } 182 183 pub fn required_burns(&self) -> Vec<&Transaction> { 184 self.burn_bundle_section.required_burns() 185 } 186 187 pub fn burn_bundle_hashes(&self) -> [String; BURN_COMMITTEE_SIZE] { 188 self.burn_bundle_section 189 .burn_bundle_hashes(self.height, &self.prev_hash, &self.miner) 190 } 191 192 pub fn included_burn_bundle_count(&self) -> usize { 193 self.burn_bundle_section.included_bundle_count() 194 } 195 } 196 197 impl FinalizerMode { 198 fn fork_choice_rank(self) -> u8 { 199 match self { 200 Self::Ticket => 0, 201 Self::Recovery => 1, 202 } 203 } 204 } 205 206 fn canonical_burn_block_items(burn_section: &str) -> String { 207 format!("burn-section-v1:{burn_section}") 208 } 209 210 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 211 pub struct LeaderProof { 212 pub ticket_id: String, 213 pub public_key: String, 214 pub signature: String, 215 } 216 217 impl LeaderProof { 218 fn rank(&self) -> String { 219 hex_hash(format!( 220 "iuna-leader-rank:{}:{}", 221 self.ticket_id, self.signature 222 )) 223 } 224 } 225 226 #[derive(Clone, Debug, Eq, PartialEq)] 227 pub(super) struct LeaderProofPayload { 228 pub(super) height: u64, 229 pub(super) prev_hash: String, 230 pub(super) finalizer_rank: u32, 231 pub(super) vdf_output: String, 232 pub(super) ticket_id: String, 233 pub(super) ticket_amount: Amount, 234 pub(super) ticket_owner: String, 235 } 236 237 impl LeaderProofPayload { 238 pub(super) fn canonical(&self) -> String { 239 if self.finalizer_rank == 0 { 240 format!( 241 "iuna-leader-proof:{}:{}:{}:{}:{}:{}", 242 self.height, 243 self.prev_hash, 244 self.vdf_output, 245 self.ticket_id, 246 self.ticket_amount, 247 self.ticket_owner 248 ) 249 } else { 250 format!( 251 "iuna-leader-proof-v2:{}:{}:{}:{}:{}:{}:{}", 252 self.height, 253 self.prev_hash, 254 self.finalizer_rank, 255 self.vdf_output, 256 self.ticket_id, 257 self.ticket_amount, 258 self.ticket_owner 259 ) 260 } 261 } 262 } 263 264 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 265 #[serde(rename_all = "camelCase")] 266 pub struct BurnLeaderRank { 267 pub rank: u32, 268 pub ticket_id: String, 269 pub owner: String, 270 pub amount: Amount, 271 pub eligible_from_height: u64, 272 pub eligible_until_height: u64, 273 } 274 275 #[derive(Clone, Debug)] 276 pub struct PreparedBlock { 277 pub(super) height: u64, 278 pub(super) prev_hash: String, 279 pub(super) timestamp_ms: u64, 280 pub(super) miner: String, 281 pub(super) reward_address: Option<String>, 282 pub(super) finalizer_mode: FinalizerMode, 283 pub(super) finalizer_rank: u32, 284 pub(super) reward: Amount, 285 pub(super) vdf_rounds: u64, 286 pub(super) vdf_seed: String, 287 pub(super) leader_ticket: Option<BurnTicket>, 288 pub(super) burn_bundle_section: BurnBundleSection, 289 pub(super) transactions: Vec<Transaction>, 290 pub(super) transactions_v2: Vec<String>, 291 } 292 293 impl PreparedBlock { 294 pub fn vdf_seed(&self) -> &str { 295 &self.vdf_seed 296 } 297 298 pub fn vdf_rounds(&self) -> u64 { 299 self.vdf_rounds 300 } 301 302 pub fn height(&self) -> u64 { 303 self.height 304 } 305 306 pub fn prev_hash(&self) -> &str { 307 &self.prev_hash 308 } 309 310 pub fn timestamp_ms(&self) -> u64 { 311 self.timestamp_ms 312 } 313 314 pub fn finish(self, wallet: &Wallet, vdf_output: String) -> Block { 315 let timestamp_ms = self.timestamp_ms; 316 self.finish_with_timestamp(wallet, vdf_output, timestamp_ms) 317 } 318 319 pub fn finish_at(self, wallet: &Wallet, vdf_output: String, timestamp_ms: u64) -> Block { 320 let timestamp_ms = match self.finalizer_mode { 321 FinalizerMode::Ticket => timestamp_ms.max(self.timestamp_ms), 322 FinalizerMode::Recovery => self.timestamp_ms, 323 }; 324 self.finish_with_timestamp(wallet, vdf_output, timestamp_ms) 325 } 326 327 fn finish_with_timestamp( 328 self, 329 wallet: &Wallet, 330 vdf_output: String, 331 timestamp_ms: u64, 332 ) -> Block { 333 let leader_proof = self.leader_ticket.as_ref().map(|leader_ticket| { 334 let proof_payload = LeaderProofPayload { 335 height: self.height, 336 prev_hash: self.prev_hash.clone(), 337 finalizer_rank: self.finalizer_rank, 338 vdf_output: vdf_output.clone(), 339 ticket_id: leader_ticket.id.clone(), 340 ticket_amount: leader_ticket.amount, 341 ticket_owner: leader_ticket.owner.clone(), 342 }; 343 wallet.leader_proof(&proof_payload) 344 }); 345 let reward_address_signature = self.reward_address.as_ref().map(|reward_address| { 346 wallet.sign_payload(&reward_address_payload( 347 self.height, 348 &self.prev_hash, 349 &self.miner, 350 reward_address, 351 )) 352 }); 353 let mut block = Block { 354 height: self.height, 355 prev_hash: self.prev_hash, 356 timestamp_ms, 357 miner: self.miner, 358 reward_address: self.reward_address, 359 reward_address_signature, 360 finalizer_mode: self.finalizer_mode, 361 finalizer_rank: self.finalizer_rank, 362 reward: self.reward, 363 vdf_rounds: self.vdf_rounds, 364 vdf_output, 365 leader_proof, 366 burn_bundle_section: self.burn_bundle_section, 367 transactions: self.transactions, 368 transactions_v2: self.transactions_v2, 369 hash: String::new(), 370 }; 371 block.hash = block.compute_hash(); 372 block 373 } 374 } 375 376 pub(super) fn reward_address_payload( 377 height: u64, 378 prev_hash: &str, 379 miner: &str, 380 reward_address: &str, 381 ) -> String { 382 format!("iuna-hybrid-reward-v1:{height}:{prev_hash}:{miner}:{reward_address}") 383 } 384 385 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 386 pub struct ChainStatus { 387 pub height: u64, 388 pub tip_hash: String, 389 pub finalized_height: Option<u64>, 390 pub finalized_hash: Option<String>, 391 pub next_leader: Option<String>, 392 pub launch_profile_hash: String, 393 pub mine_reward: Amount, 394 pub current_mine_difficulty_bits: u32, 395 pub balances: BTreeMap<String, Amount>, 396 pub pending_transactions: usize, 397 } 398 399 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 400 pub struct ChainSnapshot { 401 pub genesis_allocations: BTreeMap<String, Amount>, 402 pub vdf_rounds: u64, 403 pub launch_profile: LaunchProfile, 404 pub blocks: Vec<Block>, 405 } 406 407 #[cfg(test)] 408 mod tests { 409 use super::{ 410 Block, BurnBundleSection, FinalizerMode, LeaderProofPayload, canonical_burn_block_items, 411 }; 412 use crate::domain::{ 413 BurnTicket, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, PreparedBlock, Transaction, Wallet, 414 run_vdf, verify_vdf, 415 }; 416 417 #[test] 418 fn primary_leader_proof_payload_omits_rank_from_canonical_form() { 419 let primary = LeaderProofPayload { 420 height: 1, 421 prev_hash: "prev".to_string(), 422 finalizer_rank: 0, 423 vdf_output: "vdf".to_string(), 424 ticket_id: "ticket".to_string(), 425 ticket_amount: 2, 426 ticket_owner: "owner".to_string(), 427 }; 428 let fallback = LeaderProofPayload { 429 finalizer_rank: 1, 430 ..primary.clone() 431 }; 432 433 assert_eq!( 434 primary.canonical(), 435 "iuna-leader-proof:1:prev:vdf:ticket:2:owner" 436 ); 437 assert_eq!( 438 fallback.canonical(), 439 "iuna-leader-proof-v2:1:prev:1:vdf:ticket:2:owner" 440 ); 441 } 442 443 #[test] 444 fn burn_block_items_keep_canonical_prefix() { 445 assert_eq!( 446 canonical_burn_block_items("section"), 447 "burn-section-v1:section" 448 ); 449 } 450 451 #[test] 452 fn block_compute_hash_sets_content_and_vdf_seed_contract() { 453 let mut block = Block { 454 height: 1, 455 prev_hash: "0".repeat(64), 456 timestamp_ms: 1, 457 miner: "miner".to_string(), 458 reward_address: None, 459 reward_address_signature: None, 460 finalizer_mode: FinalizerMode::Ticket, 461 finalizer_rank: 0, 462 reward: 0, 463 vdf_rounds: 1, 464 vdf_output: "out".to_string(), 465 leader_proof: None, 466 burn_bundle_section: BurnBundleSection::default(), 467 transactions: vec![Transaction::genesis_burn("owner", 1)], 468 transactions_v2: Vec::new(), 469 hash: String::new(), 470 }; 471 472 block.hash = block.compute_hash(); 473 474 assert_eq!(block.compute_hash(), block.hash); 475 } 476 477 #[test] 478 fn post_activation_vdf_binds_transactions_but_allows_ticket_timestamp_completion() { 479 let mut block = Block { 480 height: GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 481 prev_hash: "0".repeat(64), 482 timestamp_ms: 1, 483 miner: "1".repeat(64), 484 reward_address: None, 485 reward_address_signature: None, 486 finalizer_mode: FinalizerMode::Ticket, 487 finalizer_rank: 0, 488 reward: 1, 489 vdf_rounds: 16, 490 vdf_output: String::new(), 491 leader_proof: None, 492 burn_bundle_section: BurnBundleSection::default(), 493 transactions: vec![Transaction::genesis_burn("2".repeat(64), 1)], 494 transactions_v2: Vec::new(), 495 hash: String::new(), 496 }; 497 let original_seed = block.vdf_seed(); 498 let output = run_vdf(&original_seed, block.vdf_rounds); 499 block.vdf_output = output.clone(); 500 assert!(verify_vdf(&block.vdf_seed(), block.vdf_rounds, &output)); 501 502 let mut changed_transactions = block.clone(); 503 changed_transactions.transactions = vec![Transaction::genesis_burn("3".repeat(64), 1)]; 504 assert_ne!(changed_transactions.vdf_seed(), original_seed); 505 assert!(!verify_vdf( 506 &changed_transactions.vdf_seed(), 507 changed_transactions.vdf_rounds, 508 &output 509 )); 510 511 let mut completed_later = block.clone(); 512 completed_later.timestamp_ms += 1_000; 513 assert_eq!(completed_later.vdf_seed(), original_seed); 514 assert!(verify_vdf( 515 &completed_later.vdf_seed(), 516 completed_later.vdf_rounds, 517 &output 518 )); 519 } 520 521 #[test] 522 fn transaction_v2_envelopes_are_bound_to_vdf_and_block_hash() { 523 let mut block = Block { 524 height: GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 525 prev_hash: "0".repeat(64), 526 timestamp_ms: 1, 527 miner: "1".repeat(64), 528 reward_address: None, 529 reward_address_signature: None, 530 finalizer_mode: FinalizerMode::Ticket, 531 finalizer_rank: 0, 532 reward: 1, 533 vdf_rounds: 1, 534 vdf_output: "output".to_string(), 535 leader_proof: None, 536 burn_bundle_section: BurnBundleSection::default(), 537 transactions: vec![Transaction::genesis_burn("2".repeat(64), 1)], 538 transactions_v2: vec!["00".to_string()], 539 hash: String::new(), 540 }; 541 block.hash = block.compute_hash(); 542 let original_seed = block.vdf_seed(); 543 let original_hash = block.hash.clone(); 544 545 block.transactions_v2[0] = "01".to_string(); 546 547 assert_ne!(block.vdf_seed(), original_seed); 548 assert_ne!(block.compute_hash(), original_hash); 549 } 550 551 #[test] 552 fn prepared_and_finished_blocks_share_post_activation_vdf_commitment() { 553 let wallet = Wallet::from_seed("vdf-commitment-finalizer"); 554 let transactions = vec![Transaction::genesis_burn(wallet.address(), 1)]; 555 let mut prepared = PreparedBlock { 556 height: GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 557 prev_hash: "0".repeat(64), 558 timestamp_ms: 10, 559 miner: wallet.address().to_string(), 560 reward_address: None, 561 finalizer_mode: FinalizerMode::Ticket, 562 finalizer_rank: 0, 563 reward: 1, 564 vdf_rounds: 1, 565 vdf_seed: String::new(), 566 leader_ticket: Some(BurnTicket { 567 id: "4".repeat(64), 568 owner: wallet.address().to_string(), 569 amount: 1, 570 eligible_from_height: GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 571 eligible_until_height: GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 572 }), 573 burn_bundle_section: BurnBundleSection::default(), 574 transactions, 575 transactions_v2: Vec::new(), 576 }; 577 let bundle_hashes = prepared.burn_bundle_section.burn_bundle_hashes( 578 prepared.height, 579 &prepared.prev_hash, 580 &prepared.miner, 581 ); 582 let commitment = super::vdf_content_commitment( 583 prepared.height, 584 &prepared.prev_hash, 585 &prepared.miner, 586 prepared.reward_address.as_deref(), 587 prepared.finalizer_mode, 588 prepared.finalizer_rank, 589 prepared.reward, 590 prepared.vdf_rounds, 591 prepared 592 .leader_ticket 593 .as_ref() 594 .map(|ticket| ticket.id.as_str()), 595 &prepared.burn_bundle_section, 596 &prepared.transactions, 597 &prepared.transactions_v2, 598 ); 599 prepared.vdf_seed = super::vdf_seed_for_child( 600 &prepared.prev_hash, 601 prepared.height, 602 &bundle_hashes, 603 &commitment, 604 ); 605 let expected_seed = prepared.vdf_seed.clone(); 606 let output = run_vdf(&expected_seed, prepared.vdf_rounds); 607 608 let block = prepared.finish_at(&wallet, output, 20); 609 610 assert_eq!(block.vdf_seed(), expected_seed); 611 } 612 613 #[test] 614 fn json_block_size_uses_canonical_node_representation() { 615 let compact_wire_json = format!( 616 r#"{{"height":1,"prev_hash":"{}","timestamp_ms":1,"miner":"{}","reward":0,"vdf_rounds":1,"vdf_output":"out","leader_proof":null,"transactions":[],"hash":"{}"}}"#, 617 "0".repeat(64), 618 "1".repeat(64), 619 "2".repeat(64) 620 ); 621 622 let block: Block = serde_json::from_str(&compact_wire_json).unwrap(); 623 let canonical_json_len = serde_json::to_vec(&block).unwrap().len(); 624 625 assert_eq!(block.finalizer_mode, FinalizerMode::Ticket); 626 assert_eq!(block.finalizer_rank, 0); 627 assert_eq!(block.burn_bundle_section, BurnBundleSection::default()); 628 assert_eq!(block.json_size_bytes().unwrap(), canonical_json_len); 629 assert!(canonical_json_len > compact_wire_json.len()); 630 } 631 632 #[test] 633 fn activated_reward_address_is_authenticated_by_finalizer() { 634 let wallet = Wallet::from_seed("hybrid-reward-finalizer"); 635 let reward_address = wallet.hybrid_address(crate::domain::AddressNetwork::Mainnet); 636 let prepared = PreparedBlock { 637 height: crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT, 638 prev_hash: "0".repeat(64), 639 timestamp_ms: 10, 640 miner: wallet.address().to_string(), 641 reward_address: Some(reward_address.clone()), 642 finalizer_mode: FinalizerMode::Recovery, 643 finalizer_rank: 0, 644 reward: 1, 645 vdf_rounds: 1, 646 vdf_seed: "seed".to_string(), 647 leader_ticket: None, 648 burn_bundle_section: BurnBundleSection::default(), 649 transactions: Vec::new(), 650 transactions_v2: Vec::new(), 651 }; 652 653 let block = prepared.finish(&wallet, "vdf".to_string()); 654 let signature = block.reward_address_signature.as_deref().unwrap(); 655 crate::domain::ledger_ops::verify_address_signature( 656 wallet.address(), 657 &super::reward_address_payload( 658 block.height, 659 &block.prev_hash, 660 &block.miner, 661 &reward_address, 662 ), 663 signature, 664 "reward address", 665 ) 666 .unwrap(); 667 assert!( 668 crate::domain::ledger_ops::verify_address_signature( 669 wallet.address(), 670 &super::reward_address_payload( 671 block.height, 672 &block.prev_hash, 673 &block.miner, 674 "iuna1ptampered", 675 ), 676 signature, 677 "reward address", 678 ) 679 .is_err() 680 ); 681 } 682 }