diff --git a/src/commands/build.rs b/src/commands/build.rs index 07a406d..97e5c23 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -162,6 +162,7 @@ impl Display for Tx3Type { ir::Type::List => write!(f, "list"), ir::Type::Map => write!(f, "map"), ir::Type::Custom(name) => write!(f, "custom({})", name), + _ => unreachable!(), } } } diff --git a/src/commands/expect.rs b/src/commands/expect.rs new file mode 100644 index 0000000..c4603f5 --- /dev/null +++ b/src/commands/expect.rs @@ -0,0 +1,103 @@ +use std::path::Path; + +use miette::Result; + +use crate::spawn::cshell; + +// Import Expect types from the `test` module +use crate::commands::test::ExpectUtxo; + +/// Run all checks for a slice of `ExpectUtxo` expectations. +/// Returns `Ok(true)` when any expectation failed, `Ok(false)` when all passed. +/// +/// +pub fn expect_utxo(expects: &[ExpectUtxo], test_home: &Path) -> Result { + let mut failed_any = false; + + for expect in expects.iter() { + let mut failed = false; + + let utxos = cshell::wallet_utxos(&test_home, &expect.from)?; + + if expect.datum_equals.is_none() && expect.min_amount.is_empty() { + if utxos.is_empty() { + failed_any = true; + eprintln!("Test Failed: No UTXOs found for wallet `{}`.", expect.from); + } + continue; + } + + // Find UTXOs that match the datum if specified + let matching_utxos: Vec<_> = if let Some(expected_datum) = &expect.datum_equals { + utxos + .iter() + .filter(|utxo| { + if let Some(datum) = &utxo.datum { + match expected_datum { + serde_json::Value::String(s) => hex::encode(&datum.hash) == *s, + _ => false, + } + } else { + false + } + }) + .collect() + } else { + // If no datum_equals specified, consider all UTXOs + utxos.iter().collect() + }; + + for min_req in &expect.min_amount { + let total_amount: u64 = + if let (Some(policy), Some(name)) = (&min_req.policy, &min_req.name) { + // Check for specific asset + matching_utxos + .iter() + .flat_map(|utxo| utxo.assets.iter()) + .map(|bal| { + let policy_hex = hex::encode(&bal.policy_id); + if policy_hex == *policy { + bal.assets + .iter() + .filter(|asset| String::from_utf8_lossy(&asset.name) == *name) + .map(|asset| asset.output_coin.parse::().unwrap_or(0)) + .sum::() + } else { + 0u64 + } + }) + .sum() + } else { + // Check for lovelace + matching_utxos + .iter() + .map(|utxo| utxo.coin.parse::().unwrap_or(0)) + .sum() + }; + + if total_amount < min_req.amount { + failed = true; + + let asset_desc = + if let (Some(policy), Some(name)) = (&min_req.policy, &min_req.name) { + format!("asset {}.{}", policy, name) + } else { + "lovelace".to_string() + }; + + eprintln!( + "Test Failed: wallet `{}` with insufficient {}.", + expect.from, asset_desc + ); + eprintln!("Expected minimum: {}", min_req.amount); + eprintln!("Found: {}", total_amount); + } + } + + if failed { + failed_any = true; + } + } + + Ok(failed_any) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 763f5a0..3bc06f0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod bindgen; pub mod build; pub mod check; pub mod devnet; +pub mod expect; pub mod init; pub mod inspect; pub mod publish; diff --git a/src/commands/test.rs b/src/commands/test.rs index bcf6760..6f443af 100644 --- a/src/commands/test.rs +++ b/src/commands/test.rs @@ -1,6 +1,5 @@ use std::{ collections::HashMap, - fmt::Display, path::{Path, PathBuf}, thread::sleep, time::Duration, @@ -30,7 +29,7 @@ struct Test { file: PathBuf, wallets: Vec, transactions: Vec, - expect: Vec, + expect: Vec, } #[derive(Debug, Serialize, Deserialize)] @@ -48,53 +47,17 @@ struct Transaction { } #[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "type")] -enum Expect { - Balance(ExpectBalance), - // TODO: improve expect adding more options +pub(crate) struct ExpectUtxo { + pub(crate) from: String, + pub(crate) datum_equals: Option, + pub(crate) min_amount: Vec, } #[derive(Debug, Serialize, Deserialize)] -struct ExpectBalance { - wallet: String, - amount: ExpectAmount, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] -enum ExpectAmount { - Absolute(u64), - Aprox(ExpectAmountAprox), -} - -impl ExpectAmount { - pub fn matches(&self, value: u64) -> bool { - match self { - ExpectAmount::Absolute(x) => x.eq(&value), - ExpectAmount::Aprox(x) => { - let lower = x.target.saturating_sub(x.threshold); - let upper = x.target + x.threshold; - value >= lower && value <= upper - } - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -struct ExpectAmountAprox { - target: u64, - threshold: u64, -} - -impl Display for ExpectAmount { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ExpectAmount::Absolute(value) => write!(f, "{value}"), - ExpectAmount::Aprox(value) => { - write!(f, "target: ~{} (+/- {})", value.target, value.threshold) - } - } - } +pub(crate) struct ExpectMinAmount { + pub(crate) policy: Option, + pub(crate) name: Option, + pub(crate) amount: u64, } fn ensure_test_home(test: &Test, hashable: &[u8]) -> Result { @@ -207,6 +170,75 @@ fn trigger_transaction( Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn parse_expect_utxo_toml() { + let toml = r#" + file = "./main.tx3" + + [[wallets]] + name = "oracle" + balance = 10000000 + + [[wallets]] + name = "operator" + balance = 5000000 + + [[transactions]] + description = "Simple Oracle" + template = "create" + signers = ["operator"] + args = { rate = 42, operator = "@operator", oracle = "@oracle" } + + [[expect]] + from = "@oracle" + datum_equals = 42 + + [[expect.min_amount]] + amount = 123 + + [[expect.min_amount]] + policy = "xyz" + name = "abc" + amount = 456 + "#; + + let parsed: Test = toml::from_str(toml).expect("parse toml"); + + assert_eq!(parsed.file, PathBuf::from("./main.tx3")); + assert_eq!(parsed.wallets.len(), 2); + + assert_eq!(parsed.transactions.len(), 1); + + assert_eq!(parsed.expect.len(), 1); + let e = &parsed.expect[0]; + assert_eq!(e.from, "@oracle"); + + assert!(e.datum_equals.is_some()); + let datum = e.datum_equals.as_ref().unwrap(); + match datum { + serde_json::Value::Number(n) => { + assert_eq!(n.as_i64(), Some(42)); + } + other => panic!("unexpected datum kind: {other:?}"), + } + + let mins = &e.min_amount; + assert_eq!(mins.len(), 2); + + assert_eq!(mins[0].amount, 123); + assert!(mins[0].policy.is_none() && mins[0].name.is_none()); + + assert_eq!(mins[1].policy.as_ref().unwrap(), "xyz"); + assert_eq!(mins[1].name.as_ref().unwrap(), "abc"); + assert_eq!(mins[1].amount, 456); + } +} + pub fn run(args: Args, _config: &Config, profile: &ProfileConfig) -> Result<()> { println!("== Starting tests ==\n"); let test_content = std::fs::read_to_string(args.path).into_diagnostic()?; @@ -232,28 +264,7 @@ pub fn run(args: Args, _config: &Config, profile: &ProfileConfig) -> Result<()> sleep(Duration::from_secs(BLOCK_PRODUCTION_INTERVAL_SECONDS)); } - for expect in test.expect.iter() { - match expect { - Expect::Balance(expect) => { - let balance = crate::spawn::cshell::wallet_balance(&test_home, &expect.wallet)?; - - let r#match = expect.amount.matches(balance.coin); - - if !r#match { - failed = true; - - eprintln!( - "Test Failed: `{}` Balance did not match the expected result.", - expect.wallet - ); - eprintln!("Expected: {}", expect.amount); - eprintln!("Received: {}", balance.coin); - - eprintln!("Hint: Check the tx3 file or the test file."); - } - } - } - } + failed |= crate::commands::expect::expect_utxo(&test.expect, &test_home)?; if !failed { println!("Test Passed\n"); diff --git a/src/spawn/cshell.rs b/src/spawn/cshell.rs index 9b4771e..8992aa1 100644 --- a/src/spawn/cshell.rs +++ b/src/spawn/cshell.rs @@ -37,6 +37,37 @@ pub struct OutputBalance { pub coin: u64, } +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct Asset { + #[serde(with = "hex::serde")] + pub name: Vec, + pub output_coin: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct Datum { + #[serde(with = "hex::serde")] + pub hash: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct UtxoAsset { + #[serde(with = "hex::serde")] + pub policy_id: Vec, + pub assets: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct UTxO { + #[serde(with = "hex::serde")] + pub tx: Vec, + pub tx_index: u64, + pub address: String, + pub coin: String, // To avoid overflow + pub assets: Vec, + pub datum: Option, +} + pub fn initialize_config(root: &Path) -> miette::Result { let config_path = root.join("cshell.toml"); @@ -285,6 +316,46 @@ pub fn wallet_balance(home: &Path, wallet_name: &str) -> miette::Result miette::Result> { + let mut cmd = new_generic_command(home)?; + let output = cmd + .args([ + "wallet", + "balance", + wallet_name, + "--detail", + "--output-format", + "json", + ]) + .stdout(Stdio::piped()) + .output() + .into_diagnostic() + .context("running CShell wallet utxos")?; + + if !output.status.success() { + bail!("CShell failed to get wallet utxos"); + } + + match serde_json::from_slice::>(&output.stdout) { + Ok(list) => Ok(list), + Err(_) => { + let v: serde_json::Value = serde_json::from_slice(&output.stdout).into_diagnostic()?; + if let Some(utxos_val) = v.get("utxos") { + let list: Vec = + serde_json::from_value(utxos_val.clone()).into_diagnostic()?; + Ok(list) + } else { + if v.is_array() { + let list: Vec = serde_json::from_value(v).into_diagnostic()?; + Ok(list) + } else { + bail!("unexpected CShell wallet balance output shape") + } + } + } + } +} + pub fn explorer(home: &Path) -> miette::Result { let mut cmd = new_generic_command(home)?; diff --git a/src/spawn/dolos.rs b/src/spawn/dolos.rs index e638715..c96b87d 100644 --- a/src/spawn/dolos.rs +++ b/src/spawn/dolos.rs @@ -64,10 +64,11 @@ fn initialize_wal_store(data_dir: &Path) -> miette::Result<()> { } fn initialize_ledger_store(data_dir: &Path) -> miette::Result { - let state: dolos_redb::state::LedgerStore = dolos_redb::state::LedgerStore::open(&data_dir.join("ledger"), None) - .map_err(dolos_core::StateError::from) - .into_diagnostic() - .context("creating ledger store")?; + let state: dolos_redb::state::LedgerStore = + dolos_redb::state::LedgerStore::open(&data_dir.join("ledger"), None) + .map_err(dolos_core::StateError::from) + .into_diagnostic() + .context("creating ledger store")?; Ok(state) } @@ -81,8 +82,10 @@ fn initialize_chain_store(data_dir: &Path) -> miette::Result)>) -> miette::Result> { - use dolos_cardano::pallas::ledger::traverse::{MultiEraOutput, Era}; +fn calculate_deltas( + initial_utxos: &Vec<(String, Vec)>, +) -> miette::Result> { + use dolos_cardano::pallas::ledger::traverse::{Era, MultiEraOutput}; let eras = vec![Era::Conway, Era::Babbage, Era::Alonzo, Era::Byron]; @@ -95,7 +98,11 @@ fn calculate_deltas(initial_utxos: &Vec<(String, Vec)>) -> miette::Result() + let utxo_id = address + .split('#') + .nth(1) + .unwrap_or_default() + .parse::() .into_diagnostic() .context("parsing tx id")?; @@ -113,7 +120,9 @@ fn calculate_deltas(initial_utxos: &Vec<(String, Vec)>) -> miette::Result)>) -> miette::Result)>) -> miette::Result<()> { - +fn initialize_initial_utxos( + home_dir: &Path, + initial_utxos: &Vec<(String, Vec)>, +) -> miette::Result<()> { let data_dir = initialize_data_dir(home_dir)?; initialize_wal_store(&data_dir)?; @@ -134,12 +145,14 @@ fn initialize_initial_utxos(home_dir: &Path, initial_utxos: &Vec<(String, Vec