ledger_ops.rs (22209B)
1 use std::collections::{BTreeMap, BTreeSet}; 2 3 use anyhow::{Context, Result, bail}; 4 use ed25519_dalek::{Signature, Verifier, VerifyingKey}; 5 6 use super::blinded::verify_blinded_input_signatures; 7 use super::hex::hex_hash; 8 use super::reveal::{RevealBundleSection, canonical_reveal_bundle_hashes}; 9 use super::selection::{TransactionKind, blinded_fee_rate_key, fee_rate_key}; 10 use super::ticket::ticket_is_eligible_for_height; 11 use super::transaction::{BlindedTransaction, Transaction}; 12 use super::{ 13 Amount, Block, BlockSelection, BurnTicket, FinalizerMode, LeaderProof, LeaderProofPayload, 14 Ledger, MINE_REWARD, OutPoint, PUBLIC_KEY_BYTES, RECOVERY_BLOCK_DELAY_MS, 15 REVEAL_COMMITTEE_SIZE, SIGNATURE_BYTES, TxInput, TxOutput, decode_hex_array, validate_address, 16 validate_hash, validate_protocol_id, validate_signature, 17 }; 18 19 pub(super) fn validate_genesis_allocations( 20 genesis_allocations: &BTreeMap<String, Amount>, 21 ) -> Result<()> { 22 for address in genesis_allocations.keys() { 23 validate_address(address, "genesis allocation")?; 24 } 25 Ok(()) 26 } 27 28 pub(super) fn validate_transaction_inputs(inputs: &[TxInput]) -> Result<()> { 29 for input in inputs { 30 validate_protocol_id(&input.outpoint.txid, "input outpoint txid")?; 31 validate_address(&input.owner, "input owner")?; 32 validate_signature(&input.signature, "input signature")?; 33 } 34 Ok(()) 35 } 36 37 pub(super) fn validate_transaction_outputs(outputs: &[TxOutput]) -> Result<()> { 38 for output in outputs { 39 validate_address(&output.address, "output recipient")?; 40 } 41 Ok(()) 42 } 43 44 pub(super) fn validate_genesis_burn_transaction(transaction: &Transaction) -> Result<()> { 45 let Transaction::Burn { 46 inputs, 47 change, 48 fee, 49 signature, 50 .. 51 } = transaction 52 else { 53 bail!("genesis only supports burn transactions"); 54 }; 55 if *fee != 0 { 56 bail!("genesis burn fee must be zero"); 57 } 58 validate_hash(signature, "genesis burn signature")?; 59 validate_transaction_outputs(change)?; 60 for input in inputs { 61 validate_hash(&input.outpoint.txid, "genesis burn input outpoint txid")?; 62 validate_address(&input.owner, "genesis burn input owner")?; 63 if input.signature != "genesis" { 64 bail!("genesis burn input signature is invalid"); 65 } 66 } 67 Ok(()) 68 } 69 70 pub(super) fn estimated_block_selection_size_bytes( 71 selection: &BlockSelection, 72 recovery: bool, 73 ) -> Result<usize> { 74 let block = Block { 75 height: u64::MAX, 76 prev_hash: "f".repeat(64), 77 timestamp_ms: u64::MAX, 78 miner: "f".repeat(64), 79 finalizer_mode: if recovery { 80 FinalizerMode::Recovery 81 } else { 82 FinalizerMode::Ticket 83 }, 84 finalizer_rank: 0, 85 reward: u64::MAX, 86 vdf_rounds: u64::MAX, 87 vdf_output: "f".repeat(64), 88 leader_proof: (!recovery).then(|| LeaderProof { 89 ticket_id: "f".repeat(64), 90 public_key: "f".repeat(64), 91 signature: "f".repeat(128), 92 }), 93 blinded_transactions: selection.blinded_transactions.clone(), 94 reveal_bundle_section: RevealBundleSection::default(), 95 transactions: selection.transactions.clone(), 96 hash: "f".repeat(64), 97 }; 98 block.serialized_size_bytes() 99 } 100 101 pub(super) fn ensure_transaction_fits_empty_block( 102 transaction: &Transaction, 103 max_block_bytes: usize, 104 ) -> Result<()> { 105 let selection = BlockSelection { 106 transactions: vec![transaction.clone()], 107 blinded_transactions: Vec::new(), 108 }; 109 if estimated_block_selection_size_bytes(&selection, false)? > max_block_bytes { 110 bail!("transaction exceeds max block size"); 111 } 112 Ok(()) 113 } 114 115 pub(super) fn ensure_blinded_transaction_fits_empty_block( 116 transaction: &BlindedTransaction, 117 max_block_bytes: usize, 118 ) -> Result<()> { 119 let selection = BlockSelection { 120 transactions: Vec::new(), 121 blinded_transactions: vec![transaction.clone()], 122 }; 123 if estimated_block_selection_size_bytes(&selection, false)? > max_block_bytes { 124 bail!("blinded transaction exceeds max block size"); 125 } 126 Ok(()) 127 } 128 129 pub(super) fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> { 130 let Some(proof) = &block.leader_proof else { 131 bail!("block is missing leader proof"); 132 }; 133 if proof.public_key != block.miner { 134 bail!("leader proof public key does not match block finalizer"); 135 } 136 let ticket = tickets 137 .iter() 138 .find(|ticket| { 139 ticket.id == proof.ticket_id && ticket_is_eligible_for_height(ticket, block.height) 140 }) 141 .context("leader ticket is not pending for this height")?; 142 if ticket.owner != block.miner { 143 bail!("leader ticket owner does not match block finalizer"); 144 } 145 if ticket.eligible_from_height > block.height { 146 bail!("leader ticket is not mature"); 147 } 148 149 let payload = LeaderProofPayload { 150 height: block.height, 151 prev_hash: block.prev_hash.clone(), 152 finalizer_rank: block.finalizer_rank, 153 vdf_output: block.vdf_output.clone(), 154 ticket_id: ticket.id.clone(), 155 ticket_amount: ticket.amount, 156 ticket_owner: ticket.owner.clone(), 157 }; 158 verify_leader_signature(proof, &payload)?; 159 Ok(()) 160 } 161 162 pub(super) fn verify_leader_signature( 163 proof: &LeaderProof, 164 payload: &LeaderProofPayload, 165 ) -> Result<()> { 166 verify_address_signature( 167 &proof.public_key, 168 &payload.canonical(), 169 &proof.signature, 170 "leader", 171 ) 172 } 173 174 pub(super) fn verify_address_signature( 175 address: &str, 176 payload: &str, 177 signature: &str, 178 label: &str, 179 ) -> Result<()> { 180 let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(address) 181 .with_context(|| format!("invalid {label} public key {address}"))?; 182 let signature = decode_hex_array::<SIGNATURE_BYTES>(signature) 183 .with_context(|| format!("invalid {label} signature hex"))?; 184 let verifying_key = VerifyingKey::from_bytes(&public_key) 185 .with_context(|| format!("invalid {label} public key"))?; 186 let signature = Signature::from_bytes(&signature); 187 verifying_key 188 .verify(payload.as_bytes(), &signature) 189 .with_context(|| format!("{label} signature is invalid")) 190 } 191 192 pub(super) fn vdf_seed_for_child( 193 prev_hash: &str, 194 height: u64, 195 bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE], 196 ) -> String { 197 hex_hash(format!( 198 "iuna-vdf-child:{prev_hash}:{height}:{}", 199 canonical_reveal_bundle_hashes(bundle_hashes) 200 )) 201 } 202 203 pub(super) fn recovery_vdf_seed_for_child( 204 prev_hash: &str, 205 height: u64, 206 timestamp_ms: u64, 207 bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE], 208 ) -> String { 209 hex_hash(format!( 210 "iuna-recovery-vdf-child:{prev_hash}:{height}:{timestamp_ms}:{}", 211 canonical_reveal_bundle_hashes(bundle_hashes) 212 )) 213 } 214 215 pub(super) fn apply_transaction( 216 transaction: &Transaction, 217 utxos: &mut BTreeMap<OutPoint, TxOutput>, 218 ) -> Result<()> { 219 transaction.verify_signature()?; 220 match transaction { 221 Transaction::Mine { recipient, .. } => { 222 let output = TxOutput { 223 address: recipient.clone(), 224 amount: MINE_REWARD, 225 }; 226 ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; 227 utxos.insert( 228 OutPoint { 229 txid: transaction.signature().to_string(), 230 index: 0, 231 }, 232 output, 233 ); 234 return Ok(()); 235 } 236 Transaction::Transfer { .. } | Transaction::Burn { .. } => {} 237 } 238 ensure_single_input_owner(transaction)?; 239 let input_total = spend_inputs(transaction, utxos)?; 240 let outputs = transaction.outputs(); 241 let output_total = outputs.iter().try_fold(0_u64, |total, output| { 242 total 243 .checked_add(output.amount) 244 .context("transaction outputs overflow") 245 })?; 246 let required = output_total 247 .checked_add(transaction.fee()) 248 .context("transaction outputs plus fee overflow")? 249 .checked_add(match transaction { 250 Transaction::Burn { amount, .. } => *amount, 251 Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, 252 }) 253 .context("transaction outputs plus burn overflow")?; 254 if input_total != required { 255 bail!("transaction inputs do not balance outputs, burn, and fee"); 256 } 257 ensure_outputs_do_not_overflow(utxos, &outputs)?; 258 for (index, output) in outputs.iter().enumerate() { 259 utxos.insert( 260 OutPoint { 261 txid: transaction.signature().to_string(), 262 index: index as u32, 263 }, 264 output.clone(), 265 ); 266 } 267 Ok(()) 268 } 269 270 pub(super) fn validate_block_blinded_items(block: &Block, ledger: &Ledger) -> Result<()> { 271 let mut commitments = BTreeSet::new(); 272 for transaction in &block.blinded_transactions { 273 if !commitments.insert(transaction.commitment.clone()) { 274 bail!("duplicate blinded transaction in block"); 275 } 276 ledger.validate_blinded_transaction(transaction)?; 277 if transaction.expires_at_height <= block.height { 278 bail!("blinded transaction is expired for block height"); 279 } 280 if ledger.active_blinded.contains_key(&transaction.commitment) { 281 bail!("blinded transaction is already active"); 282 } 283 if ledger.chain.iter().any(|block| { 284 block 285 .blinded_transactions 286 .iter() 287 .any(|existing| existing.commitment == transaction.commitment) 288 }) { 289 bail!("blinded transaction is already on chain"); 290 } 291 } 292 293 let mut reveals = BTreeSet::new(); 294 for reveal in block.all_blinded_reveals() { 295 if !reveals.insert(reveal.commitment.clone()) { 296 bail!("duplicate blinded reveal in block"); 297 } 298 if ledger.chain.iter().any(|block| { 299 block 300 .all_blinded_reveals() 301 .iter() 302 .any(|existing| existing.commitment == reveal.commitment) 303 }) { 304 bail!("blinded reveal is already on chain"); 305 } 306 ledger.pending_reveal_transaction(reveal)?; 307 } 308 Ok(()) 309 } 310 311 pub(super) fn fee_reward(transactions: &[Transaction]) -> Result<Amount> { 312 transactions.iter().try_fold(0_u64, |total, tx| { 313 total.checked_add(tx.fee()).context("block fees overflow") 314 }) 315 } 316 317 pub(super) fn block_reward( 318 transactions: &[Transaction], 319 aggregated_reveal_finalizer_fees: Amount, 320 ) -> Result<Amount> { 321 fee_reward(transactions)? 322 .checked_add(aggregated_reveal_finalizer_fees) 323 .context("block reward overflow") 324 } 325 326 pub(super) fn spend_inputs( 327 transaction: &Transaction, 328 utxos: &mut BTreeMap<OutPoint, TxOutput>, 329 ) -> Result<Amount> { 330 let mut seen = BTreeSet::new(); 331 let mut total = 0_u64; 332 for input in transaction.inputs() { 333 if !seen.insert(input.outpoint.clone()) { 334 bail!("duplicate input in transaction"); 335 } 336 let output = utxos.remove(&input.outpoint).with_context(|| { 337 format!("transaction spends missing output {}", input.outpoint.id()) 338 })?; 339 if output.address != input.owner { 340 bail!("transaction input owner does not match spent output"); 341 } 342 total = total 343 .checked_add(output.amount) 344 .context("transaction input total overflows")?; 345 } 346 Ok(total) 347 } 348 349 pub(super) fn apply_spendable_pending_transaction( 350 transaction: &Transaction, 351 utxos: &mut BTreeMap<OutPoint, TxOutput>, 352 ) -> Result<()> { 353 if matches!(transaction, Transaction::Mine { .. }) { 354 bail!("pending mine outputs are not spendable"); 355 } 356 transaction.verify_signature()?; 357 ensure_single_input_owner(transaction)?; 358 let input_total = transaction_input_total(transaction, utxos)?; 359 let outputs = transaction.outputs(); 360 let output_total = outputs.iter().try_fold(0_u64, |total, output| { 361 total 362 .checked_add(output.amount) 363 .context("transaction outputs overflow") 364 })?; 365 let required = output_total 366 .checked_add(transaction.fee()) 367 .context("transaction outputs plus fee overflow")? 368 .checked_add(match transaction { 369 Transaction::Burn { amount, .. } => *amount, 370 Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, 371 }) 372 .context("transaction outputs plus burn overflow")?; 373 if input_total != required { 374 bail!("transaction inputs do not balance outputs, burn, and fee"); 375 } 376 ensure_outputs_do_not_overflow(utxos, &outputs)?; 377 for input in transaction.inputs() { 378 utxos.remove(&input.outpoint); 379 } 380 for (index, output) in outputs.iter().enumerate() { 381 utxos.insert( 382 OutPoint { 383 txid: transaction.signature().to_string(), 384 index: index as u32, 385 }, 386 output.clone(), 387 ); 388 } 389 Ok(()) 390 } 391 392 pub(super) fn transaction_input_total( 393 transaction: &Transaction, 394 utxos: &BTreeMap<OutPoint, TxOutput>, 395 ) -> Result<Amount> { 396 let mut seen = BTreeSet::new(); 397 let mut total = 0_u64; 398 for input in transaction.inputs() { 399 if !seen.insert(input.outpoint.clone()) { 400 bail!("duplicate input in transaction"); 401 } 402 let output = utxos.get(&input.outpoint).with_context(|| { 403 format!("transaction spends missing output {}", input.outpoint.id()) 404 })?; 405 if output.address != input.owner { 406 bail!("transaction input owner does not match spent output"); 407 } 408 total = total 409 .checked_add(output.amount) 410 .context("transaction input total overflows")?; 411 } 412 Ok(total) 413 } 414 415 pub(super) fn spend_blinded_inputs( 416 transaction: &BlindedTransaction, 417 utxos: &mut BTreeMap<OutPoint, TxOutput>, 418 ) -> Result<Vec<TxOutput>> { 419 verify_blinded_input_signatures(transaction)?; 420 if transaction.inputs.is_empty() { 421 return Ok(Vec::new()); 422 } 423 let mut seen = BTreeSet::new(); 424 let mut locked = Vec::new(); 425 for input in &transaction.inputs { 426 if !seen.insert(input.outpoint.clone()) { 427 bail!("duplicate input in blinded transaction"); 428 } 429 let output = utxos.remove(&input.outpoint).with_context(|| { 430 format!( 431 "blinded transaction spends missing output {}", 432 input.outpoint.id() 433 ) 434 })?; 435 if output.address != input.owner { 436 bail!("blinded transaction input owner does not match spent output"); 437 } 438 locked.push(output); 439 } 440 let locked_total = locked.iter().try_fold(0_u64, |total, output| { 441 total 442 .checked_add(output.amount) 443 .context("blinded transaction locked input total overflows") 444 })?; 445 if transaction.fee > locked_total { 446 bail!("blinded transaction fee exceeds locked inputs"); 447 } 448 Ok(locked) 449 } 450 451 pub(super) fn spend_spendable_blinded_inputs( 452 transaction: &BlindedTransaction, 453 utxos: &mut BTreeMap<OutPoint, TxOutput>, 454 ) -> Result<Vec<TxOutput>> { 455 let locked = blinded_input_outputs(transaction, utxos)?; 456 for input in &transaction.inputs { 457 utxos.remove(&input.outpoint); 458 } 459 Ok(locked) 460 } 461 462 pub(super) fn blinded_input_outputs( 463 transaction: &BlindedTransaction, 464 utxos: &BTreeMap<OutPoint, TxOutput>, 465 ) -> Result<Vec<TxOutput>> { 466 verify_blinded_input_signatures(transaction)?; 467 if transaction.inputs.is_empty() { 468 return Ok(Vec::new()); 469 } 470 let mut seen = BTreeSet::new(); 471 let mut locked = Vec::new(); 472 for input in &transaction.inputs { 473 if !seen.insert(input.outpoint.clone()) { 474 bail!("duplicate input in blinded transaction"); 475 } 476 let output = utxos.get(&input.outpoint).with_context(|| { 477 format!( 478 "blinded transaction spends missing output {}", 479 input.outpoint.id() 480 ) 481 })?; 482 if output.address != input.owner { 483 bail!("blinded transaction input owner does not match spent output"); 484 } 485 locked.push(output.clone()); 486 } 487 let locked_total = locked.iter().try_fold(0_u64, |total, output| { 488 total 489 .checked_add(output.amount) 490 .context("blinded transaction locked input total overflows") 491 })?; 492 if transaction.fee > locked_total { 493 bail!("blinded transaction fee exceeds locked inputs"); 494 } 495 Ok(locked) 496 } 497 498 pub(super) fn transaction_has_missing_inputs( 499 transaction: &Transaction, 500 utxos: &BTreeMap<OutPoint, TxOutput>, 501 ) -> bool { 502 transaction 503 .inputs() 504 .iter() 505 .any(|input| !utxos.contains_key(&input.outpoint)) 506 } 507 508 pub(super) fn ensure_single_input_owner(transaction: &Transaction) -> Result<()> { 509 if matches!(transaction, Transaction::Mine { .. }) { 510 return Ok(()); 511 } 512 ensure_single_input_owner_for_inputs(transaction.inputs()) 513 } 514 515 pub(super) fn ensure_single_input_owner_for_inputs(inputs: &[TxInput]) -> Result<()> { 516 let Some(first) = inputs.first() else { 517 bail!("transaction has no inputs"); 518 }; 519 if inputs.iter().any(|input| input.owner != first.owner) { 520 bail!("transaction inputs must have one owner"); 521 } 522 Ok(()) 523 } 524 525 pub(super) fn credit_reward_output( 526 utxos: &mut BTreeMap<OutPoint, TxOutput>, 527 block: &Block, 528 ) -> Result<()> { 529 if block.reward == 0 { 530 return Ok(()); 531 } 532 let output = TxOutput { 533 address: block.miner.clone(), 534 amount: block.reward, 535 }; 536 ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; 537 utxos.insert(reward_outpoint(&block.hash), output); 538 Ok(()) 539 } 540 541 pub(super) fn ensure_outputs_do_not_overflow( 542 utxos: &BTreeMap<OutPoint, TxOutput>, 543 outputs: &[TxOutput], 544 ) -> Result<()> { 545 let mut balances = BTreeMap::new(); 546 for output in utxos.values() { 547 let balance = balances.entry(output.address.clone()).or_insert(0_u64); 548 *balance = balance 549 .checked_add(output.amount) 550 .with_context(|| format!("balance overflow for {}", output.address))?; 551 } 552 for output in outputs { 553 let balance = balances.entry(output.address.clone()).or_insert(0_u64); 554 *balance = balance 555 .checked_add(output.amount) 556 .with_context(|| format!("balance overflow for {}", output.address))?; 557 } 558 Ok(()) 559 } 560 561 pub(super) fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> { 562 if !transactions.iter().any(Transaction::is_burn) { 563 bail!("block must include at least one burn transaction"); 564 } 565 Ok(()) 566 } 567 568 pub(super) fn ensure_block_has_burn_from(transactions: &[Transaction], miner: &str) -> Result<()> { 569 if !transactions 570 .iter() 571 .any(|transaction| transaction.is_burn() && transaction.sender() == miner) 572 { 573 bail!("recovery block must include a burn from the finalizer"); 574 } 575 Ok(()) 576 } 577 578 pub(super) fn ensure_valid_recovery_block(block: &Block, parent: &Block) -> Result<()> { 579 if block.finalizer_rank != 0 { 580 bail!("recovery block finalizer rank must be 0"); 581 } 582 if block.leader_proof.is_some() { 583 bail!("recovery block must not carry a leader proof"); 584 } 585 let min_timestamp = parent.timestamp_ms.saturating_add(RECOVERY_BLOCK_DELAY_MS); 586 if block.timestamp_ms < min_timestamp { 587 bail!("recovery block is not available before timestamp {min_timestamp}"); 588 } 589 ensure_block_has_burn_from(&block.transactions, &block.miner) 590 } 591 592 pub(super) fn best_selectable_blinded_index( 593 transactions: &[BlindedTransaction], 594 utxos: &BTreeMap<OutPoint, TxOutput>, 595 ) -> Option<usize> { 596 transactions 597 .iter() 598 .enumerate() 599 .filter(|(_, transaction)| { 600 let mut utxos = utxos.clone(); 601 spend_blinded_inputs(transaction, &mut utxos).is_ok() 602 }) 603 .max_by(|(_, left), (_, right)| { 604 blinded_fee_rate_key(left) 605 .cmp(&blinded_fee_rate_key(right)) 606 .then_with(|| left.fee.cmp(&right.fee)) 607 .then_with(|| right.commitment.cmp(&left.commitment)) 608 }) 609 .map(|(index, _)| index) 610 } 611 612 pub(super) fn best_selectable_transaction_index( 613 transactions: &[Transaction], 614 utxos: &BTreeMap<OutPoint, TxOutput>, 615 required_kind: Option<TransactionKind>, 616 ) -> Option<usize> { 617 transactions 618 .iter() 619 .enumerate() 620 .filter(|(_, tx)| match required_kind { 621 Some(TransactionKind::Burn) => tx.is_burn(), 622 None => true, 623 }) 624 .filter(|(_, tx)| { 625 let mut utxos = utxos.clone(); 626 apply_transaction(tx, &mut utxos).is_ok() 627 }) 628 .max_by(|(_, left), (_, right)| { 629 fee_rate_key(left) 630 .cmp(&fee_rate_key(right)) 631 .then_with(|| left.fee().cmp(&right.fee())) 632 .then_with(|| left.is_burn().cmp(&right.is_burn())) 633 .then_with(|| right.signature().cmp(left.signature())) 634 }) 635 .map(|(index, _)| index) 636 } 637 638 pub(super) fn best_selectable_burn_from_index( 639 transactions: &[Transaction], 640 utxos: &BTreeMap<OutPoint, TxOutput>, 641 owner: &str, 642 ) -> Option<usize> { 643 transactions 644 .iter() 645 .enumerate() 646 .filter(|(_, tx)| tx.is_burn() && tx.sender() == owner) 647 .filter(|(_, tx)| { 648 let mut utxos = utxos.clone(); 649 apply_transaction(tx, &mut utxos).is_ok() 650 }) 651 .max_by(|(_, left), (_, right)| { 652 fee_rate_key(left) 653 .cmp(&fee_rate_key(right)) 654 .then_with(|| left.fee().cmp(&right.fee())) 655 .then_with(|| right.signature().cmp(left.signature())) 656 }) 657 .map(|(index, _)| index) 658 } 659 660 pub(super) fn reward_outpoint(block_hash: &str) -> OutPoint { 661 OutPoint { 662 txid: block_hash.to_string(), 663 index: u32::MAX, 664 } 665 }