From e049dc764d462273b06592d57a83d623ed010479 Mon Sep 17 00:00:00 2001 From: Ivan Schuetz Date: Wed, 8 Dec 2021 17:42:56 +0100 Subject: [PATCH] Implement applications feature --- Cargo.toml | 1 + algonaut_client/src/kmd/v1/mod.rs | 4 +- algonaut_transaction/src/account.rs | 1 + algonaut_transaction/src/api_model.rs | 2 +- src/kmd/v1/mod.rs | 4 +- tests/features_runner.rs | 12 +- tests/step_defs/integration/applications.rs | 532 ++++++++++++++++++++ tests/step_defs/integration/mod.rs | 1 + tests/step_defs/mod.rs | 1 + tests/step_defs/util.rs | 75 +++ 10 files changed, 626 insertions(+), 7 deletions(-) create mode 100644 tests/step_defs/integration/applications.rs create mode 100644 tests/step_defs/util.rs diff --git a/Cargo.toml b/Cargo.toml index 80752437..1bddbeed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ algonaut_encoding = {path = "algonaut_encoding", version = "0.3.0"} algonaut_transaction = {path = "algonaut_transaction", version = "0.3.0"} thiserror = "1.0.23" rmp-serde = "0.15.5" +tokio = { version = "1.6.0", features = ["rt-multi-thread", "macros"] } [dev-dependencies] dotenv = "0.15.0" diff --git a/algonaut_client/src/kmd/v1/mod.rs b/algonaut_client/src/kmd/v1/mod.rs index 27de43ee..ac3182aa 100644 --- a/algonaut_client/src/kmd/v1/mod.rs +++ b/algonaut_client/src/kmd/v1/mod.rs @@ -1,7 +1,7 @@ use crate::extensions::reqwest::ResponseExt; use crate::Headers; use crate::{error::ClientError, extensions::reqwest::to_header_map}; -use algonaut_core::MultisigSignature; +use algonaut_core::{Address, MultisigSignature}; use algonaut_crypto::{Ed25519PublicKey, MasterDerivationKey}; use algonaut_model::kmd::v1::{ CreateWalletRequest, CreateWalletResponse, DeleteKeyRequest, DeleteKeyResponse, @@ -263,7 +263,7 @@ impl Client { &self, wallet_handle: &str, wallet_password: &str, - address: &str, + address: &Address, ) -> Result { let req = ExportKeyRequest { wallet_handle_token: wallet_handle.to_string(), diff --git a/algonaut_transaction/src/account.rs b/algonaut_transaction/src/account.rs index 1e526bb4..e284ed1c 100644 --- a/algonaut_transaction/src/account.rs +++ b/algonaut_transaction/src/account.rs @@ -14,6 +14,7 @@ use rand::Rng; use ring::signature::{Ed25519KeyPair, KeyPair}; use serde::{Deserialize, Serialize}; +#[derive(Debug)] pub struct Account { seed: [u8; 32], address: Address, diff --git a/algonaut_transaction/src/api_model.rs b/algonaut_transaction/src/api_model.rs index 646bd7d7..36a3dc89 100644 --- a/algonaut_transaction/src/api_model.rs +++ b/algonaut_transaction/src/api_model.rs @@ -269,7 +269,7 @@ impl From for ApiTransaction { api_t.app_id = call.app_id.and_then(num_as_api_option); api_t.on_complete = num_as_api_option(application_call_on_complete_to_int(&call.on_complete)); - api_t.accounts = call.accounts.to_owned(); + api_t.accounts = call.accounts.clone().and_then(vec_as_api_option); api_t.approval_program = call .approval_program .to_owned() diff --git a/src/kmd/v1/mod.rs b/src/kmd/v1/mod.rs index 9517bebe..bcc31447 100644 --- a/src/kmd/v1/mod.rs +++ b/src/kmd/v1/mod.rs @@ -1,5 +1,5 @@ use algonaut_client::kmd::v1::Client; -use algonaut_core::{MultisigSignature, ToMsgPack}; +use algonaut_core::{Address, MultisigSignature, ToMsgPack}; use algonaut_crypto::{Ed25519PublicKey, MasterDerivationKey}; use algonaut_model::kmd::v1::{ CreateWalletResponse, DeleteKeyResponse, DeleteMultisigResponse, ExportKeyResponse, @@ -134,7 +134,7 @@ impl Kmd { &self, wallet_handle: &str, wallet_password: &str, - address: &str, + address: &Address, ) -> Result { Ok(self .client diff --git a/tests/features_runner.rs b/tests/features_runner.rs index 04d0f960..86853b90 100644 --- a/tests/features_runner.rs +++ b/tests/features_runner.rs @@ -5,8 +5,16 @@ mod step_defs; #[tokio::main] async fn main() { - integration::algod::World::run(integration_path("algod")).await; - integration::abi::World::run(integration_path("abi")).await; + // algod feature: omitted, this tests v1 and we don't support it anymore + // integration::algod::World::run(integration_path("algod")).await; + + // ABI not supported yet + // integration::abi::World::run(integration_path("abi")).await; + + integration::applications::World::cucumber() + .max_concurrent_scenarios(1) + .run(integration_path("applications")) + .await; } fn integration_path(feature_name: &str) -> String { diff --git a/tests/step_defs/integration/applications.rs b/tests/step_defs/integration/applications.rs new file mode 100644 index 00000000..021b30b3 --- /dev/null +++ b/tests/step_defs/integration/applications.rs @@ -0,0 +1,532 @@ +use crate::step_defs::util::{ + account_from_kmd_response, parse_app_args, split_addresses, split_uint64, + wait_for_pending_transaction, +}; +use algonaut::algod::AlgodBuilder; +use algonaut::kmd::KmdBuilder; +use algonaut::{algod::v2::Algod, kmd::v1::Kmd}; +use algonaut_core::{Address, CompiledTealBytes, MicroAlgos}; +use algonaut_model::algod::v2::{Application, ApplicationLocalState}; +use algonaut_transaction::account::Account; +use algonaut_transaction::builder::{ + CallApplication, ClearApplication, CloseApplication, DeleteApplication, OptInApplication, + UpdateApplication, +}; +use algonaut_transaction::transaction::StateSchema; +use algonaut_transaction::{CreateApplication, Pay, Transaction, TxnBuilder}; +use async_trait::async_trait; +use cucumber::{given, then, WorldInit}; +use data_encoding::BASE64; +use std::convert::Infallible; +use std::error::Error; +use std::fs; + +#[derive(Default, Debug, WorldInit)] +pub struct World { + algod: Option, + + kmd: Option, + handle: Option, + password: Option, + accounts: Option>, + + transient_account: Option, + + tx: Option, + tx_id: Option, + + app_id: Option, +} + +#[async_trait(?Send)] +impl cucumber::World for World { + type Error = Infallible; + + async fn new() -> Result { + Ok(Self::default()) + } +} + +#[given(expr = "an algod client")] +async fn an_algod_client(_: &mut World) { + // do nothing - we don't support v1 +} + +#[given(expr = "a kmd client")] +async fn a_kmd_client(w: &mut World) { + let kmd = KmdBuilder::new() + .bind("http://localhost:60001") + .auth("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .build_v1() + .unwrap(); + w.kmd = Some(kmd) +} + +#[given(expr = "wallet information")] +async fn wallet_information(w: &mut World) -> Result<(), Box> { + let kmd = w.kmd.as_ref().unwrap(); + + let list_response = kmd.list_wallets().await?; + let wallet_id = match list_response + .wallets + .into_iter() + .find(|wallet| wallet.name == "unencrypted-default-wallet") + { + Some(wallet) => wallet.id, + None => return Err("Wallet not found".into()), + }; + let password = ""; + let init_response = kmd.init_wallet_handle(&wallet_id, "").await?; + + let keys = kmd + .list_keys(init_response.wallet_handle_token.as_ref()) + .await?; + + w.password = Some(password.to_owned()); + w.handle = Some(init_response.wallet_handle_token); + w.accounts = Some( + keys.addresses + .into_iter() + .map(|s| s.parse().unwrap()) + .collect(), + ); + + Ok(()) +} + +#[given(regex = r#"^an algod v2 client connected to "([^"]*)" port (\d+) with token "([^"]*)"$"#)] +async fn an_algod_v2_client_connected_to(w: &mut World) { + let algod = AlgodBuilder::new() + .bind("http://localhost:60000") + .auth("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .build_v2() + .unwrap(); + + w.algod = Some(algod) +} + +#[given(regex = r#"^I create a new transient account and fund it with (\d+) microalgos\.$"#)] +async fn i_create_a_new_transient_account_and_fund_it_with_microalgos( + w: &mut World, + micro_algos: u64, +) -> Result<(), Box> { + let kmd = w.kmd.as_ref().unwrap(); + let algod = w.algod.as_ref().unwrap(); + let accounts = w.accounts.as_ref().unwrap(); + let password = w.password.as_ref().unwrap(); + let handle = w.handle.as_ref().unwrap(); + + let sender_address = accounts[1]; + + let sender_key = kmd.export_key(handle, password, &sender_address).await?; + + let sender_account = account_from_kmd_response(&sender_key)?; + + let params = algod.suggested_transaction_params().await?; + let tx = TxnBuilder::with( + params, + Pay::new( + accounts[1], + sender_account.address(), + MicroAlgos(micro_algos), + ) + .build(), + ) + .build(); + + let s_tx = sender_account.sign_transaction(&tx)?; + + let send_response = algod.broadcast_signed_transaction(&s_tx).await?; + let _ = wait_for_pending_transaction(&algod, &send_response.tx_id); + + w.transient_account = Some(sender_account); + + Ok(()) +} + +#[given( + regex = r#"^I build an application transaction with the transient account, the current application, suggested params, operation "([^"]*)", approval-program "([^"]*)", clear-program "([^"]*)", global-bytes (\d+), global-ints (\d+), local-bytes (\d+), local-ints (\d+), app-args "([^"]*)", foreign-apps "([^"]*)", foreign-assets "([^"]*)", app-accounts "([^"]*)", extra-pages (\d+)$"# +)] +async fn i_build_an_application_transaction_given( + w: &mut World, + operation: String, + approval_program_file: String, + clear_program_file: String, + global_bytes: u64, + global_ints: u64, + local_bytes: u64, + local_ints: u64, + app_args: String, + foreign_apps: String, + foreign_assets: String, + app_accounts: String, + extra_pages: u64, +) -> Result<(), Box> { + i_build_an_application_transaction( + w, + operation, + approval_program_file, + clear_program_file, + global_bytes, + global_ints, + local_bytes, + local_ints, + app_args, + foreign_apps, + foreign_assets, + app_accounts, + extra_pages, + ) + .await +} + +#[then( + regex = r#"^I build an application transaction with the transient account, the current application, suggested params, operation "([^"]*)", approval-program "([^"]*)", clear-program "([^"]*)", global-bytes (\d+), global-ints (\d+), local-bytes (\d+), local-ints (\d+), app-args "([^"]*)", foreign-apps "([^"]*)", foreign-assets "([^"]*)", app-accounts "([^"]*)", extra-pages (\d+)$"# +)] +async fn i_build_an_application_transaction_then( + w: &mut World, + operation: String, + approval_program_file: String, + clear_program_file: String, + global_bytes: u64, + global_ints: u64, + local_bytes: u64, + local_ints: u64, + app_args: String, + foreign_apps: String, + foreign_assets: String, + app_accounts: String, + extra_pages: u64, +) -> Result<(), Box> { + i_build_an_application_transaction( + w, + operation, + approval_program_file, + clear_program_file, + global_bytes, + global_ints, + local_bytes, + local_ints, + app_args, + foreign_apps, + foreign_assets, + app_accounts, + extra_pages, + ) + .await +} + +// given/when repetition: https://github.com/cucumber-rs/cucumber/issues/183 +async fn i_build_an_application_transaction( + w: &mut World, + operation: String, + approval_program_file: String, + clear_program_file: String, + global_bytes: u64, + global_ints: u64, + local_bytes: u64, + local_ints: u64, + app_args: String, + foreign_apps: String, + foreign_assets: String, + app_accounts: String, + extra_pages: u64, +) -> Result<(), Box> { + let algod = w.algod.as_ref().unwrap(); + let transient_account = w.transient_account.as_ref().unwrap(); + + let args = parse_app_args(app_args)?; + + let accounts = split_addresses(app_accounts)?; + + let foreign_apps = split_uint64(&foreign_apps)?; + let foreign_assets = split_uint64(&foreign_assets)?; + + let params = algod.suggested_transaction_params().await?; + + let tx_type = match operation.as_str() { + "create" => { + let approval_program = load_teal(&approval_program_file)?; + let clear_program = load_teal(&clear_program_file)?; + + let global_schema = StateSchema { + number_ints: global_ints, + number_byteslices: global_bytes, + }; + + let local_schema = StateSchema { + number_ints: local_ints, + number_byteslices: local_bytes, + }; + + CreateApplication::new( + transient_account.address(), + CompiledTealBytes(approval_program), + CompiledTealBytes(clear_program), + global_schema, + local_schema, + ) + .foreign_assets(foreign_assets) + .foreign_apps(foreign_apps) + .accounts(accounts) + .app_arguments(args) + .extra_pages(extra_pages) + .build() + } + "update" => { + let app_id = w.app_id.unwrap(); + + let approval_program = load_teal(&approval_program_file)?; + let clear_program = load_teal(&clear_program_file)?; + + UpdateApplication::new( + transient_account.address(), + app_id, + CompiledTealBytes(approval_program), + CompiledTealBytes(clear_program), + ) + .foreign_assets(foreign_assets) + .foreign_apps(foreign_apps) + .accounts(accounts) + .app_arguments(args) + .build() + } + "call" => { + let app_id = w.app_id.unwrap(); + CallApplication::new(transient_account.address(), app_id) + .foreign_assets(foreign_assets) + .foreign_apps(foreign_apps) + .accounts(accounts) + .app_arguments(args) + .build() + } + "optin" => { + let app_id = w.app_id.unwrap(); + + OptInApplication::new(transient_account.address(), app_id) + .foreign_assets(foreign_assets) + .foreign_apps(foreign_apps) + .accounts(accounts) + .app_arguments(args) + .build() + } + "clear" => { + let app_id = w.app_id.unwrap(); + ClearApplication::new(transient_account.address(), app_id) + .foreign_assets(foreign_assets) + .foreign_apps(foreign_apps) + .accounts(accounts) + .app_arguments(args) + .build() + } + "closeout" => { + let app_id = w.app_id.unwrap(); + CloseApplication::new(transient_account.address(), app_id) + .foreign_assets(foreign_assets) + .foreign_apps(foreign_apps) + .accounts(accounts) + .app_arguments(args) + .build() + } + "delete" => { + let app_id = w.app_id.unwrap(); + DeleteApplication::new(transient_account.address(), app_id) + .foreign_assets(foreign_assets) + .foreign_apps(foreign_apps) + .accounts(accounts) + .app_arguments(args) + .build() + } + + _ => Err(format!("Invalid str: {}", operation))?, + }; + + w.tx = Some(TxnBuilder::with(params, tx_type).build()); + + Ok(()) +} + +#[given( + regex = r#"I sign and submit the transaction, saving the txid\. If there is an error it is "([^"]*)"\.$"# +)] +async fn i_sign_and_submit_the_transaction_saving_the_tx_id_if_there_is_an_error_it_is_given( + w: &mut World, + err: String, +) { + i_sign_and_submit_the_transaction_saving_the_tx_id_if_there_is_an_error_it_is(w, err).await +} + +#[then( + regex = r#"I sign and submit the transaction, saving the txid\. If there is an error it is "([^"]*)"\.$"# +)] +async fn i_sign_and_submit_the_transaction_saving_the_tx_id_if_there_is_an_error_it_is_then( + w: &mut World, + err: String, +) { + i_sign_and_submit_the_transaction_saving_the_tx_id_if_there_is_an_error_it_is(w, err).await +} + +// given/when repetition: https://github.com/cucumber-rs/cucumber/issues/183 +async fn i_sign_and_submit_the_transaction_saving_the_tx_id_if_there_is_an_error_it_is( + w: &mut World, + err: String, +) { + let algod = w.algod.as_ref().unwrap(); + let transient_account = w.transient_account.as_ref().unwrap(); + let tx = w.tx.as_ref().unwrap(); + + let s_tx = transient_account.sign_transaction(&tx).unwrap(); + + match algod.broadcast_signed_transaction(&s_tx).await { + Ok(response) => { + w.tx_id = Some(response.tx_id); + } + Err(e) => { + assert!(e.to_string().contains(&err)); + } + } +} + +#[given(expr = "I wait for the transaction to be confirmed.")] +async fn i_wait_for_the_transaction_to_be_confirmed_given(w: &mut World) { + i_wait_for_the_transaction_to_be_confirmed(w).await +} + +#[then(expr = "I wait for the transaction to be confirmed.")] +async fn i_wait_for_the_transaction_to_be_confirmed_then(w: &mut World) { + i_wait_for_the_transaction_to_be_confirmed(w).await +} + +// given/when repetition: https://github.com/cucumber-rs/cucumber/issues/183 +async fn i_wait_for_the_transaction_to_be_confirmed(w: &mut World) { + let algod = w.algod.as_ref().unwrap(); + let tx_id = w.tx_id.as_ref().unwrap(); + + wait_for_pending_transaction(&algod, &tx_id).await.unwrap(); +} + +#[given(expr = "I remember the new application ID.")] +async fn i_remember_the_new_application_id(w: &mut World) { + let algod = w.algod.as_ref().unwrap(); + let tx_id = w.tx_id.as_ref().unwrap(); + + let p_tx = algod.pending_transaction_with_id(tx_id).await.unwrap(); + assert!(p_tx.application_index.is_some()); + + w.app_id = p_tx.application_index; +} + +#[then( + regex = r#"^The transient account should have the created app "([^"]*)" and total schema byte-slices (\d+) and uints (\d+), the application "([^"]*)" state contains key "([^"]*)" with value "([^"]*)"$"# +)] +async fn the_transient_account_should_have( + w: &mut World, + app_created: bool, + byte_slices: u64, + uints: u64, + application_state: String, + key: String, + value: String, +) -> Result<(), Box> { + let algod = w.algod.as_ref().unwrap(); + let transient_account = w.transient_account.as_ref().unwrap(); + let app_id = w.app_id.unwrap(); + + let account_infos = algod + .account_information(&transient_account.address()) + .await + .unwrap(); + + assert!(account_infos.apps_total_schema.is_some()); + let total_schema = account_infos.apps_total_schema.unwrap(); + + assert_eq!(byte_slices, total_schema.num_byte_slice); + assert_eq!(uints, total_schema.num_uint); + + let app_in_account = account_infos.created_apps.iter().any(|a| a.id == app_id); + + match (app_created, app_in_account) { + (true, false) => Err(format!("AppId {} is not found in the account", app_id))?, + (false, true) => { + // If no app was created, we don't expect it to be in the account + Err("AppId is not expected to be in the account")? + } + _ => {} + } + + if key.is_empty() { + return Ok(()); + } + + let key_values = match application_state.to_lowercase().as_ref() { + "local" => { + let local_state = account_infos + .apps_local_state + .iter() + .filter(|s| s.id == app_id) + .collect::>(); + + let len = local_state.len(); + if len == 1 { + local_state[0].key_value.clone() + } else { + Err(format!( + "Expected only one matching local state, found {}", + len + ))? + } + } + "global" => { + let apps = account_infos + .created_apps + .iter() + .filter(|s| s.id == app_id) + .collect::>(); + + let len = apps.len(); + if len == 1 { + apps[0].params.global_state.clone() + } else { + Err(format!("Expected only one matching app, found {}", len))? + } + } + _ => Err(format!("Unknown application state: {}", application_state))?, + }; + + if key_values.is_empty() { + Err("Expected key values length to be greater than 0")? + } + + let mut key_value_found = false; + for key_value in key_values.iter().filter(|kv| kv.key == key) { + if key_value.value.value_type == 1 { + let value_bytes = BASE64.decode(value.as_bytes())?; + if key_value.value.bytes != value_bytes { + Err(format!( + "Value mismatch (bytes): expected: '{:?}', got '{:?}'", + value_bytes, key_value.value.bytes + ))? + } + } else if key_value.value.value_type == 0 { + let int_value = value.parse::()?; + + if key_value.value.uint != int_value { + Err(format!( + "Value mismatch (uint): expected: '{}', got '{}'", + value, key_value.value.uint + ))? + } + } + key_value_found = true; + } + + if !key_value_found { + Err(format!("Couldn't find key: '{}'", key))? + } + + Ok(()) +} + +fn load_teal(file_name: &str) -> Result, Box> { + Ok(fs::read(format!("tests/features/resources/{}", file_name))?) +} diff --git a/tests/step_defs/integration/mod.rs b/tests/step_defs/integration/mod.rs index f6bb8748..4dd12ffb 100644 --- a/tests/step_defs/integration/mod.rs +++ b/tests/step_defs/integration/mod.rs @@ -1,2 +1,3 @@ pub mod abi; pub mod algod; +pub mod applications; diff --git a/tests/step_defs/mod.rs b/tests/step_defs/mod.rs index 5155b774..299ac737 100644 --- a/tests/step_defs/mod.rs +++ b/tests/step_defs/mod.rs @@ -1 +1,2 @@ pub mod integration; +mod util; diff --git a/tests/step_defs/util.rs b/tests/step_defs/util.rs new file mode 100644 index 00000000..e02b954c --- /dev/null +++ b/tests/step_defs/util.rs @@ -0,0 +1,75 @@ +use std::{ + convert::TryInto, + error::Error, + num::ParseIntError, + time::{Duration, Instant}, +}; + +use algonaut::{algod::v2::Algod, error::AlgonautError}; +use algonaut_core::Address; +use algonaut_model::{algod::v2::PendingTransaction, kmd::v1::ExportKeyResponse}; +use algonaut_transaction::account::Account; + +/// Utility function to wait on a transaction to be confirmed +pub async fn wait_for_pending_transaction( + algod: &Algod, + txid: &str, +) -> Result, AlgonautError> { + let timeout = Duration::from_secs(10); + let start = Instant::now(); + loop { + let pending_transaction = algod.pending_transaction_with_id(txid).await?; + // If the transaction has been confirmed or we time out, exit. + if pending_transaction.confirmed_round.is_some() { + return Ok(Some(pending_transaction)); + } else if start.elapsed() >= timeout { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(250)) + } +} + +pub fn split_uint64(args_str: &str) -> Result, ParseIntError> { + if args_str.is_empty() { + return Ok(vec![]); + } + args_str.split(",").map(|a| a.parse()).collect() +} + +pub fn split_addresses(args_str: String) -> Result, String> { + if args_str.is_empty() { + return Ok(vec![]); + } + args_str.split(",").map(|a| a.parse()).collect() +} + +pub fn parse_app_args(args_str: String) -> Result>, Box> { + if args_str.is_empty() { + return Ok(vec![]); + } + + let args = args_str.split(","); + + let mut args_bytes: Vec> = vec![]; + for arg in args { + let parts = arg.split(":").collect::>(); + let type_part = parts[0]; + match type_part { + "str" => args_bytes.push(parts[1].as_bytes().to_vec()), + "int" => { + let int = parts[1].parse::()?; + args_bytes.push(int.to_be_bytes().to_vec()); + } + _ => Err(format!( + "Applications doesn't currently support argument of type {}", + type_part + ))?, + } + } + + Ok(args_bytes) +} + +pub fn account_from_kmd_response(key_res: &ExportKeyResponse) -> Result> { + Ok(Account::from_seed(key_res.private_key[0..32].try_into()?)) +}