From 8e8715cc4fd863d6c68ddaea046891b4641654c6 Mon Sep 17 00:00:00 2001 From: Thoralf-M <46689931+Thoralf-M@users.noreply.github.com> Date: Mon, 1 Aug 2022 20:55:34 +0200 Subject: [PATCH] Utxo chains in inputs + input selection tests (#1164) * Utxo chains in inputs + input selection tests * Add change file * Add more input selection tests * clippy * Add tests with foundries * Clippy --- .changes/utxo chain inputs.md | 6 + Cargo.lock | 5 +- Cargo.toml | 3 +- .../java/iota-client-java/native/Cargo.lock | 4 +- .../input_selection/automatic.rs | 2 +- src/api/block_builder/input_selection/mod.rs | 154 ++++++++++++- .../input_selection/remainder.rs | 20 +- src/api/types.rs | 2 +- src/error.rs | 9 +- src/lib.rs | 11 +- src/secret/ledger_nano.rs | 9 +- src/secret/mod.rs | 3 +- .../input_selection/alias_foundry_outputs.rs | 206 ++++++++++++++++++ tests/input_selection/basic_outputs.rs | 65 ++++++ tests/input_selection/mod.rs | 172 +++++++++++++++ tests/input_selection/nft_outputs.rs | 103 +++++++++ tests/mod.rs | 4 + 17 files changed, 756 insertions(+), 22 deletions(-) create mode 100644 .changes/utxo chain inputs.md create mode 100644 tests/input_selection/alias_foundry_outputs.rs create mode 100644 tests/input_selection/basic_outputs.rs create mode 100644 tests/input_selection/mod.rs create mode 100644 tests/input_selection/nft_outputs.rs create mode 100644 tests/mod.rs diff --git a/.changes/utxo chain inputs.md b/.changes/utxo chain inputs.md new file mode 100644 index 000000000..c8ff0bd0f --- /dev/null +++ b/.changes/utxo chain inputs.md @@ -0,0 +1,6 @@ + +--- +"nodejs-binding": patch +--- + +Improve handling for utxo chains in input selection. diff --git a/Cargo.lock b/Cargo.lock index 9e51ac8b0..32a3805d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,9 +235,9 @@ dependencies = [ [[package]] name = "bee-block" -version = "1.0.0-beta.3" +version = "1.0.0-beta.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561813c7a9f01ab55042f5dbbfc4672b1a91711814c3d226c2b7729a20fa8521" +checksum = "400e3fbac56e7c7e4d69af820f18a02e4ee89d8cf52a5b2e267b0b9d2d85d7f7" dependencies = [ "bech32 0.9.0", "bee-pow", @@ -252,6 +252,7 @@ dependencies = [ "packable", "prefix-hex", "primitive-types", + "rand", "serde", "serde-big-array", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 4fd45dcce..99d4a5113 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ categories = [ "cryptography::cryptocurrencies" ] [dependencies] async-trait = { version = "0.1.56", default-features = false } bee-api-types = { version = "1.0.0-beta.3", default-features = false } -bee-block = { version = "1.0.0-beta.3", default-features = false, features = [ "serde", "dto", "std" ] } +bee-block = { version = "1.0.0-beta.5", default-features = false, features = [ "serde", "dto", "std" ] } bee-pow = { version = "1.0.0-alpha.1", default-features = false } derive_builder = { version = "0.11.2", default-features = false, features = [ "std" ]} futures = { version = "0.3.21", default-features = false, features = [ "thread-pool" ] } @@ -56,6 +56,7 @@ bee-ternary = { version = "1.0.0-alpha.1", default-features = false } [dev-dependencies] dotenv = { version = "0.15.0", default-features = false } +bee-block = { version = "1.0.0-beta.5", default-features = false, features = [ "rand", "std" ] } [features] default = [ "tls" ] diff --git a/bindings/java/iota-client-java/native/Cargo.lock b/bindings/java/iota-client-java/native/Cargo.lock index 1a4ed735c..a77e0c04e 100644 --- a/bindings/java/iota-client-java/native/Cargo.lock +++ b/bindings/java/iota-client-java/native/Cargo.lock @@ -158,9 +158,9 @@ dependencies = [ [[package]] name = "bee-block" -version = "1.0.0-beta.3" +version = "1.0.0-beta.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561813c7a9f01ab55042f5dbbfc4672b1a91711814c3d226c2b7729a20fa8521" +checksum = "400e3fbac56e7c7e4d69af820f18a02e4ee89d8cf52a5b2e267b0b9d2d85d7f7" dependencies = [ "bech32 0.9.0", "bee-pow", diff --git a/src/api/block_builder/input_selection/automatic.rs b/src/api/block_builder/input_selection/automatic.rs index d9e01e2a8..bd84ff651 100644 --- a/src/api/block_builder/input_selection/automatic.rs +++ b/src/api/block_builder/input_selection/automatic.rs @@ -292,7 +292,7 @@ async fn get_inputs_for_sender_and_issuer( } if !found_output { - return Err(Error::MissingInputWithEd25519UnlockCondition); + return Err(Error::MissingInputWithEd25519Address); } } } diff --git a/src/api/block_builder/input_selection/mod.rs b/src/api/block_builder/input_selection/mod.rs index 182bce1ca..4e038114d 100644 --- a/src/api/block_builder/input_selection/mod.rs +++ b/src/api/block_builder/input_selection/mod.rs @@ -15,7 +15,8 @@ use bee_block::{ input::INPUT_COUNT_MAX, output::{ unlock_condition::{AddressUnlockCondition, StorageDepositReturnUnlockCondition}, - BasicOutputBuilder, NativeTokens, Output, Rent, RentStructure, UnlockCondition, OUTPUT_COUNT_MAX, + AliasOutputBuilder, BasicOutputBuilder, FoundryOutputBuilder, NativeTokens, NftOutputBuilder, Output, Rent, + RentStructure, UnlockCondition, OUTPUT_COUNT_MAX, }, }; use packable::{bounded::TryIntoBoundedU16Error, PackableExt}; @@ -109,6 +110,86 @@ pub fn try_select_inputs( // 1. get alias, foundry or nft inputs (because amount and native tokens of these outputs will also be available for // the outputs) for input_signing_data in utxo_chain_outputs { + // Only add outputs if also exisiting on the output side or if burning is allowed + let minimum_required_storage_deposit = input_signing_data.output.rent_cost(rent_structure); + let output_id = input_signing_data.output_id()?; + + match &input_signing_data.output { + Output::Nft(nft_input) => { + // or if an output contains an nft output with the same nft id + let is_required = outputs.iter().any(|output| { + if let Output::Nft(nft_output) = output { + nft_input.nft_id().or_from_output_id(output_id) == *nft_output.nft_id() + } else { + false + } + }); + if !is_required && !allow_burning { + // Don't add if it doesn't give us any amount or native tokens + if input_signing_data.output.amount() == minimum_required_storage_deposit + && nft_input.native_tokens().is_empty() + { + continue; + } + // else add output to outputs with minimum_required_storage_deposit + let new_output = NftOutputBuilder::from(nft_input) + .with_nft_id(nft_input.nft_id().or_from_output_id(output_id)) + .with_amount(minimum_required_storage_deposit)? + .finish_output()?; + outputs.push(new_output); + } + } + Output::Alias(alias_input) => { + // Don't add if output has not the same AliasId, so we don't burn it + if !outputs.iter().any(|output| { + if let Output::Alias(alias_output) = output { + alias_input.alias_id().or_from_output_id(output_id) == *alias_output.alias_id() + } else { + false + } + }) && !allow_burning + { + // Don't add if it doesn't give us any amount or native tokens + if input_signing_data.output.amount() == minimum_required_storage_deposit + && alias_input.native_tokens().is_empty() + { + continue; + } + // else add output to outputs with minimum_required_storage_deposit + let new_output = AliasOutputBuilder::from(alias_input) + .with_alias_id(alias_input.alias_id().or_from_output_id(output_id)) + .with_state_index(alias_input.state_index() + 1) + .with_amount(minimum_required_storage_deposit)? + .finish_output()?; + outputs.push(new_output); + } + } + Output::Foundry(foundry_input) => { + // Don't add if output has not the same FoundryId, so we don't burn it + if !outputs.iter().any(|output| { + if let Output::Foundry(foundry_output) = output { + foundry_input.id() == foundry_output.id() + } else { + false + } + }) && !allow_burning + { + // Don't add if it doesn't give us any amount or native tokens + if input_signing_data.output.amount() == minimum_required_storage_deposit + && foundry_input.native_tokens().is_empty() + { + continue; + } + // else add output to outputs with minimum_required_storage_deposit + let new_output = FoundryOutputBuilder::from(foundry_input) + .with_amount(minimum_required_storage_deposit)? + .finish_output()?; + outputs.push(new_output); + } + } + _ => {} + } + let output = &input_signing_data.output; selected_input_amount += output.amount(); @@ -122,6 +203,77 @@ pub fn try_select_inputs( } selected_inputs.push(input_signing_data.clone()); + + // Updated required value with possible new input + let input_outputs = inputs.iter().map(|i| &i.output); + required = get_accumulated_output_amounts(&input_outputs.clone(), outputs.iter())?; + } + + // Validate that we have the required inputs for alias and nft outputs + for output in &outputs { + match output { + Output::Alias(alias_output) => { + // New created output requires no specific input + let alias_id = alias_output.alias_id(); + if alias_id.is_null() { + continue; + } + + if !selected_inputs.iter().any(|data| { + if let Output::Alias(input_alias_output) = &data.output { + input_alias_output + .alias_id() + .or_from_output_id(data.output_id().expect("Invalid output id")) + == *alias_id + } else { + false + } + }) { + return Err(crate::Error::MissingInput(format!( + "Missing alias input for {alias_id}" + ))); + } + } + Output::Foundry(foundry_output) => { + let required_alias = foundry_output.alias_address().alias_id(); + if !selected_inputs.iter().any(|data| { + if let Output::Alias(input_alias_output) = &data.output { + input_alias_output + .alias_id() + .or_from_output_id(data.output_id().expect("Invalid output id")) + == *required_alias + } else { + false + } + }) { + return Err(crate::Error::MissingInput(format!( + "Missing alias input {required_alias} for foundry {}", + foundry_output.id() + ))); + } + } + Output::Nft(nft_output) => { + // New created output requires no specific input + let nft_id = nft_output.nft_id(); + if nft_id.is_null() { + continue; + } + + if !selected_inputs.iter().any(|data| { + if let Output::Nft(input_nft_output) = &data.output { + input_nft_output + .nft_id() + .or_from_output_id(data.output_id().expect("Invalid output id")) + == *nft_id + } else { + false + } + }) { + return Err(crate::Error::MissingInput(format!("Missing nft input for {nft_id}"))); + } + } + _ => {} + } } // 2. get basic inputs for the required native tokens (because the amount of these outputs will also be available in diff --git a/src/api/block_builder/input_selection/remainder.rs b/src/api/block_builder/input_selection/remainder.rs index 7daae1d54..bab7c127c 100644 --- a/src/api/block_builder/input_selection/remainder.rs +++ b/src/api/block_builder/input_selection/remainder.rs @@ -149,14 +149,32 @@ pub(crate) fn get_remainder_address<'a>( inputs: impl Iterator, ) -> Result<(Address, Option)> { for input in inputs { + // todo: check expiration with time, for now we just ignore outputs with an expiration unlock condition here + if input + .output + .unlock_conditions() + .and_then(UnlockConditions::expiration) + .is_some() + { + continue; + } if let Some(address_unlock_condition) = input.output.unlock_conditions().and_then(UnlockConditions::address) { if address_unlock_condition.address().is_ed25519() { return Ok((*address_unlock_condition.address(), input.chain.clone())); } } + if let Some(governor_address_unlock_condition) = input + .output + .unlock_conditions() + .and_then(UnlockConditions::governor_address) + { + if governor_address_unlock_condition.address().is_ed25519() { + return Ok((*governor_address_unlock_condition.address(), input.chain.clone())); + } + } } - Err(Error::MissingInputWithEd25519UnlockCondition) + Err(Error::MissingInputWithEd25519Address) } // Get additional required storage deposit amount for the remainder output diff --git a/src/api/types.rs b/src/api/types.rs index 5a5dec275..e6ee5e8d1 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -24,7 +24,7 @@ use crate::{ pub struct PreparedTransactionData { /// Transaction essence pub essence: TransactionEssence, - /// Required address information for signing + /// Required input information for signing. Inputs need to be ordered by address type #[serde(rename = "inputsData")] pub inputs_data: Vec, /// Optional remainder output information diff --git a/src/error.rs b/src/error.rs index 6a2618208..251e8e671 100644 --- a/src/error.rs +++ b/src/error.rs @@ -51,6 +51,9 @@ pub enum Error { "The wallet account has enough funds, but splitted on too many outputs: {0}, max. is 128, consolidate them" )] ConsolidationRequired(usize), + /// Missing input for utxo chain + #[error("Missing input: {0}")] + MissingInput(String), /// Missing required parameters #[error("Must provide required parameter: {0}")] MissingParameter(&'static str), @@ -194,9 +197,9 @@ pub enum Error { /// Specifically used for `TryInfo` implementations for `SecretManager`. #[error("cannot unwrap a SecretManager: type mismatch!")] SecretManagerMismatch, - /// No input with matching ed25519 unlock condition provided - #[error("No input with matching ed25519 unlock condition provided")] - MissingInputWithEd25519UnlockCondition, + /// No input with matching ed25519 address provided + #[error("No input with matching ed25519 address provided")] + MissingInputWithEd25519Address, /// Ledger transport error #[cfg(feature = "ledger_nano")] #[error("ledger transport error")] diff --git a/src/lib.rs b/src/lib.rs index 8a5fdaa91..a34e33eda 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,18 +6,21 @@ //! High-level functions are accessible via the [`Client`][client::Client]. //! //! ## Sending a block without a payload -//! ```compile_fail +//! ```no_run +//! # use iota_client::{Client, Result}; +//! # #[tokio::main] +//! # async fn main() -> Result<()> { //! let client = Client::builder() //! .with_node("http://localhost:14265")? -//! .finish() -//! .await?; +//! .finish()?; //! -//! let block = iota +//! let block = client //! .block() //! .finish() //! .await?; //! //! println!("Block sent {}", block.id()); +//! # Ok(())} //! ``` #![deny(unused_extern_crates)] diff --git a/src/secret/ledger_nano.rs b/src/secret/ledger_nano.rs index 8faf01915..6aea54441 100644 --- a/src/secret/ledger_nano.rs +++ b/src/secret/ledger_nano.rs @@ -385,6 +385,7 @@ fn merge_unlocks( let mut merged_unlocks = Vec::new(); let mut block_indexes = HashMap::::new(); + // Assuming inputs_data is ordered by address type for (current_block_index, input) in prepared_transaction_data.inputs_data.iter().enumerate() { // Get the address that is required to unlock the input let (_, input_address) = Address::try_from_bech32(&input.bech32_address)?; @@ -404,18 +405,16 @@ fn merge_unlocks( // address already at this point, because the reference index needs to be lower // than the current block index if !input_address.is_ed25519() { - return Err(crate::Error::MissingInputWithEd25519UnlockCondition); + return Err(crate::Error::MissingInputWithEd25519Address); } - let unlock = unlocks - .next() - .ok_or(crate::Error::MissingInputWithEd25519UnlockCondition)?; + let unlock = unlocks.next().ok_or(crate::Error::MissingInputWithEd25519Address)?; if let Unlock::Signature(signature_unlock) = &unlock { let Signature::Ed25519(ed25519_signature) = signature_unlock.signature(); let ed25519_address = match input_address { Address::Ed25519(ed25519_address) => ed25519_address, - _ => return Err(crate::Error::MissingInputWithEd25519UnlockCondition), + _ => return Err(crate::Error::MissingInputWithEd25519Address), }; ed25519_signature.is_valid(&hashed_essence, &ed25519_address)?; } diff --git a/src/secret/mod.rs b/src/secret/mod.rs index 9f93ec9bc..6195bd26f 100644 --- a/src/secret/mod.rs +++ b/src/secret/mod.rs @@ -294,6 +294,7 @@ impl SecretManager { let mut blocks = Vec::new(); let mut block_indexes = HashMap::::new(); + // Assuming inputs_data is ordered by address type for (current_block_index, input) in prepared_transaction_data.inputs_data.iter().enumerate() { // Get the address that is required to unlock the input let (_, input_address) = Address::try_from_bech32(&input.bech32_address)?; @@ -313,7 +314,7 @@ impl SecretManager { // address already at this point, because the reference index needs to be lower // than the current block index if !input_address.is_ed25519() { - return Err(crate::Error::MissingInputWithEd25519UnlockCondition); + return Err(crate::Error::MissingInputWithEd25519Address); } let block = self diff --git a/tests/input_selection/alias_foundry_outputs.rs b/tests/input_selection/alias_foundry_outputs.rs new file mode 100644 index 000000000..0234dc020 --- /dev/null +++ b/tests/input_selection/alias_foundry_outputs.rs @@ -0,0 +1,206 @@ +// Copyright 2022 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::str::FromStr; + +use bee_block::output::{NativeToken, SimpleTokenScheme, TokenId}; +use iota_client::{ + api::input_selection::try_select_inputs, + block::output::{AliasId, Output, RentStructure}, + Error, Result, +}; +use primitive_types::U256; + +use crate::input_selection::{ + build_alias_output, build_foundry_output, build_input_signing_data_alias_outputs, + build_input_signing_data_foundry_outputs, build_input_signing_data_most_basic_outputs, build_most_basic_output, +}; + +#[test] +fn input_selection_alias() -> Result<()> { + let rent_structure = RentStructure::build() + .byte_cost(500) + .key_factor(10) + .data_factor(1) + .finish(); + + let alias_id_0 = AliasId::from_str("0x0000000000000000000000000000000000000000000000000000000000000000").unwrap(); + let alias_id_1 = AliasId::from_str("0x1111111111111111111111111111111111111111111111111111111111111111").unwrap(); + let bech32_address = "rms1qr2xsmt3v3eyp2ja80wd2sq8xx0fslefmxguf7tshzezzr5qsctzc2f5dg6"; + + // input alias == output alias + let inputs = build_input_signing_data_alias_outputs(vec![(alias_id_1, bech32_address, 1_000_000)]); + let outputs = vec![build_alias_output(alias_id_1, bech32_address, 1_000_000)]; + let selected_transaction_data = try_select_inputs(inputs.clone(), outputs, false, None, &rent_structure, false, 0)?; + assert_eq!(selected_transaction_data.inputs, inputs); + + // output amount > input amount + let inputs = build_input_signing_data_alias_outputs(vec![(alias_id_1, bech32_address, 1_000_000)]); + let outputs = vec![build_most_basic_output(bech32_address, 2_000_000)]; + match try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0) { + Err(Error::NotEnoughBalance { + found: 1_000_000, + // Amount we want to send + storage deposit for alias remainder + required: 2_251_500, + }) => {} + _ => panic!("Should return NotEnoughBalance"), + } + + // basic output with alias as input + let inputs = build_input_signing_data_alias_outputs(vec![(alias_id_1, bech32_address, 2_251_500)]); + let outputs = vec![build_most_basic_output(bech32_address, 2_000_000)]; + let selected_transaction_data = try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0)?; + // basic output + alias remainder + assert_eq!(selected_transaction_data.outputs.len(), 2); + + // mint alias + let inputs = build_input_signing_data_most_basic_outputs(vec![(bech32_address, 2_000_000)]); + let outputs = vec![build_alias_output(alias_id_0, bech32_address, 1_000_000)]; + let selected_transaction_data = try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0)?; + // One output should be added for the remainder + assert_eq!(selected_transaction_data.outputs.len(), 2); + // Output contains the new minted alias id + assert!(selected_transaction_data.outputs.iter().any(|output| { + if let Output::Alias(alias_output) = output { + *alias_output.alias_id() == alias_id_0 + } else { + false + } + })); + + // burn alias + let inputs = build_input_signing_data_alias_outputs(vec![(alias_id_1, bech32_address, 2_000_000)]); + let outputs = vec![build_most_basic_output(bech32_address, 2_000_000)]; + let selected_transaction_data = try_select_inputs(inputs, outputs, false, None, &rent_structure, true, 0)?; + // No remainder + assert_eq!(selected_transaction_data.outputs.len(), 1); + // Output is a basic output + assert!(matches!(selected_transaction_data.outputs[0], Output::Basic(_))); + + // not enough storage deposit for remainder + let inputs = build_input_signing_data_alias_outputs(vec![(alias_id_1, bech32_address, 1_000_001)]); + let outputs = vec![build_alias_output(alias_id_1, bech32_address, 1_000_000)]; + match try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0) { + Err(Error::BlockError(bee_block::Error::InsufficientStorageDepositAmount { + amount: 1, + required: 213000, + })) => {} + _ => panic!("Should return InsufficientStorageDepositAmount"), + } + + // missing input for output alias + let inputs = build_input_signing_data_most_basic_outputs(vec![(bech32_address, 1_000_000)]); + let outputs = vec![build_alias_output(alias_id_1, bech32_address, 1_000_000)]; + match try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0) { + Err(Error::MissingInput(err_msg)) => { + assert_eq!( + &err_msg, + "Missing alias input for 0x1111111111111111111111111111111111111111111111111111111111111111" + ); + } + _ => panic!("Should return missing alias input"), + } + + //////////////////////////////////////////////////////////////// + // Foundry + //////////////////////////////////////////////////////////////// + + // missing input alias for foundry + let inputs = build_input_signing_data_most_basic_outputs(vec![(bech32_address, 1_000_000)]); + let outputs = vec![build_foundry_output( + alias_id_1, + 1_000_000, + SimpleTokenScheme::new(U256::from(0), U256::from(0), U256::from(10)).unwrap(), + None, + )]; + match try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0) { + Err(Error::MissingInput(err_msg)) => { + assert_eq!( + &err_msg, + "Missing alias input 0x1111111111111111111111111111111111111111111111111111111111111111 for foundry 0x0811111111111111111111111111111111111111111111111111111111111111110000000000" + ); + } + _ => panic!("Should return missing alias input"), + } + + // existing input alias for foundry alias + let inputs = build_input_signing_data_alias_outputs(vec![(alias_id_1, bech32_address, 1251500)]); + let outputs = vec![build_foundry_output( + alias_id_1, + 1_000_000, + SimpleTokenScheme::new(U256::from(0), U256::from(0), U256::from(10)).unwrap(), + None, + )]; + let selected_transaction_data = try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0)?; + // Alias next state + foundry + assert_eq!(selected_transaction_data.outputs.len(), 2); + // Alias state index is increased + selected_transaction_data.outputs.iter().for_each(|output| { + if let Output::Alias(alias_output) = &output { + // Input alias has index 0, output should have index 1 + assert_eq!(alias_output.state_index(), 1); + } + }); + + // minted native tokens in new remainder + let inputs = build_input_signing_data_alias_outputs(vec![(alias_id_1, bech32_address, 2251500)]); + let outputs = vec![build_foundry_output( + alias_id_1, + 1_000_000, + SimpleTokenScheme::new(U256::from(10), U256::from(0), U256::from(10)).unwrap(), + None, + )]; + let selected_transaction_data = try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0)?; + // Alias next state + foundry + basic output with native tokens + assert_eq!(selected_transaction_data.outputs.len(), 3); + // Alias state index is increased + selected_transaction_data.outputs.iter().for_each(|output| { + if let Output::Alias(alias_output) = &output { + // Input alias has index 0, output should have index 1 + assert_eq!(alias_output.state_index(), 1); + } + if let Output::Basic(basic_output) = &output { + // Basic output remainder has the minted native tokens + assert_eq!(*basic_output.native_tokens().first().unwrap().amount(), U256::from(10)); + } + }); + + // melting native tokens + let mut inputs = build_input_signing_data_alias_outputs(vec![(alias_id_1, bech32_address, 1_000_000)]); + inputs.extend(build_input_signing_data_foundry_outputs(vec![( + alias_id_1, + 1_000_000, + SimpleTokenScheme::new(U256::from(10), U256::from(0), U256::from(10)).unwrap(), + Some( + NativeToken::new( + TokenId::from_str("0x0811111111111111111111111111111111111111111111111111111111111111110000000000") + .unwrap(), + U256::from(10), + ) + .unwrap(), + ), + )])); + let outputs = vec![build_foundry_output( + alias_id_1, + 1_000_000, + // Melt 5 native tokens + SimpleTokenScheme::new(U256::from(10), U256::from(5), U256::from(10)).unwrap(), + None, + )]; + let selected_transaction_data = try_select_inputs(inputs.clone(), outputs, false, None, &rent_structure, false, 0)?; + // Alias next state + foundry + basic output with native tokens + assert_eq!(selected_transaction_data.outputs.len(), 3); + // Alias state index is increased + selected_transaction_data.outputs.iter().for_each(|output| { + if let Output::Alias(alias_output) = &output { + // Input alias has index 0, output should have index 1 + assert_eq!(alias_output.state_index(), 1); + } + if let Output::Basic(basic_output) = &output { + // Basic output remainder has the remaining native tokens + assert_eq!(*basic_output.native_tokens().first().unwrap().amount(), U256::from(5)); + } + }); + + Ok(()) +} diff --git a/tests/input_selection/basic_outputs.rs b/tests/input_selection/basic_outputs.rs new file mode 100644 index 000000000..ccc8058a9 --- /dev/null +++ b/tests/input_selection/basic_outputs.rs @@ -0,0 +1,65 @@ +// Copyright 2022 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_client::{api::input_selection::try_select_inputs, block::output::RentStructure, Error, Result}; + +use crate::input_selection::{build_input_signing_data_most_basic_outputs, build_most_basic_output}; + +#[test] +fn input_selection_basic_outputs() -> Result<()> { + let rent_structure = RentStructure::build() + .byte_cost(500) + .key_factor(10) + .data_factor(1) + .finish(); + + let bech32_address = "rms1qr2xsmt3v3eyp2ja80wd2sq8xx0fslefmxguf7tshzezzr5qsctzc2f5dg6"; + + // input amount == output amount + let inputs = build_input_signing_data_most_basic_outputs(vec![(bech32_address, 1_000_000)]); + let outputs = vec![build_most_basic_output(bech32_address, 1_000_000)]; + let selected_transaction_data = try_select_inputs(inputs.clone(), outputs, false, None, &rent_structure, false, 0)?; + assert_eq!(selected_transaction_data.inputs, inputs); + + // output amount > input amount + let inputs = build_input_signing_data_most_basic_outputs(vec![(bech32_address, 1_000_000)]); + let outputs = vec![build_most_basic_output(bech32_address, 2_000_000)]; + match try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0) { + Err(Error::NotEnoughBalance { + found: 1_000_000, + required: 2_000_000, + }) => {} + _ => panic!("Should return NotEnoughBalance"), + } + + // output amount < input amount + let inputs = build_input_signing_data_most_basic_outputs(vec![(bech32_address, 2_000_000)]); + let outputs = vec![build_most_basic_output(bech32_address, 1_000_000)]; + let selected_transaction_data = try_select_inputs(inputs.clone(), outputs, false, None, &rent_structure, false, 0)?; + assert_eq!(selected_transaction_data.inputs, inputs); + // One output should be added for the remainder + assert_eq!(selected_transaction_data.outputs.len(), 2); + + // 2 inputs, only one needed + let inputs = + build_input_signing_data_most_basic_outputs(vec![(bech32_address, 2_000_000), (bech32_address, 2_000_000)]); + let outputs = vec![build_most_basic_output(bech32_address, 1_000_000)]; + let selected_transaction_data = try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0)?; + // One input has enough amount + assert_eq!(selected_transaction_data.inputs.len(), 1); + // One output should be added for the remainder + assert_eq!(selected_transaction_data.outputs.len(), 2); + + // not enough storage deposit for remainder + let inputs = build_input_signing_data_most_basic_outputs(vec![(bech32_address, 1_000_001)]); + let outputs = vec![build_most_basic_output(bech32_address, 1_000_000)]; + match try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0) { + Err(Error::BlockError(bee_block::Error::InsufficientStorageDepositAmount { + amount: 1, + required: 213000, + })) => {} + _ => panic!("Should return InsufficientStorageDepositAmount"), + } + + Ok(()) +} diff --git a/tests/input_selection/mod.rs b/tests/input_selection/mod.rs new file mode 100644 index 000000000..f09ca1d15 --- /dev/null +++ b/tests/input_selection/mod.rs @@ -0,0 +1,172 @@ +// Copyright 2022 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use bee_block::output::{ + unlock_condition::{GovernorAddressUnlockCondition, StateControllerAddressUnlockCondition}, + SimpleTokenScheme, TokenScheme, +}; +use iota_client::{ + block::{ + address::{Address, AliasAddress}, + output::{ + unlock_condition::{AddressUnlockCondition, ImmutableAliasAddressUnlockCondition, UnlockCondition}, + AliasId, AliasOutputBuilder, BasicOutputBuilder, FoundryOutputBuilder, NativeToken, NftId, + NftOutputBuilder, Output, + }, + rand::{block::rand_block_id, transaction::rand_transaction_id}, + }, + constants::SHIMMER_TESTNET_BECH32_HRP, + secret::types::{InputSigningData, OutputMetadata}, +}; + +mod alias_foundry_outputs; +mod basic_outputs; +mod nft_outputs; + +fn build_most_basic_output(bech32_address: &str, amount: u64) -> Output { + BasicOutputBuilder::new_with_amount(amount) + .unwrap() + .add_unlock_condition(UnlockCondition::Address(AddressUnlockCondition::new( + Address::try_from_bech32(bech32_address).unwrap().1, + ))) + .finish_output() + .unwrap() +} + +fn build_nft_output(nft_id: NftId, bech32_address: &str, amount: u64) -> Output { + NftOutputBuilder::new_with_amount(amount, nft_id) + .unwrap() + .add_unlock_condition(UnlockCondition::Address(AddressUnlockCondition::new( + Address::try_from_bech32(bech32_address).unwrap().1, + ))) + .finish_output() + .unwrap() +} + +fn build_alias_output(alias_id: AliasId, bech32_address: &str, amount: u64) -> Output { + let address = Address::try_from_bech32(bech32_address).unwrap().1; + AliasOutputBuilder::new_with_amount(amount, alias_id) + .unwrap() + .add_unlock_condition(UnlockCondition::StateControllerAddress( + StateControllerAddressUnlockCondition::new(address), + )) + .add_unlock_condition(UnlockCondition::GovernorAddress(GovernorAddressUnlockCondition::new( + address, + ))) + .finish_output() + .unwrap() +} + +fn build_foundry_output( + alias_id: AliasId, + amount: u64, + token_scheme: SimpleTokenScheme, + native_token: Option, +) -> Output { + let mut foundry_output_builder = + FoundryOutputBuilder::new_with_amount(amount, 0, TokenScheme::Simple(token_scheme)) + .unwrap() + .add_unlock_condition(UnlockCondition::ImmutableAliasAddress( + ImmutableAliasAddressUnlockCondition::new(AliasAddress::new(alias_id)), + )); + if let Some(native_token) = native_token { + foundry_output_builder = foundry_output_builder.add_native_token(native_token); + } + foundry_output_builder.finish_output().unwrap() +} + +fn build_input_signing_data_most_basic_outputs(outputs: Vec<(&str, u64)>) -> Vec { + outputs + .into_iter() + .map(|(bech32_address, amount)| InputSigningData { + output: build_most_basic_output(bech32_address, amount), + output_metadata: OutputMetadata { + block_id: rand_block_id(), + transaction_id: rand_transaction_id(), + output_index: 0, + is_spent: false, + milestone_index_spent: None, + milestone_timestamp_spent: None, + transaction_id_spent: None, + milestone_index_booked: 0, + milestone_timestamp_booked: 0, + ledger_index: 0, + }, + chain: None, + bech32_address: bech32_address.to_string(), + }) + .collect() +} + +fn build_input_signing_data_nft_outputs(outputs: Vec<(NftId, &str, u64)>) -> Vec { + outputs + .into_iter() + .map(|(nft_id, bech32_address, amount)| InputSigningData { + output: build_nft_output(nft_id, bech32_address, amount), + output_metadata: OutputMetadata { + block_id: rand_block_id(), + transaction_id: rand_transaction_id(), + output_index: 0, + is_spent: false, + milestone_index_spent: None, + milestone_timestamp_spent: None, + transaction_id_spent: None, + milestone_index_booked: 0, + milestone_timestamp_booked: 0, + ledger_index: 0, + }, + chain: None, + bech32_address: bech32_address.to_string(), + }) + .collect() +} + +fn build_input_signing_data_alias_outputs(outputs: Vec<(AliasId, &str, u64)>) -> Vec { + outputs + .into_iter() + .map(|(alias_id, bech32_address, amount)| InputSigningData { + output: build_alias_output(alias_id, bech32_address, amount), + output_metadata: OutputMetadata { + block_id: rand_block_id(), + transaction_id: rand_transaction_id(), + output_index: 0, + is_spent: false, + milestone_index_spent: None, + milestone_timestamp_spent: None, + transaction_id_spent: None, + milestone_index_booked: 0, + milestone_timestamp_booked: 0, + ledger_index: 0, + }, + chain: None, + bech32_address: Address::Alias(AliasAddress::new(alias_id)).to_bech32(SHIMMER_TESTNET_BECH32_HRP), + }) + .collect() +} + +fn build_input_signing_data_foundry_outputs( + outputs: Vec<(AliasId, u64, SimpleTokenScheme, Option)>, +) -> Vec { + outputs + .into_iter() + .map( + |(alias_id, amount, simple_token_scheme, native_token)| InputSigningData { + output: build_foundry_output(alias_id, amount, simple_token_scheme, native_token), + output_metadata: OutputMetadata { + block_id: rand_block_id(), + transaction_id: rand_transaction_id(), + output_index: 0, + is_spent: false, + milestone_index_spent: None, + milestone_timestamp_spent: None, + transaction_id_spent: None, + milestone_index_booked: 0, + milestone_timestamp_booked: 0, + ledger_index: 0, + }, + chain: None, + bech32_address: Address::Alias(AliasAddress::new(alias_id)).to_bech32(SHIMMER_TESTNET_BECH32_HRP), + }, + ) + .collect() +} diff --git a/tests/input_selection/nft_outputs.rs b/tests/input_selection/nft_outputs.rs new file mode 100644 index 000000000..3187f56ca --- /dev/null +++ b/tests/input_selection/nft_outputs.rs @@ -0,0 +1,103 @@ +// Copyright 2022 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::str::FromStr; + +use iota_client::{ + api::input_selection::try_select_inputs, + block::output::{NftId, Output, RentStructure}, + Error, Result, +}; + +use crate::input_selection::{ + build_input_signing_data_most_basic_outputs, build_input_signing_data_nft_outputs, build_most_basic_output, + build_nft_output, +}; + +#[test] +fn input_selection_nfts() -> Result<()> { + let rent_structure = RentStructure::build() + .byte_cost(500) + .key_factor(10) + .data_factor(1) + .finish(); + + let nft_id_0 = NftId::from_str("0x0000000000000000000000000000000000000000000000000000000000000000").unwrap(); + let nft_id_1 = NftId::from_str("0x1111111111111111111111111111111111111111111111111111111111111111").unwrap(); + let bech32_address = "rms1qr2xsmt3v3eyp2ja80wd2sq8xx0fslefmxguf7tshzezzr5qsctzc2f5dg6"; + + // input nft == output nft + let inputs = build_input_signing_data_nft_outputs(vec![(nft_id_1, bech32_address, 1_000_000)]); + let outputs = vec![build_nft_output(nft_id_1, bech32_address, 1_000_000)]; + let selected_transaction_data = try_select_inputs(inputs.clone(), outputs, false, None, &rent_structure, false, 0)?; + assert_eq!(selected_transaction_data.inputs, inputs); + + // output amount > input amount + let inputs = build_input_signing_data_nft_outputs(vec![(nft_id_1, bech32_address, 1_000_000)]); + let outputs = vec![build_most_basic_output(bech32_address, 2_000_000)]; + match try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0) { + Err(Error::NotEnoughBalance { + found: 1_000_000, + // Amount we want to send + storage deposit for nft remainder + required: 2_229_500, + }) => {} + _ => panic!("Should return NotEnoughBalance"), + } + + // basic output with nft as input + let inputs = build_input_signing_data_nft_outputs(vec![(nft_id_1, bech32_address, 2_229_500)]); + let outputs = vec![build_most_basic_output(bech32_address, 2_000_000)]; + let selected_transaction_data = try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0)?; + // basic output + nft remainder + assert_eq!(selected_transaction_data.outputs.len(), 2); + + // mint nft + let inputs = build_input_signing_data_most_basic_outputs(vec![(bech32_address, 2_000_000)]); + let outputs = vec![build_nft_output(nft_id_0, bech32_address, 1_000_000)]; + let selected_transaction_data = try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0)?; + // One output should be added for the remainder + assert_eq!(selected_transaction_data.outputs.len(), 2); + // Output contains the new minted nft id + assert!(selected_transaction_data.outputs.iter().any(|output| { + if let Output::Nft(nft_output) = output { + *nft_output.nft_id() == nft_id_0 + } else { + false + } + })); + + // burn nft + let inputs = build_input_signing_data_nft_outputs(vec![(nft_id_1, bech32_address, 2_000_000)]); + let outputs = vec![build_most_basic_output(bech32_address, 2_000_000)]; + let selected_transaction_data = try_select_inputs(inputs, outputs, false, None, &rent_structure, true, 0)?; + // No remainder + assert_eq!(selected_transaction_data.outputs.len(), 1); + // Output is a basic output + assert!(matches!(selected_transaction_data.outputs[0], Output::Basic(_))); + + // not enough storage deposit for remainder + let inputs = build_input_signing_data_nft_outputs(vec![(nft_id_1, bech32_address, 1_000_001)]); + let outputs = vec![build_nft_output(nft_id_1, bech32_address, 1_000_000)]; + match try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0) { + Err(Error::BlockError(bee_block::Error::InsufficientStorageDepositAmount { + amount: 1, + required: 213000, + })) => {} + _ => panic!("Should return InsufficientStorageDepositAmount"), + } + + // missing input for output nft + let inputs = build_input_signing_data_most_basic_outputs(vec![(bech32_address, 1_000_000)]); + let outputs = vec![build_nft_output(nft_id_1, bech32_address, 1_000_000)]; + match try_select_inputs(inputs, outputs, false, None, &rent_structure, false, 0) { + Err(Error::MissingInput(err_msg)) => { + assert_eq!( + &err_msg, + "Missing nft input for 0x1111111111111111111111111111111111111111111111111111111111111111" + ); + } + _ => panic!("Should return missing nft input"), + } + + Ok(()) +} diff --git a/tests/mod.rs b/tests/mod.rs new file mode 100644 index 000000000..805263b86 --- /dev/null +++ b/tests/mod.rs @@ -0,0 +1,4 @@ +// Copyright 2022 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod input_selection;