From 1224b6aaed0f312773eac5681f885f9c5625ca8d Mon Sep 17 00:00:00 2001 From: SW van Heerden Date: Mon, 29 Jul 2024 10:03:15 +0200 Subject: [PATCH] feat: backup spend for pre-mine (#6431) Description --- Adds backup pre-mine spend functionality. --- .../src/automation/commands.rs | 43 +++ .../minotari_console_wallet/src/cli.rs | 13 + .../src/wallet_modes.rs | 1 + .../src/output_manager_service/handle.rs | 44 +++ .../src/output_manager_service/service.rs | 257 ++++++++++++++++++ .../wallet/src/transaction_service/handle.rs | 41 +++ .../wallet/src/transaction_service/service.rs | 54 ++++ 7 files changed, 453 insertions(+) diff --git a/applications/minotari_console_wallet/src/automation/commands.rs b/applications/minotari_console_wallet/src/automation/commands.rs index 4365a7b925..1795b0d4e1 100644 --- a/applications/minotari_console_wallet/src/automation/commands.rs +++ b/applications/minotari_console_wallet/src/automation/commands.rs @@ -199,6 +199,19 @@ async fn encumber_aggregate_utxo( .map_err(CommandError::TransactionServiceError) } +async fn spend_backup_pre_mine_utxo( + mut wallet_transaction_service: TransactionServiceHandle, + fee_per_gram: MicroMinotari, + output_hash: HashOutput, + expected_commitment: PedersenCommitment, + recipient_address: TariAddress, +) -> Result { + wallet_transaction_service + .spend_backup_pre_mine_utxo(fee_per_gram, output_hash, expected_commitment, recipient_address) + .await + .map_err(CommandError::TransactionServiceError) +} + /// finalises an already encumbered a n-of-m transaction async fn finalise_aggregate_utxo( mut wallet_transaction_service: TransactionServiceHandle, @@ -794,6 +807,36 @@ pub async fn command_runner( println!("Send '{}' to parties for step 2", get_file_name(SESSION_INFO, None)); println!(); }, + PreMineSpendBackupUtxo(args) => { + let commitment = if let Ok(val) = Commitment::from_hex(&args.commitment) { + val + } else { + eprintln!("\nError: Invalid 'commitment' provided!\n"); + continue; + }; + let hash = if let Ok(val) = FixedHash::from_hex(&args.output_hash) { + val + } else { + eprintln!("\nError: Invalid 'output_hash' provided!\n"); + continue; + }; + match spend_backup_pre_mine_utxo( + transaction_service.clone(), + args.fee_per_gram, + hash, + commitment.clone(), + args.recipient_address, + ) + .await + { + Ok(tx_id) => { + println!(); + println!("Spend utxo: {} with tx_id: {}", commitment.to_hex(), tx_id); + println!(); + }, + Err(e) => eprintln!("\nError:Spent pre-mine transaction error! {}\n", e), + } + }, PreMineCreatePartyDetails(args) => { if args.alias.is_empty() || args.alias.contains(" ") { eprintln!("\nError: Alias cannot contain spaces!\n"); diff --git a/applications/minotari_console_wallet/src/cli.rs b/applications/minotari_console_wallet/src/cli.rs index b7e6685436..51d28584ad 100644 --- a/applications/minotari_console_wallet/src/cli.rs +++ b/applications/minotari_console_wallet/src/cli.rs @@ -121,6 +121,7 @@ pub enum CliCommands { PreMineEncumberAggregateUtxo(PreMineEncumberAggregateUtxoArgs), PreMineCreateInputOutputSigs(PreMineCreateInputOutputSigArgs), PreMineSpendAggregateUtxo(PreMineSpendAggregateUtxoArgs), + PreMineSpendBackupUtxo(PreMineSpendBackupUtxoArgs), SendOneSidedToStealthAddress(SendMinotariArgs), MakeItRain(MakeItRainArgs), CoinSplit(CoinSplitArgs), @@ -206,6 +207,18 @@ pub struct PreMineSpendAggregateUtxoArgs { pub input_file_names: Vec, } +#[derive(Debug, Args, Clone)] +pub struct PreMineSpendBackupUtxoArgs { + #[clap(long)] + pub fee_per_gram: MicroMinotari, + #[clap(long)] + pub commitment: String, + #[clap(long)] + pub output_hash: String, + #[clap(long)] + pub recipient_address: TariAddress, +} + #[derive(Debug, Args, Clone)] pub struct MakeItRainArgs { pub destination: TariAddress, diff --git a/applications/minotari_console_wallet/src/wallet_modes.rs b/applications/minotari_console_wallet/src/wallet_modes.rs index 8550a774ca..a3e3b6d688 100644 --- a/applications/minotari_console_wallet/src/wallet_modes.rs +++ b/applications/minotari_console_wallet/src/wallet_modes.rs @@ -613,6 +613,7 @@ mod test { CliCommands::RevalidateWalletDb => {}, CliCommands::RegisterValidatorNode(_) => {}, CliCommands::CreateTlsCerts => {}, + CliCommands::PreMineSpendBackupUtxo(_) => {}, } } assert!( diff --git a/base_layer/wallet/src/output_manager_service/handle.rs b/base_layer/wallet/src/output_manager_service/handle.rs index 65294315b9..5c722b23e5 100644 --- a/base_layer/wallet/src/output_manager_service/handle.rs +++ b/base_layer/wallet/src/output_manager_service/handle.rs @@ -73,6 +73,13 @@ pub enum OutputManagerRequest { dh_shared_secret_shares: Vec, recipient_address: TariAddress, }, + SpendBackupPreMineUtxo { + tx_id: TxId, + fee_per_gram: MicroMinotari, + output_hash: HashOutput, + expected_commitment: PedersenCommitment, + recipient_address: TariAddress, + }, PrepareToSendTransaction { tx_id: TxId, amount: MicroMinotari, @@ -167,6 +174,18 @@ impl fmt::Display for OutputManagerRequest { expected_commitment.to_hex(), output_hash ), + SpendBackupPreMineUtxo { + tx_id, + output_hash, + expected_commitment, + .. + } => write!( + f, + "spending backup pre-mine utxo with tx_id: {} and output: ({},{})", + tx_id, + expected_commitment.to_hex(), + output_hash + ), GetRecipientTransaction(_) => write!(f, "GetRecipientTransaction"), ConfirmPendingTransaction(v) => write!(f, "ConfirmPendingTransaction ({})", v), PrepareToSendTransaction { message, .. } => write!(f, "PrepareToSendTransaction ({})", message), @@ -250,6 +269,7 @@ pub enum OutputManagerResponse { PublicKey, ), ), + SpendBackupPreMineUtxo((Transaction, MicroMinotari, MicroMinotari)), OutputConfirmed, PendingTransactionConfirmed, PayToSelfTransaction((MicroMinotari, Transaction)), @@ -817,6 +837,30 @@ impl OutputManagerHandle { } } + pub async fn spend_backup_pre_mine_utxo( + &mut self, + tx_id: TxId, + fee_per_gram: MicroMinotari, + output_hash: HashOutput, + expected_commitment: PedersenCommitment, + recipient_address: TariAddress, + ) -> Result<(Transaction, MicroMinotari, MicroMinotari), OutputManagerError> { + match self + .handle + .call(OutputManagerRequest::SpendBackupPreMineUtxo { + tx_id, + fee_per_gram, + output_hash, + expected_commitment, + recipient_address, + }) + .await?? + { + OutputManagerResponse::SpendBackupPreMineUtxo((transaction, amount, fee)) => Ok((transaction, amount, fee)), + _ => Err(OutputManagerError::UnexpectedApiResponse), + } + } + pub async fn create_pay_to_self_transaction( &mut self, tx_id: TxId, diff --git a/base_layer/wallet/src/output_manager_service/service.rs b/base_layer/wallet/src/output_manager_service/service.rs index 9b1fd16eae..a0602568d1 100644 --- a/base_layer/wallet/src/output_manager_service/service.rs +++ b/base_layer/wallet/src/output_manager_service/service.rs @@ -257,6 +257,26 @@ where ) .await .map(OutputManagerResponse::EncumberAggregateUtxo), + OutputManagerRequest::SpendBackupPreMineUtxo { + tx_id, + fee_per_gram, + output_hash, + expected_commitment, + recipient_address, + } => self + .spend_backup_pre_mine_utxo( + tx_id, + fee_per_gram, + output_hash, + expected_commitment, + recipient_address, + PaymentId::Empty, + 0, + RangeProofType::BulletProofPlus, + 0.into(), + ) + .await + .map(OutputManagerResponse::SpendBackupPreMineUtxo), OutputManagerRequest::AddUnvalidatedOutput((tx_id, uo, spend_priority)) => self .add_unvalidated_output(tx_id, *uo, spend_priority) .await @@ -1491,6 +1511,243 @@ where )) } + #[allow(clippy::too_many_lines)] + pub async fn spend_backup_pre_mine_utxo( + &mut self, + tx_id: TxId, + fee_per_gram: MicroMinotari, + output_hash: HashOutput, + expected_commitment: PedersenCommitment, + recipient_address: TariAddress, + payment_id: PaymentId, + maturity: u64, + range_proof_type: RangeProofType, + minimum_value_promise: MicroMinotari, + ) -> Result<(Transaction, MicroMinotari, MicroMinotari), OutputManagerError> { + // Fetch the output from the blockchain + let output = self + .fetch_unspent_outputs_from_node(vec![output_hash]) + .await? + .pop() + .ok_or_else(|| { + OutputManagerError::ServiceError(format!( + "Output with hash {} not found in blockchain (TxId: {})", + output_hash, tx_id + )) + })?; + if output.commitment != expected_commitment { + return Err(OutputManagerError::ServiceError(format!( + "Output commitment does not match expected commitment (TxId: {})", + tx_id + ))); + } + // Retrieve the list of n public keys from the script + let public_keys = if let Some(Opcode::CheckMultiSigVerifyAggregatePubKey(_n, _m, keys, _msg)) = + output.script.as_slice().get(3) + { + keys.clone() + } else { + return Err(OutputManagerError::ServiceError(format!( + "Invalid script (TxId: {})", + tx_id + ))); + }; + // Create a deterministic encryption key from the sum of the public keys + let sum_public_keys = public_keys + .iter() + .fold(tari_common_types::types::PublicKey::default(), |acc, x| acc + x); + let encryption_private_key = public_key_to_output_encryption_key(&sum_public_keys)?; + // Decrypt the output secrets and create a new input as WalletOutput (unblinded) + let input = if let Ok((amount, spending_key, payment_id)) = + EncryptedData::decrypt_data(&encryption_private_key, &output.commitment, &output.encrypted_data) + { + if output.verify_mask(&self.resources.factories.range_proof, &spending_key, amount.as_u64())? { + let spending_key_id = self.resources.key_manager.import_key(spending_key).await?; + WalletOutput::new_with_rangeproof( + output.version, + amount, + spending_key_id, + output.features, + output.script, + ExecutionStack::default(), + self.resources.key_manager.get_spend_key().await?.key_id, // Only of the master wallet + output.sender_offset_public_key, + output.metadata_signature, + 0, + output.covenant, + output.encrypted_data, + output.minimum_value_promise, + output.proof, + payment_id, + ) + } else { + return Err(OutputManagerError::ServiceError(format!( + "Could not verify mask (TxId: {})", + tx_id + ))); + } + } else { + return Err(OutputManagerError::ServiceError(format!( + "Could not decrypt output (TxId: {})", + tx_id + ))); + }; + + // The entire input will be spent to a single recipient with no change + let output_features = OutputFeatures { + maturity, + range_proof_type, + ..Default::default() + }; + let script = script!(PushPubKey(Box::new(recipient_address.public_spend_key().clone()))); + let metadata_byte_size = self + .resources + .consensus_constants + .transaction_weight_params() + .round_up_features_and_scripts_size( + output_features.get_serialized_size()? + + script.get_serialized_size()? + + Covenant::default().get_serialized_size()?, + ); + let fee = self.get_fee_calc(); + let fee = fee.calculate(fee_per_gram, 1, 1, 1, metadata_byte_size); + let amount = input.value - fee; + + // Create sender transaction protocol builder with recipient data and no change + let mut builder = SenderTransactionProtocol::builder( + self.resources.consensus_constants.clone(), + self.resources.key_manager.clone(), + ); + builder + .with_lock_height(0) + .with_fee_per_gram(fee_per_gram) + .with_kernel_features(KernelFeatures::empty()) + .with_prevent_fee_gt_amount(self.resources.config.prevent_fee_gt_amount) + .with_input(input.clone()) + .await? + .with_recipient_data( + push_pubkey_script(recipient_address.public_spend_key()), + output_features, + Covenant::default(), + minimum_value_promise, + amount, + ) + .await? + .with_change_data( + script!(PushPubKey(Box::default())), + ExecutionStack::default(), + TariKeyId::default(), + TariKeyId::default(), + Covenant::default(), + ); + let mut stp = builder + .build() + .await + .map_err(|e| OutputManagerError::BuildError(e.message))?; + + // This call is needed to advance the state from `SingleRoundMessageReady` to `SingleRoundMessageReady`, + // but the returned value is not used + let _single_round_sender_data = stp.build_single_round_message(&self.resources.key_manager).await?; + + self.confirm_encumberance(tx_id)?; + + // Prepare receiver part of the transaction + + // Diffie-Hellman shared secret `k_Ob * K_Sb = K_Ob * k_Sb` results in a public key, which is fed into + // KDFs to produce the spending and encryption keys. + let sender_offset_private_key_id_self = + stp.get_recipient_sender_offset_private_key()? + .ok_or(OutputManagerError::ServiceError(format!( + "Missing sender offset private key ID (TxId: {})", + tx_id + )))?; + + let shared_secret = self + .resources + .key_manager + .get_diffie_hellman_shared_secret( + &sender_offset_private_key_id_self, + recipient_address + .public_view_key() + .ok_or(OutputManagerError::ServiceError(format!( + "Missing public view key (TxId: {})", + tx_id + )))?, + ) + .await?; + + let spending_key = shared_secret_to_output_spending_key(&shared_secret)?; + let spending_key_id = self.resources.key_manager.import_key(spending_key).await?; + + let encryption_private_key = shared_secret_to_output_encryption_key(&shared_secret)?; + let encryption_key_id = self.resources.key_manager.import_key(encryption_private_key).await?; + + let sender_offset_public_key = self + .resources + .key_manager + .get_public_key_at_key_id(&sender_offset_private_key_id_self) + .await?; + + let sender_message = TransactionSenderMessage::new_single_round_message( + stp.get_single_round_message(&self.resources.key_manager) + .await + .map_err(|e| service_error_with_id(tx_id, e.to_string(), true))?, + ); + + // Create the output with a partially signed metadata signature + let output = WalletOutputBuilder::new(amount, spending_key_id) + .with_features( + sender_message + .single() + .ok_or( + OutputManagerError::InvalidSenderMessage)? + .features + .clone(), + ) + .with_script(script) + .encrypt_data_for_recovery( + &self.resources.key_manager, + Some(&encryption_key_id), + payment_id.clone(), + ) + .await? + .with_input_data(ExecutionStack::default()) // Just a placeholder in the wallet + .with_sender_offset_public_key(sender_offset_public_key) + .with_script_key(self.resources.key_manager.get_spend_key().await?.key_id) + .with_minimum_value_promise(minimum_value_promise) + .sign_as_sender_and_receiver( + &self.resources.key_manager, + &sender_offset_private_key_id_self, + ) + .await + .map_err(|e|service_error_with_id(tx_id, e.to_string(), true))? + .try_build(&self.resources.key_manager) + .await + .map_err(|e|service_error_with_id(tx_id, e.to_string(), true))?; + + // Finalize the partial transaction - it will not be valid at this stage as the metadata and script + // signatures are not yet complete. + let rtp = ReceiverTransactionProtocol::new( + sender_message, + output, + &self.resources.key_manager, + &self.resources.consensus_constants.clone(), + ) + .await; + let recipient_reply = rtp.get_signed_data()?.clone(); + stp.add_presigned_recipient_info(recipient_reply)?; + stp.finalize(&self.resources.key_manager) + .await + .map_err(|e| service_error_with_id(tx_id, e.to_string(), true))?; + info!(target: LOG_TARGET, "Finalized partial one-side transaction TxId: {}", tx_id); + + let tx = stp.get_transaction()?.clone(); + + let fee = stp.get_fee_amount()?; + + Ok((tx, amount, fee)) + } + async fn create_pay_to_self_transaction( &mut self, tx_id: TxId, diff --git a/base_layer/wallet/src/transaction_service/handle.rs b/base_layer/wallet/src/transaction_service/handle.rs index 751b670e26..3f6b1f3898 100644 --- a/base_layer/wallet/src/transaction_service/handle.rs +++ b/base_layer/wallet/src/transaction_service/handle.rs @@ -122,6 +122,12 @@ pub enum TransactionServiceRequest { dh_shared_secret_shares: Vec, recipient_address: TariAddress, }, + SpendBackupPreMineUtxo { + fee_per_gram: MicroMinotari, + output_hash: HashOutput, + expected_commitment: PedersenCommitment, + recipient_address: TariAddress, + }, FetchUnspentOutputs { output_hashes: Vec, }, @@ -231,6 +237,19 @@ impl fmt::Display for TransactionServiceRequest { "Creating a new n-of-m aggregate uxto with: amount = {}, n = {}, m = {}", amount, n, m )), + Self::SpendBackupPreMineUtxo { + fee_per_gram, + output_hash, + expected_commitment, + recipient_address, + } => f.write_str(&format!( + "Spending backup pre-mine utxo with: fee_per_gram = {}, output_hash = {}, commitment = {}, recipient \ + = {}", + fee_per_gram, + output_hash, + expected_commitment.to_hex(), + recipient_address, + )), Self::EncumberAggregateUtxo { fee_per_gram, output_hash, @@ -782,6 +801,28 @@ impl TransactionServiceHandle { } } + pub async fn spend_backup_pre_mine_utxo( + &mut self, + fee_per_gram: MicroMinotari, + output_hash: HashOutput, + expected_commitment: PedersenCommitment, + recipient_address: TariAddress, + ) -> Result { + match self + .handle + .call(TransactionServiceRequest::SpendBackupPreMineUtxo { + fee_per_gram, + output_hash, + expected_commitment, + recipient_address, + }) + .await?? + { + TransactionServiceResponse::TransactionSent(tx_id) => Ok(tx_id), + _ => Err(TransactionServiceError::UnexpectedApiResponse), + } + } + pub async fn fetch_unspent_outputs( &mut self, output_hashes: Vec, diff --git a/base_layer/wallet/src/transaction_service/service.rs b/base_layer/wallet/src/transaction_service/service.rs index 8455e9723f..32c357085b 100644 --- a/base_layer/wallet/src/transaction_service/service.rs +++ b/base_layer/wallet/src/transaction_service/service.rs @@ -759,6 +759,15 @@ where ) }, ), + TransactionServiceRequest::SpendBackupPreMineUtxo { + fee_per_gram, + output_hash, + expected_commitment, + recipient_address, + } => self + .spend_backup_pre_mine_utxo(fee_per_gram, output_hash, expected_commitment, recipient_address) + .await + .map(TransactionServiceResponse::TransactionSent), TransactionServiceRequest::FetchUnspentOutputs { output_hashes } => { let unspent_outputs = self.fetch_unspent_outputs_from_node(output_hashes).await?; Ok(TransactionServiceResponse::UnspentOutputs(unspent_outputs)) @@ -1490,6 +1499,51 @@ where } } + pub async fn spend_backup_pre_mine_utxo( + &mut self, + fee_per_gram: MicroMinotari, + output_hash: HashOutput, + expected_commitment: PedersenCommitment, + recipient_address: TariAddress, + ) -> Result { + let tx_id = TxId::new_random(); + + match self + .resources + .output_manager_service + .spend_backup_pre_mine_utxo( + tx_id, + fee_per_gram, + output_hash, + expected_commitment, + recipient_address.clone(), + ) + .await + { + Ok((transaction, amount, fee)) => { + let completed_tx = CompletedTransaction::new( + tx_id, + self.resources.interactive_tari_address.clone(), + recipient_address, + amount, + fee, + transaction.clone(), + TransactionStatus::Pending, + "claimed n-of-m utxo".to_string(), + Utc::now().naive_utc(), + TransactionDirection::Outbound, + None, + None, + None, + ) + .map_err(|e| TransactionServiceProtocolError::new(tx_id, e.into()))?; + self.db.insert_completed_transaction(tx_id, completed_tx)?; + Ok(tx_id) + }, + Err(e) => Err(e.into()), + } + } + /// Creates an encumbered uninitialized transaction pub async fn finalized_aggregate_encumbed_tx( &mut self,