From 0eb252e819124f6b89f113ef24573addd4cb8bfc Mon Sep 17 00:00:00 2001 From: DaughterOfMars Date: Mon, 26 Feb 2024 04:10:35 -0500 Subject: [PATCH 1/3] Increase auto transitioned outputs amount to the minimum if it is lower (#2060) * Increase auto transitioned outputs amount to the minimum if it is lower * rename tests * better tests * Update sdk/src/types/block/output/basic.rs * Update sdk/src/types/block/output/foundry.rs * nits * Add a TODO --------- Co-authored-by: Thibault Martinez --- .../input_selection/transition.rs | 6 +- sdk/src/types/block/output/account.rs | 13 ++ sdk/src/types/block/output/anchor.rs | 13 ++ sdk/src/types/block/output/basic.rs | 14 ++ sdk/src/types/block/output/delegation.rs | 61 ++++--- sdk/src/types/block/output/foundry.rs | 22 +++ sdk/src/types/block/output/mod.rs | 1 + sdk/src/types/block/output/nft.rs | 14 ++ .../client/input_selection/account_outputs.rs | 112 +++++++++++++ .../client/input_selection/foundry_outputs.rs | 158 +++++++++++++++++- .../client/input_selection/nft_outputs.rs | 116 +++++++++++++ 11 files changed, 506 insertions(+), 24 deletions(-) diff --git a/sdk/src/client/api/block_builder/input_selection/transition.rs b/sdk/src/client/api/block_builder/input_selection/transition.rs index 58beed6813..bbfa9f644f 100644 --- a/sdk/src/client/api/block_builder/input_selection/transition.rs +++ b/sdk/src/client/api/block_builder/input_selection/transition.rs @@ -55,6 +55,7 @@ impl InputSelection { let features = input.features().iter().filter(|feature| !feature.is_sender()).cloned(); let mut builder = AccountOutputBuilder::from(input) + .with_amount_or_minimum(input.amount(), self.protocol_parameters.storage_score_parameters()) .with_account_id(account_id) .with_foundry_counter(u32::max(highest_foundry_serial_number, input.foundry_counter())) .with_features(features); @@ -102,6 +103,7 @@ impl InputSelection { 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_nft_id(nft_id) .with_features(features) .finish_output()?; @@ -139,7 +141,9 @@ impl InputSelection { return Ok(None); } - let output = FoundryOutputBuilder::from(input).finish_output()?; + let output = FoundryOutputBuilder::from(input) + .with_amount_or_minimum(input.amount(), self.protocol_parameters.storage_score_parameters()) + .finish_output()?; log::debug!("Automatic transition of {output_id:?}/{foundry_id:?}"); diff --git a/sdk/src/types/block/output/account.rs b/sdk/src/types/block/output/account.rs index 40f174656d..9f5559ceb3 100644 --- a/sdk/src/types/block/output/account.rs +++ b/sdk/src/types/block/output/account.rs @@ -73,6 +73,11 @@ impl AccountOutputBuilder { Self::new(OutputBuilderAmount::Amount(amount), account_id) } + /// Creates an [`AccountOutputBuilder`] with a provided amount, unless it is below the minimum. + pub fn new_with_amount_or_minimum(amount: u64, account_id: AccountId, params: StorageScoreParameters) -> Self { + Self::new(OutputBuilderAmount::AmountOrMinimum(amount, params), account_id) + } + /// Creates an [`AccountOutputBuilder`] with provided storage score parameters. /// The amount will be set to the minimum required amount of the resulting output. pub fn new_with_minimum_amount(params: StorageScoreParameters, account_id: AccountId) -> Self { @@ -98,6 +103,13 @@ impl AccountOutputBuilder { self } + /// Sets the amount to the provided value, unless it is below the minimum. + #[inline(always)] + pub fn with_amount_or_minimum(mut self, amount: u64, params: StorageScoreParameters) -> Self { + self.amount = OutputBuilderAmount::AmountOrMinimum(amount, params); + self + } + /// Sets the amount to the minimum required amount. #[inline(always)] pub fn with_minimum_amount(mut self, params: StorageScoreParameters) -> Self { @@ -246,6 +258,7 @@ impl AccountOutputBuilder { output.amount = match self.amount { OutputBuilderAmount::Amount(amount) => amount, + OutputBuilderAmount::AmountOrMinimum(amount, params) => output.minimum_amount(params).max(amount), OutputBuilderAmount::MinimumAmount(params) => output.minimum_amount(params), }; diff --git a/sdk/src/types/block/output/anchor.rs b/sdk/src/types/block/output/anchor.rs index 7f94894b86..f1063d8798 100644 --- a/sdk/src/types/block/output/anchor.rs +++ b/sdk/src/types/block/output/anchor.rs @@ -105,6 +105,11 @@ impl AnchorOutputBuilder { Self::new(OutputBuilderAmount::Amount(amount), anchor_id) } + /// Creates an [`AnchorOutputBuilder`] with a provided amount, unless it is below the minimum. + pub fn new_with_amount_or_minimum(amount: u64, anchor_id: AnchorId, params: StorageScoreParameters) -> Self { + Self::new(OutputBuilderAmount::AmountOrMinimum(amount, params), anchor_id) + } + /// Creates an [`AnchorOutputBuilder`] with provided storage score parameters. /// The amount will be set to the minimum required amount of the resulting output. #[inline(always)] @@ -131,6 +136,13 @@ impl AnchorOutputBuilder { self } + /// Sets the amount to the provided value, unless it is below the minimum. + #[inline(always)] + pub fn with_amount_or_minimum(mut self, amount: u64, params: StorageScoreParameters) -> Self { + self.amount = OutputBuilderAmount::AmountOrMinimum(amount, params); + self + } + /// Sets the amount to the minimum required amount. #[inline(always)] pub fn with_minimum_amount(mut self, params: StorageScoreParameters) -> Self { @@ -277,6 +289,7 @@ impl AnchorOutputBuilder { output.amount = match self.amount { OutputBuilderAmount::Amount(amount) => amount, + OutputBuilderAmount::AmountOrMinimum(amount, params) => output.minimum_amount(params).max(amount), OutputBuilderAmount::MinimumAmount(params) => output.minimum_amount(params), }; diff --git a/sdk/src/types/block/output/basic.rs b/sdk/src/types/block/output/basic.rs index 4b5de1a79e..39e784048c 100644 --- a/sdk/src/types/block/output/basic.rs +++ b/sdk/src/types/block/output/basic.rs @@ -38,6 +38,11 @@ impl BasicOutputBuilder { Self::new(OutputBuilderAmount::Amount(amount)) } + /// Creates a [`BasicOutputBuilder`] with a provided amount, unless it is below the minimum. + pub fn new_with_amount_or_minimum(amount: u64, params: StorageScoreParameters) -> Self { + Self::new(OutputBuilderAmount::AmountOrMinimum(amount, params)) + } + /// Creates an [`BasicOutputBuilder`] with provided storage score parameters. /// The amount will be set to the minimum required amount of the resulting output. #[inline(always)] @@ -61,6 +66,13 @@ impl BasicOutputBuilder { self } + /// Sets the amount to the provided value, unless it is below the minimum. + #[inline(always)] + pub fn with_amount_or_minimum(mut self, amount: u64, params: StorageScoreParameters) -> Self { + self.amount = OutputBuilderAmount::AmountOrMinimum(amount, params); + self + } + /// Sets the amount to the minimum required amount. #[inline(always)] pub fn with_minimum_amount(mut self, params: StorageScoreParameters) -> Self { @@ -182,6 +194,7 @@ impl BasicOutputBuilder { self } } + OutputBuilderAmount::AmountOrMinimum(_, _) => self, OutputBuilderAmount::MinimumAmount(_) => self, }) } @@ -211,6 +224,7 @@ impl BasicOutputBuilder { output.amount = match self.amount { OutputBuilderAmount::Amount(amount) => amount, + OutputBuilderAmount::AmountOrMinimum(amount, params) => output.minimum_amount(params).max(amount), OutputBuilderAmount::MinimumAmount(params) => output.minimum_amount(params), }; diff --git a/sdk/src/types/block/output/delegation.rs b/sdk/src/types/block/output/delegation.rs index 26bfea1274..245ea04b84 100644 --- a/sdk/src/types/block/output/delegation.rs +++ b/sdk/src/types/block/output/delegation.rs @@ -41,13 +41,20 @@ impl DelegationId { } } +// TODO maybe can be removed as part of https://github.com/iotaledger/iota-sdk/issues/1938 +#[derive(Copy, Clone)] +pub enum DelegatedAmount { + Amount(u64), + MinimumAmount(StorageScoreParameters), +} + /// Builder for a [`DelegationOutput`]. #[derive(Clone)] #[must_use] pub struct DelegationOutputBuilder { // TODO https://github.com/iotaledger/iota-sdk/issues/1938 amount: Option, - delegated_amount: OutputBuilderAmount, + delegated_amount: DelegatedAmount, delegation_id: DelegationId, validator_address: AccountAddress, start_epoch: EpochIndex, @@ -59,7 +66,19 @@ impl DelegationOutputBuilder { /// Creates a [`DelegationOutputBuilder`] with a provided amount. /// Will set the delegated amount field to match. pub fn new_with_amount(amount: u64, delegation_id: DelegationId, validator_address: AccountAddress) -> Self { - Self::new(OutputBuilderAmount::Amount(amount), delegation_id, validator_address) + Self::new(DelegatedAmount::Amount(amount), delegation_id, validator_address) + } + + /// Creates a [`DelegationOutputBuilder`] with a provided amount, unless it is below the minimum. + /// Will set the delegated amount field to match. + pub fn new_with_amount_or_minimum( + amount: u64, + delegation_id: DelegationId, + validator_address: AccountAddress, + params: StorageScoreParameters, + ) -> Self { + Self::new(DelegatedAmount::Amount(amount), delegation_id, validator_address) + .with_amount_or_minimum(amount, params) } /// Creates a [`DelegationOutputBuilder`] with provided storage score parameters. @@ -69,18 +88,10 @@ impl DelegationOutputBuilder { delegation_id: DelegationId, validator_address: AccountAddress, ) -> Self { - Self::new( - OutputBuilderAmount::MinimumAmount(params), - delegation_id, - validator_address, - ) + Self::new(DelegatedAmount::MinimumAmount(params), delegation_id, validator_address) } - fn new( - delegated_amount: OutputBuilderAmount, - delegation_id: DelegationId, - validator_address: AccountAddress, - ) -> Self { + fn new(delegated_amount: DelegatedAmount, delegation_id: DelegationId, validator_address: AccountAddress) -> Self { Self { amount: None, delegated_amount, @@ -98,9 +109,16 @@ impl DelegationOutputBuilder { self } + /// Sets the amount to the provided value, unless it is below the minimum. + #[inline(always)] + pub fn with_amount_or_minimum(mut self, amount: u64, params: StorageScoreParameters) -> Self { + self.amount = Some(OutputBuilderAmount::AmountOrMinimum(amount, params)); + self + } + /// Sets the amount to the minimum required amount. pub fn with_minimum_amount(mut self, params: StorageScoreParameters) -> Self { - if matches!(self.delegated_amount, OutputBuilderAmount::MinimumAmount(_)) { + if matches!(self.delegated_amount, DelegatedAmount::MinimumAmount(_)) { self.amount = None; } else { self.amount = Some(OutputBuilderAmount::MinimumAmount(params)); @@ -181,21 +199,22 @@ impl DelegationOutputBuilder { }; match self.delegated_amount { - OutputBuilderAmount::Amount(amount) => { + DelegatedAmount::Amount(amount) => { output.delegated_amount = amount; output.amount = self.amount.map_or(amount, |builder_amount| match builder_amount { OutputBuilderAmount::Amount(amount) => amount, + OutputBuilderAmount::AmountOrMinimum(amount, params) => output.minimum_amount(params).max(amount), OutputBuilderAmount::MinimumAmount(params) => output.minimum_amount(params), }); } - OutputBuilderAmount::MinimumAmount(params) => { + DelegatedAmount::MinimumAmount(params) => { let min = output.minimum_amount(params); output.delegated_amount = min; - output.amount = if let Some(OutputBuilderAmount::Amount(amount)) = self.amount { - amount - } else { - min - }; + output.amount = self.amount.map_or(min, |builder_amount| match builder_amount { + OutputBuilderAmount::Amount(amount) => amount, + OutputBuilderAmount::AmountOrMinimum(amount, params) => output.minimum_amount(params).max(amount), + OutputBuilderAmount::MinimumAmount(params) => output.minimum_amount(params), + }); } } @@ -212,7 +231,7 @@ impl From<&DelegationOutput> for DelegationOutputBuilder { fn from(output: &DelegationOutput) -> Self { Self { amount: Some(OutputBuilderAmount::Amount(output.amount)), - delegated_amount: OutputBuilderAmount::Amount(output.delegated_amount), + delegated_amount: DelegatedAmount::Amount(output.delegated_amount), delegation_id: output.delegation_id, validator_address: *output.validator_address.as_account(), start_epoch: output.start_epoch, diff --git a/sdk/src/types/block/output/foundry.rs b/sdk/src/types/block/output/foundry.rs index cb72eb4fda..1b6fb06a25 100644 --- a/sdk/src/types/block/output/foundry.rs +++ b/sdk/src/types/block/output/foundry.rs @@ -97,6 +97,20 @@ impl FoundryOutputBuilder { Self::new(OutputBuilderAmount::Amount(amount), serial_number, token_scheme) } + /// Creates a [`FoundryOutputBuilder`] with a provided amount, unless it is below the minimum. + pub fn new_with_amount_or_minimum( + amount: u64, + serial_number: u32, + token_scheme: TokenScheme, + params: StorageScoreParameters, + ) -> Self { + Self::new( + OutputBuilderAmount::AmountOrMinimum(amount, params), + serial_number, + token_scheme, + ) + } + /// Creates a [`FoundryOutputBuilder`] with provided storage score parameters. /// The amount will be set to the minimum required amount of the resulting output. pub fn new_with_minimum_amount( @@ -125,6 +139,13 @@ impl FoundryOutputBuilder { self } + /// Sets the amount to the provided value, unless it is below the minimum. + #[inline(always)] + pub fn with_amount_or_minimum(mut self, amount: u64, params: StorageScoreParameters) -> Self { + self.amount = OutputBuilderAmount::AmountOrMinimum(amount, params); + self + } + /// Sets the amount to the minimum required amount. #[inline(always)] pub fn with_minimum_amount(mut self, params: StorageScoreParameters) -> Self { @@ -265,6 +286,7 @@ impl FoundryOutputBuilder { output.amount = match self.amount { OutputBuilderAmount::Amount(amount) => amount, + OutputBuilderAmount::AmountOrMinimum(amount, params) => output.minimum_amount(params).max(amount), OutputBuilderAmount::MinimumAmount(params) => output.minimum_amount(params), }; diff --git a/sdk/src/types/block/output/mod.rs b/sdk/src/types/block/output/mod.rs index 15c70edc7f..d9ac7fe6c7 100644 --- a/sdk/src/types/block/output/mod.rs +++ b/sdk/src/types/block/output/mod.rs @@ -74,6 +74,7 @@ pub const OUTPUT_INDEX_RANGE: RangeInclusive = 0..=OUTPUT_INDEX_MAX; // [0. #[derive(Copy, Clone)] pub enum OutputBuilderAmount { Amount(u64), + AmountOrMinimum(u64, StorageScoreParameters), MinimumAmount(StorageScoreParameters), } diff --git a/sdk/src/types/block/output/nft.rs b/sdk/src/types/block/output/nft.rs index 2bf6e318e1..73e31b0d56 100644 --- a/sdk/src/types/block/output/nft.rs +++ b/sdk/src/types/block/output/nft.rs @@ -72,6 +72,11 @@ impl NftOutputBuilder { Self::new(OutputBuilderAmount::Amount(amount), nft_id) } + /// Creates an [`NftOutputBuilder`] with a provided amount, unless it is below the minimum. + pub fn new_with_amount_or_minimum(amount: u64, nft_id: NftId, params: StorageScoreParameters) -> Self { + Self::new(OutputBuilderAmount::AmountOrMinimum(amount, params), nft_id) + } + /// Creates an [`NftOutputBuilder`] with provided storage score parameters. /// The amount will be set to the minimum required amount of the resulting output. pub fn new_with_minimum_amount(params: StorageScoreParameters, nft_id: NftId) -> Self { @@ -96,6 +101,13 @@ impl NftOutputBuilder { self } + /// Sets the amount to the provided value, unless it is below the minimum. + #[inline(always)] + pub fn with_amount_or_minimum(mut self, amount: u64, params: StorageScoreParameters) -> Self { + self.amount = OutputBuilderAmount::AmountOrMinimum(amount, params); + self + } + /// Sets the amount to the minimum required amount. #[inline(always)] pub fn with_minimum_amount(mut self, params: StorageScoreParameters) -> Self { @@ -245,6 +257,7 @@ impl NftOutputBuilder { self } } + OutputBuilderAmount::AmountOrMinimum(_, _) => self, OutputBuilderAmount::MinimumAmount(_) => self, }) } @@ -275,6 +288,7 @@ impl NftOutputBuilder { output.amount = match self.amount { OutputBuilderAmount::Amount(amount) => amount, + OutputBuilderAmount::AmountOrMinimum(amount, params) => output.minimum_amount(params).max(amount), OutputBuilderAmount::MinimumAmount(params) => output.minimum_amount(params), }; diff --git a/sdk/tests/client/input_selection/account_outputs.rs b/sdk/tests/client/input_selection/account_outputs.rs index a18126ccbd..71989a3b20 100644 --- a/sdk/tests/client/input_selection/account_outputs.rs +++ b/sdk/tests/client/input_selection/account_outputs.rs @@ -2142,3 +2142,115 @@ fn implicit_account_transition() { // One remainder Mana assert_eq!(selected.transaction.outputs()[0].mana(), 1); } + +#[test] +fn auto_transition_account_less_than_min() { + let protocol_parameters = iota_mainnet_protocol_parameters().clone(); + let account_id_1 = AccountId::from_str(ACCOUNT_ID_1).unwrap(); + + let small_amount = 5; + + let inputs = build_inputs( + [( + Account { + amount: small_amount, + account_id: account_id_1, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + issuer: None, + }, + None, + )], + Some(SLOT_INDEX), + ); + + let selected = InputSelection::new( + inputs.clone(), + None, + [Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap()], + SLOT_INDEX, + SLOT_COMMITMENT_ID, + protocol_parameters.clone(), + ) + .with_required_inputs([*inputs[0].output_id()]) + .select() + .unwrap_err(); + + let min_amount = AccountOutputBuilder::from(inputs[0].output.as_account()) + .with_minimum_amount(protocol_parameters.storage_score_parameters()) + .finish_output() + .unwrap() + .amount(); + + assert_eq!( + selected, + Error::InsufficientAmount { + found: small_amount, + required: min_amount + }, + ); +} + +#[test] +fn auto_transition_account_less_than_min_additional() { + let protocol_parameters = iota_mainnet_protocol_parameters().clone(); + let account_id_1 = AccountId::from_str(ACCOUNT_ID_1).unwrap(); + + let small_amount = 5; + + let inputs = build_inputs( + [ + ( + Account { + amount: small_amount, + account_id: account_id_1, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + issuer: None, + }, + None, + ), + ( + Basic { + amount: 1_000_000, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + native_token: None, + sdruc: None, + timelock: None, + expiration: None, + }, + None, + ), + ], + Some(SLOT_INDEX), + ); + + let selected = InputSelection::new( + inputs.clone(), + None, + [Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap()], + SLOT_INDEX, + SLOT_COMMITMENT_ID, + protocol_parameters.clone(), + ) + .with_required_inputs([*inputs[0].output_id()]) + .select() + .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); +} diff --git a/sdk/tests/client/input_selection/foundry_outputs.rs b/sdk/tests/client/input_selection/foundry_outputs.rs index 2f64dc8b17..6969fac375 100644 --- a/sdk/tests/client/input_selection/foundry_outputs.rs +++ b/sdk/tests/client/input_selection/foundry_outputs.rs @@ -11,8 +11,8 @@ use iota_sdk::{ types::block::{ address::{AccountAddress, Address}, output::{ - unlock_condition::AddressUnlockCondition, AccountId, AccountOutputBuilder, FoundryId, Output, - SimpleTokenScheme, TokenId, + unlock_condition::AddressUnlockCondition, AccountId, AccountOutputBuilder, FoundryId, FoundryOutputBuilder, + Output, SimpleTokenScheme, TokenId, }, protocol::iota_mainnet_protocol_parameters, rand::output::{rand_output_id_with_slot_index, rand_output_metadata_with_id}, @@ -1226,3 +1226,157 @@ fn melt_and_burn_native_tokens() { } }); } + +#[test] +fn auto_transition_foundry_less_than_min() { + let protocol_parameters = iota_mainnet_protocol_parameters().clone(); + let account_id = AccountId::from_str(ACCOUNT_ID_1).unwrap(); + let foundry_id = FoundryId::build(&AccountAddress::from(account_id), 1, SimpleTokenScheme::KIND); + let token_id = TokenId::from(foundry_id); + + let small_amount_foundry = 5; + let small_amount_account = 10; + + let mut inputs = build_inputs( + [( + Foundry { + amount: small_amount_foundry, + account_id, + serial_number: 1, + token_scheme: SimpleTokenScheme::new(1000, 0, 1000).unwrap(), + native_token: Some((&token_id.to_string(), 1000)), + }, + None, + )], + Some(SLOT_INDEX), + ); + let account_output = AccountOutputBuilder::new_with_amount(small_amount_account, account_id) + .add_unlock_condition(AddressUnlockCondition::new( + Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + )) + .with_foundry_counter(1) + .finish_output() + .unwrap(); + inputs.push(InputSigningData { + output: account_output, + output_metadata: rand_output_metadata_with_id(rand_output_id_with_slot_index(SLOT_INDEX)), + chain: None, + }); + + let selected = InputSelection::new( + inputs.clone(), + None, + [Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap()], + SLOT_INDEX, + SLOT_COMMITMENT_ID, + protocol_parameters.clone(), + ) + .with_required_inputs([*inputs[0].output_id()]) + .select() + .unwrap_err(); + + let min_amount = FoundryOutputBuilder::from(inputs[0].output.as_foundry()) + .with_minimum_amount(protocol_parameters.storage_score_parameters()) + .finish_output() + .unwrap() + .amount() + + AccountOutputBuilder::from(inputs[1].output.as_account()) + .with_minimum_amount(protocol_parameters.storage_score_parameters()) + .finish_output() + .unwrap() + .amount(); + + assert_eq!( + selected, + Error::InsufficientAmount { + found: small_amount_foundry + small_amount_account, + required: min_amount + }, + ); +} + +#[test] +fn auto_transition_foundry_less_than_min_additional() { + let protocol_parameters = iota_mainnet_protocol_parameters().clone(); + let account_id = AccountId::from_str(ACCOUNT_ID_1).unwrap(); + let foundry_id = FoundryId::build(&AccountAddress::from(account_id), 1, SimpleTokenScheme::KIND); + let token_id = TokenId::from(foundry_id); + + let small_amount = 5; + + let mut inputs = build_inputs( + [ + ( + Foundry { + amount: small_amount, + account_id, + serial_number: 1, + token_scheme: SimpleTokenScheme::new(1000, 0, 1000).unwrap(), + native_token: Some((&token_id.to_string(), 1000)), + }, + None, + ), + ( + Basic { + amount: 1_000_000, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + native_token: None, + sdruc: None, + timelock: None, + expiration: None, + }, + None, + ), + ], + Some(SLOT_INDEX), + ); + let account_output = AccountOutputBuilder::new_with_amount(1_000_000, account_id) + .add_unlock_condition(AddressUnlockCondition::new( + Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + )) + .with_foundry_counter(1) + .finish_output() + .unwrap(); + inputs.push(InputSigningData { + output: account_output, + output_metadata: rand_output_metadata_with_id(rand_output_id_with_slot_index(SLOT_INDEX)), + chain: None, + }); + + let selected = InputSelection::new( + inputs.clone(), + None, + [Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap()], + SLOT_INDEX, + SLOT_COMMITMENT_ID, + protocol_parameters.clone(), + ) + .with_required_inputs([*inputs[0].output_id()]) + .select() + .unwrap(); + + assert!(unsorted_eq(&selected.inputs_data, &inputs)); + assert_eq!(selected.transaction.outputs().len(), 3); + let min_amount_foundry = FoundryOutputBuilder::from(inputs[0].output.as_foundry()) + .with_minimum_amount(protocol_parameters.storage_score_parameters()) + .finish_output() + .unwrap() + .amount(); + let foundry_output = selected + .transaction + .outputs() + .iter() + .filter_map(Output::as_foundry_opt) + .find(|o| o.id() == foundry_id) + .unwrap(); + let account_output = selected + .transaction + .outputs() + .iter() + .filter_map(Output::as_account_opt) + .find(|o| o.account_id() == &account_id) + .unwrap(); + assert_eq!(foundry_output.amount(), min_amount_foundry); + assert_eq!(account_output.amount(), 1_000_000); +} diff --git a/sdk/tests/client/input_selection/nft_outputs.rs b/sdk/tests/client/input_selection/nft_outputs.rs index 5e3cc3f1ea..59e9ea7154 100644 --- a/sdk/tests/client/input_selection/nft_outputs.rs +++ b/sdk/tests/client/input_selection/nft_outputs.rs @@ -1427,3 +1427,119 @@ fn changed_immutable_metadata() { ))) if nft_id == nft_id_1 )); } + +#[test] +fn auto_transition_nft_less_than_min() { + let protocol_parameters = iota_mainnet_protocol_parameters().clone(); + let nft_id_1 = NftId::from_str(NFT_ID_1).unwrap(); + + let small_amount = 5; + + let inputs = build_inputs( + [( + Nft { + amount: small_amount, + nft_id: nft_id_1, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + issuer: None, + sdruc: None, + expiration: None, + }, + None, + )], + Some(SLOT_INDEX), + ); + + let selected = InputSelection::new( + inputs.clone(), + None, + [Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap()], + SLOT_INDEX, + SLOT_COMMITMENT_ID, + protocol_parameters.clone(), + ) + .with_required_inputs([*inputs[0].output_id()]) + .select() + .unwrap_err(); + + 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, + Error::InsufficientAmount { + found: small_amount, + required: min_amount + }, + ); +} + +#[test] +fn auto_transition_nft_less_than_min_additional() { + let protocol_parameters = iota_mainnet_protocol_parameters().clone(); + let nft_id_1 = NftId::from_str(NFT_ID_1).unwrap(); + + let small_amount = 5; + + let inputs = build_inputs( + [ + ( + Nft { + amount: small_amount, + nft_id: nft_id_1, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + issuer: None, + sdruc: None, + expiration: None, + }, + None, + ), + ( + Basic { + amount: 1_000_000, + address: Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap(), + sender: None, + native_token: None, + sdruc: None, + timelock: None, + expiration: None, + }, + None, + ), + ], + Some(SLOT_INDEX), + ); + + let selected = InputSelection::new( + inputs.clone(), + None, + [Address::try_from_bech32(BECH32_ADDRESS_ED25519_0).unwrap()], + SLOT_INDEX, + SLOT_COMMITMENT_ID, + protocol_parameters.clone(), + ) + .with_required_inputs([*inputs[0].output_id()]) + .select() + .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(); + let nft_output = selected + .transaction + .outputs() + .iter() + .filter_map(Output::as_nft_opt) + .find(|o| o.nft_id() == &nft_id_1) + .unwrap(); + assert_eq!(nft_output.amount(), min_amount); +} From 585260e3c973bc09f70c9e1130c9388ed1729033 Mon Sep 17 00:00:00 2001 From: Thibault Martinez Date: Mon, 26 Feb 2024 10:40:08 +0100 Subject: [PATCH 2/3] Verify block slot (#2055) * Make Block::new fallible * impl verify_block_slot * finish_with_params * errors * Remove comments * Update sdk/src/types/block/error.rs Co-authored-by: Thoralf-M <46689931+Thoralf-M@users.noreply.github.com> --------- Co-authored-by: Thoralf-M <46689931+Thoralf-M@users.noreply.github.com> --- sdk/src/types/block/core/block.rs | 72 +++++++++++++++++++++++-------- sdk/src/types/block/error.rs | 13 ++++++ 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/sdk/src/types/block/core/block.rs b/sdk/src/types/block/core/block.rs index 3460514252..8d36326579 100644 --- a/sdk/src/types/block/core/block.rs +++ b/sdk/src/types/block/core/block.rs @@ -17,6 +17,7 @@ use crate::types::block::{ block_id::{BlockHash, BlockId}, core::{BasicBlockBody, ValidationBlockBody}, output::AccountId, + payload::Payload, protocol::ProtocolParameters, signature::Signature, slot::{SlotCommitmentId, SlotIndex}, @@ -56,8 +57,26 @@ impl UnsignedBlock { [self.header.hash(), self.body.hash()].concat() } + /// Finishes an [`UnsignedBlock`] into a [`Block`]. + pub fn finish_with_params<'a>( + self, + signature: impl Into, + params: impl Into>, + ) -> Result { + if let Some(params) = params.into() { + verify_block_slot(&self.header, &self.body, params)?; + } + + Ok(Block { + header: self.header, + body: self.body, + signature: signature.into(), + }) + } + + /// Finishes an [`UnsignedBlock`] into a [`Block`] without protocol validation. pub fn finish(self, signature: impl Into) -> Result { - Ok(Block::new(self.header, self.body, signature)) + self.finish_with_params(signature, None) } } @@ -154,18 +173,6 @@ impl Block { /// The maximum number of bytes in a block. pub const LENGTH_MAX: usize = 32768; - /// Creates a new [`Block`]. - #[inline(always)] - pub fn new(header: BlockHeader, body: BlockBody, signature: impl Into) -> Self { - let signature = signature.into(); - - Self { - header, - body, - signature, - } - } - /// Creates a new [`UnsignedBlock`]. #[inline(always)] pub fn build(header: BlockHeader, body: BlockBody) -> UnsignedBlock { @@ -288,7 +295,9 @@ impl Packable for Block { signature, }; - if protocol_params.is_some() { + if let Some(protocol_params) = protocol_params { + verify_block_slot(&block.header, &block.body, &protocol_params).map_err(UnpackError::Packable)?; + let block_len = if let (Some(start), Some(end)) = (start_opt, unpacker.read_bytes()) { end - start } else { @@ -304,6 +313,35 @@ impl Packable for Block { } } +fn verify_block_slot(header: &BlockHeader, body: &BlockBody, params: &ProtocolParameters) -> Result<(), Error> { + if let BlockBody::Basic(basic) = body { + if let Some(Payload::SignedTransaction(signed_transaction)) = basic.payload() { + let transaction = signed_transaction.transaction(); + let block_slot = params.slot_index(header.issuing_time / 1_000_000_000); + + if block_slot < transaction.creation_slot() { + return Err(Error::BlockSlotBeforeTransactionCreationSlot); + } + + if let Some(commitment) = signed_transaction.transaction().context_inputs().commitment() { + let commitment_slot = commitment.slot_index(); + + if !(block_slot - params.max_committable_age()..=block_slot - params.min_committable_age()) + .contains(&commitment_slot) + { + return Err(Error::TransactionCommitmentSlotNotInBlockSlotInterval); + } + + if commitment_slot > header.slot_commitment_id.slot_index() { + return Err(Error::TransactionCommitmentSlotAfterBlockCommitmentSlot); + } + } + } + } + + Ok(()) +} + #[cfg(feature = "serde")] pub(crate) mod dto { use serde::{Deserialize, Serialize}; @@ -366,11 +404,11 @@ pub(crate) mod dto { } } - Ok(Self::new( + UnsignedBlock::new( BlockHeader::try_from_dto_with_params_inner(dto.inner.header, params)?, BlockBody::try_from_dto_with_params_inner(dto.inner.body, params)?, - dto.signature, - )) + ) + .finish_with_params(dto.signature, params) } } diff --git a/sdk/src/types/block/error.rs b/sdk/src/types/block/error.rs index 56ee47c8da..1c0bebccea 100644 --- a/sdk/src/types/block/error.rs +++ b/sdk/src/types/block/error.rs @@ -209,6 +209,9 @@ pub enum Error { }, TrailingCapabilityBytes, RestrictedAddressCapability(AddressCapabilityFlag), + BlockSlotBeforeTransactionCreationSlot, + TransactionCommitmentSlotNotInBlockSlotInterval, + TransactionCommitmentSlotAfterBlockCommitmentSlot, } #[cfg(feature = "std")] @@ -450,6 +453,16 @@ impl fmt::Display for Error { } Self::TrailingCapabilityBytes => write!(f, "capability bytes have trailing zeroes"), Self::RestrictedAddressCapability(cap) => write!(f, "restricted address capability: {cap:?}"), + Self::BlockSlotBeforeTransactionCreationSlot => { + write!(f, "the block slot is before its contained transaction creation slot") + } + Self::TransactionCommitmentSlotNotInBlockSlotInterval => write!( + f, + "the transaction commitment slot is not in the allowed block slot interval" + ), + Self::TransactionCommitmentSlotAfterBlockCommitmentSlot => { + write!(f, "the transaction commitment slot is after the block commitment slot") + } } } } From 9e6976a7d400eda57b17351737baa646d87810db Mon Sep 17 00:00:00 2001 From: Thoralf-M <46689931+Thoralf-M@users.noreply.github.com> Date: Mon, 26 Feb 2024 12:04:22 +0100 Subject: [PATCH 3/3] Fix Client::finish() (#2071) --- sdk/src/client/builder.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sdk/src/client/builder.rs b/sdk/src/client/builder.rs index 85a9d73678..01a70d101c 100644 --- a/sdk/src/client/builder.rs +++ b/sdk/src/client/builder.rs @@ -195,7 +195,7 @@ impl ClientBuilder { let node_sync_interval = self.node_manager_builder.node_sync_interval; let ignore_node_health = self.node_manager_builder.ignore_node_health; - let nodes = self + let nodes: HashSet = self .node_manager_builder .primary_nodes .iter() @@ -207,7 +207,9 @@ impl ClientBuilder { let (mqtt_event_tx, mqtt_event_rx) = tokio::sync::watch::channel(MqttEvent::Connected); let client_inner = Arc::new(ClientInner { - node_manager: RwLock::new(self.node_manager_builder.build(HashSet::new())), + // Initially assume all nodes are healthy, so `fetch_network_info()` works. `sync_nodes()` will afterwards + // update the healthy nodes. + node_manager: RwLock::new(self.node_manager_builder.build(nodes.clone())), api_timeout: RwLock::new(self.api_timeout), #[cfg(feature = "mqtt")] mqtt: super::MqttInner {