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 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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 09/14] 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 From 86813fd981c4cedbeb14c0498eac3d90030b2f58 Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Fri, 12 Dec 2025 04:00:57 +0400 Subject: [PATCH 10/14] feat: update release to make more images, add action.yml file --- .github/workflows/release.yml | 91 +++++++++++++-- action.yml | 201 ++++++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+), 11 deletions(-) create mode 100644 action.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46c663d..e4d4714 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,29 +7,94 @@ on: env: IMAGE_NAME: monadfoundation/soteria + CARGO_TERM_COLOR: always jobs: - artifacts: - runs-on: ubuntu-latest + binaries: + name: Build ${{ matrix.platform.name }} + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + platform: + - name: linux x86_64 + runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact: soteria-linux-x86_64 + + - name: linux arm64 + runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + artifact: soteria-linux-arm64 + + # macOS Intel + - name: macos x86_64 + runner: macos-15-intel + target: x86_64-apple-darwin + artifact: soteria-darwin-x86_64 + + # macOS Apple Silicon + - name: macos arm64 + runner: macos-26 + target: aarch64-apple-darwin + artifact: soteria-darwin-arm64 + + # Windows x86_64 + - name: windows x86_64 + runner: windows-latest + target: x86_64-pc-windows-msvc + artifact: soteria-windows-x86_64 + + # Windows ARM64 + - name: windows arm64 + runner: windows-11-arm + target: aarch64-pc-windows-msvc + artifact: soteria-windows-arm64 + steps: - - uses: actions/checkout@v5 + - name: Checkout + uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable with: toolchain: stable + targets: ${{ matrix.platform.target }} components: rustfmt - - uses: Swatinem/rust-cache@v2 + - name: Cache dependencies + uses: Swatinem/rust-cache@v2 with: + key: ${{ matrix.platform.target }} cache-on-failure: true - - run: cargo build --release --locked --all-features -v - - - name: Upload release version artifact - uses: actions/upload-artifact@v4 + - name: Build release binary + run: cargo build --release --locked --all-features --target ${{ matrix.platform.target }} -v + + - name: Prepare artifact (Unix) + if: runner.os != 'Windows' + run: | + mkdir -p artifacts + cp target/${{ matrix.platform.target }}/release/soteria artifacts/ + cd artifacts + tar -czvf ${{ matrix.platform.artifact }}.tar.gz soteria + rm soteria + + - name: Prepare artifact (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path artifacts + Copy-Item "target/${{ matrix.platform.target }}/release/soteria.exe" -Destination "artifacts/" + Compress-Archive -Path "artifacts/soteria.exe" -DestinationPath "artifacts/${{ matrix.platform.artifact }}.zip" + Remove-Item "artifacts/soteria.exe" + + - name: Upload artifact + uses: actions/upload-artifact@v5 with: - name: soteria - path: ./target/release/soteria + name: ${{ matrix.platform.artifact }} + path: artifacts/* + if-no-files-found: error docker: runs-on: ubuntu-latest @@ -53,6 +118,10 @@ jobs: uses: docker/build-push-action@v6 with: push: true + platforms: linux/amd64,linux/arm64 sbom: true provenance: mode=max tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..2cb351a --- /dev/null +++ b/action.yml @@ -0,0 +1,201 @@ +name: 'soteria' +description: 'uses the soteria CLI to validate safe transaction hashes in JSON log files' +author: 'Monad Foundation' + +branding: + icon: 'shield' + color: 'purple' + +inputs: + directory: + description: 'Path to the directory containing JSON files to validate' + required: true + version: + description: 'Version of Soteria to use (e.g., "v0.1.5" or "latest")' + required: false + default: 'latest' + github-token: + description: 'GitHub token for API requests (to avoid rate limiting)' + required: false + default: ${{ github.token }} + fail-on-error: + description: 'Whether to fail the workflow if validation errors are found' + required: false + default: 'true' + +outputs: + validation-result: + description: 'The result of the validation (success or failure)' + value: ${{ steps.run-soteria.outputs.result }} + soteria-version: + description: 'The version of Soteria that was used' + value: ${{ steps.download.outputs.tag_name }} + +runs: + using: 'composite' + steps: + - name: Determine platform + id: platform + shell: bash + run: | + OS="${{ runner.os }}" + ARCH="${{ runner.arch }}" + + case "$OS" in + Linux) + case "$ARCH" in + X64) + echo "target=x86_64-unknown-linux-gnu" >> $GITHUB_OUTPUT + echo "asset_pattern=soteria-*linux*x86_64*" >> $GITHUB_OUTPUT + echo "fallback_pattern=soteria*linux*" >> $GITHUB_OUTPUT + ;; + ARM64) + echo "target=aarch64-unknown-linux-gnu" >> $GITHUB_OUTPUT + echo "asset_pattern=soteria-*linux*aarch64*" >> $GITHUB_OUTPUT + echo "fallback_pattern=soteria*linux*arm*" >> $GITHUB_OUTPUT + ;; + *) + echo "::error::Unsupported architecture: $ARCH" + exit 1 + ;; + esac + ;; + macOS) + case "$ARCH" in + X64) + echo "target=x86_64-apple-darwin" >> $GITHUB_OUTPUT + echo "asset_pattern=soteria-*darwin*x86_64*" >> $GITHUB_OUTPUT + echo "fallback_pattern=soteria*darwin*" >> $GITHUB_OUTPUT + ;; + ARM64) + echo "target=aarch64-apple-darwin" >> $GITHUB_OUTPUT + echo "asset_pattern=soteria-*darwin*aarch64*" >> $GITHUB_OUTPUT + echo "fallback_pattern=soteria*darwin*arm*" >> $GITHUB_OUTPUT + ;; + *) + echo "::error::Unsupported architecture: $ARCH" + exit 1 + ;; + esac + ;; + Windows) + echo "target=x86_64-pc-windows-msvc" >> $GITHUB_OUTPUT + echo "asset_pattern=soteria-*windows*x86_64*" >> $GITHUB_OUTPUT + echo "fallback_pattern=soteria*windows*" >> $GITHUB_OUTPUT + ;; + *) + echo "::error::Unsupported operating system: $OS" + exit 1 + ;; + esac + + echo "os=$OS" >> $GITHUB_OUTPUT + echo "arch=$ARCH" >> $GITHUB_OUTPUT + + - name: Download Soteria binary + id: download + uses: robinraju/release-downloader@v1 + with: + repository: 'monad-developers/soteria' + latest: ${{ inputs.version == 'latest' }} + tag: ${{ inputs.version != 'latest' && inputs.version || '' }} + fileName: '*' + out-file-path: '${{ runner.temp }}/soteria-download' + extract: true + token: ${{ inputs.github-token }} + + - name: Find and setup Soteria binary + id: setup + shell: bash + run: | + DOWNLOAD_DIR="${{ runner.temp }}/soteria-download" + INSTALL_DIR="${{ runner.temp }}/soteria-bin" + mkdir -p "$INSTALL_DIR" + + echo "Looking for Soteria binary in: $DOWNLOAD_DIR" + ls -la "$DOWNLOAD_DIR" || true + + # Look for the binary - try various patterns + BINARY="" + + # First, try to find a binary matching the platform + for pattern in "soteria" "soteria-${{ steps.platform.outputs.target }}" "soteria_${{ steps.platform.outputs.target }}"; do + if [ -f "$DOWNLOAD_DIR/$pattern" ]; then + BINARY="$DOWNLOAD_DIR/$pattern" + break + fi + if [ -f "$DOWNLOAD_DIR/${pattern}.exe" ]; then + BINARY="$DOWNLOAD_DIR/${pattern}.exe" + break + fi + done + + # If not found, search recursively for any soteria binary + if [ -z "$BINARY" ]; then + BINARY=$(find "$DOWNLOAD_DIR" -type f \( -name "soteria" -o -name "soteria.exe" -o -name "soteria-*" \) 2>/dev/null | head -n 1) + fi + + if [ -z "$BINARY" ] || [ ! -f "$BINARY" ]; then + echo "::warning::No pre-built binary found. Building from source..." + + # Install Rust if needed + if ! command -v cargo &> /dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source "$HOME/.cargo/env" + fi + + # Install soteria from source + cargo install --git https://github.com/monad-developers/soteria.git --root "$INSTALL_DIR" + BINARY="$INSTALL_DIR/bin/soteria" + else + cp "$BINARY" "$INSTALL_DIR/soteria" + BINARY="$INSTALL_DIR/soteria" + fi + + # Make executable (on Unix systems) + if [ "${{ runner.os }}" != "Windows" ]; then + chmod +x "$BINARY" + fi + + echo "binary_path=$BINARY" >> $GITHUB_OUTPUT + echo "Binary location: $BINARY" + + # Verify binary + "$BINARY" --help || "$BINARY" -h || echo "Binary verification skipped" + + - name: Run Soteria validation + id: run-soteria + shell: bash + run: | + BINARY="${{ steps.setup.outputs.binary_path }}" + DIRECTORY="${{ inputs.directory }}" + + echo "Running Soteria on directory: $DIRECTORY" + echo "Using binary: $BINARY" + + # Validate directory exists + if [ ! -d "$DIRECTORY" ]; then + echo "::error::Directory does not exist: $DIRECTORY" + exit 1 + fi + + # Run soteria + set +e + OUTPUT=$("$BINARY" "$DIRECTORY" 2>&1) + EXIT_CODE=$? + set -e + + echo "$OUTPUT" + + if [ $EXIT_CODE -eq 0 ]; then + echo "result=success" >> $GITHUB_OUTPUT + echo "::notice::Soteria validation completed successfully" + else + echo "result=failure" >> $GITHUB_OUTPUT + if [ "${{ inputs.fail-on-error }}" == "true" ]; then + echo "::error::Soteria validation failed" + exit $EXIT_CODE + else + echo "::warning::Soteria validation found issues (exit code: $EXIT_CODE)" + fi + fi From 049d17f8addce2f384c0aaf1710cc8201cea149c Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Fri, 12 Dec 2025 04:12:13 +0400 Subject: [PATCH 11/14] feat: add job to upload artifacts --- .github/workflows/release.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4d4714..6be6e33 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,6 +96,28 @@ jobs: path: artifacts/* if-no-files-found: error + publish: + name: Publish GitHub Release assets + runs-on: ubuntu-latest + needs: [binaries] + permissions: + contents: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v5 + with: + path: dist + merge-multiple: true + + - name: Update release and upload assets + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + fail_on_unmatched_files: true + files: | + dist/*.tar.gz + dist/*.zip + docker: runs-on: ubuntu-latest steps: From ba2bc216e7d93cb5f6b9b3b5e50fe53c8950f33e Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Fri, 12 Dec 2025 04:17:19 +0400 Subject: [PATCH 12/14] feat: update action name --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 2cb351a..1b834f5 100644 --- a/action.yml +++ b/action.yml @@ -1,4 +1,4 @@ -name: 'soteria' +name: 'run soteria CLI' description: 'uses the soteria CLI to validate safe transaction hashes in JSON log files' author: 'Monad Foundation' From adb947542778db325f5bdbeeff069eb710bac04c Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Fri, 12 Dec 2025 04:20:26 +0400 Subject: [PATCH 13/14] feat: bump checkout action --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c362419..f4e734a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6be6e33..5d672a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable From 94328fea133023516f5a963ac92670d23252ce6c Mon Sep 17 00:00:00 2001 From: qedk <1994constant@gmail.com> Date: Fri, 12 Dec 2025 04:46:23 +0400 Subject: [PATCH 14/14] feat: bump version to align with github version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4cbe2c..7d35535 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -459,7 +459,7 @@ dependencies = [ [[package]] name = "soteria" -version = "0.1.0" +version = "0.1.6" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 5f94f9f..2ceca3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "soteria" authors = ["QEDK "] license = "Apache-2.0" -version = "0.1.0" +version = "0.1.6" edition = "2024" [dependencies]