ledger_builders.rs (58556B)
1 use super::hex::{decode_hex, decode_hex_array, hex_encode}; 2 use super::ledger_ops::{compact_block_context, ensure_transaction_v2_fits_empty_block}; 3 use super::mining::mine_signature; 4 use super::stratum::{ 5 hash_meets_difficulty, stratum_mine_header_bytes, stratum_mine_signature, stratum_mine_template, 6 }; 7 use super::transaction::{UnsignedTxInput, UnsignedUtxoTransaction}; 8 use super::validation::validate_address; 9 use super::{ 10 AddressNetwork, Amount, HYBRID_REWARD_ACTIVATION_HEIGHT, HybridAddressBranch, Ledger, 11 LegacyTransactionId, MineSearchOutcome, OutPoint, SignatureScheme, StratumMineShare, 12 StratumMineTemplate, Transaction, TransactionV2, TransactionV2Domain, TransactionV2Input, 13 TransactionV2LegacyInput, TransactionV2Output, TxOutput, V2SpendingAuthorization, 14 VersionedAddress, Wallet, decode_versioned_address, encode_versioned_address, 15 }; 16 use anyhow::{Context, Result, bail}; 17 18 pub const HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT: u32 = 20; 19 const MAX_DISCOVERED_EXTERNAL_ADDRESSES: u32 = 10_000; 20 21 impl Ledger { 22 pub fn wallet_receive_address(&self, wallet: &Wallet) -> Result<String> { 23 let (_, address) = self 24 .wallet_external_addresses(wallet)? 25 .last() 26 .copied() 27 .context( 28 "wallet external address discovery did not return a current receive address", 29 )?; 30 encode_versioned_address(address, self.address_network()) 31 } 32 33 pub fn wallet_reward_address(&self, wallet: &Wallet, _height: u64) -> String { 34 let (_, address) = self 35 .wallet_reward_addresses(wallet) 36 .expect("valid chain has discoverable wallet reward addresses") 37 .last() 38 .copied() 39 .expect("wallet reward discovery always returns a current address"); 40 encode_versioned_address(address, self.address_network()) 41 .expect("wallet reward address has a valid fixed-size commitment") 42 } 43 44 pub fn wallet_owned_hybrid_addresses(&self, wallet: &Wallet) -> Result<Vec<VersionedAddress>> { 45 let mut addresses = self 46 .wallet_external_addresses(wallet)? 47 .into_iter() 48 .map(|(_, address)| address) 49 .collect::<Vec<_>>(); 50 if self.height() >= HYBRID_REWARD_ACTIVATION_HEIGHT { 51 for (_, address) in self.wallet_reward_addresses(wallet)? { 52 if !addresses.contains(&address) { 53 addresses.push(address); 54 } 55 } 56 } 57 Ok(addresses.into_iter().collect()) 58 } 59 60 pub fn wallet_owned_hybrid_encoded_addresses(&self, wallet: &Wallet) -> Result<Vec<String>> { 61 self.wallet_owned_hybrid_addresses(wallet)? 62 .into_iter() 63 .map(|address| encode_versioned_address(address, self.address_network())) 64 .collect() 65 } 66 67 fn address_network(&self) -> AddressNetwork { 68 AddressNetwork::from_profile_id(&self.launch_profile.profile_id) 69 } 70 71 fn wallet_external_addresses(&self, wallet: &Wallet) -> Result<Vec<(u32, VersionedAddress)>> { 72 self.recover_historical_hybrid_address_cursors(wallet)?; 73 let tip_changed = !wallet.external_discovery_tip_matches(self.tip_hash()); 74 let used = if tip_changed { 75 self.used_hybrid_addresses()? 76 } else { 77 self.pending_hybrid_addresses() 78 }; 79 let start = wallet.external_address_cursor(); 80 let mut index = start; 81 let mut highest_used = None; 82 let mut unused_run = 0_u32; 83 while index < MAX_DISCOVERED_EXTERNAL_ADDRESSES { 84 let address = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, index); 85 if used.contains(&address) { 86 highest_used = Some(index); 87 unused_run = 0; 88 } else { 89 unused_run = unused_run.saturating_add(1); 90 if unused_run >= HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT { 91 let current = highest_used 92 .and_then(|used| used.checked_add(1)) 93 .unwrap_or(start); 94 wallet.advance_external_address_cursor(current); 95 if tip_changed { 96 wallet.mark_external_discovery_tip(self.tip_hash()); 97 } 98 return Ok((0..=current) 99 .map(|index| { 100 ( 101 index, 102 wallet.hybrid_versioned_address_at( 103 HybridAddressBranch::External, 104 index, 105 ), 106 ) 107 }) 108 .collect()); 109 } 110 } 111 index = index.saturating_add(1); 112 } 113 bail!( 114 "wallet external address discovery exceeded {MAX_DISCOVERED_EXTERNAL_ADDRESSES} addresses" 115 ) 116 } 117 118 fn wallet_reward_addresses(&self, wallet: &Wallet) -> Result<Vec<(u32, VersionedAddress)>> { 119 self.recover_historical_hybrid_address_cursors(wallet)?; 120 let tip_changed = !wallet.reward_discovery_tip_matches(self.tip_hash()); 121 let spent = if tip_changed { 122 self.spent_hybrid_addresses()? 123 } else { 124 self.pending_spent_hybrid_addresses() 125 }; 126 let start = wallet.reward_address_cursor(); 127 let mut index = start; 128 let mut highest_spent = None; 129 let mut unspent_run = 0_u32; 130 while index < MAX_DISCOVERED_EXTERNAL_ADDRESSES { 131 let address = wallet.hybrid_versioned_address_at(HybridAddressBranch::Reward, index); 132 if spent.contains(&address) { 133 highest_spent = Some(index); 134 unspent_run = 0; 135 } else { 136 unspent_run = unspent_run.saturating_add(1); 137 if unspent_run >= HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT { 138 let current = highest_spent 139 .and_then(|spent| spent.checked_add(1)) 140 .unwrap_or(start); 141 wallet.advance_reward_address_cursor(current); 142 if tip_changed { 143 wallet.mark_reward_discovery_tip(self.tip_hash()); 144 } 145 return Ok((0..=current) 146 .map(|index| { 147 ( 148 index, 149 wallet.hybrid_versioned_address_at( 150 HybridAddressBranch::Reward, 151 index, 152 ), 153 ) 154 }) 155 .collect()); 156 } 157 } 158 index = index.saturating_add(1); 159 } 160 bail!( 161 "wallet reward address discovery exceeded {MAX_DISCOVERED_EXTERNAL_ADDRESSES} addresses" 162 ) 163 } 164 165 /// Restores cursors across gaps left by addresses issued to pending transactions that never 166 /// confirmed. Hybrid authorizations retain the wallet's Ed25519 public key, so confirmed 167 /// wallet-authored transactions provide an unambiguous recovery target without persisting 168 /// secret child-key material. 169 fn recover_historical_hybrid_address_cursors(&self, wallet: &Wallet) -> Result<()> { 170 if wallet.historical_address_recovery_complete() { 171 return Ok(()); 172 } 173 174 let mut targets = self.wallet_authored_hybrid_addresses(wallet)?; 175 let mut highest_external = None; 176 let mut highest_reward = None; 177 for index in 0..MAX_DISCOVERED_EXTERNAL_ADDRESSES { 178 let external = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, index); 179 if targets.contains(&external) { 180 targets.retain(|target| *target != external); 181 highest_external = Some(index); 182 } 183 let reward = wallet.hybrid_versioned_address_at(HybridAddressBranch::Reward, index); 184 if targets.contains(&reward) { 185 targets.retain(|target| *target != reward); 186 highest_reward = Some(index); 187 } 188 if targets.is_empty() { 189 break; 190 } 191 } 192 193 if let Some(index) = highest_external.and_then(|index| index.checked_add(1)) { 194 wallet.advance_external_address_cursor(index); 195 } 196 if let Some(index) = highest_reward.and_then(|index| index.checked_add(1)) { 197 wallet.advance_reward_address_cursor(index); 198 } 199 wallet.mark_historical_address_recovery_complete(); 200 Ok(()) 201 } 202 203 fn wallet_authored_hybrid_addresses(&self, wallet: &Wallet) -> Result<Vec<VersionedAddress>> { 204 let legacy_public_key = wallet.legacy_versioned_address().payload; 205 let domain = self.transaction_v2_domain()?; 206 let mut owned = Vec::new(); 207 for block in self.chain() { 208 for envelope in &block.transactions_v2 { 209 let bytes = decode_hex(envelope).context("invalid confirmed transaction-v2 hex")?; 210 let (decoded_domain, transaction) = TransactionV2::decode(&bytes)?; 211 if decoded_domain != domain { 212 continue; 213 } 214 match &transaction { 215 TransactionV2::Migration { 216 outputs, 217 authorizations, 218 .. 219 } => { 220 if authorizations.iter().any(|authorization| { 221 authorization.scheme() == SignatureScheme::Ed25519 222 && authorization.public_key().as_bytes() == legacy_public_key 223 }) { 224 for output in outputs { 225 push_unique_address(&mut owned, output.address); 226 } 227 } 228 } 229 TransactionV2::Transfer { 230 inputs, 231 outputs, 232 authorizations, 233 .. 234 } => { 235 let authored = collect_wallet_hybrid_input_addresses( 236 inputs, 237 authorizations, 238 &legacy_public_key, 239 &mut owned, 240 ); 241 if authored { 242 // Native and browser builders place the external recipient first and 243 // deterministic wallet change, when present, in subsequent outputs. 244 for output in outputs.iter().skip(1) { 245 push_unique_address(&mut owned, output.address); 246 } 247 } 248 } 249 TransactionV2::Burn { 250 inputs, 251 change, 252 authorizations, 253 .. 254 } => { 255 if collect_wallet_hybrid_input_addresses( 256 inputs, 257 authorizations, 258 &legacy_public_key, 259 &mut owned, 260 ) { 261 for output in change { 262 push_unique_address(&mut owned, output.address); 263 } 264 } 265 } 266 TransactionV2::Mine { .. } => {} 267 } 268 } 269 } 270 Ok(owned) 271 } 272 273 fn used_hybrid_addresses(&self) -> Result<Vec<VersionedAddress>> { 274 let mut used = self.pending_hybrid_addresses(); 275 let network = self.address_network(); 276 let domain = self.transaction_v2_domain()?; 277 for block in self.chain() { 278 if let Some(address) = block.reward_address.as_deref() { 279 if let Ok(address) = decode_versioned_address(address, network) { 280 if address.version == super::AddressVersion::HybridKeyCommitment { 281 push_unique_address(&mut used, address); 282 } 283 } 284 } 285 for transaction in &block.transactions { 286 collect_legacy_hybrid_outputs(transaction, network, &mut used); 287 } 288 for envelope in &block.transactions_v2 { 289 let bytes = decode_hex(envelope).context("invalid confirmed transaction-v2 hex")?; 290 let (decoded_domain, transaction) = TransactionV2::decode(&bytes)?; 291 if decoded_domain == domain { 292 collect_v2_output_addresses(&transaction, &mut used); 293 } 294 } 295 } 296 Ok(used) 297 } 298 299 /// Returns every hybrid address that has appeared as a transaction or reward output, 300 /// including unconfirmed outputs. External wallets use this to recover their deterministic 301 /// receive cursor without exposing their seed or public-key material to the node. 302 pub fn used_hybrid_encoded_addresses(&self) -> Result<Vec<String>> { 303 let network = self.address_network(); 304 self.used_hybrid_addresses()? 305 .into_iter() 306 .map(|address| encode_versioned_address(address, network)) 307 .collect() 308 } 309 310 fn pending_hybrid_addresses(&self) -> Vec<VersionedAddress> { 311 let mut used = Vec::new(); 312 let network = self.address_network(); 313 for transaction in &self.pending { 314 collect_legacy_hybrid_outputs(transaction, network, &mut used); 315 } 316 for transaction in &self.pending_v2 { 317 collect_v2_output_addresses(transaction, &mut used); 318 } 319 used 320 } 321 322 fn spent_hybrid_addresses(&self) -> Result<Vec<VersionedAddress>> { 323 let mut spent = self.pending_spent_hybrid_addresses(); 324 let domain = self.transaction_v2_domain()?; 325 for block in self.chain() { 326 for envelope in &block.transactions_v2 { 327 let bytes = decode_hex(envelope).context("invalid confirmed transaction-v2 hex")?; 328 let (decoded_domain, transaction) = TransactionV2::decode(&bytes)?; 329 if decoded_domain == domain { 330 collect_v2_input_addresses(&transaction, &mut spent); 331 } 332 } 333 } 334 Ok(spent) 335 } 336 337 fn pending_spent_hybrid_addresses(&self) -> Vec<VersionedAddress> { 338 let mut spent = Vec::new(); 339 for transaction in &self.pending_v2 { 340 collect_v2_input_addresses(transaction, &mut spent); 341 } 342 spent 343 } 344 345 /// Builds one consolidation transaction from every currently spendable legacy wallet output 346 /// into the wallet's hybrid address. Submission remains subject to the height-3000 gate. 347 pub fn build_v2_migration(&self, wallet: &Wallet, fee: Amount) -> Result<TransactionV2> { 348 let available = self.available_utxos_for_address(wallet.address())?; 349 if available.is_empty() { 350 bail!("no legacy outputs are available for migration"); 351 } 352 self.build_v2_migration_from_available(wallet, fee, &available, true) 353 } 354 355 /// Builds the largest deterministic prefix of legacy outputs that fits one block. 356 pub fn build_v2_migration_batch(&self, wallet: &Wallet, fee: Amount) -> Result<TransactionV2> { 357 let available = self.available_utxos_for_address(wallet.address())?; 358 if available.is_empty() { 359 bail!("no legacy outputs are available for migration"); 360 } 361 let mut input_count = available.len().min(1_000); 362 loop { 363 let transaction = self.build_v2_migration_from_available( 364 wallet, 365 fee, 366 &available[..input_count], 367 false, 368 )?; 369 let bytes = transaction.encoded_size_bytes(&self.transaction_v2_domain()?)?; 370 if ensure_v2_transaction_within_block_budget( 371 self, 372 &transaction, 373 &self.transaction_v2_domain()?, 374 self.launch_profile.max_block_bytes, 375 ) 376 .is_ok() 377 { 378 return Ok(transaction); 379 } 380 if input_count == 1 { 381 bail!( 382 "one-input migration requires {bytes} bytes and exceeds the {}-byte block budget", 383 self.launch_profile.max_block_bytes 384 ); 385 } 386 let proportional = ((input_count as u128) 387 .saturating_mul(self.launch_profile.max_block_bytes as u128) 388 / bytes as u128) as usize; 389 input_count = proportional.clamp(1, input_count - 1); 390 } 391 } 392 393 fn build_v2_migration_from_available( 394 &self, 395 wallet: &Wallet, 396 fee: Amount, 397 available: &[(OutPoint, TxOutput)], 398 enforce_block_budget: bool, 399 ) -> Result<TransactionV2> { 400 let mut total = 0_u64; 401 let mut inputs = Vec::with_capacity(available.len()); 402 for (outpoint, output) in available { 403 total = total 404 .checked_add(output.amount) 405 .context("migration input total overflows")?; 406 inputs.push(TransactionV2LegacyInput { 407 outpoint_id: legacy_transaction_id(&outpoint.txid)?, 408 outpoint_index: outpoint.index, 409 owner: wallet.legacy_versioned_address(), 410 }); 411 } 412 let migrated_amount = total 413 .checked_sub(fee) 414 .context("migration fee exceeds available value")?; 415 if migrated_amount == 0 { 416 bail!("migration output must be greater than zero"); 417 } 418 419 let domain = TransactionV2Domain::new( 420 self.launch_profile.profile_id.clone(), 421 decode_hex_array::<32>(self.genesis_hash()) 422 .context("ledger genesis hash is not a 32-byte hexadecimal value")?, 423 )?; 424 let mut transaction = TransactionV2::Migration { 425 inputs, 426 outputs: vec![TransactionV2Output { 427 address: wallet.hybrid_versioned_address(), 428 amount: migrated_amount, 429 }], 430 fee, 431 authorizations: Vec::new(), 432 }; 433 let payload = transaction.signing_bytes(&domain)?; 434 let authorization = 435 wallet.sign_v2_authorization(wallet.legacy_versioned_address(), &payload)?; 436 if let TransactionV2::Migration { 437 inputs, 438 authorizations, 439 .. 440 } = &mut transaction 441 { 442 authorizations.resize(inputs.len(), authorization); 443 } 444 transaction.verify_authorizations(&domain)?; 445 if enforce_block_budget { 446 ensure_v2_transaction_within_block_budget( 447 self, 448 &transaction, 449 &domain, 450 self.launch_profile.max_block_bytes, 451 )?; 452 } 453 Ok(transaction) 454 } 455 456 pub fn build_v2_transfer( 457 &self, 458 wallet: &Wallet, 459 recipient: VersionedAddress, 460 amount: Amount, 461 fee: Amount, 462 ) -> Result<TransactionV2> { 463 if recipient.version != super::AddressVersion::HybridKeyCommitment { 464 bail!("transaction v2 recipient must use address v1"); 465 } 466 if amount == 0 { 467 bail!("transfer amount must be greater than zero"); 468 } 469 let required = amount 470 .checked_add(fee) 471 .context("transfer amount plus fee overflows")?; 472 let mut available = Vec::new(); 473 for owner in self.wallet_owned_hybrid_addresses(wallet)? { 474 let owner_address = encode_versioned_address(owner, self.address_network())?; 475 available.extend( 476 self.available_utxos_for_address(&owner_address)? 477 .into_iter() 478 .map(|(outpoint, output)| (outpoint, output, owner)), 479 ); 480 } 481 available.sort_by(|(left_point, left, _), (right_point, right, _)| { 482 right 483 .amount 484 .cmp(&left.amount) 485 .then_with(|| left_point.cmp(right_point)) 486 }); 487 488 let mut total = 0_u64; 489 let mut inputs = Vec::new(); 490 for (outpoint, output, owner) in available { 491 total = total 492 .checked_add(output.amount) 493 .context("transaction v2 input total overflows")?; 494 inputs.push(TransactionV2Input { 495 outpoint_txid: decode_hex_array::<32>(&outpoint.txid) 496 .context("transaction v2 outpoint ID must be a 32-byte hash")?, 497 outpoint_index: outpoint.index, 498 owner, 499 }); 500 if total >= required { 501 break; 502 } 503 } 504 if total < required { 505 bail!("insufficient hybrid funds"); 506 } 507 508 let mut outputs = vec![TransactionV2Output { 509 address: recipient, 510 amount, 511 }]; 512 let change = total - required; 513 if change > 0 { 514 let change_address = self 515 .wallet_external_addresses(wallet)? 516 .last() 517 .map(|(_, address)| *address) 518 .context("wallet change address is unavailable")?; 519 outputs.push(TransactionV2Output { 520 address: change_address, 521 amount: change, 522 }); 523 } 524 let domain = self.transaction_v2_domain()?; 525 let mut transaction = TransactionV2::Transfer { 526 inputs, 527 outputs, 528 fee, 529 authorizations: Vec::new(), 530 }; 531 let payload = transaction.signing_bytes(&domain)?; 532 if let TransactionV2::Transfer { 533 inputs, 534 authorizations, 535 .. 536 } = &mut transaction 537 { 538 *authorizations = inputs 539 .iter() 540 .map(|input| wallet.sign_v2_authorization(input.owner, &payload)) 541 .collect::<Result<Vec<_>>>()?; 542 } 543 transaction.verify_authorizations(&domain)?; 544 ensure_v2_transaction_within_block_budget( 545 self, 546 &transaction, 547 &domain, 548 self.launch_profile.max_block_bytes, 549 )?; 550 Ok(transaction) 551 } 552 553 /// Builds a transaction-v2 transfer that consolidates exactly the selected hybrid outputs. 554 /// Inputs may belong to different derived addresses owned by the same wallet. 555 pub(crate) fn build_v2_consolidation_with_inputs( 556 &self, 557 wallet: &Wallet, 558 amount: Amount, 559 fee: Amount, 560 outpoints: &[OutPoint], 561 ) -> Result<TransactionV2> { 562 if amount == 0 { 563 bail!("transfer amount must be greater than zero"); 564 } 565 let recipient = self 566 .wallet_external_addresses(wallet)? 567 .last() 568 .map(|(_, address)| *address) 569 .context("wallet receive address is unavailable")?; 570 571 let mut available = Vec::new(); 572 for owner in self.wallet_owned_hybrid_addresses(wallet)? { 573 let owner_address = encode_versioned_address(owner, self.address_network())?; 574 available.extend( 575 self.available_utxos_for_address(&owner_address)? 576 .into_iter() 577 .map(|(outpoint, output)| (outpoint, output, owner)), 578 ); 579 } 580 let available = available 581 .into_iter() 582 .map(|(outpoint, output, owner)| (outpoint, (output, owner))) 583 .collect::<std::collections::BTreeMap<_, _>>(); 584 585 let mut seen = std::collections::BTreeSet::new(); 586 let mut total = 0_u64; 587 let mut inputs = Vec::with_capacity(outpoints.len()); 588 for outpoint in outpoints { 589 if !seen.insert(outpoint) { 590 bail!("outputs changed or are reserved; review a new preview"); 591 } 592 let (output, owner) = available 593 .get(outpoint) 594 .context("output is no longer an available hybrid output in this wallet")?; 595 total = total 596 .checked_add(output.amount) 597 .context("transaction v2 input total overflows")?; 598 inputs.push(TransactionV2Input { 599 outpoint_txid: decode_hex_array::<32>(&outpoint.txid) 600 .context("transaction v2 outpoint ID must be a 32-byte hash")?, 601 outpoint_index: outpoint.index, 602 owner: *owner, 603 }); 604 } 605 if total 606 != amount 607 .checked_add(fee) 608 .context("amount plus fee overflows")? 609 { 610 bail!("selected hybrid outputs do not match amount plus fee"); 611 } 612 613 let domain = self.transaction_v2_domain()?; 614 let mut transaction = TransactionV2::Transfer { 615 inputs, 616 outputs: vec![TransactionV2Output { 617 address: recipient, 618 amount, 619 }], 620 fee, 621 authorizations: Vec::new(), 622 }; 623 let payload = transaction.signing_bytes(&domain)?; 624 if let TransactionV2::Transfer { 625 inputs, 626 authorizations, 627 .. 628 } = &mut transaction 629 { 630 *authorizations = inputs 631 .iter() 632 .map(|input| wallet.sign_v2_authorization(input.owner, &payload)) 633 .collect::<Result<Vec<_>>>()?; 634 } 635 transaction.verify_authorizations(&domain)?; 636 ensure_v2_transaction_within_block_budget( 637 self, 638 &transaction, 639 &domain, 640 self.launch_profile.max_block_bytes, 641 )?; 642 Ok(transaction) 643 } 644 645 pub fn build_v2_burn( 646 &self, 647 wallet: &Wallet, 648 amount: Amount, 649 fee: Amount, 650 ) -> Result<TransactionV2> { 651 self.build_v2_burn_with_anchor(wallet, amount, fee, self.tip_hash()) 652 } 653 654 pub(crate) fn build_v2_burn_for_next_block( 655 &self, 656 wallet: &Wallet, 657 amount: Amount, 658 fee: Amount, 659 ) -> Result<TransactionV2> { 660 self.build_v2_burn_with_anchor(wallet, amount, fee, &self.tip().prev_hash) 661 } 662 663 fn build_v2_burn_with_anchor( 664 &self, 665 wallet: &Wallet, 666 amount: Amount, 667 fee: Amount, 668 anchor: &str, 669 ) -> Result<TransactionV2> { 670 if amount == 0 { 671 bail!("burn amount must be greater than zero"); 672 } 673 let required = amount 674 .checked_add(fee) 675 .context("burn amount plus fee overflows")?; 676 let mut selected = None; 677 for owner in self.wallet_owned_hybrid_addresses(wallet)? { 678 let owner_address = encode_versioned_address(owner, self.address_network())?; 679 let mut available = self.available_utxos_for_address(&owner_address)?; 680 available.sort_by(|(left_point, left), (right_point, right)| { 681 right 682 .amount 683 .cmp(&left.amount) 684 .then_with(|| left_point.cmp(right_point)) 685 }); 686 let total = available.iter().try_fold(0_u64, |total, (_, output)| { 687 total 688 .checked_add(output.amount) 689 .context("transaction v2 input total overflows") 690 })?; 691 if total >= required { 692 selected = Some((owner, available)); 693 break; 694 } 695 } 696 let Some((owner, available)) = selected else { 697 bail!("insufficient hybrid funds in one address for burn"); 698 }; 699 let mut total = 0_u64; 700 let mut inputs = Vec::new(); 701 for (outpoint, output) in available { 702 total = total 703 .checked_add(output.amount) 704 .context("transaction v2 input total overflows")?; 705 inputs.push(TransactionV2Input { 706 outpoint_txid: decode_hex_array::<32>(&outpoint.txid) 707 .context("transaction v2 outpoint ID must be a 32-byte hash")?, 708 outpoint_index: outpoint.index, 709 owner, 710 }); 711 if total >= required { 712 break; 713 } 714 } 715 if total < required { 716 bail!("insufficient hybrid funds"); 717 } 718 let change_amount = total - required; 719 let change_address = self 720 .wallet_external_addresses(wallet)? 721 .last() 722 .map(|(_, address)| *address) 723 .context("wallet change address is unavailable")?; 724 let change = (change_amount > 0) 725 .then_some(TransactionV2Output { 726 address: change_address, 727 amount: change_amount, 728 }) 729 .into_iter() 730 .collect(); 731 let domain = self.transaction_v2_domain()?; 732 let mut transaction = TransactionV2::Burn { 733 inputs, 734 change, 735 amount, 736 fee, 737 anchor: Some( 738 decode_hex_array::<32>(anchor).context("transaction v2 burn anchor is invalid")?, 739 ), 740 authorizations: Vec::new(), 741 }; 742 let payload = transaction.signing_bytes(&domain)?; 743 let authorization = wallet.sign_v2_authorization(owner, &payload)?; 744 if let TransactionV2::Burn { 745 inputs, 746 authorizations, 747 .. 748 } = &mut transaction 749 { 750 authorizations.resize(inputs.len(), authorization); 751 } 752 transaction.verify_authorizations(&domain)?; 753 ensure_v2_transaction_within_block_budget( 754 self, 755 &transaction, 756 &domain, 757 self.launch_profile.max_block_bytes, 758 )?; 759 Ok(transaction) 760 } 761 762 pub fn build_transfer( 763 &self, 764 wallet: &Wallet, 765 to: impl Into<String>, 766 amount: Amount, 767 fee: Amount, 768 ) -> Result<Transaction> { 769 let to = to.into(); 770 validate_address(&to, "transfer recipient")?; 771 let required = amount 772 .checked_add(fee) 773 .context("transfer amount plus fee overflows")?; 774 let mut available = self.available_utxos_for_address(wallet.address())?; 775 // Prefer the smallest sufficient single output. Otherwise minimize input count. 776 if let Some((point, _)) = available 777 .iter() 778 .filter(|(_, output)| output.amount >= required) 779 .min_by_key(|(point, output)| (output.amount, point)) 780 { 781 return self.build_transfer_with_inputs( 782 wallet, 783 to, 784 amount, 785 fee, 786 std::slice::from_ref(point), 787 ); 788 } 789 available.sort_by(|(left_point, left), (right_point, right)| { 790 right 791 .amount 792 .cmp(&left.amount) 793 .then_with(|| left_point.cmp(right_point)) 794 }); 795 let mut total = 0_u64; 796 let mut points = Vec::new(); 797 for (point, output) in available { 798 total = total 799 .checked_add(output.amount) 800 .context("selected input total overflows")?; 801 points.push(point); 802 if total >= required { 803 break; 804 } 805 } 806 if total < required { 807 bail!("insufficient funds for {}", wallet.address()); 808 } 809 self.build_transfer_with_inputs(wallet, to, amount, fee, &points) 810 } 811 812 pub fn build_transfer_with_inputs( 813 &self, 814 wallet: &Wallet, 815 to: impl Into<String>, 816 amount: Amount, 817 fee: Amount, 818 outpoints: &[OutPoint], 819 ) -> Result<Transaction> { 820 let to = to.into(); 821 validate_address(&to, "transfer recipient")?; 822 let required = amount 823 .checked_add(fee) 824 .context("transfer amount plus fee overflows")?; 825 let (inputs, input_total) = 826 self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?; 827 let mut outputs = vec![TxOutput { 828 address: to, 829 amount, 830 }]; 831 let change = input_total 832 .checked_sub(required) 833 .context("selected inputs do not cover transfer")?; 834 if change > 0 { 835 outputs.push(TxOutput { 836 address: wallet.address().to_string(), 837 amount: change, 838 }); 839 } 840 let transaction = UnsignedUtxoTransaction::Transfer { 841 inputs, 842 outputs, 843 fee, 844 } 845 .sign(wallet, &self.transaction_signing_domain())?; 846 self.validate_new_transaction(&transaction)?; 847 Ok(transaction) 848 } 849 850 pub fn build_burn(&self, wallet: &Wallet, amount: Amount, fee: Amount) -> Result<Transaction> { 851 let required = amount 852 .checked_add(fee) 853 .context("burn amount plus fee overflows")?; 854 let (inputs, input_total) = self.select_inputs(wallet.address(), required)?; 855 self.build_burn_from_inputs( 856 wallet, 857 amount, 858 fee, 859 inputs, 860 input_total, 861 self.public_burn_anchor(), 862 ) 863 } 864 865 pub fn build_burn_with_inputs( 866 &self, 867 wallet: &Wallet, 868 amount: Amount, 869 fee: Amount, 870 outpoints: &[OutPoint], 871 ) -> Result<Transaction> { 872 let required = amount 873 .checked_add(fee) 874 .context("burn amount plus fee overflows")?; 875 let (inputs, input_total) = 876 self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?; 877 self.build_burn_from_inputs( 878 wallet, 879 amount, 880 fee, 881 inputs, 882 input_total, 883 self.public_burn_anchor(), 884 ) 885 } 886 887 #[cfg(test)] 888 pub(crate) fn build_burn_for_next_block( 889 &self, 890 wallet: &Wallet, 891 amount: Amount, 892 fee: Amount, 893 ) -> Result<Transaction> { 894 let required = amount 895 .checked_add(fee) 896 .context("burn amount plus fee overflows")?; 897 let (inputs, input_total) = self.select_inputs(wallet.address(), required)?; 898 self.build_burn_from_inputs( 899 wallet, 900 amount, 901 fee, 902 inputs, 903 input_total, 904 self.next_block_burn_anchor(), 905 ) 906 } 907 908 pub(crate) fn build_burn_for_next_block_with_inputs( 909 &self, 910 wallet: &Wallet, 911 amount: Amount, 912 fee: Amount, 913 outpoints: &[OutPoint], 914 ) -> Result<Transaction> { 915 let required = amount 916 .checked_add(fee) 917 .context("burn amount plus fee overflows")?; 918 let (inputs, input_total) = 919 self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?; 920 self.build_burn_from_inputs( 921 wallet, 922 amount, 923 fee, 924 inputs, 925 input_total, 926 self.next_block_burn_anchor(), 927 ) 928 } 929 930 fn build_burn_from_inputs( 931 &self, 932 wallet: &Wallet, 933 amount: Amount, 934 fee: Amount, 935 inputs: Vec<UnsignedTxInput>, 936 input_total: Amount, 937 anchor: Option<String>, 938 ) -> Result<Transaction> { 939 let signing_height = if anchor.as_deref() == Some(self.tip_hash()) { 940 self.height().saturating_add(2) 941 } else { 942 self.height().saturating_add(1) 943 }; 944 let required = amount 945 .checked_add(fee) 946 .context("burn amount plus fee overflows")?; 947 let change_amount = input_total 948 .checked_sub(required) 949 .context("selected inputs do not cover burn")?; 950 let change = if change_amount > 0 { 951 vec![TxOutput { 952 address: wallet.address().to_string(), 953 amount: change_amount, 954 }] 955 } else { 956 Vec::new() 957 }; 958 let transaction = UnsignedUtxoTransaction::Burn { 959 inputs, 960 change, 961 amount, 962 fee, 963 anchor, 964 } 965 .sign(wallet, &self.transaction_signing_domain_at(signing_height))?; 966 self.validate_new_transaction(&transaction)?; 967 Ok(transaction) 968 } 969 970 fn public_burn_anchor(&self) -> Option<String> { 971 // Public burns enter a one-block queue: a burn signed at tip H is 972 // eligible in the block after H's direct child. 973 (self.height().saturating_add(2) >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT) 974 .then(|| self.tip().hash.clone()) 975 } 976 977 fn next_block_burn_anchor(&self) -> Option<String> { 978 // A finalizer learns its role only after the parent exists, so its 979 // mandatory local burn is signed directly against that parent's parent. 980 (self.height().saturating_add(1) >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT) 981 .then(|| self.tip().prev_hash.clone()) 982 } 983 984 pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> { 985 let recipient = recipient.into(); 986 self.validate_mine_reward_address(&recipient)?; 987 let anchor = self.tip().hash.clone(); 988 let salt = 1; 989 let difficulty_bits = self.current_mine_difficulty_bits(); 990 let signing_domain = self.transaction_signing_domain(); 991 for nonce in 0..u64::MAX { 992 let signature = mine_signature( 993 &signing_domain, 994 &recipient, 995 &anchor, 996 salt, 997 nonce, 998 difficulty_bits, 999 )?; 1000 if !hash_meets_difficulty(&signature, difficulty_bits) { 1001 continue; 1002 } 1003 let transaction = Transaction::Mine { 1004 recipient: recipient.clone(), 1005 anchor: anchor.clone(), 1006 salt, 1007 nonce, 1008 difficulty_bits, 1009 proof_header: None, 1010 signature, 1011 }; 1012 if self.has_transaction(transaction.signature()) { 1013 continue; 1014 } 1015 self.validate_new_transaction(&transaction)?; 1016 return Ok(transaction); 1017 } 1018 bail!("could not find valid mine proof"); 1019 } 1020 1021 pub fn search_mine( 1022 &self, 1023 recipient: impl Into<String>, 1024 salt: u64, 1025 start_nonce: u64, 1026 max_attempts: u64, 1027 ) -> Result<MineSearchOutcome> { 1028 let recipient = recipient.into(); 1029 self.validate_mine_reward_address(&recipient)?; 1030 let anchor = self.tip().hash.clone(); 1031 let difficulty_bits = self.current_mine_difficulty_bits(); 1032 let signing_domain = self.transaction_signing_domain(); 1033 let mut attempts = 0_u64; 1034 let mut nonce = start_nonce; 1035 while attempts < max_attempts { 1036 let signature = mine_signature( 1037 &signing_domain, 1038 &recipient, 1039 &anchor, 1040 salt, 1041 nonce, 1042 difficulty_bits, 1043 )?; 1044 attempts = attempts.saturating_add(1); 1045 let next_nonce = nonce.checked_add(1).unwrap_or(0); 1046 if hash_meets_difficulty(&signature, difficulty_bits) { 1047 let transaction = Transaction::Mine { 1048 recipient: recipient.clone(), 1049 anchor: anchor.clone(), 1050 salt, 1051 nonce, 1052 difficulty_bits, 1053 proof_header: None, 1054 signature, 1055 }; 1056 if !self.has_transaction(transaction.signature()) { 1057 self.validate_new_transaction(&transaction)?; 1058 return Ok(MineSearchOutcome { 1059 transaction: Some(transaction), 1060 next_nonce, 1061 attempts, 1062 }); 1063 } 1064 } 1065 nonce = next_nonce; 1066 } 1067 Ok(MineSearchOutcome { 1068 transaction: None, 1069 next_nonce: nonce, 1070 attempts, 1071 }) 1072 } 1073 1074 pub fn stratum_mine_template( 1075 &self, 1076 recipient: impl Into<String>, 1077 anchor: impl AsRef<str>, 1078 salt: u64, 1079 difficulty_bits: u32, 1080 ) -> Result<StratumMineTemplate> { 1081 let recipient = recipient.into(); 1082 self.validate_mine_reward_address(&recipient)?; 1083 stratum_mine_template( 1084 &self.transaction_signing_domain(), 1085 recipient, 1086 anchor.as_ref(), 1087 salt, 1088 difficulty_bits, 1089 ) 1090 } 1091 1092 fn validate_mine_reward_address(&self, recipient: &str) -> Result<()> { 1093 let height = self.height().saturating_add(1); 1094 if height < super::HYBRID_REWARD_ACTIVATION_HEIGHT { 1095 return validate_address(recipient, "mine recipient"); 1096 } 1097 let address = super::decode_versioned_address( 1098 recipient, 1099 AddressNetwork::from_profile_id(&self.launch_profile.profile_id), 1100 )?; 1101 if address.version != super::AddressVersion::HybridKeyCommitment { 1102 bail!("mine reward must use a hybrid address at height {height}"); 1103 } 1104 Ok(()) 1105 } 1106 1107 pub fn build_stratum_mine( 1108 &self, 1109 template: StratumMineTemplate, 1110 share: StratumMineShare, 1111 ) -> Result<Transaction> { 1112 let nonce = super::stratum::pack_stratum_nonce(share.extranonce2, share.header_nonce); 1113 let header = stratum_mine_header_bytes( 1114 &self.transaction_signing_domain(), 1115 &template.recipient, 1116 &template.anchor, 1117 template.salt, 1118 nonce, 1119 template.difficulty_bits, 1120 )?; 1121 let transaction = Transaction::Mine { 1122 recipient: template.recipient, 1123 anchor: template.anchor, 1124 salt: template.salt, 1125 nonce, 1126 difficulty_bits: template.difficulty_bits, 1127 proof_header: Some(hex_encode(header)), 1128 signature: stratum_mine_signature(&header), 1129 }; 1130 self.validate_new_transaction(&transaction)?; 1131 Ok(transaction) 1132 } 1133 } 1134 1135 fn collect_legacy_hybrid_outputs( 1136 transaction: &Transaction, 1137 network: AddressNetwork, 1138 used: &mut Vec<VersionedAddress>, 1139 ) { 1140 for output in transaction.outputs() { 1141 if let Ok(address) = decode_versioned_address(&output.address, network) { 1142 if address.version == super::AddressVersion::HybridKeyCommitment { 1143 push_unique_address(used, address); 1144 } 1145 } 1146 } 1147 } 1148 1149 fn collect_v2_output_addresses(transaction: &TransactionV2, used: &mut Vec<VersionedAddress>) { 1150 let outputs = match transaction { 1151 TransactionV2::Migration { outputs, .. } | TransactionV2::Transfer { outputs, .. } => { 1152 outputs.as_slice() 1153 } 1154 TransactionV2::Burn { change, .. } => change.as_slice(), 1155 TransactionV2::Mine { recipient, .. } => { 1156 push_unique_address(used, *recipient); 1157 return; 1158 } 1159 }; 1160 for output in outputs { 1161 push_unique_address(used, output.address); 1162 } 1163 } 1164 1165 fn collect_v2_input_addresses(transaction: &TransactionV2, spent: &mut Vec<VersionedAddress>) { 1166 let inputs = match transaction { 1167 TransactionV2::Transfer { inputs, .. } | TransactionV2::Burn { inputs, .. } => { 1168 inputs.as_slice() 1169 } 1170 TransactionV2::Migration { .. } | TransactionV2::Mine { .. } => return, 1171 }; 1172 for input in inputs { 1173 push_unique_address(spent, input.owner); 1174 } 1175 } 1176 1177 fn collect_wallet_hybrid_input_addresses( 1178 inputs: &[TransactionV2Input], 1179 authorizations: &[V2SpendingAuthorization], 1180 legacy_public_key: &[u8; 32], 1181 owned: &mut Vec<VersionedAddress>, 1182 ) -> bool { 1183 let mut authored = false; 1184 for (input, authorization) in inputs.iter().zip(authorizations) { 1185 let public_key = authorization.public_key().as_bytes(); 1186 if authorization.scheme() == SignatureScheme::HybridEd25519MlDsa44 1187 && public_key.get(..legacy_public_key.len()) == Some(legacy_public_key) 1188 { 1189 push_unique_address(owned, input.owner); 1190 authored = true; 1191 } 1192 } 1193 authored 1194 } 1195 1196 fn push_unique_address(addresses: &mut Vec<VersionedAddress>, address: VersionedAddress) { 1197 if !addresses.contains(&address) { 1198 addresses.push(address); 1199 } 1200 } 1201 1202 fn legacy_transaction_id(txid: &str) -> Result<LegacyTransactionId> { 1203 let bytes = decode_hex(txid).context("legacy outpoint ID is not hexadecimal")?; 1204 match bytes.len() { 1205 32 => Ok(LegacyTransactionId::Hash( 1206 bytes.try_into().expect("checked legacy hash length"), 1207 )), 1208 64 => Ok(LegacyTransactionId::Signature( 1209 bytes.try_into().expect("checked legacy signature length"), 1210 )), 1211 length => bail!("legacy outpoint ID must contain 32 or 64 bytes, got {length}"), 1212 } 1213 } 1214 1215 fn ensure_v2_transaction_within_block_budget( 1216 ledger: &Ledger, 1217 transaction: &TransactionV2, 1218 domain: &TransactionV2Domain, 1219 max_block_bytes: usize, 1220 ) -> Result<()> { 1221 let transaction_bytes = transaction.encode(domain)?.len(); 1222 if transaction_bytes > max_block_bytes { 1223 bail!( 1224 "transaction v2 requires {transaction_bytes} bytes and exceeds the {max_block_bytes}-byte block budget" 1225 ); 1226 } 1227 ensure_transaction_v2_fits_empty_block( 1228 compact_block_context(ledger), 1229 &hex_encode(transaction.encode(domain)?), 1230 max_block_bytes, 1231 )?; 1232 Ok(()) 1233 } 1234 1235 #[cfg(test)] 1236 mod v2_migration_tests { 1237 use std::collections::BTreeMap; 1238 1239 use super::*; 1240 1241 #[test] 1242 fn mine_reward_destination_switches_to_hybrid_at_3750() { 1243 let wallet = Wallet::from_seed("hybrid-mine-reward-wallet"); 1244 let mut ledger = Ledger::new(BTreeMap::new(), 1); 1245 ledger.chain.last_mut().unwrap().height = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT - 2; 1246 assert!( 1247 ledger 1248 .validate_mine_reward_address(wallet.address()) 1249 .is_ok() 1250 ); 1251 1252 ledger.chain.last_mut().unwrap().height = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT - 1; 1253 assert!( 1254 ledger 1255 .validate_mine_reward_address(wallet.address()) 1256 .is_err() 1257 ); 1258 assert!( 1259 ledger 1260 .validate_mine_reward_address(&wallet.hybrid_address(AddressNetwork::Mainnet)) 1261 .is_ok() 1262 ); 1263 } 1264 1265 #[test] 1266 fn receive_and_change_advance_after_the_current_external_address_is_used() { 1267 let wallet = Wallet::from_seed("rotating-external-wallet"); 1268 let recipient = Wallet::from_seed("rotating-external-recipient"); 1269 let mut ledger = Ledger::new(BTreeMap::new(), 1); 1270 let owner = wallet.hybrid_versioned_address(); 1271 let initial_receive = ledger.wallet_receive_address(&wallet).unwrap(); 1272 assert_eq!( 1273 initial_receive, 1274 wallet.hybrid_address(AddressNetwork::Mainnet) 1275 ); 1276 1277 ledger.pending_v2.push(TransactionV2::Migration { 1278 inputs: Vec::new(), 1279 outputs: vec![TransactionV2Output { 1280 address: owner, 1281 amount: 100, 1282 }], 1283 fee: 0, 1284 authorizations: Vec::new(), 1285 }); 1286 let rotated_receive = ledger.wallet_receive_address(&wallet).unwrap(); 1287 assert_eq!( 1288 rotated_receive, 1289 wallet.hybrid_address_at(HybridAddressBranch::External, 1, AddressNetwork::Mainnet) 1290 ); 1291 let restored = Wallet::from_seed("rotating-external-wallet"); 1292 assert_eq!( 1293 ledger.wallet_receive_address(&restored).unwrap(), 1294 rotated_receive 1295 ); 1296 1297 ledger.utxos.insert( 1298 OutPoint { 1299 txid: "42".repeat(32), 1300 index: 0, 1301 }, 1302 TxOutput { 1303 address: initial_receive, 1304 amount: 100, 1305 }, 1306 ); 1307 let transaction = ledger 1308 .build_v2_transfer(&wallet, recipient.hybrid_versioned_address(), 40, 2) 1309 .unwrap(); 1310 let TransactionV2::Transfer { 1311 outputs, 1312 authorizations, 1313 .. 1314 } = transaction 1315 else { 1316 panic!("builder returned a non-transfer transaction"); 1317 }; 1318 assert_eq!( 1319 outputs[1].address, 1320 wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 1,) 1321 ); 1322 assert_eq!(authorizations[0].committed_address().unwrap(), owner); 1323 } 1324 1325 #[test] 1326 fn reward_address_rotates_after_its_hybrid_key_is_revealed_by_a_spend() { 1327 let wallet = Wallet::from_seed("rotating-reward-wallet"); 1328 let mut ledger = Ledger::new(BTreeMap::new(), 1); 1329 let activation = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT; 1330 1331 let first = ledger.wallet_reward_address(&wallet, activation); 1332 assert_eq!( 1333 first, 1334 ledger.wallet_reward_address(&wallet, activation + 1_000) 1335 ); 1336 let first_owner = wallet.hybrid_versioned_address_at(HybridAddressBranch::Reward, 0); 1337 ledger.pending_v2.push(TransactionV2::Burn { 1338 inputs: vec![TransactionV2Input { 1339 outpoint_txid: [0x42; 32], 1340 outpoint_index: 0, 1341 owner: first_owner, 1342 }], 1343 change: Vec::new(), 1344 amount: 1, 1345 fee: 1, 1346 anchor: Some([0x24; 32]), 1347 authorizations: Vec::new(), 1348 }); 1349 let second = ledger.wallet_reward_address(&wallet, activation + 1); 1350 assert_ne!(first, second); 1351 let restored = Wallet::from_seed("rotating-reward-wallet"); 1352 assert_eq!( 1353 ledger.wallet_reward_address(&restored, activation + 1), 1354 second 1355 ); 1356 1357 ledger.chain.last_mut().unwrap().height = activation; 1358 let owned = ledger 1359 .wallet_owned_hybrid_encoded_addresses(&wallet) 1360 .unwrap(); 1361 assert!(owned.contains(&first)); 1362 assert!(owned.contains(&second)); 1363 } 1364 1365 #[test] 1366 fn seed_recovery_discovers_used_external_addresses_across_a_gap() { 1367 let wallet = Wallet::from_seed("external-gap-recovery-wallet"); 1368 let mut ledger = Ledger::new(BTreeMap::new(), 1); 1369 let used_after_gap = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 2); 1370 ledger.pending_v2.push(TransactionV2::Transfer { 1371 inputs: Vec::new(), 1372 outputs: vec![TransactionV2Output { 1373 address: used_after_gap, 1374 amount: 10, 1375 }], 1376 fee: 0, 1377 authorizations: Vec::new(), 1378 }); 1379 1380 let restored = Wallet::from_seed("external-gap-recovery-wallet"); 1381 assert_eq!( 1382 ledger.wallet_receive_address(&restored).unwrap(), 1383 restored.hybrid_address_at(HybridAddressBranch::External, 3, AddressNetwork::Mainnet,) 1384 ); 1385 assert!( 1386 ledger 1387 .wallet_owned_hybrid_addresses(&restored) 1388 .unwrap() 1389 .contains(&used_after_gap) 1390 ); 1391 } 1392 1393 #[test] 1394 fn seed_recovery_crosses_abandoned_pending_address_gap_after_restart() { 1395 let seed = "abandoned-pending-gap-recovery-wallet"; 1396 let signing_wallet = Wallet::from_seed(seed); 1397 let mut ledger = Ledger::new(BTreeMap::new(), 1); 1398 let source_index = HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT + 7; 1399 let source = 1400 signing_wallet.hybrid_versioned_address_at(HybridAddressBranch::External, source_index); 1401 let change = signing_wallet 1402 .hybrid_versioned_address_at(HybridAddressBranch::External, source_index + 1); 1403 let mut burn = TransactionV2::Burn { 1404 inputs: vec![TransactionV2Input { 1405 outpoint_txid: [0x42; 32], 1406 outpoint_index: 0, 1407 owner: source, 1408 }], 1409 change: vec![TransactionV2Output { 1410 address: change, 1411 amount: 98, 1412 }], 1413 amount: 1, 1414 fee: 1, 1415 anchor: None, 1416 authorizations: Vec::new(), 1417 }; 1418 let domain = ledger.transaction_v2_domain().unwrap(); 1419 let payload = burn.signing_bytes(&domain).unwrap(); 1420 let authorization = signing_wallet 1421 .sign_v2_authorization(source, &payload) 1422 .unwrap(); 1423 let TransactionV2::Burn { authorizations, .. } = &mut burn else { 1424 unreachable!(); 1425 }; 1426 authorizations.push(authorization); 1427 ledger 1428 .chain 1429 .last_mut() 1430 .unwrap() 1431 .transactions_v2 1432 .push(hex_encode(burn.encode(&domain).unwrap())); 1433 1434 let restored = Wallet::from_seed(seed); 1435 let owned = ledger.wallet_owned_hybrid_addresses(&restored).unwrap(); 1436 assert!(owned.contains(&source)); 1437 assert!(owned.contains(&change)); 1438 assert_eq!( 1439 ledger.wallet_receive_address(&restored).unwrap(), 1440 restored.hybrid_address_at( 1441 HybridAddressBranch::External, 1442 source_index + 2, 1443 AddressNetwork::Mainnet, 1444 ) 1445 ); 1446 } 1447 1448 #[test] 1449 fn migration_builder_consolidates_legacy_value_into_one_hybrid_output() { 1450 let wallet = Wallet::from_seed("v2-migration-builder-wallet"); 1451 let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 1452 1453 let transaction = ledger.build_v2_migration(&wallet, 3).unwrap(); 1454 let TransactionV2::Migration { 1455 inputs, 1456 outputs, 1457 fee, 1458 authorizations, 1459 } = &transaction 1460 else { 1461 panic!("builder returned a non-migration transaction"); 1462 }; 1463 assert_eq!(inputs.len(), 1); 1464 assert!(matches!( 1465 inputs[0].outpoint_id, 1466 LegacyTransactionId::Hash(_) 1467 )); 1468 assert_eq!(outputs.len(), 1); 1469 assert_eq!(outputs[0].address, wallet.hybrid_versioned_address()); 1470 assert_eq!(outputs[0].amount, 97); 1471 assert_eq!(*fee, 3); 1472 assert_eq!(authorizations.len(), inputs.len()); 1473 1474 let domain = TransactionV2Domain::new( 1475 ledger.launch_profile.profile_id.clone(), 1476 decode_hex_array::<32>(ledger.genesis_hash()).unwrap(), 1477 ) 1478 .unwrap(); 1479 transaction.verify_authorizations(&domain).unwrap(); 1480 let encoded = transaction.encode(&domain).unwrap(); 1481 assert_eq!( 1482 TransactionV2::decode(&encoded).unwrap(), 1483 (domain, transaction) 1484 ); 1485 } 1486 1487 #[test] 1488 fn migration_builder_rejects_a_transaction_larger_than_the_block_budget() { 1489 let wallet = Wallet::from_seed("v2-oversized-migration-wallet"); 1490 let profile = crate::domain::LaunchProfile { 1491 max_block_bytes: 1, 1492 ..crate::domain::LaunchProfile::default() 1493 }; 1494 let ledger = Ledger::new_with_genesis_burns_and_profile( 1495 BTreeMap::from([(wallet.address().to_string(), 100)]), 1496 Vec::new(), 1497 1, 1498 profile, 1499 ) 1500 .unwrap(); 1501 1502 let error = ledger.build_v2_migration(&wallet, 1).unwrap_err(); 1503 assert!( 1504 error 1505 .to_string() 1506 .contains("exceeds the 1-byte block budget") 1507 ); 1508 } 1509 1510 #[test] 1511 fn hybrid_transfer_builder_spends_migrated_value_and_returns_hybrid_change() { 1512 let wallet = Wallet::from_seed("v2-transfer-builder-wallet"); 1513 let recipient = Wallet::from_seed("v2-transfer-builder-recipient"); 1514 let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 1515 let migration = ledger.build_v2_migration(&wallet, 3).unwrap(); 1516 let mut migrated = ledger.clone(); 1517 migrated.utxos = ledger 1518 .validated_v2_utxos_at_height(&migration, 3_000) 1519 .unwrap(); 1520 1521 let transfer = migrated 1522 .build_v2_transfer(&wallet, recipient.hybrid_versioned_address(), 40, 2) 1523 .unwrap(); 1524 let TransactionV2::Transfer { 1525 inputs, 1526 outputs, 1527 fee, 1528 authorizations, 1529 } = &transfer 1530 else { 1531 panic!("builder returned a non-transfer transaction"); 1532 }; 1533 1534 assert_eq!(inputs.len(), 1); 1535 assert_eq!(*fee, 2); 1536 assert_eq!(outputs.len(), 2); 1537 assert_eq!(outputs[0].address, recipient.hybrid_versioned_address()); 1538 assert_eq!(outputs[0].amount, 40); 1539 assert_eq!(outputs[1].address, wallet.hybrid_versioned_address()); 1540 assert_eq!(outputs[1].amount, 55); 1541 assert_eq!(authorizations.len(), inputs.len()); 1542 migrated 1543 .validate_transaction_v2_at_height(&transfer, 3_001) 1544 .unwrap(); 1545 } 1546 1547 #[test] 1548 fn migration_batch_fits_the_real_empty_block_budget() { 1549 let wallet = Wallet::from_seed("v2-migration-batch-wallet"); 1550 let profile = crate::domain::LaunchProfile { 1551 max_block_bytes: 2_000, 1552 ..crate::domain::LaunchProfile::default() 1553 }; 1554 let mut ledger = 1555 Ledger::new_with_genesis_burns_and_profile(BTreeMap::new(), Vec::new(), 1, profile) 1556 .unwrap(); 1557 ledger.utxos = (1_u64..=50) 1558 .map(|index| { 1559 ( 1560 OutPoint { 1561 txid: format!("{index:064x}"), 1562 index: 0, 1563 }, 1564 TxOutput { 1565 address: wallet.address().to_string(), 1566 amount: 10_000, 1567 }, 1568 ) 1569 }) 1570 .collect(); 1571 ledger.chain.last_mut().unwrap().height = 2_999; 1572 1573 let transaction = ledger.build_v2_migration_batch(&wallet, 1).unwrap(); 1574 let TransactionV2::Migration { inputs, .. } = &transaction else { 1575 panic!("builder returned a non-migration transaction"); 1576 }; 1577 assert!(!inputs.is_empty()); 1578 assert!(inputs.len() < 50); 1579 ledger.submit_transaction_v2(transaction).unwrap(); 1580 } 1581 }