diff --git a/cli/src/modules/estimate.rs b/cli/src/modules/estimate.rs index 577980a8a..79b37b7cf 100644 --- a/cli/src/modules/estimate.rs +++ b/cli/src/modules/estimate.rs @@ -22,7 +22,7 @@ impl Estimate { // just use any address for an estimate (change address) let change_address = account.change_address()?; - let destination = PaymentDestination::PaymentOutputs(PaymentOutputs::try_from((change_address.clone(), amount_sompi))?); + let destination = PaymentDestination::PaymentOutputs(PaymentOutputs::from((change_address.clone(), amount_sompi))); let estimate = account.estimate(destination, priority_fee_sompi.into(), None, &abortable).await?; tprintln!(ctx, "Estimate - {estimate}"); diff --git a/cli/src/modules/send.rs b/cli/src/modules/send.rs index 56ea8976e..123909dfc 100644 --- a/cli/src/modules/send.rs +++ b/cli/src/modules/send.rs @@ -19,7 +19,7 @@ impl Send { let address = Address::try_from(argv.get(0).unwrap().as_str())?; let amount_sompi = try_parse_required_nonzero_kaspa_as_sompi_u64(argv.get(1))?; let priority_fee_sompi = try_parse_optional_kaspa_as_sompi_i64(argv.get(2))?.unwrap_or(0); - let outputs = PaymentOutputs::try_from((address.clone(), amount_sompi))?; + let outputs = PaymentOutputs::from((address.clone(), amount_sompi)); let abortable = Abortable::default(); let (wallet_secret, payment_secret) = ctx.ask_wallet_secret(Some(&account)).await?; diff --git a/cli/src/modules/transfer.rs b/cli/src/modules/transfer.rs index c73d3b496..d81fd52c6 100644 --- a/cli/src/modules/transfer.rs +++ b/cli/src/modules/transfer.rs @@ -26,7 +26,7 @@ impl Transfer { let (wallet_secret, payment_secret) = ctx.ask_wallet_secret(Some(&account)).await?; let abortable = Abortable::default(); - let outputs = PaymentOutputs::try_from((target_address.clone(), amount_sompi))?; + let outputs = PaymentOutputs::from((target_address.clone(), amount_sompi)); // let ctx_ = ctx.clone(); let (summary, _ids) = account diff --git a/consensus/wasm/src/outpoint.rs b/consensus/wasm/src/outpoint.rs index f12a02220..abf98307f 100644 --- a/consensus/wasm/src/outpoint.rs +++ b/consensus/wasm/src/outpoint.rs @@ -141,3 +141,9 @@ impl From for cctx::TransactionOutpoint { cctx::TransactionOutpoint::new(transaction_id, index) } } + +impl TransactionOutpoint { + pub fn fake() -> Self { + Self::new(TransactionId::from_slice(&[0; kaspa_hashes::HASH_SIZE]), 0) + } +} diff --git a/consensus/wasm/src/utxo.rs b/consensus/wasm/src/utxo.rs index f86eb7a29..979095571 100644 --- a/consensus/wasm/src/utxo.rs +++ b/consensus/wasm/src/utxo.rs @@ -296,3 +296,26 @@ impl TryFrom<&JsValue> for UtxoEntryReference { } } } + +impl UtxoEntryReference { + pub fn fake(amount: u64) -> Self { + use kaspa_addresses::{Prefix, Version}; + let address = Address::new(Prefix::Testnet, Version::PubKey, &[0; 32]); + Self::fake_with_address(amount, &address) + } + + pub fn fake_with_address(amount: u64, address: &Address) -> Self { + let outpoint = TransactionOutpoint::fake(); + let script_public_key = kaspa_txscript::pay_to_address_script(address); + let block_daa_score = 0; + let is_coinbase = true; + + let utxo_entry = UtxoEntry { + address: Some(address.clone()), + outpoint, + entry: cctx::UtxoEntry { amount, script_public_key, block_daa_score, is_coinbase }, + }; + + UtxoEntryReference::from(utxo_entry) + } +} diff --git a/wallet/core/src/error.rs b/wallet/core/src/error.rs index 8050ca45f..228a43576 100644 --- a/wallet/core/src/error.rs +++ b/wallet/core/src/error.rs @@ -201,6 +201,21 @@ pub enum Error { #[error(transparent)] ConsensusWasm(#[from] kaspa_consensus_wasm::error::Error), + + #[error("Fees::Include or Fees::Exclude are not allowed in sweep transactions")] + GeneratorFeesInSweepTransaction, + + #[error("Change address does not match supplied network type")] + GeneratorChangeAddressNetworkTypeMismatch, + + #[error("Payment output address does not match supplied network type")] + GeneratorPaymentOutputNetworkTypeMismatch, + + #[error("Priority fees can not be included into transactions with multiple outputs")] + GeneratorIncludeFeesRequiresOneOutput, + + #[error("Requested transaction is too heavy")] + GeneratorTransactionIsTooHeavy, } impl From for Error { diff --git a/wallet/core/src/runtime/account/mod.rs b/wallet/core/src/runtime/account/mod.rs index 357b7df9a..0f06d2d96 100644 --- a/wallet/core/src/runtime/account/mod.rs +++ b/wallet/core/src/runtime/account/mod.rs @@ -302,7 +302,7 @@ pub trait Account: AnySync + Send + Sync + 'static { let signer = Arc::new(Signer::new(self.clone().as_dyn_arc(), keydata, payment_secret)); let settings = GeneratorSettings::try_new_with_account(self.clone().as_dyn_arc(), PaymentDestination::Change, Fees::None, None)?; - let generator = Generator::new(settings, Some(signer), abortable); + let generator = Generator::try_new(settings, Some(signer), Some(abortable))?; let mut stream = generator.stream(); let mut ids = vec![]; @@ -336,7 +336,7 @@ pub trait Account: AnySync + Send + Sync + 'static { let settings = GeneratorSettings::try_new_with_account(self.clone().as_dyn_arc(), destination, priority_fee_sompi, payload)?; - let generator = Generator::new(settings, Some(signer), abortable); + let generator = Generator::try_new(settings, Some(signer), Some(abortable))?; let mut stream = generator.stream(); let mut ids = vec![]; @@ -364,7 +364,7 @@ pub trait Account: AnySync + Send + Sync + 'static { ) -> Result { let settings = GeneratorSettings::try_new_with_account(self.as_dyn_arc(), destination, priority_fee_sompi, payload)?; - let generator = Generator::new(settings, None, abortable); + let generator = Generator::try_new(settings, None, Some(abortable))?; let mut stream = generator.stream(); while let Some(_transaction) = stream.try_next().await? { @@ -448,7 +448,7 @@ pub trait DerivationCapableAccount: Account { None, )?; - let generator = Generator::new(settings, Some(signer), abortable); + let generator = Generator::try_new(settings, Some(signer), Some(abortable))?; let mut stream = generator.stream(); while let Some(transaction) = stream.try_next().await? { diff --git a/wallet/core/src/storage/transaction.rs b/wallet/core/src/storage/transaction.rs index aa3d11a35..c8a3ecfda 100644 --- a/wallet/core/src/storage/transaction.rs +++ b/wallet/core/src/storage/transaction.rs @@ -273,12 +273,12 @@ impl TransactionRecord { let PendingTransactionInner { signable_tx, - is_final, + kind, fees, aggregate_input_value, aggregate_output_value, payment_value, - change_value, + change_output_value, .. } = &*pending_tx.inner; @@ -286,13 +286,13 @@ impl TransactionRecord { let id = transaction.id(); let transaction_data = TransactionData::Outgoing { - is_final: *is_final, + is_final: kind.is_final(), fees: *fees, aggregate_input_value: *aggregate_input_value, aggregate_output_value: *aggregate_output_value, transaction, payment_value: *payment_value, - change_value: *change_value, + change_value: *change_output_value, }; TransactionRecord { diff --git a/wallet/core/src/tx/fees.rs b/wallet/core/src/tx/fees.rs index 00dca0b81..b8b00f43b 100644 --- a/wallet/core/src/tx/fees.rs +++ b/wallet/core/src/tx/fees.rs @@ -2,26 +2,62 @@ use crate::result::Result; use wasm_bindgen::prelude::*; use workflow_wasm::prelude::*; +/// Transaction fees. Fees are comprised of 2 values: +/// +/// `relay` fees - mandatory fees that are required to relay the transaction +/// `priority` fees - optional fees applied to the final outgoing transaction +/// in addition to `relay` fees. +/// +/// Fees can be: +/// - `SenderPaysAll` - (standard) fees are added to outgoing transaction value +/// - `ReceiverPaysTransfer` - aggregation fees are paid by sender, but final +/// transaction fees, including priority fees are paid by the receiver. +/// - `ReceiverPaysAll` - all transaction fees are paid by the receiver. +/// +/// NOTE: If priority fees are `0`, fee variants can be used control +/// who pays the `relay` fees. +/// +/// NOTE: `ReceiverPays` variants can fail during the aggregation process +/// if there are not enough funds to cover the final transaction. +/// There are 2 solutions to this problem: +/// +/// 1. Use estimation to check that the funds are sufficient. +/// 2. Check balance and ensure that there is a sufficient amount of funds. +/// #[derive(Debug, Clone)] pub enum Fees { + /// Fee management disabled (sweep transactions, pays all fees) None, - Include(u64), - Exclude(u64), + /// fees are are added to the transaction value + SenderPaysAll(u64), + /// all transaction fees are subtracted from transaction value + ReceiverPaysAll(u64), + /// final transaction fees are subtracted from transaction value + ReceiverPaysTransfer(u64), } +impl Fees { + pub fn is_none(&self) -> bool { + matches!(self, Fees::None) + } +} + +/// This trait converts supplied positive `i64` value as `Exclude` fees +/// and negative `i64` value as `Include` fees. I.e. `Fees::from(-100)` will +/// result in priority fees that are included in the transaction value. impl From for Fees { fn from(fee: i64) -> Self { if fee < 0 { - Fees::Include(fee.unsigned_abs()) + Fees::ReceiverPaysTransfer(fee.unsigned_abs()) } else { - Fees::Exclude(fee as u64) + Fees::SenderPaysAll(fee as u64) } } } impl From for Fees { fn from(fee: u64) -> Self { - Fees::Exclude(fee) + Fees::SenderPaysAll(fee) } } @@ -50,7 +86,7 @@ impl TryFrom for Fees { if fee.is_undefined() || fee.is_null() { Ok(Fees::None) } else if let Ok(fee) = fee.try_as_u64() { - Ok(Fees::Exclude(fee)) + Ok(Fees::SenderPaysAll(fee)) } else { Err(crate::error::Error::custom("Invalid fee")) } diff --git a/wallet/core/src/tx/generator/generator.rs b/wallet/core/src/tx/generator/generator.rs index 1f3c1d5d4..62967dd03 100644 --- a/wallet/core/src/tx/generator/generator.rs +++ b/wallet/core/src/tx/generator/generator.rs @@ -1,3 +1,58 @@ +//! +//! Transaction generator module used for creating multi-stage transactions +//! optimized for parallelized DAG processing. +//! +//! The [`Generator`] intakes a set of UTXO entries and accumulates them as +//! inputs into a single transaction. If transaction hits mass boundaries +//! before 1) desired amount is reached or 2) all UTXOs are consumed, the +//! transaction is yielded and a "relay" transaction is created. +//! +//! If "relay" transactions are created, the [`Generator`] will aggregate +//! such transactions into a single transaction and repeat the process +//! until 1) desired amount is reached or 2) all UTXOs are consumed. +//! +//! This processing results in a creation of a transaction tree where +//! each level (stage) of this tree is submitted to the network in parallel. +//! +//! +//! Tx1 Tx2 Tx3 Tx4 Tx5 Tx6 | stage 0 (relays to stage 1) +//! | | | | | | | +//! +---+ +---+ +---+ | +//! | | | | +//! Tx7 Tx8 Tx9 | stage 1 (relays to stage 2) +//! | | | | +//! +-------+-------+ | +//! | | +//! Tx10 | stage 2 (final outbound transaction) +//! +//! The generator will produce transactions in the following order: +//! Tx1, Tx2, Tx3, Tx4, Tx5, Tx6, Tx7, Tx8, Tx9, Tx10 +//! +//! Transactions within a single stage are independent of one another +//! and as such can be processed in parallel. +//! +//! The [`Generator`] acts as a transaction iterator, yielding transactions +//! for each iteration. These transactions can be obtained via an iterator +//! interface or via an async Stream interface. +//! +//! Q: Why is this not implemented as a single loop? +//! A: There are a number of requirements that need to be handled: +//! +//! 1. UTXO entry consumption while creating inputs may results in +//! additional fees, requiring additional UTXO entries to cover +//! the fees. Goto 1. (this is a classic issue, can be solved using padding) +//! +//! 2. The overall design strategy for this processor is to allow +//! concurrent processing of a large number of transactions and UTXOs. +//! This implementation avoids in-memory aggregation of all +//! transactions that may result in OOM conditions. +//! +//! 3. If used with a large UTXO set, the transaction generation process +//! needs to be asynchronous to avoid blocking the main thread. In the +//! context of WASM32 SDK, not doing that while working with large +//! UTXO sets will result in a browser UI freezing. +//! + use crate::imports::*; use crate::result::Result; use crate::tx::{ @@ -15,28 +70,139 @@ use std::collections::VecDeque; use super::SignerT; +/// Mutable [`Generator`] state used to track the current transaction generation process. struct Context { - utxo_iterator: Box + Send + Sync + 'static>, - + utxo_source_iterator: Box + Send + Sync + 'static>, + /// utxo_stage_iterator: Option + Send + Sync + 'static>>, aggregated_utxos: usize, - // total fees of all transactions issued by - // the single generator instance + /// total fees of all transactions issued by + /// the single generator instance aggregate_fees: u64, - // number of generated transactions - number_of_generated_transactions: usize, - // UTXO entry consumed from the iterator but - // was not used and remained for the next transaction + /// number of generated transactions + number_of_transactions: usize, + /// UTXO entry accumulator for each stage + /// utxo_stage_accumulator: Vec, + stage: Option>, + /// UTXO entry consumed from the iterator but + /// was not used due to mass threshold and + /// remained for the next transaction utxo_stash: VecDeque, - // final transaction id + /// final transaction id final_transaction_id: Option, - // signifies that the generator is finished - // no more items will be produced in the - // iterator or a stream + /// signifies that the generator is finished + /// no more items will be produced in the + /// iterator or a stream is_done: bool, } +/// [`Generator`] stage. A "tree level" processing stage, used to track +/// transactions processed during a stage. +#[derive(Default)] +struct Stage { + utxo_iterator: Option + Send + Sync + 'static>>, + utxo_accumulator: Vec, + aggregate_input_value: u64, + aggregate_fees: u64, + number_of_transactions: usize, +} + +impl Stage { + fn new(previous: Stage) -> Stage { + let utxo_iterator: Box + Send + Sync + 'static> = + Box::new(previous.utxo_accumulator.into_iter()); + + Stage { + utxo_iterator: Some(utxo_iterator), + utxo_accumulator: vec![], + aggregate_input_value: 0, + // aggregate_mass: 0, + aggregate_fees: 0, + number_of_transactions: 0, + } + } +} + +impl std::fmt::Debug for Stage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Stage") + .field("aggregate_input_value", &self.aggregate_input_value) + .field("aggregate_fees", &self.aggregate_fees) + .field("number_of_transactions", &self.number_of_transactions) + .finish() + } +} + +/// +/// Indicates the type of data yielded by the generator +/// +#[derive(Debug, Copy, Clone)] +pub enum DataKind { + /// No operation should be performed (abort) + /// Used for handling exceptions, such as rejecting + /// to produce dust outputs during sweep transactions. + NoOp, + /// A "tree node" or "relay" transaction meant for multi-stage + /// operations. This transaction combines multiple UTXOs + /// into a single transaction to the supplied change address. + Node, + /// A "tree edge" transaction meant for multi-stage + /// processing. Signifies completion of the tree level (stage). + /// This operation will create a new tree level (stage). + Edge, + /// Final transaction combining the entire aggregated UTXO set + /// into a single set of supplied outputs. + Final, +} + +impl DataKind { + pub fn is_final(&self) -> bool { + matches!(self, DataKind::Final) + } + pub fn is_stage_node(&self) -> bool { + matches!(self, DataKind::Node) + } + pub fn is_stage_edge(&self) -> bool { + matches!(self, DataKind::Edge) + } +} + +/// +/// Single transaction data accumulator. This structure is used to accumulate +/// and track all necessary transaction data and is then used to create +/// an actual transaction. +/// +#[derive(Debug)] +struct Data { + inputs: Vec, + utxo_entry_references: Vec, + addresses: HashSet
, + aggregate_mass: u64, + transaction_fees: u64, + aggregate_input_value: u64, + change_output_value: Option, +} + +impl Data { + fn new(calc: &MassCalculator) -> Self { + let aggregate_mass = calc.blank_transaction_mass(); + + Data { + inputs: vec![], + utxo_entry_references: vec![], + addresses: HashSet::default(), + aggregate_mass, + transaction_fees: 0, + aggregate_input_value: 0, + change_output_value: None, + } + } +} + +/// +/// Internal Generator settings and references +/// struct Inner { - abortable: Abortable, + abortable: Option, signer: Option>, mass_calculator: MassCalculator, network_type: NetworkType, @@ -48,11 +214,14 @@ struct Inner { // typically a number of keys required to sign the transaction sig_op_count: u8, // number of minimum signatures required to sign the transaction + #[allow(dead_code)] minimum_signatures: u16, // change address change_address: Address, // change_output: TransactionOutput, - change_output_mass: u64, + standard_change_output_mass: u64, + // signature mass per input + signature_mass_per_input: u64, // transaction amount (`None` results in consumption of all available UTXOs) // `None` is used for sweep transactions final_transaction_amount: Option, @@ -64,17 +233,22 @@ struct Inner { final_transaction_outputs_mass: u64, // final transaction payload final_transaction_payload: Vec, + // final transaction payload mass + final_transaction_payload_mass: u64, // execution context context: Mutex, } +/// +/// Transaction generator +/// #[derive(Clone)] pub struct Generator { inner: Arc, } impl Generator { - pub fn new(settings: GeneratorSettings, signer: Option>, abortable: &Abortable) -> Self { + pub fn try_new(settings: GeneratorSettings, signer: Option>, abortable: Option<&Abortable>) -> Result { let GeneratorSettings { network_type, multiplexer, @@ -83,7 +257,7 @@ impl Generator { sig_op_count, minimum_signatures, change_address, - final_priority_fee: final_transaction_priority_fee, + final_transaction_priority_fee, final_transaction_destination, final_transaction_payload, } = settings; @@ -91,47 +265,92 @@ impl Generator { let mass_calculator = MassCalculator::new(&network_type.into()); let (final_transaction_outputs, final_transaction_amount) = match final_transaction_destination { - // PaymentDestination::Address(address) => (vec![TransactionOutput::new(0, &pay_to_address_script(&address))], None), - PaymentDestination::Change => (vec![], None), - PaymentDestination::PaymentOutputs(outputs) => ( - outputs.iter().map(|output| TransactionOutput::new(output.amount, pay_to_address_script(&output.address))).collect(), - Some(outputs.iter().map(|output| output.amount).sum()), - ), + PaymentDestination::Change => { + if !final_transaction_priority_fee.is_none() { + return Err(Error::GeneratorFeesInSweepTransaction); + } + + (vec![], None) + } + PaymentDestination::PaymentOutputs(outputs) => { + // sanity check + for output in outputs.iter() { + if NetworkType::try_from(output.address.prefix)? != network_type { + return Err(Error::GeneratorPaymentOutputNetworkTypeMismatch); + } + } + + ( + outputs + .iter() + .map(|output| TransactionOutput::new(output.amount, pay_to_address_script(&output.address))) + .collect(), + Some(outputs.iter().map(|output| output.amount).sum()), + ) + } }; + if final_transaction_outputs.len() != 1 && matches!(final_transaction_priority_fee, Fees::ReceiverPaysTransfer(_)) { + return Err(Error::GeneratorIncludeFeesRequiresOneOutput); + } + + // sanity check + if NetworkType::try_from(change_address.prefix)? != network_type { + return Err(Error::GeneratorChangeAddressNetworkTypeMismatch); + } + + // if final_transaction_amount.is_none() && !matches!(final_transaction_priority_fee, Fees::None) { + // } + let context = Mutex::new(Context { - utxo_iterator, - number_of_generated_transactions: 0, + utxo_source_iterator: utxo_iterator, + number_of_transactions: 0, aggregated_utxos: 0, aggregate_fees: 0, + stage: Some(Box::default()), utxo_stash: VecDeque::default(), final_transaction_id: None, is_done: false, }); - let change_output_mass = - mass_calculator.calc_mass_for_output(&TransactionOutput::new(0, pay_to_address_script(&change_address))); + let standard_change_output_mass = + mass_calculator.calc_mass_for_output(&TransactionOutput::new(0, pay_to_address_script(&change_address))); + let signature_mass_per_input = mass_calculator.calc_signature_mass(minimum_signatures); let final_transaction_outputs_mass = mass_calculator.calc_mass_for_outputs(&final_transaction_outputs); + let final_transaction_payload = final_transaction_payload.unwrap_or_default(); + let final_transaction_payload_mass = mass_calculator.calc_mass_for_payload(final_transaction_payload.len()); + + // reject transactions where the payload and outputs are more than 2/3rds of the maximum tx mass + let mass_sanity_check = standard_change_output_mass + final_transaction_outputs_mass + final_transaction_payload_mass; + if mass_sanity_check > MAXIMUM_STANDARD_TRANSACTION_MASS / 3 * 2 { + return Err(Error::GeneratorTransactionIsTooHeavy); + } let inner = Inner { network_type, multiplexer, context, signer, - abortable: abortable.clone(), + abortable: abortable.cloned(), mass_calculator, utxo_context, sig_op_count, minimum_signatures, change_address, - change_output_mass, + standard_change_output_mass, + signature_mass_per_input, final_transaction_amount, final_transaction_priority_fee, final_transaction_outputs, final_transaction_outputs_mass, - final_transaction_payload: final_transaction_payload.unwrap_or_default(), + final_transaction_payload, + final_transaction_payload_mass, }; - Self { inner: Arc::new(inner) } + Ok(Self { inner: Arc::new(inner) }) + } + + pub fn network_type(&self) -> NetworkType { + self.inner.network_type } /// The underlying [`UtxoContext`] (if available). @@ -183,6 +402,206 @@ impl Generator { PendingTransactionIterator::new(self) } + /// Get next UTXO entry. This function obtains UTXO in the following order: + /// 1. From the UTXO stash (used to store UTxOs that were not used in the previous transaction) + /// 2. From the current stage + /// 3. From the UTXO source iterator + fn get_utxo_entry(&self, context: &mut Context, stage: &mut Stage) -> Option { + context + .utxo_stash + .pop_front() + .or_else(|| stage.utxo_iterator.as_mut().and_then(|utxo_stage_iterator| utxo_stage_iterator.next())) + .or_else(|| context.utxo_source_iterator.next()) + } + + /// Calculate relay transaction mass for the current transaction `data` + fn calc_relay_transaction_mass(&self, data: &Data) -> u64 { + data.aggregate_mass + self.inner.standard_change_output_mass + } + + /// Calculate relay transaction fees for the current transaction `data` + fn calc_relay_transaction_relay_fees(&self, data: &Data) -> u64 { + self.inner.mass_calculator.calc_minimum_transaction_relay_fee_from_mass(self.calc_relay_transaction_mass(data)) + } + + /// Main UTXO entry processing loop. This function sources UTXOs from [`Generator::get_utxo_entry()`] and + /// accumulates consumed UTXO entry data within the [`Context`], [`Stage`] and [`Data`] structures. + /// + /// The general processing pattern can be described as follows: + /// + /// loop { + /// 1. Obtain UTXO entry from [`Generator::get_utxo_entry()`] + /// 2. Check if UTXO entries have been depleted, if so, handle sweep processing. + /// 3. Create a new Input for the transaction from the UTXO entry. + /// 4. Check if the transaction mass threshold has been reached, if so, yield the transaction. + /// 5. Register input with the [`Data`] structures. + /// 6. Check if the final transaction amount has been reached, if so, yield the transaction. + /// } + /// + /// + fn generate_transaction_data(&self, context: &mut Context, stage: &mut Stage) -> Result<(DataKind, Data)> { + let calc = &self.inner.mass_calculator; + let mut data = Data::new(calc); + let mut input_sequence = 0; + + loop { + if let Some(abortable) = self.inner.abortable.as_ref() { + abortable.check()?; + } + + let utxo_entry_reference = if let Some(utxo_entry_reference) = self.get_utxo_entry(context, stage) { + utxo_entry_reference + } else { + // UTXO sources are depleted, handle sweep processing + if self.inner.final_transaction_amount.is_none() { + return self.finish_relay_stage_processing(context, stage, data); + } else { + return Err(Error::InsufficientFunds); + } + }; + + let UtxoEntryReference { utxo } = &utxo_entry_reference; + + let input = TransactionInput::new(utxo.outpoint.clone().into(), vec![], input_sequence, self.inner.sig_op_count); + let input_amount = utxo.amount(); + let input_mass = calc.calc_mass_for_input(&input) + self.inner.signature_mass_per_input; + input_sequence += 1; + + // mass threshold reached, yield transaction + if data.aggregate_mass + input_mass + self.inner.standard_change_output_mass > MAXIMUM_STANDARD_TRANSACTION_MASS { + context.utxo_stash.push_back(utxo_entry_reference); + data.aggregate_mass += self.inner.standard_change_output_mass; + data.transaction_fees = self.calc_relay_transaction_relay_fees(&data); + stage.aggregate_fees += data.transaction_fees; + context.aggregate_fees += data.transaction_fees; + return Ok((DataKind::Node, data)); + } + + context.aggregated_utxos += 1; + stage.aggregate_input_value += input_amount; + data.aggregate_input_value += input_amount; + data.aggregate_mass += input_mass; + data.utxo_entry_references.push(utxo_entry_reference.clone()); + data.inputs.push(input); + utxo.address.as_ref().map(|address| data.addresses.insert(address.clone())); + + // standard transaction with target value + if let Some(final_transaction_value) = self.inner.final_transaction_amount { + if let Some(kind) = self.try_finish_standard_stage_processing(context, stage, &mut data, final_transaction_value)? { + return Ok((kind, data)); + } + } + } + } + + /// Check current state and either 1) initiate a new stage or 2) finish stage accumulation processing + fn finish_relay_stage_processing(&self, context: &mut Context, stage: &mut Stage, mut data: Data) -> Result<(DataKind, Data)> { + data.transaction_fees = self.calc_relay_transaction_relay_fees(&data); + stage.aggregate_fees += data.transaction_fees; + context.aggregate_fees += data.transaction_fees; + + if context.aggregated_utxos < 2 { + Ok((DataKind::NoOp, data)) + } else if stage.number_of_transactions > 0 { + data.aggregate_mass += self.inner.standard_change_output_mass; + data.change_output_value = Some(data.aggregate_input_value - data.transaction_fees); + Ok((DataKind::Edge, data)) + } else if data.aggregate_input_value < data.transaction_fees { + Err(Error::InsufficientFunds) + } else { + let change_output_value = data.aggregate_input_value - data.transaction_fees; + if is_standard_output_amount_dust(change_output_value) { + // sweep transaction resulting in dust output + // we add dust to fees, but the transaction will be + // discarded anyways due to `Exception` status. + // data.transaction_fees += change_output_value; + Ok((DataKind::NoOp, data)) + } else { + data.aggregate_mass += self.inner.standard_change_output_mass; + data.change_output_value = Some(change_output_value); + Ok((DataKind::Final, data)) + } + } + } + + /// Check if the current state has sufficient funds for the final transaction, + /// initiate new stage if necessary, or finish stage processing creating the + /// final transaction. + fn try_finish_standard_stage_processing( + &self, + context: &mut Context, + stage: &mut Stage, + data: &mut Data, + final_transaction_value_no_fees: u64, + ) -> Result> { + let calc = &self.inner.mass_calculator; + + let final_transaction_mass = data.aggregate_mass + + self.inner.standard_change_output_mass + + self.inner.final_transaction_outputs_mass + + self.inner.final_transaction_payload_mass; + + let final_transaction_relay_fees = calc.calc_minimum_transaction_relay_fee_from_mass(final_transaction_mass); + + let total_stage_value_needed = match self.inner.final_transaction_priority_fee { + Fees::SenderPaysAll(priority_fees) => { + final_transaction_value_no_fees + stage.aggregate_fees + final_transaction_relay_fees + priority_fees + } + _ => final_transaction_value_no_fees, + }; + + if total_stage_value_needed > stage.aggregate_input_value { + Ok(None) + } else { + // if final transaction hits mass boundary or this is a stage, generate new stage + if final_transaction_mass > MAXIMUM_STANDARD_TRANSACTION_MASS || stage.number_of_transactions > 0 { + data.aggregate_mass += self.inner.standard_change_output_mass; + data.transaction_fees = calc.calc_minimum_transaction_relay_fee_from_mass(data.aggregate_mass); + stage.aggregate_fees += data.transaction_fees; + context.aggregate_fees += data.transaction_fees; + Ok(Some(DataKind::Edge)) + } else { + let (mut transaction_fees, change_output_value) = match self.inner.final_transaction_priority_fee { + Fees::SenderPaysAll(priority_fees) => { + let transaction_fees = final_transaction_relay_fees + priority_fees; + let change_output_value = data.aggregate_input_value - final_transaction_value_no_fees - transaction_fees; + (transaction_fees, change_output_value) + } + Fees::ReceiverPaysTransfer(priority_fees) => { + let transaction_fees = final_transaction_relay_fees + priority_fees; + let change_output_value = data.aggregate_input_value - final_transaction_value_no_fees; + (transaction_fees, change_output_value) + } + Fees::ReceiverPaysAll(priority_fees) => { + let transaction_fees = final_transaction_relay_fees + priority_fees; + let change_output_value = data.aggregate_input_value - final_transaction_value_no_fees; + (transaction_fees, change_output_value) + } + Fees::None => unreachable!("Fees::None is not allowed for final transactions"), + }; + + data.change_output_value = if is_standard_output_amount_dust(change_output_value) { + data.aggregate_mass += self.inner.final_transaction_outputs_mass + self.inner.final_transaction_payload_mass; + transaction_fees += change_output_value; + data.transaction_fees = transaction_fees; + stage.aggregate_fees += transaction_fees; + context.aggregate_fees += transaction_fees; + None + } else { + data.aggregate_mass += self.inner.standard_change_output_mass + + self.inner.final_transaction_outputs_mass + + self.inner.final_transaction_payload_mass; + data.transaction_fees = transaction_fees; + stage.aggregate_fees += transaction_fees; + context.aggregate_fees += transaction_fees; + Some(change_output_value) + }; + + Ok(Some(DataKind::Final)) + } + } + } + /// Generates a single transaction by draining the supplied UTXO iterator. /// This function is used by the by the available async Stream and Iterator /// implementations to generate a stream of transactions. @@ -200,183 +619,133 @@ impl Generator { if context.is_done { return Ok(None); } - let calc = &self.inner.mass_calculator; - let signature_mass_per_input = calc.calc_signature_mass(self.inner.minimum_signatures); - let final_outputs_mass = self.inner.final_transaction_outputs_mass; - let change_output_mass = self.inner.change_output_mass; - let mut transaction_amount_accumulator = 0; - let mut change_amount = 0; - let mut mass_accumulator = calc.blank_transaction_mass(); - let payload_mass = calc.calc_mass_for_payload(self.inner.final_transaction_payload.len()); - - let mut addresses = HashSet::
::default(); - let mut utxo_entry_references = vec![]; - let mut inputs = vec![]; - - let mut sequence = 0; - let mut is_final = false; - loop { - self.inner.abortable.check()?; - - // take utxo from stash or from the iterator - let utxo_entry_reference = if let Some(utxo_entry_reference) = context.utxo_stash.pop_front() { - utxo_entry_reference - } else if let Some(entry) = context.utxo_iterator.next() { - entry - } else if self.inner.final_transaction_amount.is_none() { - // we have now exhausted UTXO iterator. if final amount is None, we are - // doing a sweep transaction. Produce a final tx if all looks ok. - is_final = true; - - let final_tx_mass = mass_accumulator + change_output_mass + payload_mass; - let final_transaction_fees = calc.calc_minimum_transaction_relay_fee_from_mass(final_tx_mass); - - let change_amount = transaction_amount_accumulator - final_transaction_fees; - if is_standard_output_amount_dust(change_amount) { - return Ok(None); - } - break; - } else { - return Err(Error::InsufficientFunds); - }; - - let UtxoEntryReference { utxo } = &utxo_entry_reference; + let mut stage = context.stage.take().unwrap(); + let (kind, data) = self.generate_transaction_data(&mut context, &mut stage)?; + context.stage.replace(stage); - let input = TransactionInput::new(utxo.outpoint.clone().into(), vec![], sequence, self.inner.sig_op_count); - let input_amount = utxo.amount(); - let mass_for_input = calc.calc_mass_for_input(&input) + signature_mass_per_input; - - // maximum mass reached, require additional transaction - if mass_accumulator + mass_for_input + change_output_mass > MAXIMUM_STANDARD_TRANSACTION_MASS { - context.utxo_stash.push_back(utxo_entry_reference); - break; - } - mass_accumulator += mass_for_input; - transaction_amount_accumulator += input_amount; - utxo_entry_references.push(utxo_entry_reference.clone()); - inputs.push(input); - if let Some(address) = utxo.address.as_ref() { - addresses.insert(address.clone()); + match (kind, data) { + (DataKind::NoOp, _) => { + context.is_done = true; + context.stage.take(); + Ok(None) } - context.aggregated_utxos += 1; - sequence += 1; - - // check if we have reached the desired transaction amount - if let Some(final_transaction_amount) = self.inner.final_transaction_amount { - let final_tx_mass = mass_accumulator + final_outputs_mass + payload_mass; - let mut final_transaction_fees = calc.calc_minimum_transaction_relay_fee_from_mass(final_tx_mass); - // log_info!("final_transaction_fees A0: {final_transaction_fees:?}"); - if let Fees::Exclude(fees) = self.inner.final_transaction_priority_fee { - final_transaction_fees += fees; + (DataKind::Final, data) => { + context.is_done = true; + context.stage.take(); + + let Data { + inputs, + utxo_entry_references, + addresses, + aggregate_input_value, + change_output_value, + aggregate_mass, + transaction_fees, + .. + } = data; + + let change_output_value = change_output_value.unwrap_or(0); + + let mut final_outputs = self.inner.final_transaction_outputs.clone(); + + if let Fees::ReceiverPaysTransfer(_) = self.inner.final_transaction_priority_fee { + let output = final_outputs.get_mut(0).expect("include fees requires one output"); + output.value -= transaction_fees; } - // log_info!("final_transaction_fees A1: {final_transaction_fees:?}"); - - let final_transaction_total = final_transaction_amount + final_transaction_fees; - if transaction_amount_accumulator > final_transaction_total { - change_amount = transaction_amount_accumulator - final_transaction_total; - - if is_standard_output_amount_dust(change_amount) { - context.aggregate_fees += final_transaction_fees + change_amount; - change_amount = 0; - - is_final = final_tx_mass < MAXIMUM_STANDARD_TRANSACTION_MASS; - } else { - //re-calculate fee with change outputs - let mut final_transaction_fees = - calc.calc_minimum_transaction_relay_fee_from_mass(final_tx_mass + change_output_mass); - // workflow_log::log_info!("final_transaction_fees B0: {final_transaction_fees:?}"); - if let Fees::Exclude(fees) = self.inner.final_transaction_priority_fee { - final_transaction_fees += fees; - } - // workflow_log::log_info!("final_transaction_fees B1: {final_transaction_fees:?}"); - let final_transaction_total = final_transaction_amount + final_transaction_fees; - change_amount = transaction_amount_accumulator - final_transaction_total; - if is_standard_output_amount_dust(change_amount) { - // if we encounter dust output, we do not include it in the transaction - // instead, we remove it (adding it to the transaction fees) - context.aggregate_fees += change_amount; - change_amount = 0; - - is_final = final_tx_mass < MAXIMUM_STANDARD_TRANSACTION_MASS; - } else { - is_final = final_tx_mass + change_output_mass < MAXIMUM_STANDARD_TRANSACTION_MASS; - } - } - break; + if change_output_value > 0 { + let output = TransactionOutput::new(change_output_value, pay_to_address_script(&self.inner.change_address)); + final_outputs.push(output); } - } - } - // generate transaction from inputs aggregated so far - - if is_final { - context.is_done = true; + let aggregate_output_value = final_outputs.iter().map(|output| output.value).sum::(); + // `Fees::ReceiverPays` processing can result in outputs being larger than inputs + if aggregate_output_value > aggregate_input_value { + return Err(Error::InsufficientFunds); + } - let mut final_outputs = self.inner.final_transaction_outputs.clone(); - if change_amount > 0 { - let output = TransactionOutput::new(change_amount, pay_to_address_script(&self.inner.change_address)); - final_outputs.push(output); + let tx = Transaction::new( + 0, + inputs, + final_outputs, + 0, + SUBNETWORK_ID_NATIVE, + 0, + self.inner.final_transaction_payload.clone(), + ); + + context.final_transaction_id = Some(tx.id()); + context.number_of_transactions += 1; + + Ok(Some(PendingTransaction::try_new( + self, + tx, + utxo_entry_references, + addresses.into_iter().collect(), + self.inner.final_transaction_amount, + change_output_value, + aggregate_input_value, + aggregate_output_value, + aggregate_mass, + transaction_fees, + kind, + )?)) } + (kind, data) => { + let Data { + inputs, + utxo_entry_references, + addresses, + aggregate_input_value, + aggregate_mass, + transaction_fees, + change_output_value, + .. + } = data; + + assert_eq!(change_output_value, None); + + let output_value = aggregate_input_value - transaction_fees; + let script_public_key = pay_to_address_script(&self.inner.change_address); + let output = TransactionOutput::new(output_value, script_public_key.clone()); + let tx = Transaction::new(0, inputs, vec![output], 0, SUBNETWORK_ID_NATIVE, 0, vec![]); + context.number_of_transactions += 1; + + let utxo_entry_reference = + Self::create_batch_utxo_entry_reference(tx.id(), output_value, script_public_key, &self.inner.change_address); + + match kind { + DataKind::Node => { + // store resulting UTXO in the current stage + let stage = context.stage.as_mut().unwrap(); + stage.utxo_accumulator.push(utxo_entry_reference); + stage.number_of_transactions += 1; + } + DataKind::Edge => { + // store resulting UTXO in the current stage and create a new stage + let mut stage = context.stage.take().unwrap(); + stage.utxo_accumulator.push(utxo_entry_reference); + stage.number_of_transactions += 1; + context.stage.replace(Box::new(Stage::new(*stage))); + } + _ => unreachable!(), + } - let aggregate_input_value = utxo_entry_references.iter().map(|entry| entry.amount()).sum::(); - let aggregate_output_value = final_outputs.iter().map(|output| output.value).sum::(); - let transaction_fees = aggregate_input_value - aggregate_output_value; - context.aggregate_fees += transaction_fees; - #[cfg(any(debug_assertions, test))] - assert_eq!(transaction_amount_accumulator, aggregate_input_value); - - let tx = - Transaction::new(0, inputs, final_outputs, 0, SUBNETWORK_ID_NATIVE, 0, self.inner.final_transaction_payload.clone()); - - context.final_transaction_id = Some(tx.id()); - context.number_of_generated_transactions += 1; - - Ok(Some(PendingTransaction::try_new( - self, - tx, - utxo_entry_references, - addresses.into_iter().collect(), - self.inner.final_transaction_amount, - change_amount, - aggregate_input_value, - aggregate_output_value, - transaction_fees, - true, - )?)) - } else { - let transaction_fees = calc.calc_minimum_transaction_relay_fee_from_mass(mass_accumulator + change_output_mass); - // workflow_log::log_info!("batch transaction fees: {transaction_fees}"); - let amount = transaction_amount_accumulator - transaction_fees; - let script_public_key = pay_to_address_script(&self.inner.change_address); - let output = TransactionOutput::new(amount, script_public_key.clone()); - - let aggregate_input_value = utxo_entry_references.iter().map(|entry| entry.amount()).sum::(); - let aggregate_output_value = output.value; - context.aggregate_fees += transaction_fees; - #[cfg(any(debug_assertions, test))] - assert_eq!(transaction_amount_accumulator, aggregate_input_value); - - let tx = Transaction::new(0, inputs, vec![output], 0, SUBNETWORK_ID_NATIVE, 0, vec![]); - - let utxo_entry_reference = - Self::create_batch_utxo_entry_reference(tx.id(), amount, script_public_key, &self.inner.change_address); - context.utxo_stash.push_front(utxo_entry_reference); - - context.number_of_generated_transactions += 1; - Ok(Some(PendingTransaction::try_new( - self, - tx, - utxo_entry_references, - addresses.into_iter().collect(), - self.inner.final_transaction_amount, - amount, - aggregate_input_value, - aggregate_output_value, - transaction_fees, - false, - )?)) + Ok(Some(PendingTransaction::try_new( + self, + tx, + utxo_entry_references, + addresses.into_iter().collect(), + self.inner.final_transaction_amount, + output_value, + aggregate_input_value, + output_value, + aggregate_mass, + transaction_fees, + kind, + )?)) + } } } @@ -403,7 +772,7 @@ impl Generator { aggregated_fees: context.aggregate_fees, final_transaction_amount: self.inner.final_transaction_amount, final_transaction_id: context.final_transaction_id, - number_of_generated_transactions: context.number_of_generated_transactions, + number_of_generated_transactions: context.number_of_transactions, } } } diff --git a/wallet/core/src/tx/generator/mod.rs b/wallet/core/src/tx/generator/mod.rs index 6071b443b..d1db59e7d 100644 --- a/wallet/core/src/tx/generator/mod.rs +++ b/wallet/core/src/tx/generator/mod.rs @@ -14,3 +14,6 @@ pub use settings::*; pub use signer::*; pub use stream::*; pub use summary::*; + +#[cfg(test)] +mod test; diff --git a/wallet/core/src/tx/generator/pending.rs b/wallet/core/src/tx/generator/pending.rs index de12378f9..ad5148401 100644 --- a/wallet/core/src/tx/generator/pending.rs +++ b/wallet/core/src/tx/generator/pending.rs @@ -1,8 +1,9 @@ use crate::result::Result; -use crate::tx::Generator; +use crate::tx::{DataKind, Generator}; use crate::utxo::UtxoEntryReference; use crate::DynRpcApi; use kaspa_addresses::Address; +use kaspa_consensus_core::network::NetworkType; use kaspa_consensus_core::sign::sign_with_multiple_v2; use kaspa_consensus_core::tx::{SignableTransaction, Transaction, TransactionId}; use kaspa_rpc_core::{RpcTransaction, RpcTransactionId}; @@ -27,19 +28,38 @@ pub(crate) struct PendingTransactionInner { /// Payment value of the transaction (transaction destination amount) pub(crate) payment_value: Option, /// Change value of the transaction (transaction change amount) - pub(crate) change_value: u64, + pub(crate) change_output_value: u64, /// Total aggregate value of all inputs pub(crate) aggregate_input_value: u64, /// Total aggregate value of all outputs pub(crate) aggregate_output_value: u64, + // Transaction mass + pub(crate) mass: u64, /// Fees of the transaction pub(crate) fees: u64, - /// Whether the transaction is a final or a batch transaction - pub(crate) is_final: bool, + /// Indicates the type of the transaction + pub(crate) kind: DataKind, +} + +impl std::fmt::Debug for PendingTransaction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let transaction = self.transaction(); + f.debug_struct("PendingTransaction") + .field("utxo_entries", &self.inner.utxo_entries) + .field("addresses", &self.inner.addresses) + .field("payment_value", &self.inner.payment_value) + .field("change_output_value", &self.inner.change_output_value) + .field("aggregate_input_value", &self.inner.aggregate_input_value) + .field("mass", &self.inner.mass) + .field("fees", &self.inner.fees) + .field("kind", &self.inner.kind) + .field("transaction", &transaction) + .finish() + } } /// Meta transaction encapsulating a transaction generated by the [`Generator`]. -/// Contains auxiliary information about the transaction such as aggergate +/// Contains auxiliary information about the transaction such as aggregate /// input/output amounts, fees, etc. #[derive(Clone)] pub struct PendingTransaction { @@ -47,17 +67,19 @@ pub struct PendingTransaction { } impl PendingTransaction { + #[allow(clippy::too_many_arguments)] pub fn try_new( generator: &Generator, transaction: Transaction, utxo_entries: Vec, addresses: Vec
, payment_value: Option, - change_value: u64, + change_output_value: u64, aggregate_input_value: u64, aggregate_output_value: u64, + mass: u64, fees: u64, - is_final: bool, + kind: DataKind, ) -> Result { let entries = utxo_entries.iter().map(|e| e.utxo.entry.clone()).collect::>(); let signable_tx = Mutex::new(SignableTransaction::with_entries(transaction, entries)); @@ -69,11 +91,12 @@ impl PendingTransaction { addresses, is_committed: AtomicBool::new(false), payment_value, - change_value, + change_output_value, aggregate_input_value, aggregate_output_value, + mass, fees, - is_final, + kind, }), }) } @@ -109,15 +132,15 @@ impl PendingTransaction { } pub fn change_value(&self) -> u64 { - self.inner.change_value + self.inner.change_output_value } pub fn is_final(&self) -> bool { - self.inner.is_final + self.inner.kind.is_final() } pub fn is_batch(&self) -> bool { - !self.inner.is_final + !self.inner.kind.is_final() } async fn commit(&self) -> Result<()> { @@ -131,6 +154,10 @@ impl PendingTransaction { Ok(()) } + pub fn network_type(&self) -> NetworkType { + self.inner.generator.network_type() + } + pub fn transaction(&self) -> Transaction { self.inner.signable_tx.lock().unwrap().tx.clone() } diff --git a/wallet/core/src/tx/generator/settings.rs b/wallet/core/src/tx/generator/settings.rs index c432da7ec..ae054ff70 100644 --- a/wallet/core/src/tx/generator/settings.rs +++ b/wallet/core/src/tx/generator/settings.rs @@ -24,7 +24,7 @@ pub struct GeneratorSettings { // change address pub change_address: Address, // applies only to the final transaction - pub final_priority_fee: Fees, + pub final_transaction_priority_fee: Fees, // final transaction outputs pub final_transaction_destination: PaymentDestination, // payload @@ -55,7 +55,7 @@ impl GeneratorSettings { utxo_iterator: Box::new(utxo_iterator), utxo_context: Some(account.utxo_context().clone()), - final_priority_fee, + final_transaction_priority_fee: final_priority_fee, final_transaction_destination, final_transaction_payload, }; @@ -85,7 +85,7 @@ impl GeneratorSettings { utxo_iterator: Box::new(utxo_iterator), utxo_context: Some(utxo_context), - final_priority_fee, + final_transaction_priority_fee: final_priority_fee, final_transaction_destination, final_transaction_payload, }; @@ -114,7 +114,7 @@ impl GeneratorSettings { utxo_iterator: Box::new(utxo_iterator), utxo_context: None, - final_priority_fee, + final_transaction_priority_fee: final_priority_fee, final_transaction_destination, final_transaction_payload, }; diff --git a/wallet/core/src/tx/generator/test.rs b/wallet/core/src/tx/generator/test.rs new file mode 100644 index 000000000..1c054c440 --- /dev/null +++ b/wallet/core/src/tx/generator/test.rs @@ -0,0 +1,538 @@ +#![allow(clippy::inconsistent_digit_grouping)] + +use crate::error::Error; +use crate::result::Result; +use crate::tx::{is_standard_output_amount_dust, Fees, MassCalculator, PaymentDestination}; +use crate::utxo::UtxoEntryReference; +use crate::{tx::PaymentOutputs, utils::kaspa_to_sompi}; +use kaspa_addresses::Address; +use kaspa_consensus_core::network::NetworkType; +use kaspa_consensus_core::tx::Transaction; +use std::cell::RefCell; +use std::rc::Rc; +use workflow_log::style; + +use super::*; + +const LOGS: bool = false; + +#[derive(Clone)] +struct Sompi(u64); + +#[derive(Clone)] +struct Kaspa(f64); + +impl From for Sompi { + fn from(kaspa: Kaspa) -> Self { + Sompi(kaspa_to_sompi(kaspa.0)) + } +} + +impl From<&Kaspa> for Sompi { + fn from(kaspa: &Kaspa) -> Self { + Sompi(kaspa_to_sompi(kaspa.0)) + } +} + +enum FeesExpected { + None, + Sender(u64), + Receiver(u64), +} + +impl FeesExpected { + fn sender_pays>(v: T) -> Self { + let sompi: Sompi = v.into(); + FeesExpected::Sender(sompi.0) + } + fn receiver_pays>(v: T) -> Self { + let sompi: Sompi = v.into(); + FeesExpected::Receiver(sompi.0) + } +} + +trait PendingTransactionExtension { + fn tuple(self) -> (PendingTransaction, Transaction); + fn expect(self, expected: &Expected) -> Self; + fn accumulate(self, accumulator: &mut Accumulator) -> Self; +} + +impl PendingTransactionExtension for PendingTransaction { + fn tuple(self) -> (PendingTransaction, Transaction) { + let tx = self.transaction(); + (self, tx) + } + fn expect(self, expected: &Expected) -> Self { + expect(&self, expected); + self + } + fn accumulate(self, accumulator: &mut Accumulator) -> Self { + accumulator.list.push(self.clone()); + self + } +} + +trait GeneratorSummaryExtension { + fn check(self, accumulator: &Accumulator) -> Self; +} + +impl GeneratorSummaryExtension for GeneratorSummary { + fn check(self, accumulator: &Accumulator) -> Self { + assert_eq!(self.number_of_generated_transactions, accumulator.list.len(), "number of generated transactions"); + assert_eq!( + self.aggregated_utxos, + accumulator.list.iter().map(|pt| pt.utxo_entries().len()).sum::(), + "number of utxo entries" + ); + let aggregated_fees = accumulator.list.iter().map(|pt| pt.fees()).sum::(); + assert_eq!(self.aggregated_fees, aggregated_fees, "aggregated fees"); + self + } +} + +trait FeesExtension { + fn sender_pays_all>(v: T) -> Self; + fn receiver_pays_all>(v: T) -> Self; + fn receiver_pays_transfer>(v: T) -> Self; +} + +impl FeesExtension for Fees { + fn sender_pays_all>(v: T) -> Self { + let sompi: Sompi = v.into(); + Fees::SenderPaysAll(sompi.0) + } + fn receiver_pays_all>(v: T) -> Self { + let sompi: Sompi = v.into(); + Fees::ReceiverPaysAll(sompi.0) + } + fn receiver_pays_transfer>(v: T) -> Self { + let sompi: Sompi = v.into(); + Fees::ReceiverPaysTransfer(sompi.0) + } +} + +trait GeneratorExtension { + fn harness(self) -> Rc; +} + +impl GeneratorExtension for Generator { + fn harness(self) -> Rc { + Harness::new(self) + } +} + +#[derive(Default)] +struct Accumulator { + list: Vec, +} + +struct Expected { + is_final: bool, + input_count: usize, + aggregate_input_value: Sompi, + output_count: usize, + priority_fees: FeesExpected, +} + +fn expect(pt: &PendingTransaction, expected: &Expected) { + let tx = pt.transaction(); + + let aggregate_input_value = pt.utxo_entries().iter().map(|o| o.amount()).sum::(); + let aggregate_output_value = tx.outputs.iter().map(|o| o.value).sum::(); + assert_ne!(aggregate_input_value, aggregate_output_value, "aggregate input and output values can not be the same due to fees"); + + let pt_fees = pt.fees(); + let calc = MassCalculator::new(&pt.network_type().into()); + let transaction_mass = calc.calc_mass_for_signed_transaction(&tx, 1); + let relay_fees = calc.calc_minium_transaction_relay_fee(&tx, 1); + + assert_eq!(transaction_mass, pt.inner.mass, "pending transaction mass does not match calculated mass"); + + // let (total_output_value_with_fees, priority_fees) = + match expected.priority_fees { + FeesExpected::Sender(priority_fees) => { + let total_fees_expected = priority_fees + relay_fees; + assert!( + total_fees_expected <= pt_fees, + "total fees expected: {} are greater than the PT fees: {}", + total_fees_expected, + pt_fees + ); + let dust_disposal_fees = pt_fees - total_fees_expected; + assert!(is_standard_output_amount_dust(dust_disposal_fees)); + assert_eq!( + aggregate_input_value, + aggregate_output_value + pt_fees, + "aggregate input value vs total output value with fees" + ); + } + FeesExpected::Receiver(priority_fees) => { + let total_fees_expected = priority_fees + relay_fees; + assert!( + total_fees_expected <= pt_fees, + "total fees expected: {} is greater than PT fees: {}", + total_fees_expected, + pt_fees + ); + let dust_disposal_fees = pt_fees - total_fees_expected; + assert!(is_standard_output_amount_dust(dust_disposal_fees)); + assert_eq!( + aggregate_input_value - pt_fees, + aggregate_output_value, + "aggregate input value without fees vs total output value with fees" + ); + } + FeesExpected::None => { + assert!(relay_fees <= pt_fees, "total fees expected: {} is greater than PT fees: {}", relay_fees, pt_fees); + let dust_disposal_fees = pt_fees - relay_fees; + assert!(is_standard_output_amount_dust(dust_disposal_fees)); + let total_output_with_fees = aggregate_output_value + pt_fees; + assert_eq!(aggregate_input_value, total_output_with_fees, "aggregate input value vs total output value with fees"); + } + }; + + assert_eq!(pt.is_final(), expected.is_final, "transaction is not final"); + assert_eq!(tx.inputs.len(), expected.input_count, "input count"); + assert_eq!(aggregate_input_value, expected.aggregate_input_value.0, "aggregate input value"); + assert_eq!(tx.outputs.len(), expected.output_count, "output count"); +} + +struct Harness { + generator: Generator, + accumulator: RefCell, +} + +impl Harness { + pub fn new(generator: Generator) -> Rc { + Rc::new(Harness { generator, accumulator: RefCell::new(Accumulator::default()) }) + } + + pub fn fetch(self: &Rc, expected: &Expected) -> Rc { + if LOGS { + println!("{}", style(format!("fetch - checking transaction: {}", self.accumulator.borrow().list.len())).magenta()); + } + self.generator.generate_transaction().unwrap().unwrap().accumulate(&mut self.accumulator.borrow_mut()).expect(expected); + self.clone() + } + + pub fn drain(self: &Rc, count: usize, expected: &Expected) -> Rc { + for _n in 0..count { + if LOGS { + println!( + "{}", + style(format!("drain checking transaction: {} ({})", _n, self.accumulator.borrow().list.len())).magenta() + ); + } + self.generator.generate_transaction().unwrap().unwrap().accumulate(&mut self.accumulator.borrow_mut()).expect(expected); + } + self.clone() + } + + pub fn finalize(self: Rc) { + let pt = self.generator.generate_transaction().unwrap(); + assert!(pt.is_none(), "expected no more transactions"); + let summary = self.generator.summary(); + if LOGS { + println!("{:#?}", summary); + } + summary.check(&self.accumulator.borrow()); + } + + pub fn insufficient_funds(self: Rc) { + match &self.generator.generate_transaction() { + Ok(_pt) => { + println!("received unexpected transaction: {:?}", _pt); + panic!("expected insufficient funds"); + } + Err(err) => { + assert!(matches!(&err, Error::InsufficientFunds), "expecting insufficient funds error, received: {:?}", err); + } + } + } +} + +fn generator(network_type: NetworkType, head: &[f64], tail: &[f64], fees: Fees, outputs: &[(F, T)]) -> Result +where + T: Into + Clone, + F: FnOnce(NetworkType) -> Address + Clone, +{ + let outputs = outputs + .iter() + .map(|(address, amount)| { + let sompi: Sompi = (*amount).clone().into(); + (address.clone()(network_type), sompi.0) + }) + .collect::>(); + make_generator(network_type, head, tail, fees, change_address, PaymentOutputs::from(outputs.as_slice()).into()) +} + +fn make_generator( + network_type: NetworkType, + head: &[f64], + tail: &[f64], + fees: Fees, + change_address: F, + final_transaction_destination: PaymentDestination, +) -> Result +where + F: FnOnce(NetworkType) -> Address, +{ + let mut values = head.to_vec(); + values.extend(tail); + + let utxo_entries: Vec = values.into_iter().map(kaspa_to_sompi).map(UtxoEntryReference::fake).collect(); + let multiplexer = None; + let sig_op_count = 0; + let minimum_signatures = 0; + let utxo_iterator: Box + Send + Sync + 'static> = Box::new(utxo_entries.into_iter()); + let utxo_context = None; + let final_priority_fee = fees; + let final_transaction_payload = None; + let change_address = change_address(network_type); + + let settings = GeneratorSettings { + network_type, + multiplexer, + sig_op_count, + minimum_signatures, + change_address, + utxo_iterator, + utxo_context, + final_transaction_priority_fee: final_priority_fee, + final_transaction_destination, + final_transaction_payload, + }; + + Generator::try_new(settings, None, None) +} + +fn change_address(network_type: NetworkType) -> Address { + match network_type { + NetworkType::Mainnet => Address::try_from("kaspa:qpauqsvk7yf9unexwmxsnmg547mhyga37csh0kj53q6xxgl24ydxjsgzthw5j").unwrap(), + NetworkType::Testnet => Address::try_from("kaspatest:qqz22l98sf8jun72rwh5rqe2tm8lhwtdxdmynrz4ypwak427qed5juktjt7ju").unwrap(), + _ => unreachable!("network type not supported"), + } +} + +fn output_address(network_type: NetworkType) -> Address { + match network_type { + NetworkType::Mainnet => Address::try_from("kaspa:qrd9efkvg3pg34sgp6ztwyv3r569qlc43wa5w8nfs302532dzj47knu04aftm").unwrap(), + NetworkType::Testnet => Address::try_from("kaspatest:qqrewmx4gpuekvk8grenkvj2hp7xt0c35rxgq383f6gy223c4ud5s58ptm6er").unwrap(), + _ => unreachable!("network type not supported"), + } +} + +#[test] +fn test_generator_empty_utxo_noop() -> Result<()> { + let network_type = NetworkType::Testnet; + let generator = make_generator(network_type, &[], &[], Fees::None, change_address, PaymentDestination::Change).unwrap(); + let tx = generator.generate_transaction().unwrap(); + assert!(tx.is_none()); + Ok(()) +} + +#[test] +fn test_generator_sweep_single_utxo_noop() -> Result<()> { + let network_type = NetworkType::Testnet; + let generator = make_generator(network_type, &[10.0], &[], Fees::None, change_address, PaymentDestination::Change) + .expect("single UTXO input: generator"); + let tx = generator.generate_transaction().unwrap(); + assert!(tx.is_none()); + Ok(()) +} + +#[test] +fn test_generator_sweep_two_utxos() -> Result<()> { + let network_type = NetworkType::Testnet; + make_generator(network_type, &[10.0, 10.0], &[], Fees::None, change_address, PaymentDestination::Change) + .expect("merge 2 UTXOs without fees: generator") + .harness() + .fetch(&Expected { + is_final: true, + input_count: 2, + aggregate_input_value: Kaspa(20.0).into(), + output_count: 1, + priority_fees: FeesExpected::None, + }) + .finalize(); + Ok(()) +} + +#[test] +fn test_generator_sweep_two_utxos_with_priority_fees_rejection() -> Result<()> { + let network_type = NetworkType::Testnet; + let generator = make_generator( + network_type, + &[10.0, 10.0], + &[], + Fees::sender_pays_all(Kaspa(5.0)), + change_address, + PaymentDestination::Change, + ); + match generator { + Err(Error::GeneratorFeesInSweepTransaction) => {} + _ => panic!("merge 2 UTXOs with fees must fail generator creation"), + } + Ok(()) +} + +#[test] +fn test_generator_inputs_2_outputs_2_fees_exclude() -> Result<()> { + let network_type = NetworkType::Testnet; + generator( + network_type, + &[10.0; 2], + &[], + Fees::sender_pays_all(Kaspa(5.0)), + [(output_address, Kaspa(10.0)), (output_address, Kaspa(1.0))].as_slice(), + ) + .unwrap() + .harness() + .fetch(&Expected { + is_final: true, + input_count: 2, + aggregate_input_value: Kaspa(20.0).into(), + output_count: 3, + priority_fees: FeesExpected::sender_pays(Kaspa(5.0)), + }) + .finalize(); + + Ok(()) +} + +#[test] +fn test_generator_inputs_100_outputs_1_fees_exclude() -> Result<()> { + let network_type = NetworkType::Testnet; + generator(network_type, &[10.0; 100], &[], Fees::sender_pays_all(Kaspa(5.0)), [(output_address, Kaspa(990.0))].as_slice()) + .unwrap() + .harness() + .fetch(&Expected { + is_final: true, + input_count: 100, + aggregate_input_value: Kaspa(1000.0).into(), + output_count: 2, + priority_fees: FeesExpected::sender_pays(Kaspa(5.0)), + }); + // .finalize(); + + Ok(()) +} + +#[test] +fn test_generator_inputs_100_outputs_1_fees_include() -> Result<()> { + let network_type = NetworkType::Testnet; + generator(network_type, &[1.0; 100], &[], Fees::receiver_pays_transfer(Kaspa(5.0)), [(output_address, Kaspa(100.0))].as_slice()) + .unwrap() + .harness() + .fetch(&Expected { + is_final: true, + input_count: 100, + aggregate_input_value: Kaspa(100.0).into(), + output_count: 1, + priority_fees: FeesExpected::receiver_pays(Kaspa(5.0)), + }) + .finalize(); + + Ok(()) +} + +#[test] +fn test_generator_inputs_100_outputs_1_fees_exclude_insufficient_funds() -> Result<()> { + let network_type = NetworkType::Testnet; + generator(network_type, &[10.0; 100], &[], Fees::sender_pays_all(Kaspa(5.0)), [(output_address, Kaspa(1000.0))].as_slice()) + .unwrap() + .harness() + .insufficient_funds(); + + Ok(()) +} + +#[test] +fn test_generator_inputs_903_outputs_2_fees_exclude() -> Result<()> { + let network_type = NetworkType::Testnet; + generator(network_type, &[10.0; 1_000], &[], Fees::sender_pays_all(Kaspa(5.0)), [(output_address, Kaspa(9_000.0))].as_slice()) + .unwrap() + .harness() + .fetch(&Expected { + is_final: false, + input_count: 843, + aggregate_input_value: Kaspa(8_430.0).into(), + output_count: 1, + priority_fees: FeesExpected::None, + }) + .fetch(&Expected { + is_final: false, + input_count: 58, + aggregate_input_value: Kaspa(580.0).into(), + output_count: 1, + priority_fees: FeesExpected::None, + }) + .fetch(&Expected { + is_final: true, + input_count: 2, + aggregate_input_value: Sompi(9_009_99892258), + output_count: 2, + priority_fees: FeesExpected::sender_pays(Kaspa(5.0)), + }) + .finalize(); + + Ok(()) +} + +#[test] +fn test_generator_1m_utxos_w_1kas_to_990k_sender_pays_fees() -> Result<()> { + let network_type = NetworkType::Testnet; + + let harness = generator( + network_type, + &[1.0; 1_000_000], + &[], + Fees::sender_pays_all(Kaspa(5.0)), + [(output_address, Kaspa(990_000.0))].as_slice(), + ) + .unwrap() + .harness(); + + harness + .drain( + 1174, + &Expected { + is_final: false, + input_count: 843, + aggregate_input_value: Kaspa(843.0).into(), + output_count: 1, + priority_fees: FeesExpected::None, + }, + ) + .fetch(&Expected { + is_final: false, + input_count: 325, + aggregate_input_value: Kaspa(325.0).into(), + output_count: 1, + priority_fees: FeesExpected::None, + }) + .fetch(&Expected { + is_final: false, + input_count: 843, + aggregate_input_value: Sompi(710_648_15369544), + output_count: 1, + priority_fees: FeesExpected::None, + }) + .fetch(&Expected { + is_final: false, + input_count: 332, + aggregate_input_value: Sompi(279_357_66731392), + output_count: 1, + priority_fees: FeesExpected::None, + }) + .fetch(&Expected { + is_final: true, + input_count: 2, + aggregate_input_value: Sompi(990_005_81960862), + output_count: 2, + priority_fees: FeesExpected::sender_pays(Kaspa(5.0)), + }) + .finalize(); + + Ok(()) +} diff --git a/wallet/core/src/tx/mass.rs b/wallet/core/src/tx/mass.rs index 06951534b..e0148ff02 100644 --- a/wallet/core/src/tx/mass.rs +++ b/wallet/core/src/tx/mass.rs @@ -253,6 +253,10 @@ impl MassCalculator { calc_minimum_required_transaction_relay_fee(mass) } + pub fn calc_mass_for_signed_transaction(&self, tx: &Transaction, minimum_signatures: u16) -> u64 { + self.calc_mass_for_transaction(tx) + self.calc_signature_mass_for_inputs(tx.inputs.len(), minimum_signatures) + } + pub fn calc_minium_transaction_relay_fee(&self, tx: &Transaction, minimum_signatures: u16) -> u64 { let mass = self.calc_mass_for_transaction(tx) + self.calc_signature_mass_for_inputs(tx.inputs.len(), minimum_signatures); calc_minimum_required_transaction_relay_fee(mass) diff --git a/wallet/core/src/tx/payment.rs b/wallet/core/src/tx/payment.rs index 590092135..49eab9873 100644 --- a/wallet/core/src/tx/payment.rs +++ b/wallet/core/src/tx/payment.rs @@ -127,9 +127,28 @@ impl From for Vec { } } -impl TryFrom<(Address, u64)> for PaymentOutputs { - type Error = Error; - fn try_from((address, amount): (Address, u64)) -> Result { - Ok(PaymentOutputs { outputs: vec![PaymentOutput::new(address, amount)] }) +impl From<(Address, u64)> for PaymentOutputs { + fn from((address, amount): (Address, u64)) -> Self { + PaymentOutputs { outputs: vec![PaymentOutput::new(address, amount)] } + } +} + +impl From<(&Address, u64)> for PaymentOutputs { + fn from((address, amount): (&Address, u64)) -> Self { + PaymentOutputs { outputs: vec![PaymentOutput::new(address.clone(), amount)] } + } +} + +impl From<&[(Address, u64)]> for PaymentOutputs { + fn from(outputs: &[(Address, u64)]) -> Self { + let outputs = outputs.iter().map(|(address, amount)| PaymentOutput::new(address.clone(), *amount)).collect(); + PaymentOutputs { outputs } + } +} + +impl From<&[(&Address, u64)]> for PaymentOutputs { + fn from(outputs: &[(&Address, u64)]) -> Self { + let outputs = outputs.iter().map(|(address, amount)| PaymentOutput::new((*address).clone(), *amount)).collect(); + PaymentOutputs { outputs } } } diff --git a/wallet/core/src/wasm/tx/generator/generator.rs b/wallet/core/src/wasm/tx/generator/generator.rs index 260a54999..3cf7bded0 100644 --- a/wallet/core/src/wasm/tx/generator/generator.rs +++ b/wallet/core/src/wasm/tx/generator/generator.rs @@ -111,7 +111,7 @@ impl Generator { }; let abortable = Abortable::default(); - let generator = native::Generator::new(settings, None, &abortable); + let generator = native::Generator::try_new(settings, None, Some(&abortable))?; Ok(Self { inner: Arc::new(generator) }) }