diff --git a/sdk/src/client/api/block_builder/transaction_builder/remainder.rs b/sdk/src/client/api/block_builder/transaction_builder/remainder.rs index 0dee5047ee..7111bdfb7b 100644 --- a/sdk/src/client/api/block_builder/transaction_builder/remainder.rs +++ b/sdk/src/client/api/block_builder/transaction_builder/remainder.rs @@ -5,18 +5,16 @@ use alloc::collections::BTreeMap; use std::collections::HashMap; use crypto::keys::bip44::Bip44; +use primitive_types::U256; use super::{TransactionBuilder, TransactionBuilderError}; use crate::{ - client::api::{ - transaction_builder::requirement::native_tokens::{get_native_tokens, get_native_tokens_diff}, - RemainderData, - }, + client::api::{transaction_builder::requirement::native_tokens::get_native_tokens_diff, RemainderData}, types::block::{ address::{Address, Ed25519Address}, output::{ - unlock_condition::AddressUnlockCondition, AccountOutput, AnchorOutput, BasicOutput, BasicOutputBuilder, - NativeTokens, NativeTokensBuilder, NftOutput, Output, StorageScoreParameters, + unlock_condition::AddressUnlockCondition, AccountOutput, BasicOutput, BasicOutputBuilder, NativeToken, + NftOutput, Output, StorageScoreParameters, TokenId, }, }, }; @@ -69,23 +67,6 @@ impl TransactionBuilder { Ok(None) } - pub(crate) fn remainder_amount(&self) -> Result<(u64, bool, bool), TransactionBuilderError> { - let mut input_native_tokens = get_native_tokens(self.selected_inputs.iter().map(|input| &input.output))?; - let mut output_native_tokens = get_native_tokens(self.non_remainder_outputs())?; - let (minted_native_tokens, melted_native_tokens) = self.get_minted_and_melted_native_tokens()?; - - input_native_tokens.merge(minted_native_tokens)?; - output_native_tokens.merge(melted_native_tokens)?; - - if let Some(burn) = self.burn.as_ref() { - output_native_tokens.merge(NativeTokensBuilder::from(burn.native_tokens.clone()))?; - } - - let native_tokens_diff = get_native_tokens_diff(&input_native_tokens, &output_native_tokens)?; - - self.required_remainder_amount(native_tokens_diff) - } - pub(crate) fn storage_deposit_returns_and_remainders( &mut self, ) -> Result<(Vec, Vec), TransactionBuilderError> { @@ -109,18 +90,10 @@ impl TransactionBuilder { } } - let mut input_native_tokens = get_native_tokens(self.selected_inputs.iter().map(|input| &input.output))?; - let mut output_native_tokens = get_native_tokens(self.non_remainder_outputs())?; - let (minted_native_tokens, melted_native_tokens) = self.get_minted_and_melted_native_tokens()?; - - input_native_tokens.merge(minted_native_tokens)?; - output_native_tokens.merge(melted_native_tokens)?; - - if let Some(burn) = self.burn.as_ref() { - output_native_tokens.merge(NativeTokensBuilder::from(burn.native_tokens.clone()))?; - } - - let native_tokens_diff = get_native_tokens_diff(&input_native_tokens, &output_native_tokens)?; + let (input_nts, output_nts) = self.get_input_output_native_tokens(); + log::debug!("input_nts: {input_nts:#?}"); + log::debug!("output_nts: {output_nts:#?}"); + let native_tokens_diff = get_native_tokens_diff(input_nts, output_nts); let (input_mana, output_mana) = self.mana_sums(false)?; @@ -142,7 +115,7 @@ impl TransactionBuilder { self.remainders.added_mana = std::mem::take(&mut mana_diff); } - if input_amount == output_amount && mana_diff == 0 && native_tokens_diff.is_none() { + if input_amount == output_amount && mana_diff == 0 && native_tokens_diff.is_empty() { log::debug!("No remainder required"); return Ok((storage_deposit_returns, Vec::new())); } @@ -174,12 +147,10 @@ impl TransactionBuilder { pub(crate) fn get_output_for_added_mana(&mut self, remainder_address: &Address) -> Option<&mut Output> { // Establish the order in which we want to pick an output - let sort_order = HashMap::from([ - (AccountOutput::KIND, 1), - (BasicOutput::KIND, 2), - (NftOutput::KIND, 3), - (AnchorOutput::KIND, 4), - ]); + let sort_order = [AccountOutput::KIND, BasicOutput::KIND, NftOutput::KIND] + .into_iter() + .zip(0..) + .collect::>(); // Remove those that do not have an ordering and sort let ordered_outputs = self .provided_outputs @@ -203,11 +174,9 @@ impl TransactionBuilder { /// Calculates the required amount for required remainder outputs (multiple outputs are required if multiple native /// tokens are remaining) and returns if there are native tokens as remainder. - pub(crate) fn required_remainder_amount( - &self, - remainder_native_tokens: Option, - ) -> Result<(u64, bool, bool), TransactionBuilderError> { - let native_tokens_remainder = remainder_native_tokens.is_some(); + pub(crate) fn required_remainder_amount(&self) -> Result<(u64, bool, bool), TransactionBuilderError> { + let (input_nts, output_nts) = self.get_input_output_native_tokens(); + let remainder_native_tokens = get_native_tokens_diff(input_nts, output_nts); let remainder_builder = BasicOutputBuilder::new_with_minimum_amount(self.protocol_parameters.storage_score_parameters()) @@ -215,14 +184,19 @@ impl TransactionBuilder { [0; 32], )))); - let remainder_amount = if let Some(native_tokens) = remainder_native_tokens { + let remainder_amount = if !remainder_native_tokens.is_empty() { let nt_remainder_amount = remainder_builder - .with_native_token(*native_tokens.first().unwrap()) + .with_native_token( + remainder_native_tokens + .first_key_value() + .map(|(token_id, amount)| NativeToken::new(*token_id, amount)) + .unwrap()?, + ) .finish_output()? .amount(); // Amount can be just multiplied, because all remainder outputs with a native token have the same storage // cost. - nt_remainder_amount * native_tokens.len() as u64 + nt_remainder_amount * remainder_native_tokens.len() as u64 } else { remainder_builder.finish_output()?.amount() }; @@ -242,14 +216,14 @@ impl TransactionBuilder { mana_remainder &= selected_mana > required_mana + initial_excess; } - Ok((remainder_amount, native_tokens_remainder, mana_remainder)) + Ok((remainder_amount, !remainder_native_tokens.is_empty(), mana_remainder)) } } fn create_remainder_outputs( amount_diff: u64, mana_diff: u64, - native_tokens_diff: Option, + mut native_tokens: BTreeMap, remainder_address: Address, remainder_address_chain: Option, storage_score_parameters: StorageScoreParameters, @@ -259,24 +233,22 @@ fn create_remainder_outputs( let mut catchall_native_token = None; // Start with the native tokens - if let Some(native_tokens) = native_tokens_diff { - if let Some((last, nts)) = native_tokens.split_last() { - // Save this one for the catchall - catchall_native_token.replace(*last); - // Create remainder outputs with min amount - for native_token in nts { - let output = BasicOutputBuilder::new_with_minimum_amount(storage_score_parameters) - .add_unlock_condition(AddressUnlockCondition::new(remainder_address.clone())) - .with_native_token(*native_token) - .finish_output()?; - log::debug!( - "Created remainder output of amount {}, mana {} and native token {native_token:?} for {remainder_address:?}", - output.amount(), - output.mana() - ); - remaining_amount = remaining_amount.saturating_sub(output.amount()); - remainder_outputs.push(output); - } + if let Some((token_id, amount)) = native_tokens.pop_last() { + // Save this one for the catchall + catchall_native_token.replace(NativeToken::new(token_id, amount)?); + // Create remainder outputs with min amount + for (token_id, amount) in native_tokens { + let output = BasicOutputBuilder::new_with_minimum_amount(storage_score_parameters) + .add_unlock_condition(AddressUnlockCondition::new(remainder_address.clone())) + .with_native_token(NativeToken::new(token_id, amount)?) + .finish_output()?; + log::debug!( + "Created remainder output of amount {}, mana {} and native token ({token_id}: {amount}) for {remainder_address:?}", + output.amount(), + output.mana() + ); + remaining_amount = remaining_amount.saturating_sub(output.amount()); + remainder_outputs.push(output); } } let mut catchall = BasicOutputBuilder::new_with_amount(remaining_amount) diff --git a/sdk/src/client/api/block_builder/transaction_builder/requirement/amount.rs b/sdk/src/client/api/block_builder/transaction_builder/requirement/amount.rs index 4dced48202..81611ac609 100644 --- a/sdk/src/client/api/block_builder/transaction_builder/requirement/amount.rs +++ b/sdk/src/client/api/block_builder/transaction_builder/requirement/amount.rs @@ -1,19 +1,18 @@ // Copyright 2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::collections::{HashMap, HashSet}; +use std::{collections::HashMap, sync::OnceLock}; -use super::{native_tokens::get_native_tokens, Requirement, TransactionBuilder, TransactionBuilderError}; +use super::{Requirement, TransactionBuilder, TransactionBuilderError}; use crate::{ - client::secret::types::InputSigningData, + client::{api::transaction_builder::requirement::PriorityMap, secret::types::InputSigningData}, types::block::{ address::Address, - input::INPUT_COUNT_MAX, output::{ - unlock_condition::StorageDepositReturnUnlockCondition, AccountOutputBuilder, FoundryOutputBuilder, - MinimumOutputAmount, NftOutputBuilder, Output, OutputId, TokenId, + unlock_condition::StorageDepositReturnUnlockCondition, AccountOutput, AccountOutputBuilder, BasicOutput, + FoundryOutput, FoundryOutputBuilder, MinimumOutputAmount, NftOutput, NftOutputBuilder, Output, }, - slot::SlotIndex, + slot::{SlotCommitmentId, SlotIndex}, }, }; @@ -31,11 +30,61 @@ pub(crate) fn sdruc_not_expired( .map_or(false, |expiration| slot_index >= expiration.slot_index()); // We only have to send the storage deposit return back if the output is not expired - if !expired { Some(sdr) } else { None } + (!expired).then_some(sdr) }) } impl TransactionBuilder { + pub(crate) fn fulfill_amount_requirement(&mut self) -> Result, TransactionBuilderError> { + let (mut input_amount, mut output_amount) = self.amount_balance()?; + if input_amount >= output_amount { + log::debug!("Amount requirement already fulfilled"); + return Ok(Vec::new()); + } + + log::debug!("Fulfilling amount requirement with input amount {input_amount}, output amount {output_amount}"); + + if !self.allow_additional_input_selection { + return Err(TransactionBuilderError::AdditionalInputsRequired(Requirement::Amount)); + } + // If we have no inputs to balance with, try reducing outputs instead + if self.available_inputs.is_empty() { + if !self.reduce_funds_of_chains(input_amount, &mut output_amount)? { + return Err(TransactionBuilderError::InsufficientAmount { + found: input_amount, + required: output_amount, + }); + } + } else { + let mut priority_map = PriorityMap::::generate(&mut self.available_inputs); + loop { + let Some(input) = priority_map.next(output_amount - input_amount, self.latest_slot_commitment_id) + else { + break; + }; + log::debug!("selecting input with amount {}", input.output.amount()); + self.select_input(input)?; + (input_amount, output_amount) = self.amount_balance()?; + // Try to reduce output funds + if self.reduce_funds_of_chains(input_amount, &mut output_amount)? { + break; + } + } + // Return unselected inputs to the available list + for input in priority_map.into_inputs() { + self.available_inputs.push(input); + } + if output_amount > input_amount { + return Err(TransactionBuilderError::InsufficientAmount { + found: input_amount, + required: output_amount, + }); + } + } + + Ok(Vec::new()) + } + pub(crate) fn amount_sums(&self) -> (u64, u64, HashMap, HashMap) { let mut inputs_sum = 0; let mut outputs_sum = 0; @@ -72,386 +121,140 @@ impl TransactionBuilder { (inputs_sum, outputs_sum, inputs_sdr, outputs_sdr) } -} - -#[derive(Debug, Clone)] -struct AmountSelection { - newly_selected_inputs: HashMap, - inputs_sum: u64, - outputs_sum: u64, - inputs_sdr: HashMap, - outputs_sdr: HashMap, - remainder_amount: u64, - native_tokens_remainder: bool, - mana_remainder: bool, - selected_native_tokens: HashSet, -} - -impl AmountSelection { - fn new(transaction_builder: &TransactionBuilder) -> Result { - let (inputs_sum, outputs_sum, inputs_sdr, outputs_sdr) = transaction_builder.amount_sums(); - let selected_native_tokens = HashSet::::from_iter( - transaction_builder - .selected_inputs - .iter() - .filter_map(|i| i.output.native_token().map(|n| *n.token_id())), - ); - let (remainder_amount, native_tokens_remainder, mana_remainder) = transaction_builder.remainder_amount()?; - - Ok(Self { - newly_selected_inputs: HashMap::new(), - inputs_sum, - outputs_sum, - inputs_sdr, - outputs_sdr, - remainder_amount, - native_tokens_remainder, - mana_remainder, - selected_native_tokens, - }) - } - fn missing_amount(&self) -> u64 { - // If there is already a remainder, make sure it's enough to cover the storage deposit. - if self.inputs_sum > self.outputs_sum { - let diff = self.inputs_sum - self.outputs_sum; + pub(crate) fn amount_balance(&self) -> Result<(u64, u64), TransactionBuilderError> { + let (inputs_sum, mut outputs_sum, _, _) = self.amount_sums(); + let (remainder_amount, native_tokens_remainder, mana_remainder) = self.required_remainder_amount()?; + if inputs_sum > outputs_sum { + let diff = inputs_sum - outputs_sum; - if self.remainder_amount > diff { - self.remainder_amount - diff - } else { - 0 + if remainder_amount > diff { + outputs_sum += remainder_amount - diff } - } else if self.inputs_sum < self.outputs_sum { - self.outputs_sum - self.inputs_sum - } else if self.native_tokens_remainder || self.mana_remainder { - self.remainder_amount - } else { - 0 + } else if native_tokens_remainder || mana_remainder { + outputs_sum += remainder_amount } + Ok((inputs_sum, outputs_sum)) } - fn fulfil<'a>( + fn reduce_funds_of_chains( &mut self, - transaction_builder: &TransactionBuilder, - inputs: impl Iterator, + input_amount: u64, + output_amount: &mut u64, ) -> Result { - for input in inputs { - if self.newly_selected_inputs.contains_key(input.output_id()) { - continue; - } - - if let Some(sdruc) = sdruc_not_expired( - &input.output, - transaction_builder.latest_slot_commitment_id.slot_index(), - ) { - // Skip if no additional amount is made available - if input.output.amount() == sdruc.amount() { - continue; - } - let input_sdr = self.inputs_sdr.get(sdruc.return_address()).unwrap_or(&0) + sdruc.amount(); - let output_sdr = *self.outputs_sdr.get(sdruc.return_address()).unwrap_or(&0); - - if input_sdr > output_sdr { - let diff = input_sdr - output_sdr; - self.outputs_sum += diff; - *self.outputs_sdr.entry(sdruc.return_address().clone()).or_default() += sdruc.amount(); - } - - *self.inputs_sdr.entry(sdruc.return_address().clone()).or_default() += sdruc.amount(); - } - - if let Some(nt) = input.output.native_token() { - self.selected_native_tokens.insert(*nt.token_id()); - } - - self.inputs_sum += input.output.amount(); - self.newly_selected_inputs.insert(*input.output_id(), input.clone()); - - if input.output.native_token().is_some() { - // Recalculate the remaining amount, as a new native token may require a new remainder output. - let (remainder_amount, native_tokens_remainder, mana_remainder) = - self.remainder_amount(transaction_builder)?; + if *output_amount > input_amount { + // Only consider automatically transitioned outputs. + for output in self.added_outputs.iter_mut() { + let missing_amount = *output_amount - input_amount; + let amount = output.amount(); + let minimum_amount = output.minimum_amount(self.protocol_parameters.storage_score_parameters()); + + let new_amount = if amount >= missing_amount + minimum_amount { + *output_amount = input_amount; + amount - missing_amount + } else { + *output_amount -= amount - minimum_amount; + minimum_amount + }; + + // PANIC: unwrap is fine as non-chain outputs have been filtered out already. log::debug!( - "Calculated new remainder_amount: {remainder_amount}, native_tokens_remainder: {native_tokens_remainder}" + "Reducing amount of {} to {} to fulfill amount requirement", + output.chain_id().unwrap(), + new_amount ); - self.remainder_amount = remainder_amount; - self.native_tokens_remainder = native_tokens_remainder; - self.mana_remainder = mana_remainder; - } - if self.missing_amount() == 0 { - return Ok(true); + *output = match output { + Output::Account(output) => AccountOutputBuilder::from(&*output) + .with_amount(new_amount) + .finish_output()?, + Output::Foundry(output) => FoundryOutputBuilder::from(&*output) + .with_amount(new_amount) + .finish_output()?, + Output::Nft(output) => NftOutputBuilder::from(&*output) + .with_amount(new_amount) + .finish_output()?, + _ => continue, + }; + + if *output_amount == input_amount { + break; + } } } - Ok(false) - } - - pub(crate) fn remainder_amount( - &self, - transaction_builder: &TransactionBuilder, - ) -> Result<(u64, bool, bool), TransactionBuilderError> { - let input_native_tokens = - get_native_tokens(self.newly_selected_inputs.values().map(|input| &input.output))?.finish()?; - - transaction_builder.required_remainder_amount(Some(input_native_tokens)) - } - - fn into_newly_selected_inputs(self) -> Vec { - self.newly_selected_inputs.into_values().collect() + Ok(input_amount >= *output_amount) } } -impl TransactionBuilder { - fn fulfil<'a>( - &self, - base_inputs: impl Iterator + Clone, - amount_selection: &mut AmountSelection, - ) -> Result { - let slot_index = self.latest_slot_commitment_id.slot_index(); - - // No native token, expired SDRUC. - let inputs = base_inputs.clone().filter(|input| { - input.output.native_token().is_none() && sdruc_not_expired(&input.output, slot_index).is_none() - }); - - if amount_selection.fulfil(self, inputs)? { - return Ok(true); - } - - // No native token, unexpired SDRUC. - let inputs = base_inputs.clone().filter(|input| { - input.output.native_token().is_none() && sdruc_not_expired(&input.output, slot_index).is_some() - }); - - if amount_selection.fulfil(self, inputs)? { - return Ok(true); - } - - // Native token, expired SDRUC. - let inputs = base_inputs.clone().filter(|input| { - input.output.native_token().is_some() && sdruc_not_expired(&input.output, slot_index).is_none() - }); - - if amount_selection.fulfil(self, inputs)? { - return Ok(true); - } - - // Native token, unexpired SDRUC. - let inputs = base_inputs.clone().filter(|input| { - input.output.native_token().is_some() && sdruc_not_expired(&input.output, slot_index).is_some() - }); - - if amount_selection.fulfil(self, inputs)? { - return Ok(true); - } - - // Everything else. - if amount_selection.fulfil(self, base_inputs)? { - return Ok(true); - } +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct AmountPriority { + kind_priority: usize, + has_native_token: bool, +} - Ok(false) +impl PartialOrd for AmountPriority { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) } - - fn reduce_funds_of_chains( - &mut self, - amount_selection: &mut AmountSelection, - ) -> Result<(), TransactionBuilderError> { - // Only consider automatically transitioned outputs. - for output in self.added_outputs.iter_mut() { - let diff = amount_selection.missing_amount(); - let amount = output.amount(); - let minimum_amount = output.minimum_amount(self.protocol_parameters.storage_score_parameters()); - - let new_amount = if amount >= diff + minimum_amount { - amount - diff - } else { - minimum_amount - }; - - // TODO check that new_amount is enough for the storage cost - - // PANIC: unwrap is fine as non-chain outputs have been filtered out already. - log::debug!( - "Reducing amount of {} to {} to fulfill amount requirement", - output.chain_id().unwrap(), - new_amount - ); - - let new_output = match output { - Output::Account(output) => AccountOutputBuilder::from(&*output) - .with_amount(new_amount) - .finish_output()?, - Output::Foundry(output) => FoundryOutputBuilder::from(&*output) - .with_amount(new_amount) - .finish_output()?, - Output::Nft(output) => NftOutputBuilder::from(&*output) - .with_amount(new_amount) - .finish_output()?, - _ => panic!("only account, nft and foundry can be automatically created"), - }; - - amount_selection.outputs_sum -= amount - new_amount; - *output = new_output; - - if amount_selection.missing_amount() == 0 { - return Ok(()); - } - } - - Err(TransactionBuilderError::InsufficientAmount { - found: amount_selection.inputs_sum, - required: amount_selection.inputs_sum + amount_selection.missing_amount(), - }) +} +impl Ord for AmountPriority { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + (self.kind_priority, self.has_native_token).cmp(&(other.kind_priority, other.has_native_token)) } +} - pub(crate) fn fulfill_amount_requirement(&mut self) -> Result, TransactionBuilderError> { - let mut amount_selection = AmountSelection::new(self)?; - - if amount_selection.missing_amount() == 0 { - log::debug!("Amount requirement already fulfilled"); - return Ok(amount_selection.into_newly_selected_inputs()); - } else { - log::debug!( - "Fulfilling amount requirement with input {}, output {}, input sdrs {:?} and output sdrs {:?}", - amount_selection.inputs_sum, - amount_selection.outputs_sum, - amount_selection.inputs_sdr, - amount_selection.outputs_sdr - ); - } - - // TODO if consolidate strategy: sum all the lowest amount until diff is covered. - // TODO this would be lowest amount of input strategy. - - // Try to select outputs first with ordering from low to high amount, if that fails, try reversed. - - log::debug!("Ordering inputs from low to high amount"); - // Sort inputs per amount, low to high. - self.available_inputs - .sort_by(|left, right| left.output.amount().cmp(&right.output.amount())); - - if let Some(r) = self.fulfill_amount_requirement_inner(&mut amount_selection)? { - return Ok(r); - } - - if self.selected_inputs.len() + amount_selection.newly_selected_inputs.len() > INPUT_COUNT_MAX.into() { - // Clear before trying with reversed ordering. - log::debug!("Clearing amount selection"); - amount_selection = AmountSelection::new(self)?; - - log::debug!("Ordering inputs from high to low amount"); - // Sort inputs per amount, high to low. - self.available_inputs - .sort_by(|left, right| right.output.amount().cmp(&left.output.amount())); - - if let Some(r) = self.fulfill_amount_requirement_inner(&mut amount_selection)? { - return Ok(r); - } - } - - if self.selected_inputs.len() + amount_selection.newly_selected_inputs.len() > INPUT_COUNT_MAX.into() { - return Err(TransactionBuilderError::InvalidInputCount( - self.selected_inputs.len() + amount_selection.newly_selected_inputs.len(), - )); - } - - if amount_selection.missing_amount() != 0 { - self.reduce_funds_of_chains(&mut amount_selection)?; - } - - log::debug!( - "Outputs {:?} selected to fulfill the amount requirement", - amount_selection.newly_selected_inputs - ); - - self.available_inputs - .retain(|input| !amount_selection.newly_selected_inputs.contains_key(input.output_id())); - - Ok(amount_selection.into_newly_selected_inputs()) +impl From<&InputSigningData> for Option { + fn from(value: &InputSigningData) -> Self { + sort_order_type() + .get(&value.output.kind()) + .map(|&kind_priority| AmountPriority { + kind_priority, + has_native_token: value.output.native_token().is_some(), + }) } +} - fn fulfill_amount_requirement_inner( - &mut self, - amount_selection: &mut AmountSelection, - ) -> Result>, TransactionBuilderError> { - let slot_index = self.latest_slot_commitment_id.slot_index(); +/// Establish the order in which we want to pick an input +pub fn sort_order_type() -> &'static HashMap { + static MAP: OnceLock> = OnceLock::new(); + MAP.get_or_init(|| { + [ + BasicOutput::KIND, + AccountOutput::KIND, + NftOutput::KIND, + FoundryOutput::KIND, + ] + .into_iter() + .zip(0_usize..) + .collect::>() + }) +} - let basic_ed25519_inputs = self.available_inputs.iter().filter(|input| { - if let Output::Basic(output) = &input.output { - output - .unlock_conditions() - .locked_address( - output.address(), - slot_index, - self.protocol_parameters.committable_age_range(), - ) - .expect("slot index was provided") - .expect("expiration unlockable outputs already filtered out") - .is_ed25519() - } else { - false +impl PriorityMap { + fn next(&mut self, missing_amount: u64, slot_committment_id: SlotCommitmentId) -> Option { + let amount_sort = |output: &Output| { + let mut amount = output.amount(); + if let Some(sdruc) = sdruc_not_expired(output, slot_committment_id.slot_index()) { + amount -= sdruc.amount(); } - }); - - if self.fulfil(basic_ed25519_inputs, amount_selection)? { - return Ok(None); - } - - let basic_non_ed25519_inputs = self.available_inputs.iter().filter(|input| { - if let Output::Basic(output) = &input.output { - !output - .unlock_conditions() - .locked_address( - output.address(), - slot_index, - self.protocol_parameters.committable_age_range(), - ) - .expect("slot index was provided") - .expect("expiration unlockable outputs already filtered out") - .is_ed25519() + // If the amount is greater than the missing amount, we want the smallest ones first + if amount >= missing_amount { + (false, amount) + // Otherwise, we want the biggest first } else { - false + (true, u64::MAX - amount) } - }); - - if self.fulfil(basic_non_ed25519_inputs, amount_selection)? { - return Ok(None); - } - - // Other kinds of outputs. - - log::debug!("Trying other types of outputs"); - - let mut inputs = self - .available_inputs - .iter() - .filter(|input| !input.output.is_basic()) - .peekable(); - - if inputs.peek().is_some() { - amount_selection.fulfil(self, inputs)?; - - log::debug!( - "Outputs {:?} selected to fulfill the amount requirement", - amount_selection.newly_selected_inputs - ); - log::debug!("Triggering another amount round as non-basic outputs need to be transitioned first"); - - if self.selected_inputs.len() + amount_selection.newly_selected_inputs.len() <= INPUT_COUNT_MAX.into() { - self.available_inputs - .retain(|input| !amount_selection.newly_selected_inputs.contains_key(input.output_id())); - - // TODO explanation of Amount - self.requirements.push(Requirement::Amount); - - Ok(Some(amount_selection.clone().into_newly_selected_inputs())) - } else { - Ok(None) + }; + if let Some((priority, mut inputs)) = self.0.pop_first() { + // Sort in reverse so we can pop from the back + inputs.sort_unstable_by(|i1, i2| amount_sort(&i2.output).cmp(&amount_sort(&i1.output))); + let input = inputs.pop(); + if !inputs.is_empty() { + self.0.insert(priority, inputs); } - } else { - Ok(None) + return input; } + None } } diff --git a/sdk/src/client/api/block_builder/transaction_builder/requirement/mana.rs b/sdk/src/client/api/block_builder/transaction_builder/requirement/mana.rs index 94538ad6ec..8c945907d3 100644 --- a/sdk/src/client/api/block_builder/transaction_builder/requirement/mana.rs +++ b/sdk/src/client/api/block_builder/transaction_builder/requirement/mana.rs @@ -1,20 +1,20 @@ // Copyright 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashMap; +use std::{collections::HashMap, sync::OnceLock}; use super::{TransactionBuilder, TransactionBuilderError}; use crate::{ client::{ - api::transaction_builder::{MinManaAllotment, Requirement}, + api::transaction_builder::{requirement::PriorityMap, MinManaAllotment, Requirement}, secret::types::InputSigningData, }, types::block::{ address::Address, input::{Input, UtxoInput}, mana::ManaAllotment, - output::{AccountOutputBuilder, Output}, - payload::{dto::SignedTransactionPayloadDto, signed_transaction::Transaction, SignedTransactionPayload}, + output::{AccountOutput, AccountOutputBuilder, BasicOutput, FoundryOutput, NftOutput, Output}, + payload::{signed_transaction::Transaction, SignedTransactionPayload}, signature::Ed25519Signature, unlock::{AccountUnlock, NftUnlock, ReferenceUnlock, SignatureUnlock, Unlock, Unlocks}, BlockError, @@ -72,11 +72,6 @@ impl TransactionBuilder { let signed_transaction = SignedTransactionPayload::new(transaction, self.null_transaction_unlocks()?)?; - log::debug!( - "signed_transaction: {}", - serde_json::to_string_pretty(&SignedTransactionPayloadDto::from(&signed_transaction)).unwrap() - ); - let block_work_score = self.protocol_parameters.work_score(&signed_transaction) + self.protocol_parameters.work_score_parameters().block(); @@ -256,8 +251,11 @@ impl TransactionBuilder { return Err(TransactionBuilderError::AdditionalInputsRequired(Requirement::Mana)); } let include_generated = self.burn.as_ref().map_or(true, |b| !b.generated_mana()); - // TODO we should do as for the amount and have preferences on which inputs to pick. - while let Some(input) = self.available_inputs.pop() { + let mut priority_map = PriorityMap::::generate(&mut self.available_inputs); + loop { + let Some(input) = priority_map.next(required_mana - selected_mana) else { + break; + }; selected_mana += self.total_mana(&input, include_generated)?; if let Some(output) = self.select_input(input)? { required_mana += output.mana(); @@ -268,6 +266,10 @@ impl TransactionBuilder { break; } } + // Return unselected inputs to the available list + for input in priority_map.into_inputs() { + self.available_inputs.push(input); + } } Ok(added_inputs) } @@ -329,3 +331,71 @@ impl TransactionBuilder { }) } } + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct ManaPriority { + kind_priority: usize, + has_native_token: bool, +} + +impl PartialOrd for ManaPriority { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for ManaPriority { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + (self.kind_priority, self.has_native_token).cmp(&(other.kind_priority, other.has_native_token)) + } +} + +impl From<&InputSigningData> for Option { + fn from(value: &InputSigningData) -> Self { + sort_order_type() + .get(&value.output.kind()) + .map(|&kind_priority| ManaPriority { + kind_priority, + has_native_token: value.output.native_token().is_some(), + }) + } +} + +/// Establish the order in which we want to pick an input +pub fn sort_order_type() -> &'static HashMap { + static MAP: OnceLock> = OnceLock::new(); + MAP.get_or_init(|| { + [ + BasicOutput::KIND, + NftOutput::KIND, + AccountOutput::KIND, + FoundryOutput::KIND, + ] + .into_iter() + .zip(0_usize..) + .collect::>() + }) +} + +impl PriorityMap { + fn next(&mut self, missing_mana: u64) -> Option { + let mana_sort = |mana: u64| { + // If the mana is greater than the missing mana, we want the smallest ones first + if mana >= missing_mana { + (false, mana) + // Otherwise, we want the biggest first + } else { + (true, u64::MAX - mana) + } + }; + if let Some((priority, mut inputs)) = self.0.pop_first() { + // Sort in reverse so we can pop from the back + inputs.sort_unstable_by(|i1, i2| mana_sort(i2.output.mana()).cmp(&mana_sort(i1.output.mana()))); + let input = inputs.pop(); + if !inputs.is_empty() { + self.0.insert(priority, inputs); + } + return input; + } + None + } +} diff --git a/sdk/src/client/api/block_builder/transaction_builder/requirement/mod.rs b/sdk/src/client/api/block_builder/transaction_builder/requirement/mod.rs index 02136c4839..03a4b80369 100644 --- a/sdk/src/client/api/block_builder/transaction_builder/requirement/mod.rs +++ b/sdk/src/client/api/block_builder/transaction_builder/requirement/mod.rs @@ -13,6 +13,8 @@ pub(crate) mod native_tokens; pub(crate) mod nft; pub(crate) mod sender; +use alloc::collections::BTreeMap; + use self::{ account::is_account_with_id_non_null, delegation::is_delegation_with_id_non_null, foundry::is_foundry_with_id, nft::is_nft_with_id_non_null, @@ -236,3 +238,30 @@ impl TransactionBuilder { Ok(()) } } + +/// A mapping of prioritized inputs. +/// This allows us to avoid sorting all available inputs every loop, and instead we iterate once and sort +/// only the smaller index vectors as needed. +#[derive(Debug)] +struct PriorityMap

(BTreeMap>); + +impl PriorityMap

+where + for<'a> Option

: From<&'a InputSigningData>, +{ + fn generate(available_inputs: &mut Vec) -> Self { + let inputs = core::mem::take(available_inputs); + Self(inputs.into_iter().fold(BTreeMap::new(), |mut map, i| { + if let Some(priority) = Option::

::from(&i) { + map.entry(priority).or_default().push(i); + } else { + available_inputs.push(i); + } + map + })) + } + + fn into_inputs(self) -> impl Iterator { + self.0.into_values().flatten() + } +} diff --git a/sdk/src/client/api/block_builder/transaction_builder/requirement/native_tokens.rs b/sdk/src/client/api/block_builder/transaction_builder/requirement/native_tokens.rs index e1da4ce475..57d467bf8b 100644 --- a/sdk/src/client/api/block_builder/transaction_builder/requirement/native_tokens.rs +++ b/sdk/src/client/api/block_builder/transaction_builder/requirement/native_tokens.rs @@ -1,6 +1,7 @@ // Copyright 2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use alloc::collections::BTreeMap; use std::{cmp::Ordering, collections::HashSet}; use primitive_types::U256; @@ -9,132 +10,121 @@ use super::{TransactionBuilder, TransactionBuilderError}; use crate::{ client::secret::types::InputSigningData, types::block::{ - output::{NativeToken, NativeTokens, NativeTokensBuilder, Output, TokenScheme}, + output::{Output, TokenId, TokenScheme}, payload::signed_transaction::TransactionCapabilityFlag, }, }; -pub(crate) fn get_native_tokens<'a>( - outputs: impl Iterator, -) -> Result { - let mut required_native_tokens = NativeTokensBuilder::new(); - - for output in outputs { - if let Some(native_token) = output.native_token() { - required_native_tokens.add_native_token(*native_token)?; - } - } - - Ok(required_native_tokens) -} - -// TODO only handles one side -pub(crate) fn get_native_tokens_diff( - inputs: &NativeTokensBuilder, - outputs: &NativeTokensBuilder, -) -> Result, TransactionBuilderError> { - let mut native_tokens_diff = NativeTokensBuilder::new(); - - for (token_id, input_amount) in inputs.iter() { - match outputs.get(token_id) { - None => { - native_tokens_diff.insert(*token_id, *input_amount); - } - Some(output_amount) => { - if input_amount > output_amount { - native_tokens_diff.insert(*token_id, input_amount - output_amount); - } - } - } - } - - if native_tokens_diff.is_empty() { - Ok(None) - } else { - Ok(Some(native_tokens_diff.finish()?)) - } -} - impl TransactionBuilder { pub(crate) fn fulfill_native_tokens_requirement( &mut self, ) -> Result, TransactionBuilderError> { - let mut input_native_tokens = get_native_tokens(self.selected_inputs.iter().map(|input| &input.output))?; - let mut output_native_tokens = get_native_tokens(self.non_remainder_outputs())?; - let (minted_native_tokens, melted_native_tokens) = self.get_minted_and_melted_native_tokens()?; - - input_native_tokens.merge(minted_native_tokens)?; - output_native_tokens.merge(melted_native_tokens)?; - - if let Some(burn) = self.burn.as_ref().filter(|burn| !burn.native_tokens.is_empty()) { + let (input_nts, output_nts) = self.get_input_output_native_tokens(); + let diffs = get_native_tokens_diff(output_nts, input_nts); + if self.burn.as_ref().map_or(false, |burn| !burn.native_tokens.is_empty()) { self.transaction_capabilities .add_capability(TransactionCapabilityFlag::BurnNativeTokens); - output_native_tokens.merge(NativeTokensBuilder::from(burn.native_tokens.clone()))?; } + if diffs.is_empty() { + log::debug!("Native tokens requirement already fulfilled"); - // TODO weird that it happens in this direction? - if let Some(diffs) = get_native_tokens_diff(&output_native_tokens, &input_native_tokens)? { - log::debug!( - "Fulfilling native tokens requirement with input {input_native_tokens:?} and output {output_native_tokens:?}" - ); - - let mut newly_selected_inputs = Vec::new(); - let mut newly_selected_ids = HashSet::new(); - - for diff in diffs.iter() { - let mut amount = U256::zero(); - // TODO sort ? - let inputs = self.available_inputs.iter().filter(|input| { - input - .output - .native_token() - .is_some_and(|native_token| native_token.token_id() == diff.token_id()) - }); - - for input in inputs { - amount += input - .output - .native_token() - // PANIC: safe to unwrap as the filter guarantees inputs containing this native token. - .unwrap() - .amount(); - - if newly_selected_ids.insert(*input.output_id()) { - newly_selected_inputs.push(input.clone()); - } + return Ok(Vec::new()); + } - if amount >= diff.amount() { - break; - } + log::debug!("Fulfilling native tokens requirement"); + + let mut newly_selected_inputs = Vec::new(); + let mut newly_selected_ids = HashSet::new(); + + for (&token_id, &amount) in diffs.iter() { + let mut input_amount = U256::zero(); + // TODO sort ? + let inputs = self.available_inputs.iter().filter(|input| { + input + .output + .native_token() + .is_some_and(|native_token| native_token.token_id() == &token_id) + }); + + for input in inputs { + input_amount += input + .output + .native_token() + // PANIC: safe to unwrap as the filter guarantees inputs containing this native token. + .unwrap() + .amount(); + + if newly_selected_ids.insert(*input.output_id()) { + newly_selected_inputs.push(input.clone()); } - if amount < diff.amount() { - return Err(TransactionBuilderError::InsufficientNativeTokenAmount { - token_id: *diff.token_id(), - found: amount, - required: diff.amount(), - }); + if input_amount >= amount { + break; } } - log::debug!("Outputs {newly_selected_ids:?} selected to fulfill the native tokens requirement"); + if input_amount < amount { + return Err(TransactionBuilderError::InsufficientNativeTokenAmount { + token_id, + found: input_amount, + required: amount, + }); + } + } + + log::debug!("Outputs {newly_selected_ids:?} selected to fulfill the native tokens requirement"); - self.available_inputs - .retain(|input| !newly_selected_ids.contains(input.output_id())); + self.available_inputs + .retain(|input| !newly_selected_ids.contains(input.output_id())); - Ok(newly_selected_inputs) - } else { - log::debug!("Native tokens requirement already fulfilled"); + Ok(newly_selected_inputs) + } - Ok(Vec::new()) + pub(crate) fn get_input_output_native_tokens(&self) -> (BTreeMap, BTreeMap) { + let mut input_native_tokens = self + .selected_inputs + .iter() + .filter_map(|i| i.output.native_token().map(|t| (*t.token_id(), t.amount()))) + .fold(BTreeMap::new(), |mut nts, (token_id, amount)| { + *nts.entry(token_id).or_default() += amount; + nts + }); + let mut output_native_tokens = self + .non_remainder_outputs() + .filter_map(|output| output.native_token().map(|t| (*t.token_id(), t.amount()))) + .fold(BTreeMap::new(), |mut nts, (token_id, amount)| { + *nts.entry(token_id).or_default() += amount; + nts + }); + let (minted_native_tokens, melted_native_tokens) = self.get_minted_and_melted_native_tokens(); + + minted_native_tokens + .into_iter() + .fold(&mut input_native_tokens, |nts, (token_id, amount)| { + *nts.entry(token_id).or_default() += amount; + nts + }); + melted_native_tokens + .into_iter() + .fold(&mut output_native_tokens, |nts, (token_id, amount)| { + *nts.entry(token_id).or_default() += amount; + nts + }); + + if let Some(burn) = self.burn.as_ref() { + burn.native_tokens + .iter() + .fold(&mut output_native_tokens, |nts, (token_id, amount)| { + *nts.entry(*token_id).or_default() += *amount; + nts + }); } + (input_native_tokens, output_native_tokens) } - pub(crate) fn get_minted_and_melted_native_tokens( - &self, - ) -> Result<(NativeTokensBuilder, NativeTokensBuilder), TransactionBuilderError> { - let mut minted_native_tokens = NativeTokensBuilder::new(); - let mut melted_native_tokens = NativeTokensBuilder::new(); + pub(crate) fn get_minted_and_melted_native_tokens(&self) -> (BTreeMap, BTreeMap) { + let mut minted_native_tokens = BTreeMap::new(); + let mut melted_native_tokens = BTreeMap::new(); for output in self.non_remainder_outputs() { if let Output::Foundry(output_foundry) = output { @@ -157,15 +147,13 @@ impl TransactionBuilder { let minted_native_token_amount = output_foundry_simple_ts.circulating_supply() - input_foundry_simple_ts.circulating_supply(); - minted_native_tokens - .add_native_token(NativeToken::new(token_id, minted_native_token_amount)?)?; + *minted_native_tokens.entry(token_id).or_default() += minted_native_token_amount; } Ordering::Less => { let melted_native_token_amount = input_foundry_simple_ts.circulating_supply() - output_foundry_simple_ts.circulating_supply(); - melted_native_tokens - .add_native_token(NativeToken::new(token_id, melted_native_token_amount)?)?; + *melted_native_tokens.entry(token_id).or_default() += melted_native_token_amount; } Ordering::Equal => {} } @@ -176,16 +164,25 @@ impl TransactionBuilder { // If we created the foundry with this transaction, then we need to add the circulating supply as minted // tokens if initial_creation { - let circulating_supply = output_foundry_simple_ts.circulating_supply(); - - if !circulating_supply.is_zero() { - minted_native_tokens - .add_native_token(NativeToken::new(output_foundry.token_id(), circulating_supply)?)?; - } + *minted_native_tokens.entry(output_foundry.token_id()).or_default() += + output_foundry_simple_ts.circulating_supply(); } } } - Ok((minted_native_tokens, melted_native_tokens)) + (minted_native_tokens, melted_native_tokens) } } + +pub(crate) fn get_native_tokens_diff( + first: BTreeMap, + second: BTreeMap, +) -> BTreeMap { + first + .into_iter() + .filter_map(|(id, in_amount)| { + let out_amount = second.get(&id).copied().unwrap_or_default(); + (in_amount > out_amount).then_some((id, in_amount.saturating_sub(out_amount))) + }) + .collect() +} diff --git a/sdk/tests/client/signing/mod.rs b/sdk/tests/client/signing/mod.rs index 258fda7eea..39a93557ad 100644 --- a/sdk/tests/client/signing/mod.rs +++ b/sdk/tests/client/signing/mod.rs @@ -23,7 +23,6 @@ use iota_sdk::{ payload::{signed_transaction::Transaction, SignedTransactionPayload}, protocol::iota_mainnet_protocol_parameters, slot::{SlotCommitmentHash, SlotCommitmentId, SlotIndex}, - unlock::{SignatureUnlock, Unlock}, }, }; use pretty_assertions::assert_eq; @@ -401,83 +400,11 @@ async fn all_combined() -> Result<(), Box> { .transaction_unlocks(&prepared_transaction_data, &protocol_parameters) .await?; - assert_eq!(unlocks.len(), 15); - assert_eq!((*unlocks).first().unwrap().kind(), SignatureUnlock::KIND); - match (*unlocks).get(1).unwrap() { - Unlock::Reference(a) => { - assert_eq!(a.index(), 0); - } - _ => panic!("Invalid unlock 1"), - } - assert_eq!((*unlocks).get(2).unwrap().kind(), SignatureUnlock::KIND); - assert_eq!((*unlocks).get(3).unwrap().kind(), SignatureUnlock::KIND); - match (*unlocks).get(4).unwrap() { - Unlock::Reference(a) => { - assert_eq!(a.index(), 3); - } - _ => panic!("Invalid unlock 4"), - } - match (*unlocks).get(5).unwrap() { - Unlock::Reference(a) => { - assert_eq!(a.index(), 3); - } - _ => panic!("Invalid unlock 5"), - } - match (*unlocks).get(6).unwrap() { - Unlock::Account(a) => { - assert_eq!(a.index(), 5); - } - _ => panic!("Invalid unlock 6"), - } - match (*unlocks).get(7).unwrap() { - Unlock::Account(a) => { - assert_eq!(a.index(), 5); - } - _ => panic!("Invalid unlock 7"), - } - match (*unlocks).get(8).unwrap() { - Unlock::Reference(a) => { - assert_eq!(a.index(), 3); - } - _ => panic!("Invalid unlock 8"), - } - - match (*unlocks).get(9).unwrap() { - Unlock::Nft(a) => { - assert_eq!(a.index(), 8); - } - _ => panic!("Invalid unlock 9"), - } - match (*unlocks).get(10).unwrap() { - Unlock::Account(a) => { - assert_eq!(a.index(), 9); - } - _ => panic!("Invalid unlock 10"), - } - match (*unlocks).get(11).unwrap() { - Unlock::Account(a) => { - assert_eq!(a.index(), 9); - } - _ => panic!("Invalid unlock 11"), - } - match (*unlocks).get(12).unwrap() { - Unlock::Account(a) => { - assert_eq!(a.index(), 9); - } - _ => panic!("Invalid unlock 12"), - } - match (*unlocks).get(13).unwrap() { - Unlock::Nft(a) => { - assert_eq!(a.index(), 11); - } - _ => panic!("Invalid unlock 13"), - } - match (*unlocks).get(14).unwrap() { - Unlock::Nft(a) => { - assert_eq!(a.index(), 10); - } - _ => panic!("Invalid unlock 14"), - } + assert_eq!(unlocks.len(), 13); + assert_eq!(unlocks.iter().filter(|u| u.is_signature()).count(), 3); + assert_eq!(unlocks.iter().filter(|u| u.is_reference()).count(), 4); + assert_eq!(unlocks.iter().filter(|u| u.is_account()).count(), 3); + assert_eq!(unlocks.iter().filter(|u| u.is_nft()).count(), 3); let tx_payload = SignedTransactionPayload::new(prepared_transaction_data.transaction.clone(), unlocks)?; diff --git a/sdk/tests/client/transaction_builder/basic_outputs.rs b/sdk/tests/client/transaction_builder/basic_outputs.rs index 56362ea42c..49427af41f 100644 --- a/sdk/tests/client/transaction_builder/basic_outputs.rs +++ b/sdk/tests/client/transaction_builder/basic_outputs.rs @@ -1562,7 +1562,7 @@ fn two_inputs_remainder_3() { Some(SLOT_INDEX), ); let outputs = build_outputs([Basic { - amount: 1_750_000, + amount: 2_750_000, address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), native_token: None, sender: None, @@ -1589,7 +1589,7 @@ fn two_inputs_remainder_3() { if !outputs.contains(output) { assert_remainder_or_return( output, - 1_250_000, + 250_000, Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), None, ); @@ -1754,7 +1754,7 @@ fn too_many_inputs() { ) }) .take(129), - None, + Some(SLOT_INDEX), ); let outputs = build_outputs([Basic { amount: 129_000_000, @@ -1803,7 +1803,7 @@ fn more_than_max_inputs_only_one_needed() { ) }) .take(1000), - None, + Some(SLOT_INDEX), ); // Add the needed input let needed_input = build_inputs( diff --git a/sdk/tests/client/transaction_builder/native_tokens.rs b/sdk/tests/client/transaction_builder/native_tokens.rs index a84f317993..9154120c13 100644 --- a/sdk/tests/client/transaction_builder/native_tokens.rs +++ b/sdk/tests/client/transaction_builder/native_tokens.rs @@ -962,15 +962,24 @@ fn two_basic_outputs_1() { .unwrap(); assert_eq!(selected.inputs_data.len(), 1); - assert!(selected.inputs_data.contains(&inputs[0])); + assert!(selected.inputs_data.contains(&inputs[0]) || selected.inputs_data.contains(&inputs[1])); assert_eq!(selected.transaction.outputs().len(), 2); assert!(selected.transaction.outputs().contains(&outputs[0])); - assert_remainder_or_return( - &selected.transaction.outputs()[1], - 500_000, - Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), - Some((TOKEN_ID_1, 100)), - ); + if selected.inputs_data.contains(&inputs[0]) { + assert_remainder_or_return( + &selected.transaction.outputs()[1], + 500_000, + Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + Some((TOKEN_ID_1, 100)), + ); + } else { + assert_remainder_or_return( + &selected.transaction.outputs()[1], + 500_000, + Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + Some((TOKEN_ID_1, 200)), + ); + } } #[test] diff --git a/sdk/tests/client/transaction_builder/outputs.rs b/sdk/tests/client/transaction_builder/outputs.rs index df100cc656..6335a3df09 100644 --- a/sdk/tests/client/transaction_builder/outputs.rs +++ b/sdk/tests/client/transaction_builder/outputs.rs @@ -10,7 +10,7 @@ use iota_sdk::{ }, types::block::{ address::Address, - output::{unlock_condition::AddressUnlockCondition, AccountId, BasicOutputBuilder}, + output::{unlock_condition::AddressUnlockCondition, AccountId, BasicOutputBuilder, NftId}, payload::signed_transaction::{TransactionCapabilities, TransactionCapabilityFlag}, protocol::iota_mainnet_protocol_parameters, rand::output::{rand_output_id_with_slot_index, rand_output_metadata_with_id}, @@ -20,8 +20,9 @@ use pretty_assertions::assert_eq; use crate::client::{ assert_remainder_or_return, build_inputs, build_outputs, unsorted_eq, - Build::{Account, Basic}, - ACCOUNT_ID_1, ACCOUNT_ID_2, BECH32_ADDRESS_ED25519_0, BECH32_ADDRESS_ED25519_1, SLOT_COMMITMENT_ID, SLOT_INDEX, + Build::{Account, Basic, Nft}, + ACCOUNT_ID_1, ACCOUNT_ID_2, BECH32_ADDRESS_ED25519_0, BECH32_ADDRESS_ED25519_1, NFT_ID_1, SLOT_COMMITMENT_ID, + SLOT_INDEX, }; #[test] @@ -320,15 +321,16 @@ fn two_addresses_one_missing() { SLOT_COMMITMENT_ID, protocol_parameters, ) - .finish(); + .finish() + .unwrap_err(); - assert!(matches!( + assert_eq!( selected, - Err(TransactionBuilderError::InsufficientAmount { + TransactionBuilderError::InsufficientAmount { found: 1_000_000, required: 2_000_000, - }) - )); + } + ); } #[test] @@ -455,3 +457,117 @@ fn consolidate_with_min_allotment() { assert_eq!(selected.transaction.allotments()[0].mana(), 39440); assert_eq!(selected.transaction.outputs().iter().map(|o| o.mana()).sum::(), 0); } + +#[test] +fn transition_no_more_than_needed_for_account_amount() { + let protocol_parameters = iota_mainnet_protocol_parameters().clone(); + let account_id_2 = AccountId::from_str(ACCOUNT_ID_2).unwrap(); + let nft_id_1 = NftId::from_str(NFT_ID_1).unwrap(); + + let inputs = build_inputs( + [ + ( + Account { + amount: 500_000, + account_id: account_id_2, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + issuer: None, + }, + None, + ), + ( + Nft { + amount: 500_000, + nft_id: nft_id_1, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + issuer: None, + sdruc: None, + expiration: None, + }, + None, + ), + ], + Some(SLOT_INDEX), + ); + let outputs = build_outputs([Account { + amount: 500_000, + account_id: account_id_2, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + issuer: None, + }]); + + let selected = TransactionBuilder::new( + inputs.clone(), + outputs.clone(), + [Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap()], + SLOT_INDEX, + SLOT_COMMITMENT_ID, + protocol_parameters, + ) + .finish() + .unwrap(); + + assert_eq!(selected.inputs_data.len(), 1); + assert!(unsorted_eq(&selected.transaction.outputs(), &outputs)); +} + +#[test] +fn transition_no_more_than_needed_for_nft_amount() { + let protocol_parameters = iota_mainnet_protocol_parameters().clone(); + let account_id_2 = AccountId::from_str(ACCOUNT_ID_2).unwrap(); + let nft_id_1 = NftId::from_str(NFT_ID_1).unwrap(); + + let inputs = build_inputs( + [ + ( + Account { + amount: 500_000, + account_id: account_id_2, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + issuer: None, + }, + None, + ), + ( + Nft { + amount: 500_000, + nft_id: nft_id_1, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + issuer: None, + sdruc: None, + expiration: None, + }, + None, + ), + ], + Some(SLOT_INDEX), + ); + let outputs = build_outputs([Nft { + amount: 500_000, + nft_id: nft_id_1, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + issuer: None, + sdruc: None, + expiration: None, + }]); + + let selected = TransactionBuilder::new( + inputs.clone(), + outputs.clone(), + [Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap()], + SLOT_INDEX, + SLOT_COMMITMENT_ID, + protocol_parameters, + ) + .finish() + .unwrap(); + + assert_eq!(selected.inputs_data.len(), 1); + assert!(unsorted_eq(&selected.transaction.outputs(), &outputs)); +}