diff --git a/sdk/src/client/api/block_builder/options.rs b/sdk/src/client/api/block_builder/options.rs index 44d95f98d4..5a49015fc4 100644 --- a/sdk/src/client/api/block_builder/options.rs +++ b/sdk/src/client/api/block_builder/options.rs @@ -6,7 +6,7 @@ use alloc::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; use crate::{ - client::api::transaction_builder::Burn, + client::api::transaction_builder::{transition::Transitions, Burn}, types::block::{ address::Address, output::{AccountId, OutputId}, @@ -25,6 +25,8 @@ pub struct TransactionOptions { pub tagged_data_payload: Option, /// Inputs that must be used for the transaction. pub required_inputs: BTreeSet, + /// Specifies what needs to be transitioned in the transaction and how. + pub transitions: Option, /// Specifies what needs to be burned in the transaction. pub burn: Option, /// A string attached to the transaction. @@ -45,6 +47,7 @@ impl Default for TransactionOptions { remainder_value_strategy: Default::default(), tagged_data_payload: Default::default(), required_inputs: Default::default(), + transitions: Default::default(), burn: Default::default(), note: Default::default(), allow_micro_amount: false, diff --git a/sdk/src/client/api/block_builder/transaction_builder/error.rs b/sdk/src/client/api/block_builder/transaction_builder/error.rs index 910501d47b..8c3f50e910 100644 --- a/sdk/src/client/api/block_builder/transaction_builder/error.rs +++ b/sdk/src/client/api/block_builder/transaction_builder/error.rs @@ -11,10 +11,11 @@ use super::Requirement; use crate::types::block::{ context_input::ContextInputError, mana::ManaError, - output::{ChainId, NativeTokenError, OutputError, OutputId, TokenId}, + output::{feature::FeatureError, AccountId, ChainId, NativeTokenError, OutputError, OutputId, TokenId}, payload::PayloadError, semantic::TransactionFailureReason, signature::SignatureError, + slot::EpochIndex, unlock::UnlockError, BlockError, }; @@ -25,9 +26,16 @@ use crate::types::block::{ pub enum TransactionBuilderError { #[error("additional inputs required for {0:?}, but additional input selection is disabled")] AdditionalInputsRequired(Requirement), + #[error("account {0} is already staking")] + AlreadyStaking(AccountId), /// Can't burn and transition an output at the same time. #[error("can't burn and transition an output at the same time, chain ID: {0}")] BurnAndTransition(ChainId), + #[error("account {account_id} cannot end staking until {end_epoch}")] + CannotEndStaking { + account_id: AccountId, + end_epoch: EpochIndex, + }, #[error("mana rewards provided without an associated burn or custom input, output ID: {0}")] ExtraManaRewards(OutputId), /// Insufficient amount provided. @@ -72,9 +80,15 @@ pub enum TransactionBuilderError { /// No available inputs were provided to transaction builder. #[error("no available inputs provided")] NoAvailableInputsProvided, + #[error("account {0} is not staking")] + NotStaking(AccountId), /// Required input is not available. #[error("required input {0} is not available")] RequiredInputIsNotAvailable(OutputId), + #[error("new staking period {additional_epochs} is less than the minimum {min}")] + StakingPeriodLessThanMin { additional_epochs: u32, min: u32 }, + #[error("cannot transition non-implicit-account output {0}")] + TransitionNonImplicitAccount(OutputId), /// Unfulfillable requirement. #[error("unfulfillable requirement {0:?}")] UnfulfillableRequirement(Requirement), @@ -105,6 +119,9 @@ pub enum TransactionBuilderError { /// Unlock errors. #[error("{0}")] Unlock(#[from] UnlockError), + /// Feature errors. + #[error("{0}")] + Feature(#[from] FeatureError), /// Semantic errors. #[error("{0}")] Semantic(#[from] TransactionFailureReason), diff --git a/sdk/src/client/api/block_builder/transaction_builder/mod.rs b/sdk/src/client/api/block_builder/transaction_builder/mod.rs index a09e45bd13..238fe78337 100644 --- a/sdk/src/client/api/block_builder/transaction_builder/mod.rs +++ b/sdk/src/client/api/block_builder/transaction_builder/mod.rs @@ -16,7 +16,7 @@ use std::collections::{HashMap, HashSet}; use crypto::keys::bip44::Bip44; -pub use self::{burn::Burn, error::TransactionBuilderError, requirement::Requirement}; +pub use self::{burn::Burn, error::TransactionBuilderError, requirement::Requirement, transition::Transitions}; use crate::{ client::{ api::{ @@ -30,11 +30,11 @@ use crate::{ types::block::{ address::{AccountAddress, Address, NftAddress, ToBech32Ext}, context_input::{BlockIssuanceCreditContextInput, CommitmentContextInput, ContextInput, RewardContextInput}, - input::{Input, UtxoInput, INPUT_COUNT_RANGE}, + input::{Input, UtxoInput, INPUT_COUNT_MAX, INPUT_COUNT_RANGE}, mana::ManaAllotment, output::{ - AccountId, AccountOutputBuilder, AnchorOutputBuilder, BasicOutputBuilder, NftOutputBuilder, Output, - OutputId, OUTPUT_COUNT_RANGE, + AccountId, AccountOutputBuilder, BasicOutputBuilder, ChainId, NftOutputBuilder, Output, OutputId, + OUTPUT_COUNT_RANGE, }, payload::{ signed_transaction::{Transaction, TransactionCapabilities, TransactionCapabilityFlag}, @@ -185,6 +185,7 @@ pub struct TransactionBuilder { provided_outputs: Vec, added_outputs: Vec, addresses: HashSet
, + transitions: Option, burn: Option, remainders: Remainders, creation_slot: SlotIndex, @@ -205,6 +206,7 @@ pub(crate) struct MinManaAllotment { issuer_id: AccountId, reference_mana_cost: u64, allotment_debt: u64, + required_allotment: Option, } #[derive(Clone, Debug, Default)] @@ -212,7 +214,8 @@ pub(crate) struct Remainders { address: Option
, data: Vec, storage_deposit_returns: Vec, - added_mana: u64, + added_amount: HashMap, u64>, + added_mana: HashMap, u64>, } impl TransactionBuilder { @@ -257,6 +260,7 @@ impl TransactionBuilder { provided_outputs: outputs.into_iter().collect(), added_outputs: Vec::new(), addresses, + transitions: None, burn: None, remainders: Default::default(), creation_slot: creation_slot_index.into(), @@ -374,25 +378,47 @@ impl TransactionBuilder { return Err(TransactionBuilderError::InvalidInputCount(self.selected_inputs.len())); } - if self.remainders.added_mana > 0 { - let remainder_address = self - .get_remainder_address()? - .ok_or(TransactionBuilderError::MissingInputWithEd25519Address)? - .0; - let added_mana = self.remainders.added_mana; - if let Some(output) = self.get_output_for_added_mana(&remainder_address) { - log::debug!("Adding {added_mana} excess input mana to output with address {remainder_address}"); + let remainder_address = self + .get_remainder_address()? + .ok_or(TransactionBuilderError::MissingInputWithEd25519Address)? + .0; + + let mut added_amount_mana = HashMap::, (u64, u64)>::new(); + for (chain_id, added_amount) in self.remainders.added_amount.drain() { + added_amount_mana.entry(chain_id).or_default().0 = added_amount; + } + for (chain_id, added_mana) in self.remainders.added_mana.drain() { + added_amount_mana.entry(chain_id).or_default().1 = added_mana; + } + + for (chain_id, (added_amount, added_mana)) in added_amount_mana { + let mut output = self.get_output_for_remainder(chain_id, &remainder_address); + if output.is_none() { + output = self.get_output_for_remainder(None, &remainder_address); + } + if let Some(output) = output { + log::debug!( + "Adding {added_amount} excess amount and {added_mana} excess mana to output with address {remainder_address} and {chain_id:?}" + ); + let new_amount = output.amount() + added_amount; let new_mana = output.mana() + added_mana; *output = match output { - Output::Basic(b) => BasicOutputBuilder::from(&*b).with_mana(new_mana).finish_output()?, - Output::Account(a) => AccountOutputBuilder::from(&*a).with_mana(new_mana).finish_output()?, - Output::Anchor(a) => AnchorOutputBuilder::from(&*a).with_mana(new_mana).finish_output()?, - Output::Nft(n) => NftOutputBuilder::from(&*n).with_mana(new_mana).finish_output()?, + Output::Basic(b) => BasicOutputBuilder::from(&*b) + .with_amount(new_amount) + .with_mana(new_mana) + .finish_output()?, + Output::Account(a) => AccountOutputBuilder::from(&*a) + .with_amount(new_amount) + .with_mana(new_mana) + .finish_output()?, + Output::Nft(n) => NftOutputBuilder::from(&*n) + .with_amount(new_amount) + .with_mana(new_mana) + .finish_output()?, _ => unreachable!(), }; } } - // If we're burning generated mana, set the capability flag. if self.burn.as_ref().map_or(false, |b| b.generated_mana()) { // Get the mana sums with generated mana to see whether there's a difference. @@ -474,9 +500,16 @@ impl TransactionBuilder { Ok(data) } - fn select_input(&mut self, input: InputSigningData) -> Result, TransactionBuilderError> { + /// Select an input and return whether an output was created. + fn select_input(&mut self, input: InputSigningData) -> Result { log::debug!("Selecting input {:?}", input.output_id()); + if self.selected_inputs.len() >= INPUT_COUNT_MAX as usize { + return Err(TransactionBuilderError::InvalidInputCount( + self.selected_inputs.len() + 1, + )); + } + let mut added_output = false; if let Some(output) = self.transition_input(&input)? { // No need to check for `outputs_requirements` because @@ -507,28 +540,39 @@ impl TransactionBuilder { .expect("expiration unlockable outputs already filtered out"); self.selected_inputs.insert(required_address, input); - Ok(added_output.then(|| self.added_outputs.last().unwrap())) + // Remove the cached allotment value because it's no longer valid + if let Some(MinManaAllotment { required_allotment, .. }) = self.min_mana_allotment.as_mut() { + *required_allotment = None; + } + + Ok(added_output) } - /// Sets the required inputs of an [`TransactionBuilder`]. + /// Sets the required inputs of a [`TransactionBuilder`]. pub fn with_required_inputs(mut self, inputs: impl IntoIterator) -> Self { self.required_inputs = inputs.into_iter().collect(); self } - /// Sets the burn of an [`TransactionBuilder`]. + /// Sets the transitions of a [`TransactionBuilder`]. + pub fn with_transitions(mut self, transitions: impl Into>) -> Self { + self.transitions = transitions.into(); + self + } + + /// Sets the burn of a [`TransactionBuilder`]. pub fn with_burn(mut self, burn: impl Into>) -> Self { self.burn = burn.into(); self } - /// Sets the remainder address of an [`TransactionBuilder`]. + /// Sets the remainder address of a [`TransactionBuilder`]. pub fn with_remainder_address(mut self, address: impl Into>) -> Self { self.remainders.address = address.into(); self } - /// Sets the mana allotments of an [`TransactionBuilder`]. + /// Sets the mana allotments of a [`TransactionBuilder`]. pub fn with_mana_allotments(mut self, mana_allotments: impl IntoIterator) -> Self { self.mana_allotments = mana_allotments.into_iter().collect(); self @@ -558,6 +602,7 @@ impl TransactionBuilder { issuer_id: account_id, reference_mana_cost, allotment_debt: 0, + required_allotment: None, }); self } 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 9b16b37c71..6c805d95a6 100644 --- a/sdk/src/client/api/block_builder/transaction_builder/remainder.rs +++ b/sdk/src/client/api/block_builder/transaction_builder/remainder.rs @@ -2,76 +2,31 @@ // SPDX-License-Identifier: Apache-2.0 use alloc::collections::BTreeMap; -use std::collections::HashMap; +use std::sync::OnceLock; 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_diff, RemainderData}, + client::api::{ + transaction_builder::{requirement::native_tokens::get_native_tokens_diff, Remainders}, + RemainderData, + }, types::block::{ address::{Address, Ed25519Address}, - output::{ - unlock_condition::AddressUnlockCondition, AccountOutput, BasicOutput, BasicOutputBuilder, NativeToken, - NftOutput, Output, StorageScoreParameters, TokenId, - }, + output::{unlock_condition::AddressUnlockCondition, BasicOutputBuilder, ChainId, NativeToken, Output, TokenId}, }, }; impl TransactionBuilder { /// Updates the remainders, overwriting old values. pub(crate) fn update_remainders(&mut self) -> Result<(), TransactionBuilderError> { - let (storage_deposit_returns, remainders) = self.storage_deposit_returns_and_remainders()?; - - self.remainders.storage_deposit_returns = storage_deposit_returns; - self.remainders.data = remainders; - - Ok(()) - } - - /// Gets the remainder address from configuration or finds one from the inputs. - pub(crate) fn get_remainder_address(&self) -> Result)>, TransactionBuilderError> { - if let Some(remainder_address) = &self.remainders.address { - // Search in inputs for the Bip44 chain for the remainder address, so the ledger can regenerate it - for input in self.available_inputs.iter().chain(self.selected_inputs.iter()) { - let required_address = input - .output - .required_address( - self.latest_slot_commitment_id.slot_index(), - self.protocol_parameters.committable_age_range(), - )? - .expect("expiration unlockable outputs already filtered out"); - - if &required_address == remainder_address { - return Ok(Some((remainder_address.clone(), input.chain))); - } - } - return Ok(Some((remainder_address.clone(), None))); - } - - for input in self.selected_inputs.iter() { - let required_address = input - .output - .required_address( - self.latest_slot_commitment_id.slot_index(), - self.protocol_parameters.committable_age_range(), - )? - .expect("expiration unlockable outputs already filtered out"); - - if let Some(&required_address) = required_address.backing_ed25519() { - return Ok(Some((required_address.into(), input.chain))); - } - } - - Ok(None) - } - - pub(crate) fn storage_deposit_returns_and_remainders( - &mut self, - ) -> Result<(Vec, Vec), TransactionBuilderError> { + self.remainders = Remainders { + address: self.remainders.address.take(), + ..Default::default() + }; let (input_amount, output_amount, inputs_sdr, outputs_sdr) = self.amount_sums(); - let mut storage_deposit_returns = Vec::new(); for (address, amount) in inputs_sdr { let output_sdr_amount = *outputs_sdr.get(&address).unwrap_or(&0); @@ -86,7 +41,7 @@ impl TransactionBuilder { log::debug!("Created storage deposit return output of {diff} for {address:?}"); - storage_deposit_returns.push(srd_output); + self.remainders.storage_deposit_returns.push(srd_output); } } @@ -97,7 +52,7 @@ impl TransactionBuilder { let (input_mana, output_mana) = self.mana_sums(false)?; - let amount_diff = input_amount.checked_sub(output_amount).expect("amount underflow"); + let mut amount_diff = input_amount.checked_sub(output_amount).expect("amount underflow"); let mut mana_diff = input_mana.checked_sub(output_mana).expect("mana underflow"); // If we are burning mana, then we can subtract out the burned amount. @@ -109,33 +64,111 @@ impl TransactionBuilder { .get_remainder_address()? .ok_or(TransactionBuilderError::MissingInputWithEd25519Address)?; + // Amount can be just multiplied, because all remainder outputs with a native token have the same storage + // cost. + let nt_min_amount = self.native_token_remainder().amount() * native_tokens_diff.len() as u64; + + // If there is an amount remainder (above any nt min amount), try to fit it in an existing output + if amount_diff > nt_min_amount { + for (chain_id, (input_amount, output_amount)) in self.amount_chains()? { + if input_amount > output_amount + && (self.output_for_remainder_exists(Some(chain_id), &remainder_address) + || self.output_for_remainder_exists(None, &remainder_address)) + { + // Get the lowest of the total diff or the diff for this chain + let amount_to_add = (amount_diff - nt_min_amount).min(input_amount - output_amount); + log::debug!( + "Allocating {amount_to_add} excess input amount for output with address {remainder_address} and chain id {chain_id}" + ); + amount_diff -= amount_to_add; + self.remainders.added_amount.insert(Some(chain_id), amount_to_add); + } + } + // Any leftover amount diff can go in any output with the right address + if amount_diff > nt_min_amount && self.output_for_remainder_exists(None, &remainder_address) { + let amount_to_add = amount_diff - nt_min_amount; + log::debug!( + "Allocating {amount_to_add} excess input amount for output with address {remainder_address}" + ); + amount_diff = nt_min_amount; + self.remainders.added_amount.insert(None, amount_to_add); + } + } + // If there is a mana remainder, try to fit it in an existing output - if mana_diff > 0 && self.output_for_added_mana_exists(&remainder_address) { - log::debug!("Allocating {mana_diff} excess input mana for output with address {remainder_address}"); - self.remainders.added_mana = std::mem::take(&mut mana_diff); + if mana_diff > 0 { + for (chain_id, (input_mana, output_mana)) in self.mana_chains()? { + if input_mana > output_mana + && (self.output_for_remainder_exists(Some(chain_id), &remainder_address) + || self.output_for_remainder_exists(None, &remainder_address)) + { + // Get the lowest of the total diff or the diff for this chain + let mana_to_add = mana_diff.min(input_mana - output_mana); + log::debug!( + "Allocating {mana_to_add} excess input mana for output with address {remainder_address} and chain id {chain_id}" + ); + mana_diff -= mana_to_add; + self.remainders.added_mana.insert(Some(chain_id), mana_to_add); + } + } + // Any leftover mana diff can go in any output with the right address + if mana_diff > 0 && self.output_for_remainder_exists(None, &remainder_address) { + log::debug!("Allocating {mana_diff} excess input mana for output with address {remainder_address}"); + self.remainders.added_mana.insert(None, std::mem::take(&mut mana_diff)); + } } - if input_amount == output_amount && mana_diff == 0 && native_tokens_diff.is_empty() { + if amount_diff == 0 && mana_diff == 0 && native_tokens_diff.is_empty() { log::debug!("No remainder required"); - return Ok((storage_deposit_returns, Vec::new())); + return Ok(()); } - let remainder_outputs = create_remainder_outputs( - amount_diff, - mana_diff, - native_tokens_diff, - remainder_address, - chain, - self.protocol_parameters.storage_score_parameters(), - )?; + self.create_remainder_outputs(amount_diff, mana_diff, native_tokens_diff, remainder_address, chain)?; - Ok((storage_deposit_returns, remainder_outputs)) + Ok(()) } - fn output_for_added_mana_exists(&self, remainder_address: &Address) -> bool { + /// Gets the remainder address from configuration or finds one from the inputs. + pub(crate) fn get_remainder_address(&self) -> Result)>, TransactionBuilderError> { + if let Some(remainder_address) = &self.remainders.address { + // Search in inputs for the Bip44 chain for the remainder address, so the ledger can regenerate it + for input in self.available_inputs.iter().chain(self.selected_inputs.iter()) { + let required_address = input + .output + .required_address( + self.latest_slot_commitment_id.slot_index(), + self.protocol_parameters.committable_age_range(), + )? + .expect("expiration unlockable outputs already filtered out"); + + if &required_address == remainder_address { + return Ok(Some((remainder_address.clone(), input.chain))); + } + } + return Ok(Some((remainder_address.clone(), None))); + } + + for input in self.selected_inputs.iter() { + let required_address = input + .output + .required_address( + self.latest_slot_commitment_id.slot_index(), + self.protocol_parameters.committable_age_range(), + )? + .expect("expiration unlockable outputs already filtered out"); + + if let Some(&required_address) = required_address.backing_ed25519() { + return Ok(Some((required_address.into(), input.chain))); + } + } + + Ok(None) + } + + fn output_for_remainder_exists(&self, chain_id: Option, remainder_address: &Address) -> bool { // Find the first value that matches the remainder address - self.non_remainder_outputs().any(|o| { - (o.is_basic() || o.is_account() || o.is_anchor() || o.is_nft()) + self.added_outputs.iter().any(|o| { + (o.chain_id() == chain_id || chain_id.is_none() && (o.is_basic() || o.is_account() || o.is_nft())) && o.unlock_conditions().expiration().is_none() && o.unlock_conditions().timelock().is_none() && matches!(o.required_address( @@ -145,67 +178,55 @@ 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 = [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 - .iter_mut() - .chain(&mut self.added_outputs) - .filter(|o| o.unlock_conditions().expiration().is_none() && o.unlock_conditions().timelock().is_none()) - .filter_map(|o| sort_order.get(&o.kind()).map(|order| (*order, o))) - .collect::>(); - - // Find the first value that matches the remainder address - ordered_outputs.into_values().find(|o| { - matches!(o.required_address( - self.latest_slot_commitment_id.slot_index(), - self.protocol_parameters.committable_age_range(), - ), Ok(Some(address)) if &address == remainder_address) + pub(crate) fn get_output_for_remainder( + &mut self, + chain_id: Option, + remainder_address: &Address, + ) -> Option<&mut Output> { + self.added_outputs.iter_mut().find(|o| { + (o.chain_id() == chain_id || chain_id.is_none() && (o.is_basic() || o.is_account() || o.is_nft())) + && o.unlock_conditions().expiration().is_none() + && o.unlock_conditions().timelock().is_none() + && matches!(o.required_address( + self.latest_slot_commitment_id.slot_index(), + self.protocol_parameters.committable_age_range(), + ), Ok(Some(address)) if &address == remainder_address) }) } /// 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) -> Result<(u64, bool, bool), TransactionBuilderError> { + pub(crate) fn required_remainder_amount(&mut 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()) - .add_unlock_condition(AddressUnlockCondition::new(Address::from(Ed25519Address::from( - [0; 32], - )))); - let remainder_amount = if !remainder_native_tokens.is_empty() { - let nt_remainder_amount = remainder_builder - .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 * remainder_native_tokens.len() as u64 + self.native_token_remainder().amount() * remainder_native_tokens.len() as u64 } else { - remainder_builder.finish_output()?.amount() + self.basic_remainder().amount() }; let (selected_mana, required_mana) = self.mana_sums(false)?; let remainder_address = self.get_remainder_address()?.map(|v| v.0); + let mana_chains = self.mana_chains()?; + // Mana can potentially be added to an appropriate existing output instead of a new remainder output let mut mana_remainder = selected_mana > required_mana && remainder_address.map_or(true, |remainder_address| { - !self.output_for_added_mana_exists(&remainder_address) + let mut mana_diff = selected_mana - required_mana; + for (chain_id, (mana_in, mana_out)) in mana_chains { + if mana_in > mana_out && self.output_for_remainder_exists(Some(chain_id), &remainder_address) { + mana_diff -= mana_diff.min(mana_in - mana_out); + if mana_diff == 0 { + return false; + } + } + } + mana_diff > 0 && !self.output_for_remainder_exists(None, &remainder_address) }); // If we are burning mana, we may not need a mana remainder if self.burn.as_ref().map_or(false, |b| b.mana()) { @@ -215,61 +236,83 @@ impl TransactionBuilder { Ok((remainder_amount, !remainder_native_tokens.is_empty(), mana_remainder)) } -} -fn create_remainder_outputs( - amount_diff: u64, - mana_diff: u64, - mut native_tokens: BTreeMap, - remainder_address: Address, - remainder_address_chain: Option, - storage_score_parameters: StorageScoreParameters, -) -> Result, TransactionBuilderError> { - let mut remainder_outputs = Vec::new(); - let mut remaining_amount = amount_diff; - let mut catchall_native_token = None; - - // Start with the native tokens - 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); + fn create_remainder_outputs( + &mut self, + amount_diff: u64, + mana_diff: u64, + mut native_tokens: BTreeMap, + remainder_address: Address, + remainder_address_chain: Option, + ) -> Result<(), TransactionBuilderError> { + let mut remaining_amount = amount_diff; + let mut catchall_native_token = None; + + // Start with the native tokens + 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(self.protocol_parameters.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()); + self.remainders.data.push(RemainderData { + output, + chain: remainder_address_chain, + address: remainder_address.clone(), + }); + } } - } - let mut catchall = BasicOutputBuilder::new_with_amount(remaining_amount) - .with_mana(mana_diff) - .add_unlock_condition(AddressUnlockCondition::new(remainder_address.clone())); - if let Some(native_token) = catchall_native_token { - catchall = catchall.with_native_token(native_token); - } - let catchall = catchall.finish_output()?; - catchall.verify_storage_deposit(storage_score_parameters)?; - log::debug!( - "Created remainder output of amount {}, mana {} and native token {:?} for {remainder_address:?}", - catchall.amount(), - catchall.mana(), - catchall.native_token(), - ); - remainder_outputs.push(catchall); - - Ok(remainder_outputs - .into_iter() - .map(|o| RemainderData { - output: o, + let mut catchall = BasicOutputBuilder::new_with_amount(remaining_amount) + .with_mana(mana_diff) + .add_unlock_condition(AddressUnlockCondition::new(remainder_address.clone())); + if let Some(native_token) = catchall_native_token { + catchall = catchall.with_native_token(native_token); + } + let catchall = catchall.finish_output()?; + catchall.verify_storage_deposit(self.protocol_parameters.storage_score_parameters())?; + log::debug!( + "Created remainder output of amount {}, mana {} and native token {:?} for {remainder_address:?}", + catchall.amount(), + catchall.mana(), + catchall.native_token(), + ); + self.remainders.data.push(RemainderData { + output: catchall, chain: remainder_address_chain, address: remainder_address.clone(), + }); + + Ok(()) + } + + pub(crate) fn basic_remainder(&self) -> &'static Output { + static OUTPUT_LOCK: OnceLock = OnceLock::new(); + OUTPUT_LOCK.get_or_init(|| { + BasicOutputBuilder::new_with_minimum_amount(self.protocol_parameters.storage_score_parameters()) + .add_unlock_condition(AddressUnlockCondition::new(Ed25519Address::null())) + .finish_output() + .unwrap() }) - .collect()) + } + + pub(crate) fn native_token_remainder(&self) -> &'static Output { + static OUTPUT_LOCK: OnceLock = OnceLock::new(); + OUTPUT_LOCK.get_or_init(|| { + BasicOutputBuilder::new_with_minimum_amount(self.protocol_parameters.storage_score_parameters()) + .add_unlock_condition(AddressUnlockCondition::new(Ed25519Address::null())) + .with_native_token(NativeToken::new(TokenId::null(), 1).unwrap()) + .finish_output() + .unwrap() + }) + } } 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 9247178b0e..c11324eb81 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,16 +1,17 @@ // Copyright 2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::{collections::HashMap, sync::OnceLock}; +use std::collections::HashMap; use super::{Requirement, TransactionBuilder, TransactionBuilderError}; use crate::{ - client::{api::transaction_builder::requirement::PriorityMap, secret::types::InputSigningData}, + client::secret::types::InputSigningData, types::block::{ address::Address, + input::{UtxoInput, INPUT_COUNT_MAX}, output::{ - unlock_condition::StorageDepositReturnUnlockCondition, AccountOutput, AccountOutputBuilder, BasicOutput, - FoundryOutput, FoundryOutputBuilder, MinimumOutputAmount, NftOutput, NftOutputBuilder, Output, + unlock_condition::StorageDepositReturnUnlockCondition, AccountOutput, BasicOutput, ChainId, FoundryOutput, + NftOutput, Output, }, slot::{SlotCommitmentId, SlotIndex}, }, @@ -42,40 +43,27 @@ impl TransactionBuilder { 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, - }); + return Err(TransactionBuilderError::InsufficientAmount { + found: input_amount, + required: output_amount, + }); + } + + while let Some(input) = self.next_input_for_amount(output_amount - input_amount, self.latest_slot_commitment_id) + { + self.select_input(input)?; + (input_amount, output_amount) = self.amount_balance()?; + if input_amount >= output_amount { + break; } } + if output_amount > input_amount { + return Err(TransactionBuilderError::InsufficientAmount { + found: input_amount, + required: output_amount, + }); + } Ok(()) } @@ -117,7 +105,7 @@ impl TransactionBuilder { (inputs_sum, outputs_sum, inputs_sdr, outputs_sdr) } - pub(crate) fn amount_balance(&self) -> Result<(u64, u64), TransactionBuilderError> { + pub(crate) fn amount_balance(&mut 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 { @@ -132,124 +120,88 @@ impl TransactionBuilder { Ok((inputs_sum, outputs_sum)) } - fn reduce_funds_of_chains( - &mut self, - input_amount: u64, - output_amount: &mut u64, - ) -> Result { - 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!( - "Reducing amount of {} to {} to fulfill amount requirement", - output.chain_id().unwrap(), - new_amount - ); - - *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; - } + pub(crate) fn amount_chains(&self) -> Result, TransactionBuilderError> { + let mut res = self + .non_remainder_outputs() + .filter_map(|o| o.chain_id().map(|id| (id, (0, o.amount())))) + .collect::>(); + for input in self.selected_inputs.iter() { + if let Some(chain_id) = input + .output + .chain_id() + .map(|id| id.or_from_output_id(input.output_id())) + { + res.entry(chain_id).or_default().0 += input.output.amount(); } } - - Ok(input_amount >= *output_amount) - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -struct AmountPriority { - kind_priority: usize, - has_native_token: bool, -} - -impl PartialOrd for AmountPriority { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) + Ok(res) } -} -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)) - } -} -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 next_input_for_amount( + &mut self, + missing_amount: u64, + slot_commitment_id: SlotCommitmentId, + ) -> Option { + self.available_inputs + .iter() + .enumerate() + .filter_map(|(idx, input)| { + self.score_for_amount(input, missing_amount, slot_commitment_id.slot_index()) + .map(|score| (score, idx)) }) + .max_by_key(|(score, _)| *score) + .map(|(_, idx)| self.available_inputs.swap_remove(idx)) } -} -/// 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(|| { - [ + // Score an input based on how desirable it is. + fn score_for_amount(&self, input: &InputSigningData, missing_amount: u64, slot_index: SlotIndex) -> Option { + ([ BasicOutput::KIND, - AccountOutput::KIND, NftOutput::KIND, + AccountOutput::KIND, FoundryOutput::KIND, ] - .into_iter() - .zip(0_usize..) - .collect::>() - }) -} - -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(); + .contains(&input.output.kind())) + .then(|| { + let mut work_score = self + .protocol_parameters + .work_score(&UtxoInput::from(*input.output_id())); + let mut amount_gained = input.output.amount(); + let mut remainder_work_score = 0; + if let Some(sdruc) = sdruc_not_expired(&input.output, slot_index) { + amount_gained = amount_gained.saturating_sub(sdruc.amount()); + remainder_work_score = self.protocol_parameters.work_score(self.basic_remainder()) } - // 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 { - (true, u64::MAX - amount) + + if let Ok(Some(output)) = self.transition_input(input) { + amount_gained = amount_gained.saturating_sub(output.amount()); + work_score += self.protocol_parameters.work_score(&output); + } else if input.output.native_token().is_some() { + amount_gained = amount_gained.saturating_sub(self.native_token_remainder().amount()); + remainder_work_score += self.protocol_parameters.work_score(self.native_token_remainder()); + } else if amount_gained > missing_amount { + amount_gained = amount_gained.saturating_sub(self.basic_remainder().amount()); + remainder_work_score = self.protocol_parameters.work_score(self.basic_remainder()); } - }; - 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); + work_score += remainder_work_score; + + if amount_gained == 0 { + return None; } - return input; - } - None + + let amount_diff = amount_gained.abs_diff(missing_amount) as f64; + // Exp(-x) creates a curve which is 1 when x is 0, and approaches 0 as x increases + // If the amount is insufficient, the score will decrease the more inputs are selected + let amount_score = if amount_gained >= missing_amount { + (-amount_diff / u64::MAX as f64).exp() + } else { + (-amount_diff / missing_amount as f64).exp() + * ((INPUT_COUNT_MAX as f64 - self.selected_inputs.len() as f64) / INPUT_COUNT_MAX as f64) + }; + let work_score = (-(work_score as f64) / u32::MAX as f64).exp(); + // Normalize scores between 0..1 with 1 being desirable + Some((amount_score * work_score * usize::MAX as f64).round() as _) + }) + .flatten() } } 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 d98500d904..5a91f9cdf2 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,21 +1,22 @@ // Copyright 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::{collections::HashMap, sync::OnceLock}; +use std::collections::HashMap; use super::{TransactionBuilder, TransactionBuilderError}; use crate::{ client::{ - api::transaction_builder::{requirement::PriorityMap, MinManaAllotment, Requirement}, + api::transaction_builder::{MinManaAllotment, Requirement}, secret::types::InputSigningData, }, types::block::{ address::Address, - input::{Input, UtxoInput}, + input::{Input, UtxoInput, INPUT_COUNT_MAX}, mana::ManaAllotment, - output::{AccountOutput, AccountOutputBuilder, BasicOutput, FoundryOutput, NftOutput, Output}, + output::{AccountOutput, AccountOutputBuilder, BasicOutput, ChainId, FoundryOutput, NftOutput, Output}, payload::{signed_transaction::Transaction, SignedTransactionPayload}, signature::Ed25519Signature, + slot::{SlotCommitmentId, SlotIndex}, unlock::{AccountUnlock, NftUnlock, ReferenceUnlock, SignatureUnlock, Unlock, Unlocks}, BlockError, }, @@ -23,70 +24,27 @@ use crate::{ impl TransactionBuilder { pub(crate) fn fulfill_mana_requirement(&mut self) -> Result<(), TransactionBuilderError> { - let Some(MinManaAllotment { - issuer_id, - reference_mana_cost, - .. - }) = self.min_mana_allotment - else { + if self.min_mana_allotment.is_none() { // If there is no min allotment calculation needed, just check mana self.get_inputs_for_mana_balance()?; return Ok(()); - }; + } let mut should_recalculate = false; - if !self.selected_inputs.is_empty() && self.all_outputs().next().is_some() { - let inputs = self - .selected_inputs - .sorted_iter() - .map(|i| Input::Utxo(UtxoInput::from(*i.output_id()))); - - let outputs = self.all_outputs().cloned(); - - let mut builder = Transaction::builder(self.protocol_parameters.network_id()) - .with_inputs(inputs) - .with_outputs(outputs); - - if let Some(payload) = &self.payload { - builder = builder.with_payload(payload.clone()); - } - - // Add the empty allotment so the work score includes it - self.mana_allotments.entry(issuer_id).or_default(); - - let transaction = builder - .with_context_inputs(self.context_inputs()) - .with_mana_allotments( - self.mana_allotments - .iter() - .map(|(&account_id, &mana)| ManaAllotment { account_id, mana }), - ) - .finish_with_params(&self.protocol_parameters)?; - - let signed_transaction = SignedTransactionPayload::new(transaction, self.null_transaction_unlocks()?)?; - - let block_work_score = self.protocol_parameters.work_score(&signed_transaction) - + self.protocol_parameters.work_score_parameters().block(); - - let required_allotment_mana = block_work_score as u64 * reference_mana_cost; - + if let Some(required_allotment) = self.required_allotment()? { let MinManaAllotment { issuer_id, allotment_debt, .. - } = self - .min_mana_allotment - .as_mut() - .ok_or(TransactionBuilderError::UnfulfillableRequirement(Requirement::Mana))?; - + } = self.min_mana_allotment.as_mut().unwrap(); // Add the required allotment to the issuing allotment - if required_allotment_mana > self.mana_allotments[issuer_id] { - log::debug!("Allotting at least {required_allotment_mana} mana to account ID {issuer_id}"); - let additional_allotment = required_allotment_mana - self.mana_allotments[issuer_id]; + if required_allotment > self.mana_allotments[issuer_id] { + log::debug!("Allotting at least {required_allotment} mana to account ID {issuer_id}"); + let additional_allotment = required_allotment - self.mana_allotments[issuer_id]; log::debug!("{additional_allotment} additional mana required to meet minimum allotment"); // Unwrap: safe because we always add the record above - *self.mana_allotments.get_mut(issuer_id).unwrap() = required_allotment_mana; + *self.mana_allotments.get_mut(issuer_id).unwrap() = required_allotment; log::debug!("Adding {additional_allotment} to allotment debt {allotment_debt}"); *allotment_debt += additional_allotment; should_recalculate = true; @@ -122,6 +80,58 @@ impl TransactionBuilder { Ok(()) } + pub(crate) fn required_allotment(&mut self) -> Result, TransactionBuilderError> { + let Some(MinManaAllotment { + issuer_id, + reference_mana_cost, + required_allotment, + .. + }) = self.min_mana_allotment + else { + return Ok(None); + }; + + if required_allotment.is_none() && !self.selected_inputs.is_empty() && self.all_outputs().next().is_some() { + let inputs = self + .selected_inputs + .sorted_iter() + .map(|i| Input::Utxo(UtxoInput::from(*i.output_id()))); + + let mut builder = Transaction::builder(self.protocol_parameters.network_id()) + .with_inputs(inputs) + .with_outputs(self.all_outputs().cloned()); + + if let Some(payload) = &self.payload { + builder = builder.with_payload(payload.clone()); + } + + // Add the empty allotment so the work score includes it + self.mana_allotments.entry(issuer_id).or_default(); + + let transaction = builder + .with_context_inputs(self.context_inputs()) + .with_mana_allotments( + self.mana_allotments + .iter() + .map(|(&account_id, &mana)| ManaAllotment { account_id, mana }), + ) + .finish_with_params(&self.protocol_parameters)?; + + let signed_transaction = SignedTransactionPayload::new(transaction, self.null_transaction_unlocks()?)?; + + let block_work_score = self.protocol_parameters.work_score(&signed_transaction) + + self.protocol_parameters.work_score_parameters().block(); + + let MinManaAllotment { required_allotment, .. } = self.min_mana_allotment.as_mut().unwrap(); + required_allotment.replace(block_work_score as u64 * reference_mana_cost); + + return Ok(*required_allotment); + } + Ok(required_allotment) + } + + /// Reduce an account output by an allotment value, if one exists. + /// This will only affect automatically transitioned accounts. fn reduce_account_output(&mut self) -> Result { let MinManaAllotment { issuer_id, @@ -132,9 +142,8 @@ impl TransactionBuilder { .as_mut() .ok_or(TransactionBuilderError::UnfulfillableRequirement(Requirement::Mana))?; if let Some(output) = self - .provided_outputs + .added_outputs .iter_mut() - .chain(&mut self.added_outputs) .filter(|o| o.is_account() && o.mana() != 0) .find(|o| o.as_account().account_id() == issuer_id) { @@ -240,31 +249,39 @@ impl TransactionBuilder { let mut added_inputs = false; if selected_mana >= required_mana { log::debug!("Mana requirement already fulfilled"); - } else { - if !self.allow_additional_input_selection { - return Err(TransactionBuilderError::AdditionalInputsRequired(Requirement::Mana)); - } - let include_generated = self.burn.as_ref().map_or(true, |b| !b.generated_mana()); - 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(); - } - added_inputs = true; - - if selected_mana >= required_mana { - break; + return Ok(false); + } + if !self.allow_additional_input_selection { + return Err(TransactionBuilderError::AdditionalInputsRequired(Requirement::Mana)); + } + let include_generated = self.burn.as_ref().map_or(true, |b| !b.generated_mana()); + while let Some(input) = self.next_input_for_mana( + required_mana - selected_mana, + include_generated, + self.latest_slot_commitment_id, + ) { + selected_mana += self.total_mana(&input, include_generated)?; + if self.select_input(input)? { + let output = self.added_outputs.last().unwrap(); + // If we're allotting, it's possible the added output should be reduced, so just exit early and + // We will re-calculate the allotment. + if let Some(MinManaAllotment { issuer_id, .. }) = &self.min_mana_allotment { + if output + .as_account_opt() + .is_some_and(|account| account.account_id() == issuer_id) + { + return Ok(true); + } } + required_mana += output.mana(); } - // Return unselected inputs to the available list - for input in priority_map.into_inputs() { - self.available_inputs.push(input); + added_inputs = true; + + if selected_mana >= required_mana { + break; } } + Ok(added_inputs) } @@ -284,13 +301,28 @@ impl TransactionBuilder { Ok(input_mana.saturating_sub(output_mana)) } - pub(crate) fn mana_sums(&self, include_remainders: bool) -> Result<(u64, u64), TransactionBuilderError> { - let mut required_mana = - self.non_remainder_outputs().map(|o| o.mana()).sum::() + self.mana_allotments.values().sum::(); + pub(crate) fn mana_sums(&mut self, include_remainders: bool) -> Result<(u64, u64), TransactionBuilderError> { + let allotments_sum = if let Some(MinManaAllotment { issuer_id, .. }) = self.min_mana_allotment { + let required_allotment = self.required_allotment()?.unwrap_or_default(); + self.mana_allotments + .iter() + .filter_map(|(id, value)| (id != &issuer_id).then_some(value)) + .sum::() + + self + .mana_allotments + .get(&issuer_id) + .copied() + .unwrap_or_default() + .max(required_allotment) + } else { + self.mana_allotments.values().sum::() + }; + let mut required_mana = self.non_remainder_outputs().map(|o| o.mana()).sum::() + allotments_sum; if include_remainders { // Add the remainder outputs mana as well as the excess mana we've allocated to add to existing outputs // later. - required_mana += self.remainder_outputs().map(|o| o.mana()).sum::() + self.remainders.added_mana; + required_mana += self.remainder_outputs().map(|o| o.mana()).sum::() + + self.remainders.added_mana.values().sum::(); } Ok((self.total_selected_mana(None)?, required_mana)) @@ -312,7 +344,11 @@ impl TransactionBuilder { Ok(selected_mana) } - fn total_mana(&self, input: &InputSigningData, include_generated: bool) -> Result { + pub(crate) fn total_mana( + &self, + input: &InputSigningData, + include_generated: bool, + ) -> Result { Ok(self.mana_rewards.get(input.output_id()).copied().unwrap_or_default() + if include_generated { input.output.available_mana( @@ -324,72 +360,112 @@ impl TransactionBuilder { input.output.mana() }) } -} -#[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)) + pub(crate) fn mana_chains(&self) -> Result, TransactionBuilderError> { + let include_generated = self.burn.as_ref().map_or(true, |b| !b.generated_mana()); + let mut res = self + .non_remainder_outputs() + .filter_map(|o| o.chain_id().map(|id| (id, (0, o.mana())))) + .collect::>(); + for input in self.selected_inputs.iter() { + if let Some(chain_id) = input + .output + .chain_id() + .map(|id| id.or_from_output_id(input.output_id())) + { + res.entry(chain_id).or_default().0 += self.total_mana(input, include_generated)?; + } + } + Ok(res) } -} -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(), + fn next_input_for_mana( + &mut self, + missing_mana: u64, + include_generated: bool, + slot_commitment_id: SlotCommitmentId, + ) -> Option { + self.available_inputs + .iter() + .enumerate() + .filter_map(|(idx, input)| { + self.score_for_mana(input, missing_mana, include_generated, slot_commitment_id.slot_index()) + .map(|score| (score, idx)) }) + .max_by_key(|(score, _)| *score) + .map(|(_, idx)| self.available_inputs.swap_remove(idx)) } -} -/// 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(|| { - [ + // Score an input based on how desirable it is. + fn score_for_mana( + &self, + input: &InputSigningData, + missing_mana: u64, + include_generated: bool, + slot_index: SlotIndex, + ) -> Option { + ([ BasicOutput::KIND, NftOutput::KIND, AccountOutput::KIND, FoundryOutput::KIND, ] - .into_iter() - .zip(0_usize..) - .collect::>() - }) -} + .contains(&input.output.kind())) + .then(|| { + let mut work_score = self + .protocol_parameters + .work_score(&UtxoInput::from(*input.output_id())); + let mut mana_gained = self.total_mana(input, include_generated).unwrap_or_default(); + let mut remainder_work_score = 0; + if super::amount::sdruc_not_expired(&input.output, slot_index).is_some() { + remainder_work_score = self.protocol_parameters.work_score(self.basic_remainder()) + } -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 Ok(Some(output)) = self.transition_input(input) { + work_score += self.protocol_parameters.work_score(&output); + if let Some(allotment) = self.min_mana_allotment { + // If we're allotting to this output account, we will have more mana from the reduction. + if output + .as_account_opt() + .is_some_and(|account| account.account_id() == &allotment.issuer_id) + { + // We can regain as much as the full account mana value + // by reducing the mana on the account. + let new_required_allotment = allotment.required_allotment.unwrap_or_default() + + (work_score as u64 * allotment.reference_mana_cost); + mana_gained += new_required_allotment.min(output.mana()); + } + } + mana_gained = mana_gained.saturating_sub(output.mana()); + } else if input.output.native_token().is_some() { + remainder_work_score += self.protocol_parameters.work_score(self.native_token_remainder()) + } else if mana_gained > missing_mana { + remainder_work_score = self.protocol_parameters.work_score(self.basic_remainder()) } - }; - 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); + work_score += remainder_work_score; + + // The gained mana is reduced by the amount we'll need to allot later. + if let Some(allotment) = self.min_mana_allotment { + mana_gained = mana_gained.saturating_sub(work_score as u64 * allotment.reference_mana_cost); } - return input; - } - None + + if mana_gained == 0 { + return None; + } + + let mana_diff = mana_gained.abs_diff(missing_mana) as f64; + // Exp(-x) creates a curve which is 1 when x is 0, and approaches 0 as x increases + // If the mana is insufficient, the score will decrease the more inputs are selected + let mana_score = if mana_gained >= missing_mana { + (-mana_diff / u64::MAX as f64).exp() + } else { + (-mana_diff / missing_mana as f64).exp() + * ((INPUT_COUNT_MAX as f64 - self.selected_inputs.len() as f64) / INPUT_COUNT_MAX as f64) + }; + let work_score = (-(work_score as f64) / u32::MAX as f64).exp(); + // Normalize scores between 0..1 with 1 being desirable + Some((mana_score * work_score * usize::MAX as f64).round() as _) + }) + .flatten() } } 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 91dfdba87d..beea15f5a7 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 @@ -12,20 +12,15 @@ 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, }; use super::{TransactionBuilder, TransactionBuilderError}; -use crate::{ - client::secret::types::InputSigningData, - types::block::{ - address::Address, - output::{AccountId, ChainId, DelegationId, Features, FoundryId, NftId, Output}, - payload::signed_transaction::TransactionCapabilityFlag, - }, +use crate::types::block::{ + address::Address, + output::{AccountId, ChainId, DelegationId, Features, FoundryId, NftId, Output}, + payload::signed_transaction::TransactionCapabilityFlag, }; /// A requirement, imposed by outputs, that needs to be resolved by selected inputs. @@ -231,30 +226,3 @@ 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/transition.rs b/sdk/src/client/api/block_builder/transaction_builder/transition.rs index 1999cf5174..2901b03321 100644 --- a/sdk/src/client/api/block_builder/transaction_builder/transition.rs +++ b/sdk/src/client/api/block_builder/transaction_builder/transition.rs @@ -1,6 +1,10 @@ // Copyright 2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + use super::{ requirement::{account::is_account_with_id_non_null, foundry::is_foundry_with_id, nft::is_nft_with_id_non_null}, TransactionBuilder, TransactionBuilderError, @@ -8,18 +12,21 @@ use super::{ use crate::{ client::secret::types::InputSigningData, types::block::{ + address::Address, output::{ - AccountOutput, AccountOutputBuilder, FoundryOutput, FoundryOutputBuilder, NftOutput, NftOutputBuilder, - Output, OutputId, + feature::{BlockIssuerFeature, BlockIssuerKey, BlockIssuerKeys, StakingFeature}, + AccountId, AccountOutput, AccountOutputBuilder, AddressUnlockCondition, BasicOutput, FoundryOutput, + FoundryOutputBuilder, NftOutput, NftOutputBuilder, Output, OutputId, }, - payload::signed_transaction::TransactionCapabilityFlag, + slot::EpochIndex, }, + utils::serde::string, }; impl TransactionBuilder { /// Transitions an account input by creating a new account output if required. fn transition_account_input( - &mut self, + &self, input: &AccountOutput, output_id: &OutputId, ) -> Result, TransactionBuilderError> { @@ -55,25 +62,96 @@ impl TransactionBuilder { } // Remove potential sender feature because it will not be needed anymore as it only needs to be verified once. - let features = input.features().iter().filter(|feature| !feature.is_sender()).cloned(); + let mut features = input + .features() + .iter() + .filter(|feature| !feature.is_sender()) + .cloned() + .collect::>(); + + if let Some(change) = self.transitions.as_ref().and_then(|t| t.accounts.get(&account_id)) { + match change { + AccountChange::BeginStaking { + staked_amount, + fixed_cost, + staking_period, + } => { + if input.features().staking().is_some() { + return Err(TransactionBuilderError::AlreadyStaking(account_id)); + } + let start_epoch = self.protocol_parameters.epoch_index_of( + self.protocol_parameters + .past_bounded_slot(self.latest_slot_commitment_id), + ); + features.push( + StakingFeature::new( + *staked_amount, + *fixed_cost, + start_epoch, + staking_period + .map(|period| start_epoch + period) + .unwrap_or(EpochIndex(u32::MAX)), + ) + .into(), + ); + } + AccountChange::ExtendStaking { additional_epochs } => { + if let Some(feature) = features.iter_mut().find(|f| f.is_staking()) { + let future_bounded_epoch = self + .protocol_parameters + .future_bounded_epoch(self.latest_slot_commitment_id); + let staking_feature = feature.as_staking(); + // Just extend the end epoch if it's still possible + if future_bounded_epoch <= staking_feature.end_epoch() { + *feature = StakingFeature::new( + staking_feature.staked_amount(), + staking_feature.fixed_cost(), + staking_feature.start_epoch(), + staking_feature.end_epoch().saturating_add(*additional_epochs), + ) + .into(); + // Otherwise, we'll have to claim the rewards + } else { + if *additional_epochs < self.protocol_parameters.staking_unbonding_period() { + return Err(TransactionBuilderError::StakingPeriodLessThanMin { + additional_epochs: *additional_epochs, + min: self.protocol_parameters.staking_unbonding_period(), + }); + } + let past_bounded_epoch = self + .protocol_parameters + .past_bounded_epoch(self.latest_slot_commitment_id); + let end_epoch = past_bounded_epoch.saturating_add(*additional_epochs); + *feature = StakingFeature::new( + staking_feature.staked_amount(), + staking_feature.fixed_cost(), + past_bounded_epoch, + end_epoch, + ) + .into(); + } + } else { + return Err(TransactionBuilderError::NotStaking(account_id)); + } + } + AccountChange::EndStaking => { + if input.features().staking().is_none() { + return Err(TransactionBuilderError::NotStaking(account_id)); + } + features.retain(|f| !f.is_staking()); + } + } + } let mut builder = AccountOutputBuilder::from(input) - .with_amount_or_minimum(input.amount(), self.protocol_parameters.storage_score_parameters()) + .with_minimum_amount(self.protocol_parameters.storage_score_parameters()) + .with_mana(0) .with_account_id(account_id) .with_foundry_counter(u32::max(highest_foundry_serial_number, input.foundry_counter())) .with_features(features); if input.is_block_issuer() { - if !self.burn.as_ref().map_or(false, |b| b.generated_mana()) { - builder = builder.with_mana(input.available_mana( - &self.protocol_parameters, - output_id.transaction_id().slot_index(), - self.creation_slot, - )?) - } else { - self.transaction_capabilities - .add_capability(TransactionCapabilityFlag::BurnMana); - } + builder = builder.with_mana(input.mana()); } let output = builder.finish_output()?; @@ -83,9 +161,37 @@ impl TransactionBuilder { Ok(Some(output)) } + fn transition_implicit_account_input( + &self, + input: &BasicOutput, + output_id: &OutputId, + ) -> Result, TransactionBuilderError> { + if let Some(block_issuer_key) = self + .transitions + .as_ref() + .and_then(|t| t.implicit_accounts.get(output_id)) + { + if !input.is_implicit_account() { + return Err(TransactionBuilderError::TransitionNonImplicitAccount(*output_id)); + } + let ed25519_address = *input.address().as_implicit_account_creation().ed25519_address(); + let account_id = AccountId::from(output_id); + let account = AccountOutput::build_with_amount(input.amount(), account_id) + .with_unlock_conditions([AddressUnlockCondition::from(Address::from(ed25519_address))]) + .with_features([BlockIssuerFeature::new( + u32::MAX, + BlockIssuerKeys::from_vec(vec![block_issuer_key.clone()])?, + )?]) + .finish_output()?; + Ok(Some(account)) + } else { + Ok(None) + } + } + /// Transitions an nft input by creating a new nft output if required. fn transition_nft_input( - &mut self, + &self, input: &NftOutput, output_id: &OutputId, ) -> Result, TransactionBuilderError> { @@ -115,7 +221,8 @@ impl TransactionBuilder { let features = input.features().iter().filter(|feature| !feature.is_sender()).cloned(); let output = NftOutputBuilder::from(input) - .with_amount_or_minimum(input.amount(), self.protocol_parameters.storage_score_parameters()) + .with_minimum_amount(self.protocol_parameters.storage_score_parameters()) + .with_mana(0) .with_nft_id(nft_id) .with_features(features) .finish_output()?; @@ -127,7 +234,7 @@ impl TransactionBuilder { /// Transitions a foundry input by creating a new foundry output if required. fn transition_foundry_input( - &mut self, + &self, input: &FoundryOutput, output_id: &OutputId, ) -> Result, TransactionBuilderError> { @@ -154,7 +261,7 @@ impl TransactionBuilder { } let output = FoundryOutputBuilder::from(input) - .with_amount_or_minimum(input.amount(), self.protocol_parameters.storage_score_parameters()) + .with_minimum_amount(self.protocol_parameters.storage_score_parameters()) .finish_output()?; log::debug!("Automatic transition of {output_id:?}/{foundry_id:?}"); @@ -164,15 +271,85 @@ impl TransactionBuilder { /// Transitions an input by creating a new output if required. /// If no `account_transition` is provided, assumes a state transition. - pub(crate) fn transition_input( - &mut self, - input: &InputSigningData, - ) -> Result, TransactionBuilderError> { + pub(crate) fn transition_input(&self, input: &InputSigningData) -> Result, TransactionBuilderError> { match &input.output { Output::Account(account_input) => self.transition_account_input(account_input, input.output_id()), Output::Foundry(foundry_input) => self.transition_foundry_input(foundry_input, input.output_id()), Output::Nft(nft_input) => self.transition_nft_input(nft_input, input.output_id()), + Output::Basic(basic_output) => self.transition_implicit_account_input(basic_output, input.output_id()), _ => Ok(None), } } } + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AccountChange { + BeginStaking { + /// The amount of tokens to stake. + #[serde(with = "string")] + staked_amount: u64, + /// The fixed cost of the validator, which it receives as part of its Mana rewards. + #[serde(with = "string")] + fixed_cost: u64, + /// The staking period (in epochs). Will default to the staking unbonding period. + staking_period: Option, + }, + ExtendStaking { + additional_epochs: u32, + }, + EndStaking, +} + +/// A type to specify intended transitions. +#[derive(Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Transitions { + /// Implicit accounts to transition. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub(crate) implicit_accounts: HashMap, + /// Accounts to transition. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub(crate) accounts: HashMap, +} + +impl Transitions { + /// Creates a new set of transitions. + pub fn new() -> Self { + Self::default() + } + + /// Adds an implicit account to transition. + pub fn add_implicit_account(mut self, output_id: OutputId, block_issuer_key: BlockIssuerKey) -> Self { + self.implicit_accounts.insert(output_id, block_issuer_key); + self + } + + /// Sets the implicit accounts to transition. + pub fn set_implicit_accounts(mut self, implicit_accounts: HashMap) -> Self { + self.implicit_accounts = implicit_accounts; + self + } + + /// Returns the implicit accounts to transition. + pub fn implicit_accounts(&self) -> &HashMap { + &self.implicit_accounts + } + + /// Adds an account to transition. + pub fn add_account(mut self, account_id: AccountId, change: AccountChange) -> Self { + self.accounts.insert(account_id, change); + self + } + + /// Sets the accounts to transition. + pub fn set_accounts(mut self, accounts: HashMap) -> Self { + self.accounts = accounts; + self + } + + /// Returns the accounts to transition. + pub fn accounts(&self) -> &HashMap { + &self.accounts + } +} diff --git a/sdk/src/wallet/operations/transaction/account.rs b/sdk/src/wallet/operations/transaction/account.rs index a6dc6e9454..74e4063582 100644 --- a/sdk/src/wallet/operations/transaction/account.rs +++ b/sdk/src/wallet/operations/transaction/account.rs @@ -2,17 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 use crate::{ - client::{api::PreparedTransactionData, secret::SecretManage, ClientError}, - types::block::{ - address::Address, - output::{ - feature::{ - BlockIssuerFeature, BlockIssuerKey, BlockIssuerKeySource, BlockIssuerKeys, - Ed25519PublicKeyHashBlockIssuerKey, - }, - unlock_condition::AddressUnlockCondition, - AccountId, AccountOutput, OutputId, - }, + client::{ + api::{transaction_builder::transition::Transitions, PreparedTransactionData}, + secret::SecretManage, + ClientError, + }, + types::block::output::{ + feature::{BlockIssuerKey, BlockIssuerKeySource, Ed25519PublicKeyHashBlockIssuerKey}, + AccountId, OutputId, }, wallet::{ operations::transaction::{TransactionOptions, TransactionWithMetadata}, @@ -52,20 +49,22 @@ where where WalletError: From, { - let wallet_ledger = self.ledger().await; - let implicit_account_data = wallet_ledger - .unspent_outputs - .get(output_id) - .ok_or(WalletError::ImplicitAccountNotFound)?; - let implicit_account = if implicit_account_data.output.is_implicit_account() { - implicit_account_data.output.as_basic() - } else { - return Err(WalletError::ImplicitAccountNotFound); + let ed25519_address = { + let wallet_ledger = self.ledger().await; + let implicit_account_data = wallet_ledger + .unspent_outputs + .get(output_id) + .ok_or(WalletError::ImplicitAccountNotFound)?; + let implicit_account = if implicit_account_data.output.is_implicit_account() { + implicit_account_data.output.as_basic() + } else { + return Err(WalletError::ImplicitAccountNotFound); + }; + *implicit_account + .address() + .as_implicit_account_creation() + .ed25519_address() }; - let ed25519_address = *implicit_account - .address() - .as_implicit_account_creation() - .ed25519_address(); let block_issuer_key = BlockIssuerKey::from(match key_source.into() { BlockIssuerKeySource::ImplicitAccountAddress => Ed25519PublicKeyHashBlockIssuerKey::new(*ed25519_address), @@ -86,24 +85,13 @@ where ), }); - let account_id = AccountId::from(output_id); - let account = AccountOutput::build_with_amount(implicit_account.amount(), account_id) - .with_unlock_conditions([AddressUnlockCondition::from(Address::from(ed25519_address))]) - .with_features([BlockIssuerFeature::new( - u32::MAX, - BlockIssuerKeys::from_vec(vec![block_issuer_key])?, - )?]) - .finish_output()?; - - drop(wallet_ledger); - let transaction_options = TransactionOptions { required_inputs: [*output_id].into(), - issuer_id: Some(account_id), + issuer_id: Some(AccountId::from(output_id)), + transitions: Some(Transitions::new().add_implicit_account(*output_id, block_issuer_key)), ..Default::default() }; - self.prepare_send_outputs(vec![account], transaction_options.clone()) - .await + self.prepare_send_outputs(None, transaction_options).await } } diff --git a/sdk/src/wallet/operations/transaction/high_level/staking/begin.rs b/sdk/src/wallet/operations/transaction/high_level/staking/begin.rs index 387cf32cae..e3d04154e0 100644 --- a/sdk/src/wallet/operations/transaction/high_level/staking/begin.rs +++ b/sdk/src/wallet/operations/transaction/high_level/staking/begin.rs @@ -5,14 +5,15 @@ use serde::{Deserialize, Serialize}; use crate::{ client::{ - api::{options::TransactionOptions, PreparedTransactionData}, + api::{ + options::TransactionOptions, + transaction_builder::transition::{AccountChange, Transitions}, + PreparedTransactionData, + }, secret::SecretManage, ClientError, }, - types::block::{ - output::{feature::StakingFeature, AccountId, AccountOutputBuilder}, - slot::EpochIndex, - }, + types::block::output::AccountId, utils::serde::string, wallet::{types::TransactionWithMetadata, Wallet, WalletError}, }; @@ -57,52 +58,25 @@ where ) -> Result { log::debug!("[TRANSACTION] prepare_begin_staking"); - let account_id = params.account_id; - let account_output_data = self - .ledger() - .await - .unspent_account_output(&account_id) - .cloned() - .ok_or_else(|| WalletError::AccountNotFound)?; - - if account_output_data - .output - .features() - .map_or(false, |f| f.staking().is_some()) - { - return Err(WalletError::StakingFailed(format!( - "account id {account_id} already has a staking feature" - ))); - } - - let protocol_parameters = self.client().get_protocol_parameters().await?; + let change = AccountChange::BeginStaking { + staked_amount: params.staked_amount, + fixed_cost: params.fixed_cost, + staking_period: params.staking_period, + }; - if let Some(staking_period) = params.staking_period { - if staking_period < protocol_parameters.staking_unbonding_period() { - return Err(WalletError::StakingFailed(format!( - "staking period {staking_period} is less than the minimum {}", - protocol_parameters.staking_unbonding_period() - ))); + let mut options = options.into(); + if let Some(options) = options.as_mut() { + if let Some(transitions) = options.transitions.take() { + options.transitions = Some(transitions.add_account(params.account_id, change)); } + } else { + options.replace(TransactionOptions { + transitions: Some(Transitions::new().add_account(params.account_id, change)), + ..Default::default() + }); } - let slot_commitment_id = self.client().get_issuance().await?.latest_commitment.id(); - let start_epoch = protocol_parameters.epoch_index_of(protocol_parameters.past_bounded_slot(slot_commitment_id)); - - let output = AccountOutputBuilder::from(account_output_data.output.as_account()) - .with_account_id(account_id) - .add_feature(StakingFeature::new( - params.staked_amount, - params.fixed_cost, - start_epoch, - params - .staking_period - .map(|period| start_epoch + period) - .unwrap_or(EpochIndex(u32::MAX)), - )) - .finish_output()?; - - let transaction = self.prepare_send_outputs([output], options).await?; + let transaction = self.prepare_send_outputs(None, options).await?; Ok(transaction) } diff --git a/sdk/src/wallet/operations/transaction/high_level/staking/end.rs b/sdk/src/wallet/operations/transaction/high_level/staking/end.rs index 4dd5ca0abf..600fd98da0 100644 --- a/sdk/src/wallet/operations/transaction/high_level/staking/end.rs +++ b/sdk/src/wallet/operations/transaction/high_level/staking/end.rs @@ -3,11 +3,15 @@ use crate::{ client::{ - api::{options::TransactionOptions, PreparedTransactionData}, + api::{ + options::TransactionOptions, + transaction_builder::transition::{AccountChange, Transitions}, + PreparedTransactionData, + }, secret::SecretManage, ClientError, }, - types::block::output::{AccountId, AccountOutputBuilder}, + types::block::output::AccountId, wallet::{types::TransactionWithMetadata, Wallet, WalletError}, }; @@ -35,46 +39,19 @@ where ) -> Result { log::debug!("[TRANSACTION] prepare_end_staking"); - let account_output_data = self - .ledger() - .await - .unspent_account_output(&account_id) - .cloned() - .ok_or_else(|| WalletError::AccountNotFound)?; - - let staking_feature = account_output_data - .output - .features() - .and_then(|f| f.staking()) - .ok_or_else(|| WalletError::StakingFailed(format!("account id {account_id} is not staking")))?; - - let protocol_parameters = self.client().get_protocol_parameters().await?; - - let slot_commitment_id = self.client().get_issuance().await?.latest_commitment.id(); - let future_bounded_epoch = protocol_parameters.future_bounded_epoch(slot_commitment_id); - - if future_bounded_epoch <= staking_feature.end_epoch() { - let end_epoch = protocol_parameters.epoch_index_of(slot_commitment_id.slot_index()) - + (staking_feature.end_epoch() - future_bounded_epoch); - return Err(WalletError::StakingFailed(format!( - "account id {account_id} cannot end staking until {end_epoch}" - ))); + let mut options = options.into(); + if let Some(options) = options.as_mut() { + if let Some(transitions) = options.transitions.take() { + options.transitions = Some(transitions.add_account(account_id, AccountChange::EndStaking)); + } + } else { + options.replace(TransactionOptions { + transitions: Some(Transitions::new().add_account(account_id, AccountChange::EndStaking)), + ..Default::default() + }); } - let features = account_output_data - .output - .features() - .map(|f| f.iter().filter(|f| !f.is_staking())) - .into_iter() - .flatten() - .cloned(); - - let output = AccountOutputBuilder::from(account_output_data.output.as_account()) - .with_account_id(account_id) - .with_features(features) - .finish_output()?; - - let transaction = self.prepare_send_outputs([output], options).await?; + let transaction = self.prepare_send_outputs(None, options).await?; Ok(transaction) } diff --git a/sdk/src/wallet/operations/transaction/high_level/staking/extend.rs b/sdk/src/wallet/operations/transaction/high_level/staking/extend.rs index a4c8b9414f..0b1a75aff1 100644 --- a/sdk/src/wallet/operations/transaction/high_level/staking/extend.rs +++ b/sdk/src/wallet/operations/transaction/high_level/staking/extend.rs @@ -3,11 +3,15 @@ use crate::{ client::{ - api::{options::TransactionOptions, PreparedTransactionData}, + api::{ + options::TransactionOptions, + transaction_builder::transition::{AccountChange, Transitions}, + PreparedTransactionData, + }, secret::SecretManage, ClientError, }, - types::block::output::{feature::StakingFeature, AccountId, AccountOutputBuilder}, + types::block::output::AccountId, wallet::{types::TransactionWithMetadata, Wallet, WalletError}, }; @@ -39,57 +43,21 @@ where ) -> Result { log::debug!("[TRANSACTION] prepare_extend_staking"); - let account_output_data = self - .ledger() - .await - .unspent_account_output(&account_id) - .cloned() - .ok_or_else(|| WalletError::AccountNotFound)?; + let change = AccountChange::ExtendStaking { additional_epochs }; - let protocol_parameters = self.client().get_protocol_parameters().await?; - - let slot_commitment_id = self.client().get_issuance().await?.latest_commitment.id(); - - let future_bounded_epoch = protocol_parameters.future_bounded_epoch(slot_commitment_id); - - let staking_feature = account_output_data - .output - .features() - .and_then(|f| f.staking()) - .ok_or_else(|| WalletError::StakingFailed(format!("account id {account_id} is not staking")))?; - - let mut output_builder = - AccountOutputBuilder::from(account_output_data.output.as_account()).with_account_id(account_id); - - // Just extend the end epoch if it's still possible - if future_bounded_epoch <= staking_feature.end_epoch() { - output_builder = output_builder.replace_feature(StakingFeature::new( - staking_feature.staked_amount(), - staking_feature.fixed_cost(), - staking_feature.start_epoch(), - staking_feature.end_epoch().saturating_add(additional_epochs), - )); - // Otherwise, we'll have to claim the rewards - } else { - if additional_epochs < protocol_parameters.staking_unbonding_period() { - return Err(WalletError::StakingFailed(format!( - "new staking period {additional_epochs} is less than the minimum {}", - protocol_parameters.staking_unbonding_period() - ))); + let mut options = options.into(); + if let Some(options) = options.as_mut() { + if let Some(transitions) = options.transitions.take() { + options.transitions = Some(transitions.add_account(account_id, change)); } - let past_bounded_epoch = protocol_parameters.past_bounded_epoch(slot_commitment_id); - let end_epoch = past_bounded_epoch.saturating_add(additional_epochs); - output_builder = output_builder.replace_feature(StakingFeature::new( - staking_feature.staked_amount(), - staking_feature.fixed_cost(), - past_bounded_epoch, - end_epoch, - )); + } else { + options.replace(TransactionOptions { + transitions: Some(Transitions::new().add_account(account_id, change)), + ..Default::default() + }); } - let output = output_builder.finish_output()?; - - let transaction = self.prepare_send_outputs([output], options).await?; + let transaction = self.prepare_send_outputs(None, options).await?; Ok(transaction) } diff --git a/sdk/src/wallet/operations/transaction/send_outputs.rs b/sdk/src/wallet/operations/transaction/send_outputs.rs index 1cfb01f5ac..c678a8dbcf 100644 --- a/sdk/src/wallet/operations/transaction/send_outputs.rs +++ b/sdk/src/wallet/operations/transaction/send_outputs.rs @@ -66,12 +66,12 @@ where /// Get inputs and build the transaction pub async fn prepare_send_outputs( &self, - outputs: impl Into> + Send, + outputs: impl IntoIterator + Send, options: impl Into> + Send, ) -> Result { log::debug!("[TRANSACTION] prepare_send_outputs"); let options = options.into().unwrap_or_default(); - let outputs = outputs.into(); + let outputs = outputs.into_iter().collect::>(); let prepare_send_outputs_start_time = Instant::now(); let storage_score_params = self.client().get_storage_score_parameters().await?; diff --git a/sdk/tests/client/signing/mod.rs b/sdk/tests/client/signing/mod.rs index d9a0fcfc2e..8122e5e0d5 100644 --- a/sdk/tests/client/signing/mod.rs +++ b/sdk/tests/client/signing/mod.rs @@ -427,10 +427,10 @@ async fn all_combined() -> Result<(), Box> { .await?; 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_signature()).count(), 2); + assert_eq!(unlocks.iter().filter(|u| u.is_reference()).count(), 3); assert_eq!(unlocks.iter().filter(|u| u.is_account()).count(), 3); - assert_eq!(unlocks.iter().filter(|u| u.is_nft()).count(), 3); + assert_eq!(unlocks.iter().filter(|u| u.is_nft()).count(), 5); SignedTransactionPayload::new(prepared_transaction_data.transaction.clone(), unlocks)?; diff --git a/sdk/tests/client/transaction_builder/account_outputs.rs b/sdk/tests/client/transaction_builder/account_outputs.rs index 4783903cb9..38611fac17 100644 --- a/sdk/tests/client/transaction_builder/account_outputs.rs +++ b/sdk/tests/client/transaction_builder/account_outputs.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use iota_sdk::{ client::{ - api::transaction_builder::{Burn, Requirement, TransactionBuilder, TransactionBuilderError}, + api::transaction_builder::{Burn, Requirement, TransactionBuilder, TransactionBuilderError, Transitions}, secret::types::InputSigningData, }, types::block::{ @@ -1204,7 +1204,7 @@ fn take_amount_from_account_to_fund_basic() { [ ( Account { - amount: 2_000_000, + amount: 1_000_000, mana: 0, account_id: account_id_1, address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), @@ -1257,7 +1257,7 @@ fn take_amount_from_account_to_fund_basic() { selected.transaction.outputs().iter().for_each(|output| { if !outputs.contains(output) { assert!(output.is_account()); - assert_eq!(output.amount(), 1_800_000); + assert_eq!(output.amount(), 800_000); assert_eq!(*output.as_account().account_id(), account_id_1); assert_eq!(output.as_account().unlock_conditions().len(), 1); assert_eq!(output.as_account().features().len(), 0); @@ -1834,9 +1834,9 @@ fn min_allot_account_mana_additional() { let provided_allotment = 1000; let required_allotment = 7900; // The account does not have enough to cover the requirement - let account_mana = required_allotment - 100; + let account_mana = required_allotment - 500; // But there is additional available mana elsewhere - let additional_available_mana = 111; + let additional_available_mana = 511; let inputs = [ AccountOutputBuilder::new_with_amount(2_000_000, account_id_1) @@ -2008,19 +2008,14 @@ fn min_allot_account_mana_requirement_twice() { .unwrap(); assert!(unsorted_eq(&selected.inputs_data, &inputs)); - assert_eq!(selected.transaction.outputs().len(), 2); - let account_output = selected - .transaction - .outputs() - .iter() - .filter_map(Output::as_account_opt) - .find(|o| o.account_id() == &account_id_1) - .unwrap(); + assert_eq!(selected.transaction.outputs().len(), 1); + let account_output = selected.transaction.outputs()[0].as_account(); assert_eq!(selected.transaction.allotments().len(), 1); assert_eq!( selected.transaction.allotments()[0], ManaAllotment::new(account_id_1, required_allotment).unwrap() ); + assert_eq!(account_output.account_id(), &account_id_1); assert_eq!(account_output.mana(), 100); } @@ -2207,27 +2202,11 @@ fn implicit_account_transition() { Some(SLOT_INDEX), ); let input_output_id = *inputs[0].output_id(); - let account_id = AccountId::from(&input_output_id); - let outputs = vec![ - AccountOutputBuilder::new_with_amount(1_000_000, account_id) - .add_unlock_condition(AddressUnlockCondition::new( - Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), - )) - .with_features([BlockIssuerFeature::new( - u32::MAX, - BlockIssuerKeys::from_vec(vec![ - Ed25519PublicKeyHashBlockIssuerKey::new(**ed25519_address.as_ed25519()).into(), - ]) - .unwrap(), - ) - .unwrap()]) - .finish_output() - .unwrap(), - ]; + let block_issuer_key = Ed25519PublicKeyHashBlockIssuerKey::new(**ed25519_address.as_ed25519()); let selected = TransactionBuilder::new( inputs.clone(), - outputs.clone(), + None, [Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap()], SLOT_INDEX, SLOT_COMMITMENT_ID, @@ -2235,6 +2214,7 @@ fn implicit_account_transition() { ) .with_required_inputs(vec![input_output_id]) .with_min_mana_allotment(account_id_1, 2) + .with_transitions(Transitions::new().add_implicit_account(input_output_id, block_issuer_key.into())) .finish() .unwrap(); @@ -2349,20 +2329,13 @@ fn auto_transition_account_less_than_min_additional() { .unwrap(); assert!(unsorted_eq(&selected.inputs_data, &inputs)); - assert_eq!(selected.transaction.outputs().len(), 2); - let min_amount = AccountOutputBuilder::from(inputs[0].output.as_account()) - .with_minimum_amount(protocol_parameters.storage_score_parameters()) - .finish_output() - .unwrap() - .amount(); - let account_output = selected - .transaction - .outputs() - .iter() - .filter_map(Output::as_account_opt) - .find(|o| o.account_id() == &account_id_1) - .unwrap(); - assert_eq!(account_output.amount(), min_amount); + assert_eq!(selected.transaction.outputs().len(), 1); + let account_output = selected.transaction.outputs()[0].as_account(); + assert_eq!(account_output.account_id(), &account_id_1); + assert_eq!( + account_output.amount(), + inputs.iter().map(|i| i.output.amount()).sum::() + ); } #[test] @@ -2373,6 +2346,7 @@ fn account_transition_with_required_context_inputs() { let inputs = [ BasicOutputBuilder::new_with_amount(1_000_000) + .with_mana(11000) .add_unlock_condition(AddressUnlockCondition::new(ed25519_address.clone())) .finish_output() .unwrap(), diff --git a/sdk/tests/client/transaction_builder/burn.rs b/sdk/tests/client/transaction_builder/burn.rs index 7a183f3a64..56d6c47542 100644 --- a/sdk/tests/client/transaction_builder/burn.rs +++ b/sdk/tests/client/transaction_builder/burn.rs @@ -922,7 +922,7 @@ fn burn_foundry_present() { assert_eq!(selected.inputs_data.len(), 2); assert!(selected.inputs_data.contains(&inputs[0])); assert!(selected.inputs_data.contains(&inputs[1])); - assert_eq!(selected.transaction.outputs().len(), 3); + assert_eq!(selected.transaction.outputs().len(), 2); assert!(selected.transaction.outputs().contains(&outputs[0])); selected.transaction.outputs().iter().for_each(|output| { if !outputs.contains(output) { @@ -934,7 +934,7 @@ fn burn_foundry_present() { None, ); } else if output.is_account() { - assert_eq!(output.amount(), 1_000_000); + assert_eq!(output.amount(), 1_500_000); assert_eq!(*output.as_account().account_id(), account_id_1); assert_eq!(output.as_account().unlock_conditions().len(), 1); assert_eq!(output.as_account().features().len(), 0); @@ -1415,7 +1415,7 @@ fn burn_mana_need_additional() { let protocol_parameters = iota_mainnet_protocol_parameters().clone(); let inputs = [ - BasicOutputBuilder::new_with_amount(100_000) + BasicOutputBuilder::new_with_amount(114_100) .with_mana(1000) .add_unlock_condition(AddressUnlockCondition::new( Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), @@ -1465,8 +1465,11 @@ fn burn_mana_need_additional() { &TransactionCapabilities::from([TransactionCapabilityFlag::BurnMana]) ); assert!(unsorted_eq(&selected.inputs_data, &inputs)); - assert_eq!(selected.transaction.outputs().len(), 1); - assert_eq!(selected.transaction.outputs()[0].mana(), 700); + assert_eq!(selected.transaction.outputs().len(), 2); + assert_eq!( + selected.transaction.outputs().iter().map(|o| o.mana()).sum::(), + 700 + ); } #[test] @@ -1686,7 +1689,7 @@ fn burn_generated_mana_account() { &TransactionCapabilities::from([TransactionCapabilityFlag::BurnMana]) ); assert!(unsorted_eq(&selected.inputs_data, &inputs)); - assert_eq!(selected.transaction.outputs().len(), 2); + assert_eq!(selected.transaction.outputs().len(), 1); assert_eq!( selected.transaction.outputs().iter().map(|o| o.mana()).sum::(), 1200 diff --git a/sdk/tests/client/transaction_builder/foundry_outputs.rs b/sdk/tests/client/transaction_builder/foundry_outputs.rs index a4a2dc32c8..aa404b7034 100644 --- a/sdk/tests/client/transaction_builder/foundry_outputs.rs +++ b/sdk/tests/client/transaction_builder/foundry_outputs.rs @@ -285,7 +285,7 @@ fn melt_native_tokens() { ], Some(SLOT_INDEX), ); - let account_output = AccountOutputBuilder::new_with_amount(1_000_000, account_id_1) + let account_output = AccountOutputBuilder::new_with_amount(10_000, account_id_1) .add_unlock_condition(AddressUnlockCondition::new( Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), )) @@ -690,12 +690,12 @@ fn simple_foundry_transition_basic_not_needed_with_remainder() { assert_eq!(selected.inputs_data.len(), 2); assert!(selected.inputs_data.contains(&inputs[1])); assert!(selected.inputs_data.contains(&inputs[2])); - assert_eq!(selected.transaction.outputs().len(), 3); + assert_eq!(selected.transaction.outputs().len(), 2); assert!(selected.transaction.outputs().contains(&outputs[0])); selected.transaction.outputs().iter().for_each(|output| { if !outputs.contains(output) { if output.is_account() { - assert_eq!(output.amount(), 2_000_000); + assert_eq!(output.amount(), 3_000_000); assert_eq!(*output.as_account().account_id(), account_id_1); assert_eq!(output.as_account().unlock_conditions().len(), 1); assert_eq!(output.as_account().features().len(), 0); @@ -1220,7 +1220,7 @@ fn melt_and_burn_native_tokens() { ], Some(SLOT_INDEX), ); - let account_output = AccountOutputBuilder::new_with_amount(1_000_000, account_id) + let account_output = AccountOutputBuilder::new_with_amount(10_000, account_id) .add_unlock_condition(AddressUnlockCondition::new( Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), )) @@ -1374,7 +1374,7 @@ fn auto_transition_foundry_less_than_min_additional() { ], Some(SLOT_INDEX), ); - let account_output = AccountOutputBuilder::new_with_amount(1_000_000, account_id) + let account_output = AccountOutputBuilder::new_with_amount(10_000, account_id) .add_unlock_condition(AddressUnlockCondition::new( Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), )) @@ -1400,7 +1400,7 @@ fn auto_transition_foundry_less_than_min_additional() { .unwrap(); assert!(unsorted_eq(&selected.inputs_data, &inputs)); - assert_eq!(selected.transaction.outputs().len(), 3); + assert_eq!(selected.transaction.outputs().len(), 2); let min_amount_foundry = FoundryOutputBuilder::from(inputs[0].output.as_foundry()) .with_minimum_amount(protocol_parameters.storage_score_parameters()) .finish_output() @@ -1421,5 +1421,5 @@ fn auto_transition_foundry_less_than_min_additional() { .find(|o| o.account_id() == &account_id) .unwrap(); assert_eq!(foundry_output.amount(), min_amount_foundry); - assert_eq!(account_output.amount(), 1_000_000); + assert_eq!(account_output.amount(), 1_010_000 - min_amount_foundry + small_amount); } diff --git a/sdk/tests/client/transaction_builder/nft_outputs.rs b/sdk/tests/client/transaction_builder/nft_outputs.rs index 1f243a8b3a..fe2130d7b8 100644 --- a/sdk/tests/client/transaction_builder/nft_outputs.rs +++ b/sdk/tests/client/transaction_builder/nft_outputs.rs @@ -1168,7 +1168,7 @@ fn take_amount_from_nft_to_fund_basic() { [ ( Nft { - amount: 2_000_000, + amount: 1_000_000, mana: 0, nft_id: nft_id_1, address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), @@ -1223,7 +1223,7 @@ fn take_amount_from_nft_to_fund_basic() { selected.transaction.outputs().iter().for_each(|output| { if !outputs.contains(output) { assert!(output.is_nft()); - assert_eq!(output.amount(), 1_800_000); + assert_eq!(output.amount(), 800_000); assert_eq!(output.as_nft().unlock_conditions().len(), 1); assert_eq!(output.as_nft().features().len(), 0); assert_eq!( @@ -1598,12 +1598,7 @@ fn auto_transition_nft_less_than_min_additional() { .unwrap(); assert!(unsorted_eq(&selected.inputs_data, &inputs)); - assert_eq!(selected.transaction.outputs().len(), 2); - let min_amount = NftOutputBuilder::from(inputs[0].output.as_nft()) - .with_minimum_amount(protocol_parameters.storage_score_parameters()) - .finish_output() - .unwrap() - .amount(); + assert_eq!(selected.transaction.outputs().len(), 1); let nft_output = selected .transaction .outputs() @@ -1611,5 +1606,5 @@ fn auto_transition_nft_less_than_min_additional() { .filter_map(Output::as_nft_opt) .find(|o| o.nft_id() == &nft_id_1) .unwrap(); - assert_eq!(nft_output.amount(), min_amount); + assert_eq!(nft_output.amount(), 1_000_000 + small_amount); } diff --git a/sdk/tests/client/transaction_builder/outputs.rs b/sdk/tests/client/transaction_builder/outputs.rs index 2306523045..64a9b7f433 100644 --- a/sdk/tests/client/transaction_builder/outputs.rs +++ b/sdk/tests/client/transaction_builder/outputs.rs @@ -10,7 +10,11 @@ use iota_sdk::{ }, types::block::{ address::Address, - output::{unlock_condition::AddressUnlockCondition, AccountId, BasicOutputBuilder, NftId}, + output::{ + feature::{BlockIssuerFeature, BlockIssuerKeys, Ed25519PublicKeyHashBlockIssuerKey}, + unlock_condition::AddressUnlockCondition, + AccountId, AccountOutputBuilder, 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}, @@ -600,7 +604,7 @@ fn insufficient_mana() { [ ( Basic { - amount: 1_000_000, + amount: 1_014_100, mana: 0, address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), native_token: None, @@ -685,3 +689,89 @@ fn insufficient_mana() { .finish() .unwrap(); } + +#[test] +fn do_not_select_too_many_inputs_for_mana_requirement() { + let protocol_parameters = iota_mainnet_protocol_parameters().clone(); + let account_id_1 = AccountId::from_str(ACCOUNT_ID_1).unwrap(); + let nft_id_1 = NftId::from_str(NFT_ID_1).unwrap(); + let ed25519_address = Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(); + + let mut inputs = build_inputs( + vec![ + ( + Basic { + amount: 1_000_000, + mana: 1, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + native_token: None, + sender: None, + sdruc: None, + timelock: None, + expiration: None, + }, + None, + ); + 130 + ], + Some(SLOT_INDEX), + ); + inputs.extend(build_inputs( + [( + Nft { + amount: 100_000, + mana: 130, + 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), + )); + inputs.push(InputSigningData { + output: AccountOutputBuilder::new_with_amount(1_000_000, account_id_1) + .with_mana(11_000) + .add_unlock_condition(AddressUnlockCondition::new( + Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + )) + .with_features([BlockIssuerFeature::new( + u32::MAX, + BlockIssuerKeys::from_vec(vec![ + Ed25519PublicKeyHashBlockIssuerKey::new(**ed25519_address.as_ed25519()).into(), + ]) + .unwrap(), + ) + .unwrap()]) + .finish_output() + .unwrap(), + output_metadata: rand_output_metadata_with_id(rand_output_id_with_slot_index(SLOT_INDEX)), + chain: None, + }); + + let outputs = build_outputs([Basic { + amount: 1_000_000, + mana: 130, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_1).unwrap(), + native_token: None, + sender: None, + sdruc: None, + timelock: None, + expiration: None, + }]); + + TransactionBuilder::new( + inputs.clone(), + outputs.clone(), + [Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap()], + SLOT_INDEX, + SLOT_COMMITMENT_ID, + protocol_parameters, + ) + .with_min_mana_allotment(account_id_1, 2) + .finish() + .unwrap(); +}