wallet.rs (23079B)
1 use std::collections::BTreeMap; 2 3 use anyhow::{Context, Result, bail}; 4 5 use crate::domain::{ 6 AddressNetwork, Amount, Block, BurnBundle, DEFAULT_TRANSACTION_FEE, Ledger, OutPoint, 7 PreparedBlock, StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome, 8 TransactionV2, VersionedAddress, Wallet, hex_encode, run_vdf, 9 }; 10 11 use super::{ 12 ExternalMineJob, FeeEstimate, GossipEnvelope, NodeCore, QuantumMigrationPreview, 13 helpers::converge_fee_by_byte, now_ms, 14 }; 15 16 #[derive(Clone, Debug)] 17 pub(super) enum NodeWallet { 18 Unlocked(Wallet), 19 Locked { address: String }, 20 } 21 22 impl NodeWallet { 23 pub(super) fn address(&self) -> &str { 24 match self { 25 Self::Unlocked(wallet) => wallet.address(), 26 Self::Locked { address } => address, 27 } 28 } 29 30 pub(super) fn unlocked(&self) -> Result<&Wallet> { 31 match self { 32 Self::Unlocked(wallet) => Ok(wallet), 33 Self::Locked { .. } => bail!("wallet is locked"), 34 } 35 } 36 37 pub(super) fn is_locked(&self) -> bool { 38 matches!(self, Self::Locked { .. }) 39 } 40 } 41 42 impl NodeCore { 43 pub fn preview_quantum_migration( 44 &self, 45 fee_per_byte: Amount, 46 ) -> Result<QuantumMigrationPreview> { 47 if fee_per_byte == 0 { 48 bail!("migration fee per byte must be greater than zero"); 49 } 50 let ledger = self.wallet_build_ledger()?; 51 let wallet = self.wallet.unlocked()?; 52 let domain = ledger.transaction_v2_domain()?; 53 let initial = ledger.build_v2_migration_batch(wallet, 1)?; 54 let bytes = initial.encoded_size_bytes(&domain)?; 55 let fee = fee_per_byte 56 .checked_mul(bytes as u64) 57 .context("migration fee overflows")?; 58 let transaction = ledger.build_v2_migration_batch(wallet, fee)?; 59 let bytes = transaction.encoded_size_bytes(&domain)?; 60 let transaction_id = hex_encode(transaction.transaction_id(&domain)?); 61 let (input_count, amount) = match &transaction { 62 TransactionV2::Migration { 63 inputs, outputs, .. 64 } => ( 65 inputs.len(), 66 outputs.iter().try_fold(0_u64, |total, output| { 67 total 68 .checked_add(output.amount) 69 .context("migration amount overflows") 70 })?, 71 ), 72 _ => unreachable!("migration builder returned another transaction kind"), 73 }; 74 Ok(QuantumMigrationPreview { 75 address: wallet.hybrid_address(AddressNetwork::from_profile_id( 76 &ledger.launch_profile().profile_id, 77 )), 78 transaction_id, 79 input_count, 80 remaining_legacy_utxos: ledger 81 .available_utxos_for_address(wallet.address())? 82 .len() 83 .saturating_sub(input_count), 84 bytes, 85 fee, 86 amount, 87 }) 88 } 89 90 pub fn submit_quantum_migration( 91 &mut self, 92 fee_per_byte: Amount, 93 max_fee: Amount, 94 expected_transaction_id: &str, 95 ) -> Result<QuantumMigrationPreview> { 96 let preview = self.preview_quantum_migration(fee_per_byte)?; 97 if preview.fee > max_fee || preview.transaction_id != expected_transaction_id { 98 bail!("wallet outputs or migration fee changed; request a new preview"); 99 } 100 let ledger = self.wallet_build_ledger()?; 101 let transaction = ledger.build_v2_migration_batch(self.wallet.unlocked()?, preview.fee)?; 102 self.submit_public_transaction_v2(transaction)?; 103 Ok(preview) 104 } 105 106 pub fn estimate_hybrid_transfer_fee( 107 &self, 108 recipient: VersionedAddress, 109 amount: Amount, 110 fee_per_byte: Amount, 111 ) -> Result<FeeEstimate> { 112 self.build_hybrid_transfer_with_fee_rate(recipient, amount, fee_per_byte) 113 .map(|(_, estimate)| estimate) 114 } 115 116 pub fn transfer_hybrid_with_fee_rate( 117 &mut self, 118 recipient: VersionedAddress, 119 amount: Amount, 120 fee_per_byte: Amount, 121 ) -> Result<String> { 122 let (transaction, _) = 123 self.build_hybrid_transfer_with_fee_rate(recipient, amount, fee_per_byte)?; 124 let domain = self.ledger.transaction_v2_domain()?; 125 let transaction_id = hex_encode(transaction.transaction_id(&domain)?); 126 self.submit_public_transaction_v2(transaction)?; 127 Ok(transaction_id) 128 } 129 130 fn build_hybrid_transfer_with_fee_rate( 131 &self, 132 recipient: VersionedAddress, 133 amount: Amount, 134 fee_per_byte: Amount, 135 ) -> Result<(TransactionV2, FeeEstimate)> { 136 if fee_per_byte == 0 { 137 bail!("fee per byte must be greater than zero"); 138 } 139 let ledger = self.wallet_build_ledger()?; 140 let domain = ledger.transaction_v2_domain()?; 141 let wallet = self.wallet.unlocked()?; 142 let mut fee = 1; 143 for _ in 0..64 { 144 let transaction = ledger.build_v2_transfer(wallet, recipient, amount, fee)?; 145 let bytes = transaction.encoded_size_bytes(&domain)?; 146 let required_fee = fee_per_byte 147 .checked_mul(bytes as Amount) 148 .context("fee per byte times transaction bytes overflows")? 149 .max(1); 150 if fee >= required_fee { 151 return Ok((transaction, FeeEstimate { bytes, fee })); 152 } 153 fee = required_fee; 154 } 155 bail!("hybrid transfer fee did not converge") 156 } 157 158 /// Accept a transaction signed by an external/lightweight wallet. 159 /// Mining actions are deliberately excluded from the public wallet API. 160 pub fn submit_external_wallet_transaction( 161 &mut self, 162 tx: Transaction, 163 ) -> Result<TransactionSubmitOutcome> { 164 if matches!(tx, Transaction::Mine { .. }) { 165 bail!("the wallet endpoint accepts only transfer and burn transactions"); 166 } 167 let outcome = self.ledger.submit_transaction_with_outcome(tx.clone())?; 168 if outcome.added() { 169 self.outbox.push(GossipEnvelope::Transaction(tx)); 170 } 171 Ok(outcome) 172 } 173 174 /// Accept a canonical v2 envelope signed by an external/lightweight wallet. 175 /// Mining and burn transactions remain node-managed operations. 176 pub fn submit_external_wallet_transaction_v2( 177 &mut self, 178 envelope: &str, 179 ) -> Result<(String, TransactionSubmitOutcome)> { 180 let encoded = crate::domain::decode_hex(envelope)?; 181 let transaction = self.ledger.decode_transaction_v2(&encoded)?; 182 if !matches!( 183 transaction, 184 TransactionV2::Migration { .. } | TransactionV2::Transfer { .. } 185 ) { 186 bail!("the wallet endpoint accepts only migration and transfer v2 transactions"); 187 } 188 let domain = self.ledger.transaction_v2_domain()?; 189 let transaction_id = hex_encode(transaction.transaction_id(&domain)?); 190 let outcome = self.ledger.submit_transaction_v2(transaction)?; 191 if outcome.added() { 192 self.outbox.push(GossipEnvelope::TransactionV2 { 193 envelope: hex_encode(encoded), 194 }); 195 } 196 Ok((transaction_id, outcome)) 197 } 198 199 pub fn burn(&mut self, amount: Amount) -> Result<Transaction> { 200 self.burn_with_fee(amount, DEFAULT_TRANSACTION_FEE) 201 } 202 203 pub fn burn_with_fee(&mut self, amount: Amount, fee: Amount) -> Result<Transaction> { 204 let tx = self 205 .wallet_build_ledger()? 206 .build_burn(self.wallet.unlocked()?, amount, fee)?; 207 self.submit_public_transaction(tx) 208 } 209 210 pub fn burn_with_fee_rate( 211 &mut self, 212 amount: Amount, 213 fee_per_byte: Amount, 214 ) -> Result<(Transaction, FeeEstimate)> { 215 let (tx, estimate) = self.build_burn_with_fee_rate(amount, fee_per_byte)?; 216 self.submit_public_transaction(tx.clone())?; 217 Ok((tx, estimate)) 218 } 219 220 pub fn estimate_burn_fee(&self, amount: Amount, fee_per_byte: Amount) -> Result<FeeEstimate> { 221 match self.build_burn_with_fee_rate(amount, fee_per_byte) { 222 Ok((_, estimate)) => Ok(estimate), 223 Err(legacy_error) => self 224 .build_v2_burn_with_fee_rate_on_ledger( 225 &self.wallet_build_ledger()?, 226 amount, 227 fee_per_byte, 228 false, 229 ) 230 .map(|(_, estimate)| estimate) 231 .with_context(|| format!("legacy burn unavailable: {legacy_error:#}")), 232 } 233 } 234 235 pub fn transfer(&mut self, to: impl Into<String>, amount: Amount) -> Result<Transaction> { 236 self.transfer_with_fee(to, amount, DEFAULT_TRANSACTION_FEE) 237 } 238 239 pub fn transfer_with_fee( 240 &mut self, 241 to: impl Into<String>, 242 amount: Amount, 243 fee: Amount, 244 ) -> Result<Transaction> { 245 let tx = 246 self.wallet_build_ledger()? 247 .build_transfer(self.wallet.unlocked()?, to, amount, fee)?; 248 self.submit_public_transaction(tx) 249 } 250 251 pub fn transfer_with_fee_spending( 252 &mut self, 253 to: impl Into<String>, 254 amount: Amount, 255 fee: Amount, 256 outpoints: &[OutPoint], 257 ) -> Result<Transaction> { 258 let tx = self.wallet_build_ledger()?.build_transfer_with_inputs( 259 self.wallet.unlocked()?, 260 to, 261 amount, 262 fee, 263 outpoints, 264 )?; 265 self.submit_public_transaction(tx) 266 } 267 268 pub fn transfer_with_fee_rate( 269 &mut self, 270 to: impl Into<String>, 271 amount: Amount, 272 fee_per_byte: Amount, 273 outpoints: &[OutPoint], 274 ) -> Result<(Transaction, FeeEstimate)> { 275 let (tx, estimate) = 276 self.build_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)?; 277 self.submit_public_transaction(tx.clone())?; 278 Ok((tx, estimate)) 279 } 280 281 pub fn estimate_transfer_fee( 282 &self, 283 to: impl Into<String>, 284 amount: Amount, 285 fee_per_byte: Amount, 286 outpoints: &[OutPoint], 287 ) -> Result<FeeEstimate> { 288 self.build_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints) 289 .map(|(_, estimate)| estimate) 290 } 291 292 pub fn mine_pow_reward(&mut self) -> Result<Transaction> { 293 let (tx, _) = self.build_mine_estimate()?; 294 self.submit_public_mine_action(tx) 295 } 296 297 pub fn estimate_mine_fee(&self, _fee_per_byte: Amount) -> Result<FeeEstimate> { 298 let tx = Transaction::Mine { 299 recipient: self.reward_address_for_next_block()?, 300 anchor: self.ledger.tip_hash().to_string(), 301 salt: 0, 302 nonce: 0, 303 difficulty_bits: self.ledger.current_mine_difficulty_bits(), 304 proof_header: None, 305 signature: "0".repeat(64), 306 }; 307 Ok(FeeEstimate { 308 bytes: tx.economic_size_bytes(), 309 fee: tx.fee(), 310 }) 311 } 312 313 pub fn external_mine_job( 314 &self, 315 recipient: impl Into<String>, 316 salt: u64, 317 ) -> Result<ExternalMineJob> { 318 let recipient = recipient.into(); 319 let tip = self 320 .chain() 321 .last() 322 .context("cannot build mine job without a chain tip")?; 323 let difficulty_bits = self.ledger.current_mine_difficulty_bits(); 324 Ok(ExternalMineJob { 325 template: self.ledger.stratum_mine_template( 326 recipient, 327 &tip.hash, 328 salt, 329 difficulty_bits, 330 )?, 331 }) 332 } 333 334 pub fn submit_external_mine( 335 &mut self, 336 recipient: impl Into<String>, 337 template: StratumMineTemplate, 338 share: StratumMineShare, 339 ) -> Result<Transaction> { 340 let tx = self.ledger.build_stratum_mine(template, share)?; 341 let recipient = recipient.into(); 342 if tx.to() != Some(recipient.as_str()) { 343 bail!("submitted mine recipient does not match worker"); 344 } 345 self.submit_public_mine_action(tx) 346 } 347 348 pub(super) fn usable_burn_bundles(&self) -> Vec<BurnBundle> { 349 let next_height = self.ledger.height().saturating_add(1); 350 let mut bundles = self 351 .burn_bundles 352 .iter() 353 .filter(|((height, slot, member), _)| { 354 *height == next_height 355 && !self.equivocated_burn_bundle_slots.contains(&( 356 *height, 357 *slot, 358 member.clone(), 359 )) 360 }) 361 .map(|(_, bundle)| bundle.clone()) 362 .collect::<Vec<_>>(); 363 bundles.sort_by_key(|bundle| bundle.slot); 364 bundles 365 } 366 367 pub(super) fn usable_burn_bundles_for_finalizer_rank( 368 &self, 369 finalizer_rank: u32, 370 ) -> Vec<BurnBundle> { 371 let committee = self 372 .ledger 373 .burn_committee_for_next_ticket_block(finalizer_rank) 374 .into_iter() 375 .map(|member| (member.slot, member.owner)) 376 .collect::<BTreeMap<_, _>>(); 377 self.usable_burn_bundles() 378 .into_iter() 379 .filter(|bundle| { 380 committee 381 .get(&bundle.slot) 382 .is_some_and(|owner| *owner == bundle.member) 383 }) 384 .collect() 385 } 386 387 pub(crate) fn burn_bundles_for_request( 388 &self, 389 height: u64, 390 prev_hash: &str, 391 slots: &[u8], 392 ) -> Vec<BurnBundle> { 393 self.usable_burn_bundles() 394 .into_iter() 395 .filter(|bundle| bundle.height == height && bundle.prev_hash == prev_hash) 396 .filter(|bundle| slots.is_empty() || slots.contains(&bundle.slot)) 397 .collect() 398 } 399 400 pub(super) fn prune_burn_bundles(&mut self) { 401 let height = self.ledger.height(); 402 self.burn_bundles 403 .retain(|(bundle_height, _, _), _| *bundle_height > height); 404 self.equivocated_burn_bundle_slots 405 .retain(|(bundle_height, _, _)| *bundle_height > height); 406 } 407 408 pub(super) fn publish_burn_bundle_for_next_block(&mut self) -> Result<()> { 409 let wallet = match &self.wallet { 410 NodeWallet::Unlocked(wallet) => wallet, 411 NodeWallet::Locked { .. } => return Ok(()), 412 }; 413 let (ledger, _) = self.ledger_with_local_block_anchor(); 414 for bundle in ledger.build_burn_bundles(wallet)? { 415 let key = (bundle.height, bundle.slot, bundle.member.clone()); 416 if self.equivocated_burn_bundle_slots.contains(&key) { 417 continue; 418 } 419 if let Some(existing) = self.burn_bundles.get(&key) { 420 self.outbox 421 .push(GossipEnvelope::BurnBundle(existing.clone())); 422 continue; 423 } 424 ledger.validate_next_block_burn_bundles(vec![bundle.clone()])?; 425 self.burn_bundles.insert(key, bundle.clone()); 426 self.outbox.push(GossipEnvelope::BurnBundle(bundle)); 427 } 428 Ok(()) 429 } 430 431 pub(super) fn submit_public_mine_action(&mut self, tx: Transaction) -> Result<Transaction> { 432 if !matches!(tx, Transaction::Mine { .. }) { 433 bail!("only mine actions may be submitted as public mempool transactions"); 434 } 435 if self 436 .ledger 437 .submit_transaction_with_outcome(tx.clone())? 438 .added() 439 { 440 self.outbox.push(GossipEnvelope::Transaction(tx.clone())); 441 } 442 Ok(tx) 443 } 444 445 pub(super) fn submit_public_transaction(&mut self, tx: Transaction) -> Result<Transaction> { 446 if matches!(tx, Transaction::Mine { .. }) { 447 return self.submit_public_mine_action(tx); 448 } 449 if self 450 .ledger 451 .submit_transaction_with_outcome(tx.clone())? 452 .added() 453 { 454 self.outbox.push(GossipEnvelope::Transaction(tx.clone())); 455 } 456 Ok(tx) 457 } 458 459 pub(super) fn build_burn_with_fee_rate( 460 &self, 461 amount: Amount, 462 fee_per_byte: Amount, 463 ) -> Result<(Transaction, FeeEstimate)> { 464 let ledger = self.wallet_build_ledger()?; 465 self.build_burn_with_fee_rate_on_ledger(&ledger, amount, fee_per_byte) 466 } 467 468 pub(super) fn build_burn_with_fee_rate_on_ledger( 469 &self, 470 ledger: &Ledger, 471 amount: Amount, 472 fee_per_byte: Amount, 473 ) -> Result<(Transaction, FeeEstimate)> { 474 converge_fee_by_byte(fee_per_byte, |fee| { 475 ledger.build_burn(self.wallet.unlocked()?, amount, fee) 476 }) 477 } 478 479 pub(super) fn build_v2_burn_with_fee_rate_on_ledger( 480 &self, 481 ledger: &Ledger, 482 amount: Amount, 483 fee_per_byte: Amount, 484 for_next_block: bool, 485 ) -> Result<(TransactionV2, FeeEstimate)> { 486 if fee_per_byte == 0 { 487 bail!("fee per byte must be greater than zero"); 488 } 489 let domain = ledger.transaction_v2_domain()?; 490 let wallet = self.wallet.unlocked()?; 491 let mut fee = 1; 492 for _ in 0..64 { 493 let transaction = if for_next_block { 494 ledger.build_v2_burn_for_next_block(wallet, amount, fee)? 495 } else { 496 ledger.build_v2_burn(wallet, amount, fee)? 497 }; 498 let bytes = transaction.encoded_size_bytes(&domain)?; 499 let required_fee = fee_per_byte 500 .checked_mul(bytes as Amount) 501 .context("fee per byte times transaction bytes overflows")? 502 .max(1); 503 if fee >= required_fee { 504 return Ok((transaction, FeeEstimate { bytes, fee })); 505 } 506 fee = required_fee; 507 } 508 bail!("hybrid burn fee did not converge") 509 } 510 511 pub(super) fn build_transfer_with_fee_rate( 512 &self, 513 to: impl Into<String>, 514 amount: Amount, 515 fee_per_byte: Amount, 516 outpoints: &[OutPoint], 517 ) -> Result<(Transaction, FeeEstimate)> { 518 let to = to.into(); 519 let ledger = self.wallet_build_ledger()?; 520 converge_fee_by_byte(fee_per_byte, |fee| { 521 if outpoints.is_empty() { 522 ledger.build_transfer(self.wallet.unlocked()?, to.clone(), amount, fee) 523 } else { 524 ledger.build_transfer_with_inputs( 525 self.wallet.unlocked()?, 526 to.clone(), 527 amount, 528 fee, 529 outpoints, 530 ) 531 } 532 }) 533 } 534 535 pub(super) fn build_mine_estimate(&self) -> Result<(Transaction, FeeEstimate)> { 536 let tx = self 537 .ledger 538 .build_mine(self.reward_address_for_next_block()?)?; 539 Ok(( 540 tx.clone(), 541 FeeEstimate { 542 bytes: tx.economic_size_bytes(), 543 fee: tx.fee(), 544 }, 545 )) 546 } 547 548 pub(super) fn wallet_build_ledger(&self) -> Result<Ledger> { 549 let mut ledger = self.ledger.clone(); 550 self.reserve_local_block_anchor_inputs(&mut ledger)?; 551 Ok(ledger) 552 } 553 554 pub(super) fn reward_address_for_next_block(&self) -> Result<String> { 555 if self.ledger.height().saturating_add(1) < crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT { 556 return Ok(self.wallet.address().to_string()); 557 } 558 Ok(self.ledger.wallet_reward_address( 559 self.wallet.unlocked()?, 560 self.ledger.height().saturating_add(1), 561 )) 562 } 563 564 pub(super) fn wallet_anchor_build_ledger(&self) -> Result<Ledger> { 565 let mut ledger = self.ledger.clone(); 566 self.reserve_local_block_anchor_inputs(&mut ledger)?; 567 ledger.clear_pending_transactions(); 568 Ok(ledger) 569 } 570 571 pub(super) fn queue_local_block_anchor(&self, ledger: &mut Ledger) -> Result<()> { 572 let Some((height, burn)) = &self.local_block_anchor_burn else { 573 return Ok(()); 574 }; 575 if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { 576 let _ = ledger.submit_transaction(burn.clone())?; 577 } 578 Ok(()) 579 } 580 581 pub(super) fn reserve_local_block_anchor_inputs(&self, ledger: &mut Ledger) -> Result<()> { 582 let Some((height, burn)) = &self.local_block_anchor_burn else { 583 return Ok(()); 584 }; 585 if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { 586 let _ = ledger.reserve_transaction_inputs(burn); 587 } 588 Ok(()) 589 } 590 591 pub fn mine_one(&mut self) -> Result<Block> { 592 self.mine_one_at(now_ms()) 593 } 594 595 pub fn mine_one_at(&mut self, timestamp_ms: u64) -> Result<Block> { 596 self.publish_burn_bundle_for_next_block()?; 597 let work = self.prepare_next_block_with_local_anchor(timestamp_ms)?; 598 let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); 599 self.complete_prepared_block_at(work, vdf_output, timestamp_ms) 600 } 601 602 pub fn complete_prepared_block( 603 &mut self, 604 work: PreparedBlock, 605 vdf_output: String, 606 ) -> Result<Block> { 607 self.complete_prepared_block_at(work, vdf_output, now_ms()) 608 } 609 610 pub fn complete_prepared_block_at( 611 &mut self, 612 work: PreparedBlock, 613 vdf_output: String, 614 timestamp_ms: u64, 615 ) -> Result<Block> { 616 let block = work.finish_at(self.wallet.unlocked()?, vdf_output, timestamp_ms); 617 self.ledger.apply_locally_mined_block(block.clone())?; 618 self.clear_stale_local_block_anchor(); 619 self.prune_burn_bundles(); 620 self.outbox.push(GossipEnvelope::Block(block.clone())); 621 Ok(block) 622 } 623 624 pub fn precheck_prepared_block_without_vdf_at( 625 &self, 626 work: &PreparedBlock, 627 timestamp_ms: u64, 628 ) -> Result<()> { 629 let block = work.clone().finish_at( 630 self.wallet.unlocked()?, 631 "precheck-vdf-output".to_string(), 632 timestamp_ms, 633 ); 634 self.ledger 635 .block_requires_vdf_verification_at(&block, timestamp_ms)?; 636 Ok(()) 637 } 638 } 639 640 #[cfg(test)] 641 mod quantum_migration_tests { 642 use std::collections::BTreeMap; 643 644 use crate::{ 645 app::NodeCore, 646 domain::{Ledger, Wallet}, 647 }; 648 649 #[test] 650 fn migration_preview_reports_the_canonical_hybrid_transaction() { 651 let wallet = Wallet::from_seed("migration-preview-wallet"); 652 let ledger = Ledger::new( 653 BTreeMap::from([(wallet.address().to_string(), 1_000_000)]), 654 1, 655 ); 656 let node = NodeCore::from_ledger(wallet, ledger, 0); 657 658 let preview = node.preview_quantum_migration(2).unwrap(); 659 660 assert_eq!(preview.input_count, 1); 661 assert_eq!(preview.remaining_legacy_utxos, 0); 662 assert_eq!(preview.fee, preview.bytes as u64 * 2); 663 assert_eq!(preview.amount + preview.fee, 1_000_000); 664 assert_eq!(preview.transaction_id.len(), 64); 665 assert!(preview.address.starts_with("iuna1p")); 666 } 667 }