From 45435e5d70131c41069d108eed1d98f2bd835cfa Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Tue, 11 Nov 2025 15:18:59 -0500 Subject: [PATCH 1/9] feat: read safe address from file --- .gitignore | 2 +- src/main.rs | 174 ++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 141 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index eee0792..722f871 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ /target .DS_Store -mocks/* +mocks/ diff --git a/src/main.rs b/src/main.rs index 3f7cea6..25491ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,17 +11,22 @@ const SAFE_TX_TYPE: &str = "SafeTx(address to,uint256 value,bytes data,uint8 ope fn main() -> Result<()> { let args = CliArgs::from_env()?; - let mut files = collect_targets(&args.inputs)?; + let mut files = collect_targets(&args.inputs, args.safe_address.as_deref())?; if files.is_empty() { println!("No JSON files found. Provide one or more directories or files to process."); return Ok(()); } - files.sort(); + files.sort_by(|a, b| a.path.cmp(&b.path)); let mut mismatches = Vec::new(); - for path in files { - match process_file(&path, args.chain_id, args.safe_address.as_deref()) { + for target in files { + match process_file( + &target.path, + args.chain_id, + args.safe_address.as_deref(), + target.directory_safe.as_deref(), + ) { Ok(report) => { println!("{}", report.render_line()); if report.is_mismatch() { @@ -29,14 +34,15 @@ fn main() -> Result<()> { } } Err(err) => { - println!("{} :: error :: {}", path.display(), err); + println!("{} :: error :: {}", target.path.display(), err); } } } if !mismatches.is_empty() { - println!("\n{} mismatches detected.", mismatches.len()); - if args.fail_on_mismatch { + if args.ignore_error { + println!("\n{} mismatches detected.", mismatches.len()); + } else { return Err(anyhow!("expected hash mismatch")); } } @@ -49,7 +55,7 @@ struct CliArgs { inputs: Vec, chain_id: u64, safe_address: Option, - fail_on_mismatch: bool, + ignore_error: bool, } impl CliArgs { @@ -58,7 +64,7 @@ impl CliArgs { let mut inputs = Vec::new(); let mut chain_id = 143u64; let mut safe_address = None; - let mut fail_on_mismatch = false; + let mut ignore_error = false; while let Some(arg) = args.next() { match arg.as_str() { @@ -74,8 +80,8 @@ impl CliArgs { let next = args.next().context("missing value for --safe-address")?; safe_address = Some(next); } - "--fail-on-mismatch" => { - fail_on_mismatch = true; + "--ignore-error" => { + ignore_error = true; } "--help" | "-h" => { print_help(); @@ -96,7 +102,7 @@ impl CliArgs { inputs, chain_id, safe_address, - fail_on_mismatch, + ignore_error, }) } } @@ -107,7 +113,7 @@ fn print_help() { println!("\nOptions:"); println!(" --chain-id Override the chain id (default: 143)"); println!(" --safe-address Fallback Safe address if files omit it"); - println!(" --fail-on-mismatch Return a non-zero exit status on mismatch"); + println!(" --ignore-error Return a zero exit status on mismatch"); println!(" --input Directory or JSON file to include"); println!(" -h, --help Show this help text"); } @@ -150,6 +156,12 @@ struct Report { safe_address_used: String, } +#[derive(Clone, Debug)] +struct Target { + path: PathBuf, + directory_safe: Option, +} + impl Report { fn render_line(&self) -> String { match (&self.expected_hash, self.matched) { @@ -187,46 +199,68 @@ impl Report { } } -fn collect_targets(inputs: &[PathBuf]) -> Result> { - let mut files = Vec::new(); +fn collect_targets(inputs: &[PathBuf], cli_safe: Option<&str>) -> Result> { + let mut targets = Vec::new(); for input in inputs { if input.is_dir() { - for entry in fs::read_dir(input).with_context(|| format!("cannot read directory {}", input.display()))? { - let entry = entry?; - let path = entry.path(); - if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("json")) { - if !is_empty_file(&path)? { - files.push(path); - } - } - } + gather_directory_targets(input, cli_safe, &mut targets)?; } else if input.is_file() { - if input + if !input .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) { - if !is_empty_file(input)? { - files.push(input.clone()); - } + continue; + } + + if is_empty_file(input)? { + continue; + } + + if is_account_config(input) { + // Account config files are not transaction payloads. + continue; } + + let directory_safe = if cli_safe.is_none() { + if let Some(parent) = input.parent() { + if let Some(account_config) = find_account_config(parent)? { + load_account_config_safe(&account_config)? + } else { + None + } + } else { + None + } + } else { + None + }; + + targets.push(Target { + path: input.clone(), + directory_safe, + }); } } - Ok(files) + Ok(targets) } fn is_empty_file(path: &Path) -> Result { Ok(path.is_file() && fs::metadata(path)?.len() == 0) } -fn process_file(path: &Path, chain_id: u64, fallback_safe: Option<&str>) -> Result { +fn process_file( + path: &Path, + chain_id: u64, + cli_safe: Option<&str>, + directory_safe: Option<&str>, +) -> Result { let data = fs::read_to_string(path).with_context(|| format!("unable to read {}", path.display()))?; let tx: TransactionLog = serde_json::from_str(&data) .with_context(|| format!("failed to parse {}", path.display()))?; - let safe_address_str = tx - .safe_address - .as_deref() - .or(fallback_safe) + let safe_address_str = cli_safe + .or(directory_safe) + .or(tx.safe_address.as_deref()) .or(tx.final_signer.as_deref()) .ok_or_else(|| anyhow!("no Safe address provided"))?; @@ -245,6 +279,78 @@ fn process_file(path: &Path, chain_id: u64, fallback_safe: Option<&str>) -> Resu }) } +fn gather_directory_targets(dir: &Path, cli_safe: Option<&str>, targets: &mut Vec) -> Result<()> { + let mut entries = Vec::new(); + for entry in fs::read_dir(dir).with_context(|| format!("cannot read directory {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + if path.is_file() + && path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) + && !is_empty_file(&path)? + { + entries.push(path); + } + } + + let directory_safe = if cli_safe.is_none() { + if let Some(account_config) = entries.iter().find(|path| is_account_config(path)) { + load_account_config_safe(account_config)? + } else if let Some(account_config) = find_account_config(dir)? { + load_account_config_safe(&account_config)? + } else { + None + } + } else { + None + }; + + for path in entries { + if is_account_config(&path) { + continue; + } + + targets.push(Target { + path, + directory_safe: directory_safe.clone(), + }); + } + + Ok(()) +} + +fn is_account_config(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.eq_ignore_ascii_case("accountConfig.json")) + .unwrap_or(false) +} + +fn find_account_config(dir: &Path) -> Result> { + for entry in fs::read_dir(dir).with_context(|| format!("cannot read directory {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + if path.is_file() && is_account_config(&path) && !is_empty_file(&path)? { + return Ok(Some(path)); + } + } + Ok(None) +} + +#[derive(Debug, Deserialize)] +struct AccountConfig { + #[serde(rename = "safe_address")] + safe_address: Option, +} + +fn load_account_config_safe(path: &Path) -> Result> { + let data = fs::read_to_string(path).with_context(|| format!("unable to read {}", path.display()))?; + let config: AccountConfig = serde_json::from_str(&data) + .with_context(|| format!("failed to parse {}", path.display()))?; + Ok(config.safe_address) +} + fn compute_safe_tx_hash(tx: &TransactionLog, safe_address: &str, chain_id: u64) -> Result { let to = parse_address(&tx.address_to).context("invalid to address")?; let safe = parse_address(safe_address).context("invalid safe address")?; From 24e56e3dc1056a07c6aada5a45391df6da2dd2fb Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Tue, 11 Nov 2025 15:59:00 -0500 Subject: [PATCH 2/9] cleanup: log struct --- src/main.rs | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/src/main.rs b/src/main.rs index 25491ff..fbb7280 100644 --- a/src/main.rs +++ b/src/main.rs @@ -120,30 +120,20 @@ fn print_help() { #[derive(Debug, Deserialize)] struct TransactionLog { - #[serde(rename = "address_to")] address_to: String, - #[serde(rename = "native_value", deserialize_with = "string_or_number")] + #[serde(default, deserialize_with = "string_or_number")] native_value: String, calldata: String, - #[serde(default)] operation: Option, - #[serde(rename = "current_nonce")] current_nonce: u64, - #[serde(default, rename = "safe_address")] - safe_address: Option, - #[serde(default, rename = "final_signer")] - final_signer: Option, - #[serde(default, rename = "expected_hash")] expected_hash: Option, - #[serde(default, rename = "safe_tx_gas", deserialize_with = "string_or_number_opt")] + #[serde(default, deserialize_with = "string_or_number_opt")] safe_tx_gas: Option, - #[serde(default, rename = "base_gas", deserialize_with = "string_or_number_opt")] + #[serde(default, deserialize_with = "string_or_number_opt")] base_gas: Option, - #[serde(default, rename = "gas_price", deserialize_with = "string_or_number_opt")] + #[serde(default, deserialize_with = "string_or_number_opt")] gas_price: Option, - #[serde(default, rename = "gas_token")] gas_token: Option, - #[serde(default, rename = "refund_receiver")] refund_receiver: Option, } @@ -199,11 +189,11 @@ impl Report { } } -fn collect_targets(inputs: &[PathBuf], cli_safe: Option<&str>) -> Result> { +fn collect_targets(inputs: &[PathBuf], safe_address: Option<&str>) -> Result> { let mut targets = Vec::new(); for input in inputs { if input.is_dir() { - gather_directory_targets(input, cli_safe, &mut targets)?; + gather_directory_targets(input, safe_address, &mut targets)?; } else if input.is_file() { if !input .extension() @@ -221,7 +211,7 @@ fn collect_targets(inputs: &[PathBuf], cli_safe: Option<&str>) -> Result Result { fn process_file( path: &Path, chain_id: u64, - cli_safe: Option<&str>, + safe_address: Option<&str>, directory_safe: Option<&str>, ) -> Result { let data = fs::read_to_string(path).with_context(|| format!("unable to read {}", path.display()))?; let tx: TransactionLog = serde_json::from_str(&data) .with_context(|| format!("failed to parse {}", path.display()))?; - let safe_address_str = cli_safe + let safe_address_str = safe_address .or(directory_safe) - .or(tx.safe_address.as_deref()) - .or(tx.final_signer.as_deref()) .ok_or_else(|| anyhow!("no Safe address provided"))?; let computed = compute_safe_tx_hash(&tx, safe_address_str, chain_id)?; @@ -340,7 +328,6 @@ fn find_account_config(dir: &Path) -> Result> { #[derive(Debug, Deserialize)] struct AccountConfig { - #[serde(rename = "safe_address")] safe_address: Option, } From a6403aee29e9ed81dcdf18454c8af957b43626c4 Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Tue, 11 Nov 2025 16:10:01 -0500 Subject: [PATCH 3/9] feat: add a dash of colour, migrate to clap --- Cargo.lock | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + src/main.rs | 169 ++++++++++++++++++++--------------------- 3 files changed, 297 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c477ea1..e051cd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,56 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -44,6 +94,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "const_format" version = "0.2.35" @@ -111,6 +207,12 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -147,6 +249,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.15" @@ -165,6 +273,18 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -299,7 +419,9 @@ name = "safe-hash-action" version = "0.1.0" dependencies = [ "anyhow", + "clap", "hex", + "owo-colors", "primitive-types", "serde", "serde_json", @@ -355,6 +477,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.110" @@ -435,12 +563,98 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.13" diff --git a/Cargo.toml b/Cargo.toml index d2d9ee9..411f429 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,9 @@ edition = "2024" [dependencies] anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } hex = "0.4" +owo-colors = "4.0" primitive-types = "0.12" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/main.rs b/src/main.rs index fbb7280..eceee4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,8 @@ use anyhow::{anyhow, Context, Result}; +use clap::{ArgAction, Parser}; +use owo_colors::OwoColorize; use primitive_types::U256; use serde::Deserialize; -use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -10,17 +11,22 @@ const DOMAIN_TYPE: &str = "EIP712Domain(uint256 chainId,address verifyingContrac const SAFE_TX_TYPE: &str = "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"; fn main() -> Result<()> { - let args = CliArgs::from_env()?; - let mut files = collect_targets(&args.inputs, args.safe_address.as_deref())?; - if files.is_empty() { - println!("No JSON files found. Provide one or more directories or files to process."); + let args = CliArgs::parse(); + let resolved_inputs = args.resolved_inputs(); + let mut targets = collect_targets(&resolved_inputs, args.safe_address.as_deref())?; + if targets.is_empty() { + println!( + "{}", + "No JSON files found. Provide one or more directories or files to process." + .yellow() + ); return Ok(()); } - files.sort_by(|a, b| a.path.cmp(&b.path)); + targets.sort_by(|a, b| a.path.cmp(&b.path)); let mut mismatches = Vec::new(); - for target in files { + for target in targets { match process_file( &target.path, args.chain_id, @@ -34,15 +40,18 @@ fn main() -> Result<()> { } } Err(err) => { - println!("{} :: error :: {}", target.path.display(), err); + let message = format!("{} :: error :: {}", target.path.display(), err); + println!("{}", message.red()); } } } if !mismatches.is_empty() { + let message = format!("{} mismatches detected.", mismatches.len()); if args.ignore_error { - println!("\n{} mismatches detected.", mismatches.len()); + println!("\n{}", message.yellow()); } else { + println!("{}", message.red()); return Err(anyhow!("expected hash mismatch")); } } @@ -50,74 +59,44 @@ fn main() -> Result<()> { Ok(()) } -#[derive(Clone, Debug)] +#[derive(Parser, Debug)] +#[command( + name = "safe-hash-action", + version, + author, + about = "Compute Safe transaction hashes for JSON log files" +)] struct CliArgs { - inputs: Vec, + /// Additional file or directory inputs (repeatable via --input) + #[arg(short = 'i', long = "input", alias = "dir", value_name = "PATH", action = ArgAction::Append)] + input: Vec, + /// Positional paths to include + #[arg(value_name = "PATH")] + paths: Vec, + /// Chain ID for the Monad network + #[arg(long = "chain-id", default_value_t = 143)] chain_id: u64, + /// Override Safe address for all transactions + #[arg(long = "safe-address")] safe_address: Option, + /// Do not fail the process when mismatches occur + #[arg(long = "ignore-error", action = ArgAction::SetTrue)] ignore_error: bool, } impl CliArgs { - fn from_env() -> Result { - let mut args = env::args().skip(1); + fn resolved_inputs(&self) -> Vec { let mut inputs = Vec::new(); - let mut chain_id = 143u64; - let mut safe_address = None; - let mut ignore_error = false; - - while let Some(arg) = args.next() { - match arg.as_str() { - "--input" | "--dir" | "-i" => { - let next = args.next().context("missing value for --input")?; - inputs.push(PathBuf::from(next)); - } - "--chain-id" => { - let next = args.next().context("missing value for --chain-id")?; - chain_id = next.parse().context("invalid chain id")?; - } - "--safe-address" => { - let next = args.next().context("missing value for --safe-address")?; - safe_address = Some(next); - } - "--ignore-error" => { - ignore_error = true; - } - "--help" | "-h" => { - print_help(); - std::process::exit(0); - } - other => { - inputs.push(PathBuf::from(other)); - } - } - } - + inputs.extend(self.input.iter().cloned()); + inputs.extend(self.paths.iter().cloned()); if inputs.is_empty() { inputs.push(PathBuf::from("src/logs")); inputs.push(PathBuf::from("src/accountConfigs")); } - - Ok(Self { - inputs, - chain_id, - safe_address, - ignore_error, - }) + inputs } } -fn print_help() { - println!("Compute Safe transaction hashes for JSON logs."); - println!("Usage: cargo run --release -- [options] [paths...]"); - println!("\nOptions:"); - println!(" --chain-id Override the chain id (default: 143)"); - println!(" --safe-address Fallback Safe address if files omit it"); - println!(" --ignore-error Return a zero exit status on mismatch"); - println!(" --input Directory or JSON file to include"); - println!(" -h, --help Show this help text"); -} - #[derive(Debug, Deserialize)] struct TransactionLog { address_to: String, @@ -126,6 +105,10 @@ struct TransactionLog { calldata: String, operation: Option, current_nonce: u64, + #[serde(default)] + safe_address: Option, + #[serde(default, rename = "final_signer")] + final_signer: Option, expected_hash: Option, #[serde(default, deserialize_with = "string_or_number_opt")] safe_tx_gas: Option, @@ -154,33 +137,41 @@ struct Target { impl Report { fn render_line(&self) -> String { + let path = self.path.display().to_string(); + let safe = format!("safe={}", self.safe_address_used); + match (&self.expected_hash, self.matched) { - (Some(_), Some(true)) => format!( - "{} :: hash={} (expected) :: safe={}", - self.path.display(), - self.computed_hash, - self.safe_address_used - ), - (Some(expected), Some(false)) => format!( - "{} :: hash={} (expected {}) :: safe={} :: mismatch", - self.path.display(), - self.computed_hash, - expected, - self.safe_address_used - ), - (Some(expected), None) => format!( - "{} :: hash={} (expected {}) :: safe={}", - self.path.display(), - self.computed_hash, - expected, - self.safe_address_used - ), - (None, _) => format!( - "{} :: hash={} :: safe={}", - self.path.display(), - self.computed_hash, - self.safe_address_used - ), + (Some(_), Some(true)) => { + let hash = format!("hash={} (expected)", self.computed_hash); + format!("{} :: {} :: {}", path.cyan(), hash.green(), safe.magenta()) + } + (Some(expected), Some(false)) => { + let hash = format!("hash={}", self.computed_hash); + let expected_segment = format!("expected {}", expected); + format!( + "{} :: {} :: {} :: {} :: {}", + path.cyan(), + hash.red(), + expected_segment.yellow(), + safe.magenta(), + "mismatch".bright_red() + ) + } + (Some(expected), None) => { + let hash = format!("hash={}", self.computed_hash); + let expected_segment = format!("expected {}", expected); + format!( + "{} :: {} :: {} :: {}", + path.cyan(), + hash.cyan(), + expected_segment.yellow(), + safe.magenta() + ) + } + (None, _) => { + let hash = format!("hash={}", self.computed_hash); + format!("{} :: {} :: {}", path.cyan(), hash.bright_white(), safe.magenta()) + } } } @@ -250,6 +241,8 @@ fn process_file( let safe_address_str = safe_address .or(directory_safe) + .or(tx.safe_address.as_deref()) + .or(tx.final_signer.as_deref()) .ok_or_else(|| anyhow!("no Safe address provided"))?; let computed = compute_safe_tx_hash(&tx, safe_address_str, chain_id)?; From 54f1cffedb11b1d6ea6b764dc367c8d6d6acc6dc Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Tue, 11 Nov 2025 17:37:02 -0500 Subject: [PATCH 4/9] feat: make reporting better --- Cargo.toml | 2 +- src/main.rs | 39 ++++++++++++++------------------------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 411f429..5336f24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ edition = "2024" anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } hex = "0.4" -owo-colors = "4.0" +owo-colors = "4.2" primitive-types = "0.12" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/main.rs b/src/main.rs index eceee4f..036a696 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,7 @@ fn main() -> Result<()> { } } Err(err) => { - let message = format!("{} :: error :: {}", target.path.display(), err); + let message = format!("{}\nerror\n{}", target.path.display(), err); println!("{}", message.red()); } } @@ -49,10 +49,10 @@ fn main() -> Result<()> { if !mismatches.is_empty() { let message = format!("{} mismatches detected.", mismatches.len()); if args.ignore_error { - println!("\n{}", message.yellow()); + println!("{}", message.yellow()); } else { println!("{}", message.red()); - return Err(anyhow!("expected hash mismatch")); + std::process::exit(1); } } @@ -89,10 +89,6 @@ impl CliArgs { let mut inputs = Vec::new(); inputs.extend(self.input.iter().cloned()); inputs.extend(self.paths.iter().cloned()); - if inputs.is_empty() { - inputs.push(PathBuf::from("src/logs")); - inputs.push(PathBuf::from("src/accountConfigs")); - } inputs } } @@ -105,10 +101,6 @@ struct TransactionLog { calldata: String, operation: Option, current_nonce: u64, - #[serde(default)] - safe_address: Option, - #[serde(default, rename = "final_signer")] - final_signer: Option, expected_hash: Option, #[serde(default, deserialize_with = "string_or_number_opt")] safe_tx_gas: Option, @@ -138,30 +130,29 @@ struct Target { impl Report { fn render_line(&self) -> String { let path = self.path.display().to_string(); - let safe = format!("safe={}", self.safe_address_used); + let safe = format!("safe address = {}", self.safe_address_used); match (&self.expected_hash, self.matched) { (Some(_), Some(true)) => { - let hash = format!("hash={} (expected)", self.computed_hash); - format!("{} :: {} :: {}", path.cyan(), hash.green(), safe.magenta()) + let hash = format!("computed hash = {} (expected)", self.computed_hash); + format!("{}:\n{}\n{}", path.cyan(), hash.green(), safe.magenta()) } (Some(expected), Some(false)) => { - let hash = format!("hash={}", self.computed_hash); - let expected_segment = format!("expected {}", expected); + let hash = format!("computed hash = {} (mismatch)", self.computed_hash); + let expected_segment = format!("expected hash = {}", expected); format!( - "{} :: {} :: {} :: {} :: {}", + "{}:\n{}\n{}\n{}", path.cyan(), hash.red(), expected_segment.yellow(), safe.magenta(), - "mismatch".bright_red() ) } (Some(expected), None) => { - let hash = format!("hash={}", self.computed_hash); - let expected_segment = format!("expected {}", expected); + let hash = format!("computed hash = {}", self.computed_hash); + let expected_segment = format!("expected hash = {}", expected); format!( - "{} :: {} :: {} :: {}", + "{}:\n{}\n{}\n{}", path.cyan(), hash.cyan(), expected_segment.yellow(), @@ -169,8 +160,8 @@ impl Report { ) } (None, _) => { - let hash = format!("hash={}", self.computed_hash); - format!("{} :: {} :: {}", path.cyan(), hash.bright_white(), safe.magenta()) + let hash = format!("computed hash = {}", self.computed_hash); + format!("{}:\n{}\n{}\n", path.cyan(), hash.bright_white(), safe.magenta()) } } } @@ -241,8 +232,6 @@ fn process_file( let safe_address_str = safe_address .or(directory_safe) - .or(tx.safe_address.as_deref()) - .or(tx.final_signer.as_deref()) .ok_or_else(|| anyhow!("no Safe address provided"))?; let computed = compute_safe_tx_hash(&tx, safe_address_str, chain_id)?; From 234cb38436d67401bd7a5f236632f19e2d2b5144 Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Tue, 11 Nov 2025 18:55:48 -0500 Subject: [PATCH 5/9] feat: implement dockerfile --- Cargo.lock | 28 ++++++++++++++-------------- Cargo.toml | 2 +- Dockerfile | 17 +++++++++++++++++ README.md | 14 ++++++++++++++ src/main.rs | 2 +- 5 files changed, 47 insertions(+), 16 deletions(-) create mode 100644 Dockerfile create mode 100644 README.md diff --git a/Cargo.lock b/Cargo.lock index e051cd8..d4cbe2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -414,20 +414,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "safe-hash-action" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "hex", - "owo-colors", - "primitive-types", - "serde", - "serde_json", - "tiny-keccak", -] - [[package]] name = "serde" version = "1.0.228" @@ -471,6 +457,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "soteria" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "hex", + "owo-colors", + "primitive-types", + "serde", + "serde_json", + "tiny-keccak", +] + [[package]] name = "static_assertions" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5336f24..41aee19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "safe-hash-action" +name = "soteria" authors = ["Monad Foundation"] license = "Apache-2.0" version = "0.1.0" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8206173 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM rust:slim AS builder + +WORKDIR /app + +COPY . . + +RUN cargo build --profile release --bin soteria + +FROM gcr.io/distroless/cc-debian12:nonroot AS runtime + +WORKDIR /data + +COPY --from=builder /app/target/release/soteria /usr/local/bin/soteria + +USER 65532:65532 + +ENTRYPOINT ["/usr/local/bin/soteria"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4a9127 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# safe-hash-action + + +## Quickstart + +### Using Docker +```bash +docker build -t soteria . +``` + +Replace `$(pwd)/src/mocks` with your directory of files to check: +```bash +docker run -v $(pwd)/src/mocks:/mnt/data soteria /mnt/data +``` diff --git a/src/main.rs b/src/main.rs index 036a696..a662054 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,7 +61,7 @@ fn main() -> Result<()> { #[derive(Parser, Debug)] #[command( - name = "safe-hash-action", + name = "soteria", version, author, about = "Compute Safe transaction hashes for JSON log files" From 5ab9ce70000ba35a63365bcfd380abb917220528 Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Tue, 11 Nov 2025 20:41:56 -0500 Subject: [PATCH 6/9] feat: build CLI statically --- Cargo.toml | 21 ++++++++++++--------- Dockerfile | 16 ++++++++++++++-- README.md | 10 ++++++++-- src/main.rs | 4 ++-- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 41aee19..5f94f9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,19 @@ [package] name = "soteria" -authors = ["Monad Foundation"] +authors = ["QEDK "] license = "Apache-2.0" version = "0.1.0" edition = "2024" [dependencies] -anyhow = "1.0" -clap = { version = "4.5", features = ["derive"] } -hex = "0.4" -owo-colors = "4.2" -primitive-types = "0.12" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -tiny-keccak = { version = "2", features = ["keccak"] } +# anyhow should not be pinned +anyhow = "1" +clap = { version = "=4.5.51", features = ["derive"] } +hex = "=0.4.3" +owo-colors = "=4.2.3" +primitive-types = "=0.12.2" +# serde should not be pinned +serde = { version = "1", features = ["derive"] } +# serde_json should not be pinned +serde_json = "1" +tiny-keccak = { version = "=2.0.2", features = ["keccak"] } diff --git a/Dockerfile b/Dockerfile index 8206173..e561166 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,14 @@ -FROM rust:slim AS builder +FROM rust:alpine AS builder WORKDIR /app +RUN apk add --no-cache musl-dev + COPY . . RUN cargo build --profile release --bin soteria -FROM gcr.io/distroless/cc-debian12:nonroot AS runtime +FROM gcr.io/distroless/static-debian12:nonroot AS runtime WORKDIR /data @@ -15,3 +17,13 @@ COPY --from=builder /app/target/release/soteria /usr/local/bin/soteria USER 65532:65532 ENTRYPOINT ["/usr/local/bin/soteria"] + +LABEL name="soteria" +LABEL description="A simple CLI tool for validating Safe transaction hashes from JSON log files" +LABEL org.opencontainers.image.title="soteria" +LABEL org.opencontainers.image.description="A simple CLI tool for validating Safe transaction hashes from JSON log files" +LABEL org.opencontainers.image.authors="QEDK " +LABEL org.opencontainers.image.vendor="Monad Foundation" +LABEL org.opencontainers.image.licenses="Apache-2.0" +LABEL org.opencontainers.image.source="https://github.com/monad-developers/soteria" +LABEL org.opencontainers.image.documentation="https://github.com/monad-developers/soteria/README.md" diff --git a/README.md b/README.md index c4a9127..803f6b7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ -# safe-hash-action - +# 🚦 soteria +A simple CLI tool that validates Safe transaction hashes in JSON log files. ## Quickstart +### Using CLI +```bash +cargo install --git https://github.com/monad-developers/soteria.git +soteria /path/to/your/logs/directory +``` + ### Using Docker ```bash docker build -t soteria . diff --git a/src/main.rs b/src/main.rs index a662054..33a28bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,10 +64,10 @@ fn main() -> Result<()> { name = "soteria", version, author, - about = "Compute Safe transaction hashes for JSON log files" + about = "Validate Safe transaction hashes for JSON log files" )] struct CliArgs { - /// Additional file or directory inputs (repeatable via --input) + /// Additional file or directory inputs #[arg(short = 'i', long = "input", alias = "dir", value_name = "PATH", action = ArgAction::Append)] input: Vec, /// Positional paths to include From ba438f129b2a2ea532982769d4b0be6154dc2f83 Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Wed, 12 Nov 2025 00:45:54 -0500 Subject: [PATCH 7/9] feat: add workflow --- .github/workflows/ci.yml | 28 +++++++++++++++++ .github/workflows/release.yml | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c362419 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: ci + +on: + push: + branches: + - 'master' + pull_request: + branches: + - 'master' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: rustfmt + + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - run: cargo build --locked --all-features -v + - run: cargo test --all-features -v + - run: cargo fmt --all -- --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..09f37ee --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: release + +on: + push: + tags: + - 'v*' + +env: + IMAGE_NAME: monadfoundation/soteria + +jobs: + artifacts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: rustfmt + + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - run: cargo build --release --locked --all-features -v + + - name: Upload release version artifact + uses: actions/upload-artifact@v3 + with: + name: soteria + path: ./target/release/soteria + + docker: + runs-on: ubuntu-latest + steps: + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + push: true + sbom: true + provenance: mode=max + tags: ${{ steps.meta.outputs.tags }} From f3def63ef35e6f1acf6f3cf46e821f4525d952a6 Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Wed, 12 Nov 2025 00:49:38 -0500 Subject: [PATCH 8/9] fmt: cargo fmt --- src/main.rs | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index 33a28bf..070b3cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; use clap::{ArgAction, Parser}; use owo_colors::OwoColorize; use primitive_types::U256; @@ -17,8 +17,7 @@ fn main() -> Result<()> { if targets.is_empty() { println!( "{}", - "No JSON files found. Provide one or more directories or files to process." - .yellow() + "No JSON files found. Provide one or more directories or files to process.".yellow() ); return Ok(()); } @@ -161,7 +160,12 @@ impl Report { } (None, _) => { let hash = format!("computed hash = {}", self.computed_hash); - format!("{}:\n{}\n{}\n", path.cyan(), hash.bright_white(), safe.magenta()) + format!( + "{}:\n{}\n{}\n", + path.cyan(), + hash.bright_white(), + safe.magenta() + ) } } } @@ -226,7 +230,8 @@ fn process_file( safe_address: Option<&str>, directory_safe: Option<&str>, ) -> Result { - let data = fs::read_to_string(path).with_context(|| format!("unable to read {}", path.display()))?; + let data = + fs::read_to_string(path).with_context(|| format!("unable to read {}", path.display()))?; let tx: TransactionLog = serde_json::from_str(&data) .with_context(|| format!("failed to parse {}", path.display()))?; @@ -249,9 +254,15 @@ fn process_file( }) } -fn gather_directory_targets(dir: &Path, cli_safe: Option<&str>, targets: &mut Vec) -> Result<()> { +fn gather_directory_targets( + dir: &Path, + cli_safe: Option<&str>, + targets: &mut Vec, +) -> Result<()> { let mut entries = Vec::new(); - for entry in fs::read_dir(dir).with_context(|| format!("cannot read directory {}", dir.display()))? { + for entry in + fs::read_dir(dir).with_context(|| format!("cannot read directory {}", dir.display()))? + { let entry = entry?; let path = entry.path(); if path.is_file() @@ -298,7 +309,9 @@ fn is_account_config(path: &Path) -> bool { } fn find_account_config(dir: &Path) -> Result> { - for entry in fs::read_dir(dir).with_context(|| format!("cannot read directory {}", dir.display()))? { + for entry in + fs::read_dir(dir).with_context(|| format!("cannot read directory {}", dir.display()))? + { let entry = entry?; let path = entry.path(); if path.is_file() && is_account_config(&path) && !is_empty_file(&path)? { @@ -314,7 +327,8 @@ struct AccountConfig { } fn load_account_config_safe(path: &Path) -> Result> { - let data = fs::read_to_string(path).with_context(|| format!("unable to read {}", path.display()))?; + let data = + fs::read_to_string(path).with_context(|| format!("unable to read {}", path.display()))?; let config: AccountConfig = serde_json::from_str(&data) .with_context(|| format!("failed to parse {}", path.display()))?; Ok(config.safe_address) From 32a82d6cc749df1ef3652373e56ac0cacb3ff2ee Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Wed, 12 Nov 2025 00:50:01 -0500 Subject: [PATCH 9/9] feat: bump upload-artifact action --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09f37ee..46c663d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: - run: cargo build --release --locked --all-features -v - name: Upload release version artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: soteria path: ./target/release/soteria