diff --git a/applications/minotari_console_wallet/src/automation/commands.rs b/applications/minotari_console_wallet/src/automation/commands.rs index 2410174218..98e0925e1c 100644 --- a/applications/minotari_console_wallet/src/automation/commands.rs +++ b/applications/minotari_console_wallet/src/automation/commands.rs @@ -2302,10 +2302,10 @@ pub async fn command_runner( .await .map_err(CommandError::TransactionServiceError) { - Ok(tx_id) => { - debug!(target: LOG_TARGET, "send-minotari concluded with tx_id {tx_id}"); + Ok(tx_ids) => { + debug!(target: LOG_TARGET, "scrape-wallet concluded with tx_ids {:?}", tx_ids); let duration = config.command_send_wait_timeout; - match timeout(duration, monitor_transactions(tms.clone(), vec![tx_id], wait_stage)).await { + match timeout(duration, monitor_transactions(tms.clone(), tx_ids, wait_stage)).await { Ok(txs) => { debug!( target: LOG_TARGET, diff --git a/base_layer/wallet/src/output_manager_service/handle.rs b/base_layer/wallet/src/output_manager_service/handle.rs index 50be171764..a2b13dbcfc 100644 --- a/base_layer/wallet/src/output_manager_service/handle.rs +++ b/base_layer/wallet/src/output_manager_service/handle.rs @@ -137,7 +137,6 @@ pub enum OutputManagerRequest { PreviewCoinJoin((Vec, MicroMinotari)), PreviewCoinSplitEven((Vec, usize, MicroMinotari)), ScrapeWallet { - tx_id: TxId, fee_per_gram: MicroMinotari, }, CreateCoinJoin { @@ -199,8 +198,8 @@ impl fmt::Display for OutputManagerRequest { v.metadata_signature.u_y().to_hex(), v.metadata_signature.u_a().to_hex(), ), - ScrapeWallet { tx_id, fee_per_gram } => { - write!(f, "ScrapeWallet (tx_id: {tx_id}, fee_per_gram: {fee_per_gram})") + ScrapeWallet { fee_per_gram } => { + write!(f, "ScrapeWallet (fee_per_gram: {fee_per_gram})") }, EncumberAggregateUtxo { expected_commitment, @@ -327,6 +326,7 @@ pub enum OutputManagerResponse { PendingTransactionConfirmed, PayToSelfTransaction((MicroMinotari, Transaction, TxId)), TransactionBuilderToSend(Box>), + TransactionBuildersToSend(Vec<(TxId, Box>)>), TransactionCancelled, SpentOutputs(Vec), UnspentOutputs(Vec), @@ -645,16 +645,17 @@ where KM: LegacyTransactionKeyManagerInterface pub async fn scrape_wallet( &mut self, - tx_id: TxId, fee_per_gram: MicroMinotari, - ) -> Result, OutputManagerError> { + ) -> Result)>, OutputManagerError> { match self .handle - .call(OutputManagerRequest::ScrapeWallet { tx_id, fee_per_gram }) + .call(OutputManagerRequest::ScrapeWallet { fee_per_gram }) .await .inspect_err(|e| warn!(target: LOG_TARGET, "OutputManagerRequest::ScrapeWallet({e})"))?? { - OutputManagerResponse::TransactionBuilderToSend(tx_builder) => Ok(*tx_builder), + OutputManagerResponse::TransactionBuildersToSend(batches) => { + Ok(batches.into_iter().map(|(tx_id, builder)| (tx_id, *builder)).collect()) + }, _ => Err(OutputManagerError::UnexpectedApiResponse( "OutputManagerRequest::ScrapeWallet".to_string(), )), diff --git a/base_layer/wallet/src/output_manager_service/service.rs b/base_layer/wallet/src/output_manager_service/service.rs index 700a1e0825..d743b35a4a 100644 --- a/base_layer/wallet/src/output_manager_service/service.rs +++ b/base_layer/wallet/src/output_manager_service/service.rs @@ -448,9 +448,16 @@ where .await?, )) }, - OutputManagerRequest::ScrapeWallet { tx_id, fee_per_gram } => self - .scrape_wallet(tx_id, fee_per_gram) - .map(|tx_builder| OutputManagerResponse::TransactionBuilderToSend(Box::new(tx_builder))), + OutputManagerRequest::ScrapeWallet { fee_per_gram } => self + .scrape_wallet(fee_per_gram) + .map(|batches| { + OutputManagerResponse::TransactionBuildersToSend( + batches + .into_iter() + .map(|(tx_id, builder)| (tx_id, Box::new(builder))) + .collect(), + ) + }), OutputManagerRequest::PreviewCoinSplitEven((commitments, number_of_splits, fee_per_gram)) => { Ok(OutputManagerResponse::CoinPreview( @@ -2723,34 +2730,49 @@ where pub fn scrape_wallet( &mut self, - tx_id: TxId, fee_per_gram: MicroMinotari, - ) -> Result, OutputManagerError> { + ) -> Result)>, OutputManagerError> { let src_outputs = self .resources .db .fetch_all_unspent_outputs(&self.resources.key_manager)?; - let mut builder = TransactionBuilder::new( - self.resources.consensus_constants.clone(), - self.resources.key_manager.clone(), - self.resources.network, - )?; - builder - .with_fee_per_gram(fee_per_gram) - .with_memo( - MemoField::new_open_from_string("scraping wallet", TxType::PaymentToOther) - .map_err(OutputManagerError::InvalidPaymentIdFormat)?, - ) - .with_prevent_fee_gt_amount(self.resources.config.prevent_fee_gt_amount); - - for uo in &src_outputs { - builder.with_input(uo.wallet_output.clone())?; + if src_outputs.is_empty() { + debug!(target: LOG_TARGET, "scrape_wallet called but wallet has no unspent outputs"); + return Ok(vec![]); } - // encumbering transaction - self.resources.db.encumber_outputs(tx_id, src_outputs.clone(), vec![])?; - Ok(builder) + let mut batches = Vec::new(); + for batch in src_outputs.chunks(TRANSACTION_INPUTS_LIMIT as usize) { + let tx_id = TxId::new_random(); + let mut builder = TransactionBuilder::new( + self.resources.consensus_constants.clone(), + self.resources.key_manager.clone(), + self.resources.network, + )?; + builder + .with_fee_per_gram(fee_per_gram) + .with_memo( + MemoField::new_open_from_string("scraping wallet", TxType::PaymentToOther) + .map_err(OutputManagerError::InvalidPaymentIdFormat)?, + ) + .with_prevent_fee_gt_amount(self.resources.config.prevent_fee_gt_amount); + + for uo in batch { + builder.with_input(uo.wallet_output.clone())?; + } + + // encumber this batch of outputs + self.resources.db.encumber_outputs(tx_id, batch.to_vec(), vec![])?; + batches.push((tx_id, builder)); + } + debug!( + target: LOG_TARGET, + "scrape_wallet: created {} transaction batch(es) from {} unspent outputs", + batches.len(), + src_outputs.len() + ); + Ok(batches) } pub async fn fetch_unspent_outputs_from_node( diff --git a/base_layer/wallet/src/transaction_service/handle.rs b/base_layer/wallet/src/transaction_service/handle.rs index 25db01ae39..6fc6919178 100644 --- a/base_layer/wallet/src/transaction_service/handle.rs +++ b/base_layer/wallet/src/transaction_service/handle.rs @@ -834,7 +834,7 @@ impl TransactionServiceHandle { &mut self, destination: TariAddress, fee_per_gram: MicroMinotari, - ) -> Result { + ) -> Result, TransactionServiceError> { match self .handle .call(TransactionServiceRequest::ScrapeWallet { @@ -844,7 +844,7 @@ impl TransactionServiceHandle { .await .inspect_err(|e| warn!(target: LOG_TARGET, "TransactionServiceRequest::ScrapeWallet({e})"))?? { - TransactionServiceResponse::TransactionSent(tx_id) => Ok(tx_id), + TransactionServiceResponse::TransactionsSent(tx_ids) => Ok(tx_ids), _ => Err(TransactionServiceError::UnexpectedApiResponse( "TransactionServiceRequest::ScrapeWallet".to_string(), )), diff --git a/base_layer/wallet/src/transaction_service/service.rs b/base_layer/wallet/src/transaction_service/service.rs index fe7c2135ee..14bcde762c 100644 --- a/base_layer/wallet/src/transaction_service/service.rs +++ b/base_layer/wallet/src/transaction_service/service.rs @@ -845,7 +845,7 @@ where let res = self .scrape_wallet(destination, fee_per_gram, transaction_broadcast_join_handles) .await?; - Ok(TransactionServiceResponse::TransactionSent(res)) + Ok(TransactionServiceResponse::TransactionsSent(res)) } .await }, @@ -2584,157 +2584,162 @@ where transaction_broadcast_join_handles: &mut FuturesUnordered< JoinHandle>>, >, - ) -> Result { - let temp_tx_id = TxId::new_random(); + ) -> Result, TransactionServiceError> { self.verify_send(&dest_address, TariAddressFeatures::create_one_sided_only())?; - // Prepare sender part of the transaction - let mut tx_builder = self + // Prepare sender part of the transactions, batched by TRANSACTION_INPUTS_LIMIT + let batches = self .resources .output_manager_service - .scrape_wallet(temp_tx_id, fee_per_gram) + .scrape_wallet(fee_per_gram) .await?; - // Prepare receiver part of the transaction + let mut tx_ids = Vec::with_capacity(batches.len()); - // 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, rewind, and encryption keys - let sender_offset_private_key = self - .resources - .transaction_key_manager_service - .get_random_key(None, Some(LedgerKeyBranch::OneSidedSenderOffset))?; + for (temp_tx_id, mut tx_builder) in batches { + // Prepare receiver part of the transaction - let shared_secret = self - .resources - .transaction_key_manager_service - .get_diffie_hellman_shared_secret( - &sender_offset_private_key.key_id, - dest_address - .public_view_key() - .ok_or(TransactionServiceProtocolError::new( - temp_tx_id, - TransactionServiceError::OneSidedTransactionError("Missing public view key".to_string()), - ))?, - )?; - let commitment_mask_private_key = public_key_to_output_spending_key(&shared_secret) - .map_err(|e| TransactionServiceProtocolError::new(temp_tx_id, e.into()))?; - let commitment_mask_key_id = &self - .resources - .transaction_key_manager_service - .create_encrypted_key(commitment_mask_private_key.clone(), None)?; + // 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, rewind, and encryption keys + let sender_offset_private_key = self + .resources + .transaction_key_manager_service + .get_random_key(None, Some(LedgerKeyBranch::OneSidedSenderOffset))?; - let script_spending_key = self - .resources - .transaction_key_manager_service - .stealth_address_script_spending_key(commitment_mask_key_id, dest_address.public_spend_key())?; - let script = push_pubkey_script(&script_spending_key); + let shared_secret = self + .resources + .transaction_key_manager_service + .get_diffie_hellman_shared_secret( + &sender_offset_private_key.key_id, + dest_address + .public_view_key() + .ok_or(TransactionServiceProtocolError::new( + temp_tx_id, + TransactionServiceError::OneSidedTransactionError("Missing public view key".to_string()), + ))?, + )?; + let commitment_mask_private_key = public_key_to_output_spending_key(&shared_secret) + .map_err(|e| TransactionServiceProtocolError::new(temp_tx_id, e.into()))?; + let commitment_mask_key_id = &self + .resources + .transaction_key_manager_service + .create_encrypted_key(commitment_mask_private_key.clone(), None)?; - let encryption_private_key = public_key_to_output_encryption_key(&shared_secret)?; - let encryption_key = self - .resources - .transaction_key_manager_service - .create_encrypted_key(encryption_private_key, None)?; + let script_spending_key = self + .resources + .transaction_key_manager_service + .stealth_address_script_spending_key(commitment_mask_key_id, dest_address.public_spend_key())?; + let script = push_pubkey_script(&script_spending_key); - let spending_key_id = self - .resources - .transaction_key_manager_service - .create_encrypted_key(commitment_mask_private_key, None)?; + let encryption_private_key = public_key_to_output_encryption_key(&shared_secret)?; + let encryption_key = self + .resources + .transaction_key_manager_service + .create_encrypted_key(encryption_private_key, None)?; - let sender_offset_public_key = self - .resources - .transaction_key_manager_service - .get_public_key_at_key_id(&sender_offset_private_key.key_id)?; - let amount = tx_builder.get_total_input_value()?; - let fee = tx_builder.get_fee_estimate_without_change()?; - let minimum_value_promise = MicroMinotari::zero(); - let payment_id = MemoField::new_address_and_data( - self.resources.one_sided_tari_address.clone(), - fee, - true, - TxType::PaymentToOther, - vec![], - ) - .map_err(|e| TransactionServiceError::InvalidPaymentId(e.to_string()))?; - let output = WalletOutputBuilder::new(amount, spending_key_id) - .with_features(Default::default()) - .with_script(script) - .encrypt_data_for_recovery( - &self.resources.transaction_key_manager_service, - Some(&encryption_key), - payment_id.clone(), - )? - .with_input_data(Default::default()) - .with_sender_offset_public_key(sender_offset_public_key) - .with_script_key(TariKeyId::Zero) - .with_minimum_value_promise(minimum_value_promise) - .sign_metadata_signature_user_verified( - &self.resources.transaction_key_manager_service, - &sender_offset_private_key.key_id, - &dest_address, - )? - .try_build(&self.resources.transaction_key_manager_service)?; + let spending_key_id = self + .resources + .transaction_key_manager_service + .create_encrypted_key(commitment_mask_private_key, None)?; - tx_builder.add_recipient( - dest_address.clone(), - output.clone(), - Some(sender_offset_private_key.key_id), - Some(encryption_key), - )?; + let sender_offset_public_key = self + .resources + .transaction_key_manager_service + .get_public_key_at_key_id(&sender_offset_private_key.key_id)?; + let amount = tx_builder.get_total_input_value()?; + let fee = tx_builder.get_fee_estimate_without_change()?; + let minimum_value_promise = MicroMinotari::zero(); + let payment_id = MemoField::new_address_and_data( + self.resources.one_sided_tari_address.clone(), + fee, + true, + TxType::PaymentToOther, + vec![], + ) + .map_err(|e| TransactionServiceError::InvalidPaymentId(e.to_string()))?; + let output = WalletOutputBuilder::new(amount, spending_key_id) + .with_features(Default::default()) + .with_script(script) + .encrypt_data_for_recovery( + &self.resources.transaction_key_manager_service, + Some(&encryption_key), + payment_id.clone(), + )? + .with_input_data(Default::default()) + .with_sender_offset_public_key(sender_offset_public_key) + .with_script_key(TariKeyId::Zero) + .with_minimum_value_promise(minimum_value_promise) + .sign_metadata_signature_user_verified( + &self.resources.transaction_key_manager_service, + &sender_offset_private_key.key_id, + &dest_address, + )? + .try_build(&self.resources.transaction_key_manager_service)?; + + tx_builder.add_recipient( + dest_address.clone(), + output.clone(), + Some(sender_offset_private_key.key_id), + Some(encryption_key), + )?; - let finalized = tx_builder.build()?; + let finalized = tx_builder.build()?; - info!(target: LOG_TARGET, "Finalized one-side transaction TxId: {}", finalized.tx_id); + info!(target: LOG_TARGET, "Finalized one-side transaction TxId: {}", finalized.tx_id); - // This event being sent is important, but not critical to the protocol being successful. Send only fails if - // there are no subscribers. - let _result = self - .event_publisher - .send(Arc::new(TransactionEvent::TransactionCompletedImmediately( - finalized.tx_id, - ))); + // This event being sent is important, but not critical to the protocol being successful. Send only fails if + // there are no subscribers. + let _result = self + .event_publisher + .send(Arc::new(TransactionEvent::TransactionCompletedImmediately( + finalized.tx_id, + ))); - // Broadcast one-sided transaction + // Broadcast one-sided transaction + + let tx = finalized.transaction.clone(); + let fee = finalized.fee; + self.resources + .output_manager_service + .add_output_with_tx_id(temp_tx_id, output.clone(), Some(SpendingPriority::HtlcSpendAsap)) + .await?; + let change = finalized.change.clone().map(|change| vec![change]); + self.resources + .output_manager_service + .confirm_pending_transaction(temp_tx_id, Some(finalized.tx_id), change) + .await + .map_err(|e| TransactionServiceProtocolError::new(finalized.tx_id, e.into()))?; + let received_hashes = finalized.sent_output_hashes.clone(); + let change_hashes = finalized.change_output_hashes.clone(); - let tx = finalized.transaction.clone(); - let fee = finalized.fee; - self.resources - .output_manager_service - .add_output_with_tx_id(temp_tx_id, output.clone(), Some(SpendingPriority::HtlcSpendAsap)) + let mut final_payment_id = payment_id.clone(); + final_payment_id.set_fee(fee); + self.submit_transaction( + transaction_broadcast_join_handles, + CompletedTransaction::new_with_output_hashes( + finalized.tx_id, + self.resources.one_sided_tari_address.clone(), + dest_address.clone(), + amount, + fee, + tx.clone(), + LegacyTransactionStatus::Completed, + Utc::now(), + TransactionDirection::Outbound, + None, + None, + final_payment_id, + vec![], + received_hashes, + change_hashes, + )?, + ) .await?; - let change = finalized.change.clone().map(|change| vec![change]); - self.resources - .output_manager_service - .confirm_pending_transaction(temp_tx_id, Some(finalized.tx_id), change) - .await - .map_err(|e| TransactionServiceProtocolError::new(finalized.tx_id, e.into()))?; - let received_hashes = finalized.sent_output_hashes.clone(); - let change_hashes = finalized.change_output_hashes.clone(); - let mut final_payment_id = payment_id.clone(); - final_payment_id.set_fee(fee); - self.submit_transaction( - transaction_broadcast_join_handles, - CompletedTransaction::new_with_output_hashes( - finalized.tx_id, - self.resources.one_sided_tari_address.clone(), - dest_address, - amount, - fee, - tx.clone(), - LegacyTransactionStatus::Completed, - Utc::now(), - TransactionDirection::Outbound, - None, - None, - final_payment_id, - vec![], - received_hashes, - change_hashes, - )?, - ) - .await?; + tx_ids.push(finalized.tx_id); + } - Ok(finalized.tx_id) + Ok(tx_ids) } /// Sends a one side payment transaction to a recipient diff --git a/base_layer/wallet_ffi/src/lib.rs b/base_layer/wallet_ffi/src/lib.rs index fcbfd0387e..63f137bdcd 100644 --- a/base_layer/wallet_ffi/src/lib.rs +++ b/base_layer/wallet_ffi/src/lib.rs @@ -7452,7 +7452,10 @@ pub unsafe extern "C" fn wallet_send_transaction( /// as an out parameter. Returns a 0 if any pointer argument is null. /// /// ## Returns -/// `unsigned long long` - Returns 0 if unsuccessful or the TxId of the sent transaction if successful +/// `unsigned long long` - Returns 0 if unsuccessful or the TxId of the first sent transaction if successful. Returns 0 +/// with no error set if the wallet has no unspent outputs to scrape. For large wallets, multiple transactions may be +/// created (batched by the maximum inputs limit). The caller should query the completed transaction list to find all +/// transactions created by this call. /// /// # Safety /// None @@ -7484,7 +7487,7 @@ pub unsafe extern "C" fn scrape_wallet( .transaction_service .scrape_wallet((*destination).clone(), MicroMinotari::from(fee_per_gram)), ) { - Ok(tx_id) => tx_id.as_u64(), + Ok(tx_ids) => tx_ids.into_iter().next().map(|id| id.as_u64()).unwrap_or(0), Err(e) => { *error_out = LibWalletError::from(WalletError::TransactionServiceError(e)).code; 0 diff --git a/base_layer/wallet_ffi/wallet.h b/base_layer/wallet_ffi/wallet.h index 5d5828a182..c3ce85574f 100644 --- a/base_layer/wallet_ffi/wallet.h +++ b/base_layer/wallet_ffi/wallet.h @@ -3331,7 +3331,9 @@ unsigned long long wallet_send_transaction(struct TariWallet *wallet, * as an out parameter. Returns a 0 if any pointer argument is null. * * ## Returns - * `unsigned long long` - Returns 0 if unsuccessful or the TxId of the sent transaction if successful + * `unsigned long long` - Returns 0 if unsuccessful or the TxId of the first sent transaction if successful. For large + * wallets, multiple transactions may be created (batched by the maximum inputs limit). The caller should query the + * completed transaction list to find all transactions created by this call. * * # Safety * None