wallet.rs (21522B)
1 use anyhow::{Context, Result, bail}; 2 3 use crate::domain::{ 4 Amount, BlindedTransaction, Block, BuiltBlindedTransaction, DEFAULT_TRANSACTION_FEE, Ledger, 5 OutPoint, PreparedBlock, RevealBundle, StratumMineShare, StratumMineTemplate, Transaction, 6 Wallet, run_vdf, 7 }; 8 9 use super::{ 10 ExternalMineJob, FeeEstimate, GossipEnvelope, NodeCore, helpers::converge_fee_by_byte, now_ms, 11 }; 12 13 #[derive(Clone, Debug)] 14 pub(super) enum NodeWallet { 15 Unlocked(Wallet), 16 Locked { address: String }, 17 } 18 19 impl NodeWallet { 20 pub(super) fn address(&self) -> &str { 21 match self { 22 Self::Unlocked(wallet) => wallet.address(), 23 Self::Locked { address } => address, 24 } 25 } 26 27 pub(super) fn unlocked(&self) -> Result<&Wallet> { 28 match self { 29 Self::Unlocked(wallet) => Ok(wallet), 30 Self::Locked { .. } => bail!("wallet is locked"), 31 } 32 } 33 34 pub(super) fn is_locked(&self) -> bool { 35 matches!(self, Self::Locked { .. }) 36 } 37 } 38 39 impl NodeCore { 40 pub fn burn(&mut self, amount: Amount) -> Result<Transaction> { 41 self.burn_with_fee(amount, 0) 42 } 43 44 pub fn burn_with_fee(&mut self, amount: Amount, fee: Amount) -> Result<Transaction> { 45 let tx = self 46 .wallet_build_ledger()? 47 .build_burn(self.wallet.unlocked()?, amount, fee)?; 48 self.submit_transaction_as_owned_blinded(tx) 49 } 50 51 pub fn burn_with_fee_rate( 52 &mut self, 53 amount: Amount, 54 fee_per_byte: Amount, 55 ) -> Result<(Transaction, FeeEstimate)> { 56 let (built, estimate) = self.build_blinded_burn_with_fee_rate(amount, fee_per_byte)?; 57 let tx = built.payload.clone(); 58 self.submit_owned_blinded_transaction(built)?; 59 Ok((tx, estimate)) 60 } 61 62 pub fn blinded_burn_with_fee( 63 &mut self, 64 amount: Amount, 65 fee: Amount, 66 expires_at_height: u64, 67 ) -> Result<BlindedTransaction> { 68 let built = self.wallet_build_ledger()?.build_blinded_burn( 69 self.wallet.unlocked()?, 70 amount, 71 fee, 72 expires_at_height, 73 )?; 74 self.submit_owned_blinded_transaction(built) 75 } 76 77 pub fn estimate_burn_fee(&self, amount: Amount, fee_per_byte: Amount) -> Result<FeeEstimate> { 78 self.build_burn_with_fee_rate(amount, fee_per_byte) 79 .map(|(_, estimate)| estimate) 80 } 81 82 pub fn transfer(&mut self, to: impl Into<String>, amount: Amount) -> Result<Transaction> { 83 self.transfer_with_fee(to, amount, DEFAULT_TRANSACTION_FEE) 84 } 85 86 pub fn transfer_with_fee( 87 &mut self, 88 to: impl Into<String>, 89 amount: Amount, 90 fee: Amount, 91 ) -> Result<Transaction> { 92 let tx = 93 self.wallet_build_ledger()? 94 .build_transfer(self.wallet.unlocked()?, to, amount, fee)?; 95 self.submit_transaction_as_owned_blinded(tx) 96 } 97 98 pub fn transfer_with_fee_spending( 99 &mut self, 100 to: impl Into<String>, 101 amount: Amount, 102 fee: Amount, 103 outpoints: &[OutPoint], 104 ) -> Result<Transaction> { 105 let tx = self.wallet_build_ledger()?.build_transfer_with_inputs( 106 self.wallet.unlocked()?, 107 to, 108 amount, 109 fee, 110 outpoints, 111 )?; 112 self.submit_transaction_as_owned_blinded(tx) 113 } 114 115 pub fn blinded_transfer_with_fee( 116 &mut self, 117 to: impl Into<String>, 118 amount: Amount, 119 fee: Amount, 120 expires_at_height: u64, 121 ) -> Result<BlindedTransaction> { 122 let built = self.wallet_build_ledger()?.build_blinded_transfer( 123 self.wallet.unlocked()?, 124 to, 125 amount, 126 fee, 127 expires_at_height, 128 )?; 129 self.submit_owned_blinded_transaction(built) 130 } 131 132 pub fn transfer_with_fee_rate( 133 &mut self, 134 to: impl Into<String>, 135 amount: Amount, 136 fee_per_byte: Amount, 137 outpoints: &[OutPoint], 138 ) -> Result<(Transaction, FeeEstimate)> { 139 let (built, estimate) = 140 self.build_blinded_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)?; 141 let tx = built.payload.clone(); 142 self.submit_owned_blinded_transaction(built)?; 143 Ok((tx, estimate)) 144 } 145 146 pub fn estimate_transfer_fee( 147 &self, 148 to: impl Into<String>, 149 amount: Amount, 150 fee_per_byte: Amount, 151 outpoints: &[OutPoint], 152 ) -> Result<FeeEstimate> { 153 self.build_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints) 154 .map(|(_, estimate)| estimate) 155 } 156 157 pub fn mine_pow_reward(&mut self) -> Result<Transaction> { 158 let (tx, _) = self.build_mine_estimate()?; 159 self.submit_public_mine_action(tx) 160 } 161 162 pub fn estimate_mine_fee(&self, _fee_per_byte: Amount) -> Result<FeeEstimate> { 163 let tx = Transaction::Mine { 164 recipient: self.wallet.address().to_string(), 165 anchor: self.ledger.tip_hash().to_string(), 166 salt: 0, 167 nonce: 0, 168 difficulty_bits: self.ledger.current_mine_difficulty_bits(), 169 proof_header: None, 170 signature: "0".repeat(64), 171 }; 172 Ok(FeeEstimate { 173 bytes: tx.economic_size_bytes(), 174 fee: tx.fee(), 175 }) 176 } 177 178 pub fn external_mine_job( 179 &self, 180 recipient: impl Into<String>, 181 salt: u64, 182 ) -> Result<ExternalMineJob> { 183 let recipient = recipient.into(); 184 let tip = self 185 .chain() 186 .last() 187 .context("cannot build mine job without a chain tip")?; 188 let difficulty_bits = self.ledger.current_mine_difficulty_bits(); 189 Ok(ExternalMineJob { 190 template: self.ledger.stratum_mine_template( 191 recipient, 192 &tip.hash, 193 salt, 194 difficulty_bits, 195 )?, 196 }) 197 } 198 199 pub fn submit_external_mine( 200 &mut self, 201 recipient: impl Into<String>, 202 template: StratumMineTemplate, 203 share: StratumMineShare, 204 ) -> Result<Transaction> { 205 let tx = self.ledger.build_stratum_mine(template, share)?; 206 let recipient = recipient.into(); 207 if tx.to() != Some(recipient.as_str()) { 208 bail!("submitted mine recipient does not match worker"); 209 } 210 self.submit_public_mine_action(tx) 211 } 212 213 pub(super) fn usable_reveal_bundles(&self) -> Vec<RevealBundle> { 214 let next_height = self.ledger.height().saturating_add(1); 215 let mut bundles = self 216 .reveal_bundles 217 .iter() 218 .filter(|((height, slot), _)| { 219 *height == next_height 220 && !self 221 .equivocated_reveal_bundle_slots 222 .contains(&(*height, *slot)) 223 }) 224 .map(|(_, bundle)| bundle.clone()) 225 .collect::<Vec<_>>(); 226 bundles.sort_by_key(|bundle| bundle.slot); 227 bundles 228 } 229 230 pub(super) fn prune_reveal_bundles(&mut self) { 231 let height = self.ledger.height(); 232 self.reveal_bundles 233 .retain(|(bundle_height, _), _| *bundle_height > height); 234 self.equivocated_reveal_bundle_slots 235 .retain(|(bundle_height, _)| *bundle_height > height); 236 } 237 238 pub(super) fn publish_reveal_bundle_for_next_block(&mut self) -> Result<()> { 239 let wallet = match &self.wallet { 240 NodeWallet::Unlocked(wallet) => wallet, 241 NodeWallet::Locked { .. } => return Ok(()), 242 }; 243 let Some(bundle) = self.ledger.build_reveal_bundle(wallet)? else { 244 return Ok(()); 245 }; 246 let key = (bundle.height, bundle.slot); 247 if self.equivocated_reveal_bundle_slots.contains(&key) 248 || self.reveal_bundles.contains_key(&key) 249 { 250 return Ok(()); 251 } 252 self.ledger 253 .validate_next_block_reveal_bundles(vec![bundle.clone()])?; 254 self.reveal_bundles.insert(key, bundle.clone()); 255 self.outbox.push(GossipEnvelope::RevealBundle(bundle)); 256 Ok(()) 257 } 258 259 pub(super) fn submit_public_mine_action(&mut self, tx: Transaction) -> Result<Transaction> { 260 if !matches!(tx, Transaction::Mine { .. }) { 261 bail!("only mine actions may be submitted as public mempool transactions"); 262 } 263 if self 264 .ledger 265 .submit_transaction_with_outcome(tx.clone())? 266 .added() 267 { 268 self.outbox.push(GossipEnvelope::MineAction(tx.clone())); 269 } 270 Ok(tx) 271 } 272 273 pub(super) fn build_burn_with_fee_rate( 274 &self, 275 amount: Amount, 276 fee_per_byte: Amount, 277 ) -> Result<(Transaction, FeeEstimate)> { 278 let (built, estimate) = self.build_blinded_burn_with_fee_rate(amount, fee_per_byte)?; 279 Ok((built.payload, estimate)) 280 } 281 282 pub(super) fn build_blinded_burn_with_fee_rate( 283 &self, 284 amount: Amount, 285 fee_per_byte: Amount, 286 ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { 287 let ledger = self.wallet_build_ledger()?; 288 self.build_blinded_burn_with_fee_rate_on_ledger(&ledger, amount, fee_per_byte) 289 } 290 291 pub(super) fn build_blinded_burn_with_fee_rate_on_ledger( 292 &self, 293 ledger: &Ledger, 294 amount: Amount, 295 fee_per_byte: Amount, 296 ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { 297 let expires_at_height = self.default_blinded_transaction_expiry_height(); 298 converge_fee_by_byte(fee_per_byte, |fee| { 299 let tx = ledger.build_burn(self.wallet.unlocked()?, amount, fee)?; 300 ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) 301 }) 302 } 303 304 pub(super) fn build_blinded_burn_with_fee_on_ledger( 305 &self, 306 ledger: &Ledger, 307 amount: Amount, 308 fee: Amount, 309 ) -> Result<BuiltBlindedTransaction> { 310 let expires_at_height = self.default_blinded_transaction_expiry_height(); 311 let tx = ledger.build_burn(self.wallet.unlocked()?, amount, fee)?; 312 ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) 313 } 314 315 pub(super) fn build_transfer_with_fee_rate( 316 &self, 317 to: impl Into<String>, 318 amount: Amount, 319 fee_per_byte: Amount, 320 outpoints: &[OutPoint], 321 ) -> Result<(Transaction, FeeEstimate)> { 322 let (built, estimate) = 323 self.build_blinded_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)?; 324 Ok((built.payload, estimate)) 325 } 326 327 pub(super) fn build_blinded_transfer_with_fee_rate( 328 &self, 329 to: impl Into<String>, 330 amount: Amount, 331 fee_per_byte: Amount, 332 outpoints: &[OutPoint], 333 ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { 334 let to = to.into(); 335 let ledger = self.wallet_build_ledger()?; 336 let expires_at_height = self.default_blinded_transaction_expiry_height(); 337 converge_fee_by_byte(fee_per_byte, |fee| { 338 let tx = if outpoints.is_empty() { 339 ledger.build_transfer(self.wallet.unlocked()?, to.clone(), amount, fee) 340 } else { 341 ledger.build_transfer_with_inputs( 342 self.wallet.unlocked()?, 343 to.clone(), 344 amount, 345 fee, 346 outpoints, 347 ) 348 }?; 349 ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) 350 }) 351 } 352 353 pub(super) fn build_mine_estimate(&self) -> Result<(Transaction, FeeEstimate)> { 354 let tx = self.ledger.build_mine(self.wallet.address())?; 355 Ok(( 356 tx.clone(), 357 FeeEstimate { 358 bytes: tx.economic_size_bytes(), 359 fee: tx.fee(), 360 }, 361 )) 362 } 363 364 pub(super) fn wallet_build_ledger(&self) -> Result<Ledger> { 365 let mut ledger = self.ledger.clone(); 366 self.reserve_local_block_anchor_inputs(&mut ledger)?; 367 self.queue_owned_blinded_payloads(&mut ledger)?; 368 Ok(ledger) 369 } 370 371 pub(super) fn wallet_anchor_build_ledger(&self) -> Result<Ledger> { 372 let mut ledger = self.ledger.clone(); 373 self.reserve_local_block_anchor_inputs(&mut ledger)?; 374 ledger.clear_pending_transactions(); 375 ledger.clear_pending_blinded_transactions(); 376 Ok(ledger) 377 } 378 379 pub(super) fn queue_local_block_anchor(&self, ledger: &mut Ledger) -> Result<()> { 380 let Some((height, burn)) = &self.local_block_anchor_burn else { 381 return Ok(()); 382 }; 383 if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { 384 let _ = ledger.submit_transaction(burn.clone())?; 385 } 386 Ok(()) 387 } 388 389 pub(super) fn reserve_local_block_anchor_inputs(&self, ledger: &mut Ledger) -> Result<()> { 390 let Some((height, burn)) = &self.local_block_anchor_burn else { 391 return Ok(()); 392 }; 393 if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { 394 let _ = ledger.reserve_transaction_inputs(burn); 395 } 396 Ok(()) 397 } 398 399 pub fn mine_one(&mut self) -> Result<Block> { 400 self.mine_one_at(now_ms()) 401 } 402 403 pub fn mine_one_at(&mut self, timestamp_ms: u64) -> Result<Block> { 404 self.publish_reveal_bundle_for_next_block()?; 405 let work = self.prepare_next_block_with_local_anchor(timestamp_ms)?; 406 let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); 407 self.complete_prepared_block_at(work, vdf_output, timestamp_ms) 408 } 409 410 pub fn complete_prepared_block( 411 &mut self, 412 work: PreparedBlock, 413 vdf_output: String, 414 ) -> Result<Block> { 415 self.complete_prepared_block_at(work, vdf_output, now_ms()) 416 } 417 418 pub fn complete_prepared_block_at( 419 &mut self, 420 work: PreparedBlock, 421 vdf_output: String, 422 timestamp_ms: u64, 423 ) -> Result<Block> { 424 let block = work.finish_at(self.wallet.unlocked()?, vdf_output, timestamp_ms); 425 self.ledger.apply_locally_mined_block(block.clone())?; 426 self.clear_stale_local_block_anchor(); 427 self.prune_reveal_bundles(); 428 self.prune_owned_blinded_payloads_for_block(&block); 429 self.outbox.push(GossipEnvelope::Block(block.clone())); 430 self.publish_owned_reveals_for_block(&block)?; 431 Ok(block) 432 } 433 } 434 435 #[cfg(test)] 436 mod tests { 437 use std::collections::BTreeMap; 438 439 use crate::{ 440 app::{FeeEstimate, NodeCore}, 441 domain::{ 442 GenesisBurn, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, Transaction, VDF_TARGET_BLOCK_MS, 443 Wallet, run_vdf, 444 }, 445 }; 446 447 #[test] 448 fn fee_rate_transfer_and_burn_pay_at_least_bytes_times_rate() { 449 let transfer_sender = Wallet::from_seed("fee-rate-transfer-sender"); 450 let transfer_recipient = Wallet::from_seed("fee-rate-transfer-recipient"); 451 let mut transfer_genesis = BTreeMap::new(); 452 transfer_genesis.insert(transfer_sender.address().to_string(), 10 * MICRO_IUNA); 453 let transfer_ledger = Ledger::new_with_genesis_burns( 454 transfer_genesis, 455 vec![GenesisBurn::new(transfer_sender.address(), MICRO_IUNA)], 456 1, 457 ) 458 .unwrap(); 459 let mut transfer_node = NodeCore::from_ledger(transfer_sender, transfer_ledger, 0); 460 461 let (transfer, transfer_estimate) = transfer_node 462 .transfer_with_fee_rate(transfer_recipient.address(), MICRO_IUNA, 2, &[]) 463 .unwrap(); 464 let transfer_blinded_bytes = 465 transfer_node.ledger().pending_blinded_transactions()[0].fee_rate_size_bytes(); 466 assert_eq!(transfer_estimate.bytes, transfer_blinded_bytes); 467 let minimum_transfer_fee = transfer_blinded_bytes as u64 * 2; 468 assert!(transfer.fee() >= minimum_transfer_fee); 469 assert!(transfer_node.ledger().pending().is_empty()); 470 assert_eq!( 471 transfer_node.ledger().pending_blinded_transactions().len(), 472 1 473 ); 474 475 let burn_wallet = Wallet::from_seed("fee-rate-burn-wallet"); 476 let mut burn_genesis = BTreeMap::new(); 477 burn_genesis.insert(burn_wallet.address().to_string(), 10 * MICRO_IUNA); 478 let burn_ledger = Ledger::new_with_genesis_burns( 479 burn_genesis, 480 vec![GenesisBurn::new(burn_wallet.address(), MICRO_IUNA)], 481 1, 482 ) 483 .unwrap(); 484 let mut burn_node = NodeCore::from_ledger(burn_wallet, burn_ledger, 0); 485 let (burn, burn_estimate) = burn_node.burn_with_fee_rate(MICRO_IUNA, 3).unwrap(); 486 let burn_blinded_bytes = 487 burn_node.ledger().pending_blinded_transactions()[0].fee_rate_size_bytes(); 488 assert_eq!(burn_estimate.bytes, burn_blinded_bytes); 489 let minimum_burn_fee = burn_blinded_bytes as u64 * 3; 490 assert!(burn.fee() >= minimum_burn_fee); 491 assert!(burn_node.ledger().pending().is_empty()); 492 assert_eq!(burn_node.ledger().pending_blinded_transactions().len(), 1); 493 } 494 495 #[test] 496 fn mine_fee_estimate_uses_template_without_searching_pow() { 497 let wallet = Wallet::from_seed("mine-fee-template-wallet"); 498 let mut genesis = BTreeMap::new(); 499 genesis.insert(wallet.address().to_string(), MICRO_IUNA); 500 let ledger = Ledger::new_with_genesis_burns( 501 genesis, 502 vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], 503 1, 504 ) 505 .unwrap(); 506 let node = NodeCore::from_ledger(wallet, ledger, 0); 507 508 let estimate = node.estimate_mine_fee(MINE_FINALIZER_FEE).unwrap(); 509 510 assert_eq!( 511 estimate, 512 FeeEstimate { 513 bytes: Transaction::Mine { 514 recipient: node.wallet_address().to_string(), 515 anchor: node.ledger().tip_hash().to_string(), 516 salt: 0, 517 nonce: 0, 518 difficulty_bits: node.ledger().current_mine_difficulty_bits(), 519 proof_header: None, 520 signature: "0".repeat(64), 521 } 522 .economic_size_bytes(), 523 fee: MINE_FINALIZER_FEE, 524 } 525 ); 526 } 527 528 #[test] 529 fn wallet_building_reserves_local_anchor_burn_inputs() { 530 let alice = Wallet::from_seed("local-anchor-reserve-alice"); 531 let finalizers = [alice.clone()]; 532 let mut allocations = BTreeMap::new(); 533 allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); 534 let mut ledger = Ledger::new_with_genesis_burns( 535 allocations, 536 finalizers 537 .iter() 538 .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) 539 .collect(), 540 1, 541 ) 542 .unwrap(); 543 let leader = ledger.expected_leader_for_next_block().unwrap(); 544 let leader_wallet = finalizers 545 .iter() 546 .find(|wallet| wallet.address() == leader) 547 .unwrap() 548 .clone(); 549 let split = ledger 550 .build_transfer(&leader_wallet, leader_wallet.address(), MICRO_IUNA, 0) 551 .unwrap(); 552 ledger.submit_transaction(split).unwrap(); 553 let anchor = ledger.build_burn(&leader_wallet, 1, 0).unwrap(); 554 ledger.submit_transaction(anchor).unwrap(); 555 let split_block = ledger.mine_next_block(&leader_wallet, 1).unwrap(); 556 ledger.apply_block(split_block).unwrap(); 557 let mut node = 558 NodeCore::from_ledger_with_burn_fee_and_enabled(leader_wallet, ledger, true, 0, 1); 559 560 let plan = node.prepare_automatic_finalization(1); 561 assert!(plan.burned.is_some()); 562 assert!(node.status().wallet_balance < node.ledger().balance_of(node.wallet_address())); 563 let (_, anchor_burn) = node 564 .local_block_anchor_burn 565 .clone() 566 .expect("leader burn should be held as a local block anchor"); 567 let Transaction::Burn { inputs, .. } = anchor_burn else { 568 panic!("local block anchor must be a burn"); 569 }; 570 let anchor_inputs = inputs 571 .iter() 572 .map(|input| input.outpoint.clone()) 573 .collect::<Vec<_>>(); 574 575 let blinded = node 576 .blinded_burn_with_fee(MICRO_IUNA / 20, 1, node.chain_height() + 4) 577 .unwrap(); 578 579 assert!( 580 blinded 581 .inputs 582 .iter() 583 .all(|input| !anchor_inputs.contains(&input.outpoint)), 584 "blinded wallet transactions must not spend inputs reserved by the local anchor burn" 585 ); 586 587 let work = node.prepare_next_block_with_local_anchor(2).unwrap(); 588 let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); 589 let mut peer_ledger = node.clone_ledger(); 590 let block = node 591 .complete_prepared_block_at(work, vdf_output, VDF_TARGET_BLOCK_MS * 2) 592 .unwrap(); 593 assert!(block.transactions.iter().any(Transaction::is_burn)); 594 assert!( 595 block 596 .blinded_transactions 597 .iter() 598 .any(|transaction| transaction.commitment == blinded.commitment) 599 ); 600 peer_ledger.apply_block_at(block, u64::MAX).unwrap(); 601 assert_eq!( 602 node.ledger().status().tip_hash, 603 peer_ledger.status().tip_hash 604 ); 605 } 606 }