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