diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml deleted file mode 100644 index 957175fe..00000000 --- a/.github/workflows/typos.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Typos - -on: - push: - branches: [master] - pull_request: - branches: [master] - -env: - TERM: xterm-256color - -jobs: - check: - name: Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: crate-ci/typos@v1.24.5 diff --git a/CLAUDE.md b/CLAUDE.md index ffd2b02d..8447d79a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ This is a Rust workspace with six main crates: - **`crates/core/`**: Core functionality including bytecode decoder, encoder, section detector, bytecode stripper, and CFG-IR generation. The detection module includes section isolation (`detection/sections.rs`) and dispatcher pattern detection (`detection/dispatcher.rs`) - **`crates/analysis/`**: Analysis utilities for measuring bytecode complexity and obfuscation quality through comprehensive metrics - **`crates/transforms/`**: Obfuscation passes including opaque predicates, control flow shuffling, function dispatcher obfuscation, and jump address transformation. The `obfuscator.rs` module orchestrates all transforms -- **`crates/verification/`**: Formal verification engine providing mathematical proofs of semantic equivalence using Z3 SMT solver, plus practical testing with REVM. Supports multiple verification levels and generates cryptographic certificates +- **`crates/verification/`**: Experimental semantic and SMT primitives. The production-facing equivalence entry point fails closed with `VerificationUnavailable`; current proof records and checksums are not mathematical proof evidence or cryptographic certificates. REVM tests provide bounded test evidence only. - **`crates/utils/`**: Shared utilities including deterministic seed generation and error types - **`crates/cli/`**: Command-line interface (`azoth` binary) with subcommands for decode, strip, cfg, and obfuscate @@ -115,8 +115,9 @@ Write documentation in clear, professional prose rather than fragmented bullet p /// /// This function performs the core transformation from linear bytecode instructions /// into a structured graph representation that enables sophisticated analysis and -/// obfuscation transforms. The resulting CFG maintains semantic equivalence while -/// providing the structural information necessary for advanced code analysis. +/// obfuscation transforms. The resulting CFG preserves decoded structural relationships needed +/// for analysis and fail-closed transformation checks; CFG construction alone does not prove +/// semantic equivalence. /// /// The construction process involves several phases: basic block identification /// through control flow analysis, edge creation based on jump target resolution, diff --git a/Cargo.lock b/Cargo.lock index 95e23325..e3306aac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1296,7 +1296,10 @@ dependencies = [ "imara-diff", "owo-colors", "petgraph", + "revm", "serde", + "serde_json", + "sha3", "thiserror 2.0.16", "tokio", "tracing", @@ -1322,6 +1325,7 @@ dependencies = [ "serde", "serde_json", "sha3", + "tempfile", "thiserror 2.0.16", "tokio", "tracing", @@ -1332,11 +1336,10 @@ dependencies = [ name = "azoth-core" version = "0.1.0" dependencies = [ - "eot", - "heimdall-disassembler", "hex", "petgraph", "rand 0.9.2", + "rand_chacha 0.9.0", "revm", "serde", "sha3", @@ -1354,7 +1357,9 @@ dependencies = [ "azoth-transform", "chrono", "hex", + "revm", "serde_json", + "sha3", "tokio", ] @@ -1367,7 +1372,6 @@ dependencies = [ "azoth-transform", "azoth-verification", "color-eyre", - "heimdall-disassembler", "hex", "hex-literal", "paste", @@ -1387,6 +1391,7 @@ version = "0.1.0" dependencies = [ "azoth-core", "hex", + "hmac", "petgraph", "rand 0.9.2", "revm", @@ -1421,7 +1426,6 @@ version = "0.1.0" dependencies = [ "azoth-core", "chrono", - "eot", "hex", "indexmap 2.11.4", "num-bigint", @@ -2387,15 +2391,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "eot" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c685edb5c949f9e07e31fc36a8f02b15654eb725c29ec29b4fcb25990e2552" -dependencies = [ - "serde", -] - [[package]] name = "equivalent" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 3618fe08..8010cbba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,8 +50,6 @@ azoth-transform = { version = "0.1.0", path = "crates/transforms" } azoth-verification = { version = "0.1.0", path = "crates/verification" } # Ethereum -eot = { version = "0.2.0", features = ["serde", "unified-opcodes"] } -heimdall = { git = "https://github.com/Jon-Becker/heimdall-rs", package = "heimdall-disassembler", tag = "0.9.0" } revm = { version = "29", features = ["std", "serde", "tracer"] } # Async runtime @@ -70,6 +68,7 @@ clap = { version = "4.5", features = ["derive", "color", "suggestions"] } # Cryptography sha3 = "0.10" +hmac = "0.12" tiny-keccak = { version = "2.0", features = ["keccak"] } hex = "0.4" @@ -82,7 +81,8 @@ num-bigint = { version = "0.4", features = ["serde"] } # Utilities chrono = { version = "0.4", features = ["serde"] } -rand = { version = "0.9", features = ["small_rng"] } +rand = "=0.9.2" +rand_chacha = "=0.9.0" tempfile = "3.20" paste = "1.0.15" hex-literal = "1.0.0" diff --git a/README.md b/README.md index d91c3ca5..380dfa19 100644 --- a/README.md +++ b/README.md @@ -3,23 +3,29 @@ ## What is Azoth? -Azoth is a deterministic EVM bytecode obfuscator designed to make Mirage's execution contracts indistinguishable from ordinary, unverified deployments on Ethereum. The name "[Azoth](https://www.wikiwand.com/en/articles/Azoth)" derives from medieval alchemy, where it referred to the universal solvent: a hypothetical substance capable of dissolving any material and serving as the essential agent of transformation. +Azoth is an experimental deterministic EVM bytecode variation engine. Its design goal is to vary Mirage execution contracts while preserving supported EVM behavior, but the current safe profile has **not** demonstrated indistinguishability from ordinary unverified Ethereum deployments. The name "[Azoth](https://www.wikiwand.com/en/articles/Azoth)" derives from medieval alchemy, where it referred to the universal solvent: a hypothetical substance capable of dissolving any material and serving as the essential agent of transformation. ## How does it work? 1. Dissection: decode the contract’s init/runtime layout, resolve sections, and build a control-flow graph of block bodies and jump targets. -2. Transformation: apply deterministic transformations (e.g dispatcher transforms, block shuffling etc.) that changes the structure of the bytecode without blowing gas or size limits. +2. Transformation: apply admitted deterministic transformations transactionally. The safe default currently admits relationship-aware cluster shuffling; experimental transforms require separate validation. -3. Recovery: lower the rewritten runtime, patch init-code offsets, and mask any exact constructor-argument suffix so the final bytecode stays deployable without retaining an ABI-aligned plaintext tail. +3. Recovery: lower the rewritten runtime, patch only proven init-code and immutable-reference locations, preserve compiler suffixes, and enforce EVM size limits. Constructor-argument and selector rewriting are disabled in the safe profile. -Azoth also incorporates a formal verification system that provides mathematical guarantees of functional equivalence between original and obfuscated contracts. +Formal equivalence verification is not available. The production-facing verifier fails closed with `VerificationUnavailable`; REVM deployment checks and differential tests are useful test evidence, not mathematical proof. + +Disassembly in the production pipeline is native and synchronous. Azoth owns the legacy-EVM +opcode table and performs a one-pass byte walk; it does not invoke Heimdall or EOT. Heimdall remains +isolated in the analysis crate for optional decompiler/diff views. See the +[native decoder design](docs/native-bytecode-decoder.md) for fork scope, malformed-byte handling, +and the protocol-update checklist. Constructor-argument masking is an obfuscation boundary, not encryption: it defeats verbatim static suffix recovery, but public creation code can still be analyzed or executed to recover values. See the [constructor-argument security and benchmark report](docs/constructor-argument-obfuscation.md). ## Status -Azoth is under active development: the parsing pipeline, CFG builder, and several core transforms are in daily use, while additional passes, verification tooling, and resilience metrics are landing incrementally as we harden the stack for production-facing deployments. +Azoth is under active development and is not production-ready. Unsupported bytecode relationships and constructor shapes are rejected or produce an unchanged identity result. An unchanged result is safe fallback behavior, not evidence that a variation objective was achieved. ## Getting Started @@ -27,7 +33,7 @@ Azoth is available through a command-line interface. This could be used for loca ## Fuzzing -Azoth includes a built-in fuzzer for testing the obfuscation pipeline. The fuzzer generates random seeds and transform combinations, running them against test contracts to discover edge cases and potential issues. +Azoth includes a built-in deterministic parameter-campaign harness. A case index fixes its contract, seed, and transform subset, so worker scheduling does not change finite-run coverage. It currently covers the bundled escrow and counter fixtures; it is not an arbitrary-bytecode fuzzer. ### Basic Usage @@ -44,7 +50,7 @@ cargo run --bin azoth -- fuzz -d 60 # Use 4 parallel workers cargo run --bin azoth -- fuzz -j 4 -# Enable deployment verification (checks obfuscated bytecode deploys correctly) +# Enable the REVM creation smoke check (not behavioral equivalence) cargo run --bin azoth -- fuzz --check-deploy ``` @@ -68,7 +74,7 @@ cargo run --bin azoth -- fuzz replay crashes/crash_abc123.json | `-i, --iterations ` | Maximum iterations, 0 for infinite (default: 0) | | `-d, --duration ` | Maximum duration in seconds, 0 for infinite (default: 0) | | `--crash-dir ` | Directory to save crash files (default: `crashes/`) | -| `--check-deploy` | Verify obfuscated bytecode deploys successfully via REVM | +| `--check-deploy` | Check that creation succeeds in REVM; this does not prove runtime behavior | ## Contributing diff --git a/crates/analysis/Cargo.toml b/crates/analysis/Cargo.toml index 0e0acdc4..0a3b4646 100644 --- a/crates/analysis/Cargo.toml +++ b/crates/analysis/Cargo.toml @@ -8,12 +8,15 @@ azoth-core.workspace = true azoth-transform.workspace = true petgraph.workspace = true serde.workspace = true +serde_json.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true hex.workspace = true +sha3.workspace = true thiserror.workspace = true chrono.workspace = true +revm.workspace = true alloy = "1.1" heimdall-decompiler = { git = "https://github.com/Jon-Becker/heimdall-rs", tag = "0.9.0" } diff --git a/crates/analysis/README.md b/crates/analysis/README.md index 55163a00..64e55945 100644 --- a/crates/analysis/README.md +++ b/crates/analysis/README.md @@ -11,6 +11,7 @@ The analysis crate focuses on quantifying bytecode complexity through: 3. **Dominator Analysis** - Control flow critical points using dominator/post-dominator overlap 4. **Size Metrics** - Bytecode length tracking 5. **Obfuscation Persistence** - Longest preserved byte sequences and n-gram diversity across randomized obfuscations +6. **Red-team Detection** - Linear-time opcode signatures, labelled-corpus metrics, and exact metadata linkage ## Key Components @@ -56,7 +57,8 @@ This balances control flow complexity against dominator overlap, with higher sco ### Obfuscation Experiment (`obfuscation.rs`) -Runs multiple obfuscation attempts with randomized seeds and aggregates: +Runs multiple obfuscation attempts with deterministic child seeds derived from a required private +root seed and aggregates: - Longest common preserved byte sequences per iteration - Summary statistics (average, median, percentiles, range, standard deviation) @@ -65,3 +67,58 @@ Runs multiple obfuscation attempts with randomized seeds and aggregates: - N-gram diversity (n = 2, 4, 8) across obfuscated outputs Use `AnalysisConfig` to configure iterations, transform passes, and output path, then call `analyze_obfuscation(config)` to produce a markdown report. The CLI subcommand `azoth analyze` builds on this module. + +### Red-team detector (`detector.rs`) + +The detector parses EVM instructions without treating PUSH immediates as opcodes and reports exact +dispatcher, push-split, arithmetic-chain, constructor-mask-decoder, tail-density, +malformed-bytecode, and terminal Solidity metadata features. It also runs cheap normalization +attacks: PUSH-immediate erasure, opcode-only basic-block sorting, and recognizable PushSplit +folding. Feature extraction is linear in bytecode size; sorting block fingerprints is +`O(blocks * log(blocks))`. The score is a stable heuristic, **not** a probability. +Detection-quality fields are emitted only when a corpus contains both labelled positives and +labelled negatives. + +Run the standalone JSON harness with: + +```bash +cargo run -p azoth-analysis --example detect_corpus -- corpus.json > detector-report.json +``` + +The input is a JSON array. `label` and `family` are optional; `family` should identify variants of +the same original source when measuring exact-metadata linkability. + +```json +[ + {"id":"azoth-escrow-001","bytecode":"0x...","label":true,"family":"escrow-a"}, + {"id":"ethereum-negative-001","bytecode":"0x...","label":false,"family":"negative-001"} +] +``` + +The report includes per-sample features and scores, AUROC, tie-aware average precision, fixed +threshold confusion matrices, empirical TPR at several low-FPR ceilings, one-sided 95% Wilson +upper bounds for FPR, feature prevalence by class, repeated metadata suffix clusters, and +normalized source-family linkability. It warns when family variants are being treated as separate +samples or the negative corpus cannot support a low-FPR claim. Unlabelled corpora produce +descriptive results without classification claims. + +To generate deterministic local Azoth positives from the checked-in escrow and counter fixtures: + +`runtime` mode executes each transformed creation payload in REVM and records the materialized +deployed code that an on-chain observer would see, including constructor-written immutables. The +generator aborts instead of emitting a partial corpus if any requested fixture/seed fails or if a +selected artifact is byte-for-byte identical to its baseline. Exact identities are therefore never +mislabelled as positive examples. The final positional argument deterministically selects `all` +(the default), `escrow-erc20`, or `counter`; selecting `counter` is useful when a current safety +gate intentionally leaves the ERC20 fixture unchanged. + +```bash +# 20 seeds per fixture; fails atomically if any selected output is unchanged +cargo run -p azoth-analysis --example generate_azoth_corpus -- 20 azoth-foundation-v4 runtime no-mask all + +# Request only genuinely changed counter samples +cargo run -p azoth-analysis --example generate_azoth_corpus -- 20 azoth-foundation-v4 runtime no-mask counter + +# Explicitly red-team the non-production constructor mask +cargo run -p azoth-analysis --example generate_azoth_corpus -- 20 mask creation mask counter +``` diff --git a/crates/analysis/examples/detect_corpus.rs b/crates/analysis/examples/detect_corpus.rs new file mode 100644 index 00000000..403c2fe2 --- /dev/null +++ b/crates/analysis/examples/detect_corpus.rs @@ -0,0 +1,22 @@ +//! Score a JSON corpus with Azoth's reproducible red-team detector. + +use azoth_analysis::detector::{CorpusSample, evaluate_corpus}; +use std::io::Read; + +fn main() -> Result<(), Box> { + let path = std::env::args().nth(1); + let mut input = String::new(); + match path.as_deref() { + Some("-") | None => { + std::io::stdin().read_to_string(&mut input)?; + } + Some(path) => { + input = std::fs::read_to_string(path)?; + } + } + let samples: Vec = serde_json::from_str(&input)?; + let report = evaluate_corpus(&samples)?; + serde_json::to_writer_pretty(std::io::stdout().lock(), &report)?; + println!(); + Ok(()) +} diff --git a/crates/analysis/examples/generate_azoth_corpus.rs b/crates/analysis/examples/generate_azoth_corpus.rs new file mode 100644 index 00000000..a8fa3a2f --- /dev/null +++ b/crates/analysis/examples/generate_azoth_corpus.rs @@ -0,0 +1,305 @@ +//! Generate a reproducible labelled corpus from Azoth's current pipeline. +//! +//! This is intentionally separate from `detect_corpus`: generation requires the transform crate, +//! while the detector harness can score externally collected contracts without running Azoth. + +#![recursion_limit = "256"] + +use azoth_analysis::detector::CorpusSample; +use azoth_core::seed::Seed; +use azoth_transform::obfuscator::{ObfuscationConfig, obfuscate_bytecode}; +use revm::context::TxEnv; +use revm::context::result::{ExecutionResult, Output}; +use revm::database::InMemoryDB; +use revm::primitives::{Address, Bytes, TxKind, U256}; +use revm::state::AccountInfo; +use revm::{Context, ExecuteEvm, MainBuilder, MainContext}; +use std::error::Error; + +const EXPECTED_PIPELINE_PROFILE: &str = "azoth-foundation-v4"; + +const ERC20_DEPLOYMENT: &str = + include_str!("../../../examples/escrow-bytecode/artifacts/erc20_deployment.hex"); +const ERC20_RUNTIME: &str = + include_str!("../../../examples/escrow-bytecode/artifacts/erc20_runtime.hex"); +const COUNTER_DEPLOYMENT: &str = + include_str!("../../../tests/bytecode/counter/counter_deployment.hex"); +const COUNTER_RUNTIME: &str = include_str!("../../../tests/bytecode/counter/counter_runtime.hex"); + +struct Fixture { + family: &'static str, + deployment: &'static str, + runtime: &'static str, + constructor_words: Vec<[u8; 32]>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FixtureSelection { + All, + EscrowErc20, + Counter, +} + +impl FixtureSelection { + fn parse(value: &str) -> Result { + match value { + "all" => Ok(Self::All), + "escrow-erc20" => Ok(Self::EscrowErc20), + "counter" => Ok(Self::Counter), + _ => Err(format!( + "fixture filter must be `all`, `escrow-erc20`, or `counter`; got `{value}`" + )), + } + } + + fn includes(self, family: &str) -> bool { + match self { + Self::All => true, + Self::EscrowErc20 => family == "escrow-erc20", + Self::Counter => family == "counter", + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let seeds = std::env::args() + .nth(1) + .map_or(Ok(20usize), |value| value.parse())?; + let cohort = std::env::args() + .nth(2) + .unwrap_or_else(|| EXPECTED_PIPELINE_PROFILE.to_string()); + let artifact_kind = std::env::args() + .nth(3) + .unwrap_or_else(|| "runtime".to_string()); + if !matches!(artifact_kind.as_str(), "runtime" | "creation") { + return Err("artifact kind must be `runtime` or `creation`".into()); + } + let mask_constructor_arguments = match std::env::args().nth(4).as_deref() { + None | Some("no-mask") => false, + Some("mask") => true, + Some(_) => return Err("constructor mode must be `no-mask` or `mask`".into()), + }; + let fixture_selection = + FixtureSelection::parse(std::env::args().nth(5).as_deref().unwrap_or("all"))?; + if std::env::args().nth(6).is_some() { + return Err("too many arguments; expected: [seeds] [cohort] [runtime|creation] [no-mask|mask] [all|escrow-erc20|counter]".into()); + } + if seeds == 0 { + return Err("seed count must be positive".into()); + } + + let fixtures = [ + Fixture { + family: "escrow-erc20", + deployment: ERC20_DEPLOYMENT, + runtime: ERC20_RUNTIME, + constructor_words: vec![ + abi_address([0x11; 20]), + abi_address([0x22; 20]), + abi_u256(1_000), + abi_u256(0), + abi_u256(0), + ], + }, + Fixture { + family: "counter", + deployment: COUNTER_DEPLOYMENT, + runtime: COUNTER_RUNTIME, + constructor_words: Vec::new(), + }, + ]; + + let fixtures: Vec<_> = fixtures + .into_iter() + .filter(|fixture| fixture_selection.includes(fixture.family)) + .collect(); + let expected_samples = fixtures.len() * seeds; + let mut samples = Vec::with_capacity(expected_samples); + for fixture in fixtures { + let deployment = deployment_with_arguments(&fixture)?; + let runtime = normalize_hex(fixture.runtime); + let baseline_artifact = if artifact_kind == "creation" { + deployment.clone() + } else { + deploy_runtime(&deployment).map_err(|error| { + format!( + "failed to materialize baseline runtime for {cohort}/{}: {error}", + fixture.family + ) + })? + }; + for index in 0..seeds { + let mut config = ObfuscationConfig::with_seed(sequential_seed(index)); + config.obfuscate_constructor_arguments = mask_constructor_arguments; + match obfuscate_bytecode(&deployment, &runtime, config).await { + Ok(result) => { + if result.integrity.pipeline_profile != EXPECTED_PIPELINE_PROFILE { + return Err(format!( + "corpus generator expects pipeline profile {}, but Azoth emitted {}", + EXPECTED_PIPELINE_PROFILE, result.integrity.pipeline_profile + ) + .into()); + } + let bytecode = if artifact_kind == "creation" { + result.obfuscated_bytecode + } else { + deploy_runtime(&result.obfuscated_bytecode).map_err(|error| { + format!( + "failed to materialize on-chain runtime for {cohort}/{} seed {index}: {error}", + fixture.family + ) + })? + }; + if bytecode_equal(&bytecode, &baseline_artifact)? { + return Err(format!( + "{cohort}/{} seed {index} produced an exact-identity {artifact_kind} artifact; refusing to label unchanged bytecode as an Azoth-positive sample or emit a partial corpus", + fixture.family + ) + .into()); + } + samples.push(CorpusSample { + id: format!("{cohort}-{artifact_kind}-{}-{index:04}", fixture.family), + bytecode, + label: Some(true), + family: Some(format!("{artifact_kind}-{}", fixture.family)), + }); + } + Err(error) => { + return Err(format!( + "failed {cohort}/{} seed {index}; refusing to emit a partial, survivor-biased corpus: {}", + fixture.family, error.message + ) + .into()); + } + } + } + } + + assert_eq!( + samples.len(), + expected_samples, + "every requested fixture/seed pair must produce exactly one corpus sample" + ); + + serde_json::to_writer_pretty(std::io::stdout().lock(), &samples)?; + println!(); + Ok(()) +} + +fn deployment_with_arguments(fixture: &Fixture) -> Result { + let mut deployment = hex::decode(normalize_hex(fixture.deployment))?; + for word in &fixture.constructor_words { + deployment.extend_from_slice(word); + } + Ok(hex::encode(deployment)) +} + +fn normalize_hex(value: &str) -> String { + value + .trim() + .trim_start_matches("0x") + .chars() + .filter(|character| !character.is_whitespace() && *character != '_') + .collect() +} + +fn bytecode_equal(left: &str, right: &str) -> Result { + Ok(hex::decode(normalize_hex(left))? == hex::decode(normalize_hex(right))?) +} + +fn sequential_seed(index: usize) -> Seed { + let mut bytes = [0u8; 32]; + bytes[24..].copy_from_slice(&(index as u64).to_be_bytes()); + Seed::from_bytes(bytes) +} + +fn abi_address(address: [u8; 20]) -> [u8; 32] { + let mut word = [0u8; 32]; + word[12..].copy_from_slice(&address); + word +} + +fn abi_u256(value: u128) -> [u8; 32] { + let mut word = [0u8; 32]; + word[16..].copy_from_slice(&value.to_be_bytes()); + word +} + +fn deploy_runtime(creation_hex: &str) -> Result { + let creation = hex::decode(creation_hex.trim().trim_start_matches("0x")) + .map_err(|error| format!("invalid transformed creation hex: {error}"))?; + let deployer = Address::from([0x42u8; 20]); + let mut db = InMemoryDB::default(); + db.insert_account_info( + deployer, + AccountInfo { + balance: U256::from(1_000_000_000_000_000_000u128), + nonce: 0, + code_hash: revm::primitives::KECCAK_EMPTY, + code: None, + }, + ); + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + let outcome = evm + .transact(TxEnv { + caller: deployer, + gas_limit: 30_000_000, + kind: TxKind::Create, + data: Bytes::copy_from_slice(&creation), + value: U256::ZERO, + nonce: 0, + ..Default::default() + }) + .map_err(|error| format!("EVM error: {error:?}"))?; + + match outcome.result { + ExecutionResult::Success { + output: Output::Create(runtime, Some(_)), + .. + } => Ok(format!("0x{}", hex::encode(runtime))), + ExecutionResult::Success { output, .. } => { + Err(format!("unexpected successful create output: {output:?}")) + } + ExecutionResult::Revert { output, gas_used } => Err(format!( + "creation reverted with 0x{} (gas {gas_used})", + hex::encode(output) + )), + ExecutionResult::Halt { reason, gas_used } => { + Err(format!("creation halted with {reason:?} (gas {gas_used})")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixture_selection_is_explicit_and_deterministic() { + assert_eq!(FixtureSelection::parse("all"), Ok(FixtureSelection::All)); + assert_eq!( + FixtureSelection::parse("escrow-erc20"), + Ok(FixtureSelection::EscrowErc20) + ); + assert_eq!( + FixtureSelection::parse("counter"), + Ok(FixtureSelection::Counter) + ); + assert!(FixtureSelection::parse("erc20").is_err()); + + assert!(FixtureSelection::All.includes("escrow-erc20")); + assert!(FixtureSelection::All.includes("counter")); + assert!(FixtureSelection::EscrowErc20.includes("escrow-erc20")); + assert!(!FixtureSelection::EscrowErc20.includes("counter")); + assert!(FixtureSelection::Counter.includes("counter")); + assert!(!FixtureSelection::Counter.includes("escrow-erc20")); + } + + #[test] + fn exact_identity_comparison_normalizes_hex_syntax() { + assert!(bytecode_equal("0x60_00\n5b", "60005B").unwrap()); + assert!(!bytecode_equal("0x60005b", "0x60015b").unwrap()); + assert!(bytecode_equal("0x", "").unwrap()); + } +} diff --git a/crates/analysis/src/decompile_diff/mod.rs b/crates/analysis/src/decompile_diff/mod.rs index 574db1ee..b2673e47 100644 --- a/crates/analysis/src/decompile_diff/mod.rs +++ b/crates/analysis/src/decompile_diff/mod.rs @@ -159,7 +159,7 @@ impl AggregatedStats { .iter() .map(|(k, &v)| (k, v)) .collect(); - sorted.sort_by(|a, b| b.1.cmp(&a.1)); + sorted.sort_by_key(|entry| std::cmp::Reverse(entry.1)); sorted.truncate(n); sorted } diff --git a/crates/analysis/src/detector.rs b/crates/analysis/src/detector.rs new file mode 100644 index 00000000..8a09ce98 --- /dev/null +++ b/crates/analysis/src/detector.rs @@ -0,0 +1,2006 @@ +//! Reproducible, linear-time detection features for red-team evaluation. +//! +//! The detector deliberately reports a heuristic score rather than a probability. A score only +//! measures the presence of motifs implemented here; meaningful detection claims require a +//! labelled, representative negative corpus. [`evaluate_corpus`] computes empirical ranking and +//! operating-point metrics when both labels are present and otherwise limits the report to +//! descriptive statistics. + +use serde::{Deserialize, Serialize}; +use sha3::{Digest, Keccak256}; +use std::cmp::Reverse; +use std::collections::{BTreeMap, HashMap, HashSet}; +use thiserror::Error; + +/// Version of the feature definitions and scoring weights. +pub const DETECTOR_VERSION: &str = "azoth-linear-signatures-v3"; + +const MAX_HEURISTIC_SCORE: u32 = 100; +const LOW_FPR_TARGETS: [f64; 3] = [0.01, 0.001, 0.0001]; +const REPORT_THRESHOLDS: [u32; 3] = [25, 50, 75]; + +type FingerprintExtractor = fn(&SignatureFeatures) -> &str; + +/// Resource limits for corpus evaluation. +#[derive(Debug, Clone)] +pub struct DetectorConfig { + /// Maximum number of corpus records accepted in one evaluation. + pub max_samples: usize, + /// Maximum decoded bytecode size accepted for one record. + pub max_bytecode_bytes: usize, + /// Maximum number of sample identifiers retained for each metadata cluster. + pub max_cluster_members_in_report: usize, +} + +impl Default for DetectorConfig { + fn default() -> Self { + Self { + max_samples: 100_000, + max_bytecode_bytes: 64 * 1024, + max_cluster_members_in_report: 64, + } + } +} + +/// One bytecode record supplied to the corpus evaluator. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CorpusSample { + /// Stable identifier used in the output report. + pub id: String, + /// Hex-encoded deployment or runtime bytecode, with an optional `0x` prefix. + pub bytecode: String, + /// Optional ground-truth class (`true` means Azoth-generated). + #[serde(default)] + pub label: Option, + /// Optional source-family identifier used to measure metadata linkability. + #[serde(default)] + pub family: Option, +} + +/// Counts and ratios extracted from one bytecode blob. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SignatureFeatures { + /// Total blob length, including a recognized Solidity metadata suffix. + pub byte_len: usize, + /// Bytes decoded as EVM code before a recognized Solidity metadata suffix. + pub code_len: usize, + /// Number of decoded EVM instructions. + pub instruction_count: usize, + /// Number of truncated PUSH instructions encountered by the decoder. + pub malformed_pushes: usize, + /// Count of `JUMPDEST INVALID` sink blocks. + pub dispatcher_invalid_sinks: usize, + /// Count of Azoth's constant-false dispatcher decoy sequence. + pub dispatcher_constant_false_decoys: usize, + /// Count of high-slot `SLOAD; ISZERO` controller gates. + pub dispatcher_storage_gates: usize, + /// Count of selector-byte extraction controller gates. + pub dispatcher_byte_gates: usize, + /// Count of `PUSH0; (PUSH; arithmetic-op){2,}` chains. + pub push_split_chains: usize, + /// Total arithmetic terms in all detected push-split chains. + pub push_split_terms: usize, + /// Count of ArithmeticChain's fixed CODECOPY/MLOAD loading sequence. + pub arithmetic_codecopy_loads: usize, + /// Count of adjacent wide constants followed by a foldable arithmetic operation. + pub wide_constant_chain_starts: usize, + /// Count of constructor-mask decoder chunks (`DUP1; MLOAD; ...; XOR; SWAP1; MSTORE`). + /// + /// Two or more chunks are a strong signature of Azoth's current constructor-argument pass. + pub constructor_mask_decoder_chunks: usize, + /// Fraction of dispatcher-associated opcodes in the final instruction quartile. + pub tail_dispatcher_opcode_density: f64, + /// Number of instructions used to calculate the tail density. + pub tail_instruction_count: usize, + /// Byte length of a plausible terminal Solidity CBOR suffix, including its length word. + pub metadata_suffix_len: Option, + /// Keccak-256 of the exact terminal metadata suffix, useful as a cluster key. + pub metadata_suffix_keccak256: Option, + /// Keccak-256 of the ordered opcode stream after metadata and PUSH immediates are removed. + /// + /// This deliberately weak normalization models a cheap analyst attack: selector relabelling, + /// jump-address changes, and suffix-only changes cannot change this fingerprint. + pub opcode_skeleton_keccak256: String, + /// Keccak-256 of the opcode-only basic-block multiset. + /// + /// Blocks are sorted before hashing, so this also removes pure block-layout shuffling. + pub block_multiset_keccak256: String, + /// Ordered opcode fingerprint after constant-folding recognizable PushSplit chains. + pub folded_opcode_skeleton_keccak256: String, + /// Block-multiset fingerprint after constant-folding recognizable PushSplit chains. + pub folded_block_multiset_keccak256: String, +} + +/// One piece of evidence contributing to a heuristic score. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignatureEvidence { + /// Stable feature name. + pub feature: String, + /// Number of occurrences in this bytecode. + pub count: usize, + /// Weight added before the score is capped at 100. + pub weight: u32, +} + +/// Detector output for one bytecode blob. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DetectorResult { + /// Feature-definition version. + pub detector_version: String, + /// Bounded heuristic score. This is not a calibrated probability. + pub heuristic_score: u32, + /// Evidence that contributed to the score. + pub evidence: Vec, + /// Raw extracted features. + pub features: SignatureFeatures, +} + +/// Detector output annotated with corpus ground truth. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScoredSample { + /// Stable input identifier. + pub id: String, + /// Optional ground-truth class. + pub label: Option, + /// Optional source-family identifier. + pub family: Option, + /// Detector result. + pub detector: DetectorResult, +} + +/// Summary of a score distribution. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ScoreDistribution { + /// Number of observations. + pub count: usize, + /// Smallest score. + pub min: Option, + /// Largest score. + pub max: Option, + /// Arithmetic mean. + pub mean: Option, + /// Median, averaging the middle pair for an even count. + pub median: Option, +} + +/// Feature prevalence split by supplied label. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FeaturePrevalence { + /// Number of all records containing the feature. + pub all: usize, + /// Number of labelled-positive records containing the feature. + pub positive: usize, + /// Number of labelled-negative records containing the feature. + pub negative: usize, + /// Number of unlabelled records containing the feature. + pub unlabelled: usize, +} + +/// Confusion counts and derived rates at one fixed score threshold. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThresholdMetrics { + /// Samples at or above this score are predicted positive. + pub threshold: u32, + /// True positives. + pub true_positives: usize, + /// False positives. + pub false_positives: usize, + /// True negatives. + pub true_negatives: usize, + /// False negatives. + pub false_negatives: usize, + /// True-positive rate. + pub true_positive_rate: f64, + /// False-positive rate. + pub false_positive_rate: f64, + /// Positive predictive value within the supplied corpus. + pub precision: Option, +} + +/// Best empirical threshold satisfying a requested false-positive-rate ceiling. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OperatingPoint { + /// Requested empirical false-positive-rate ceiling. + pub target_false_positive_rate: f64, + /// Selected threshold. + pub threshold: u32, + /// False-positive rate observed in this corpus. + pub observed_false_positive_rate: f64, + /// True-positive rate observed in this corpus. + pub observed_true_positive_rate: f64, + /// Number of false positives at the selected threshold. + pub false_positives: usize, + /// Number of true positives at the selected threshold. + pub true_positives: usize, + /// Whether the negative corpus is large enough for one false positive to resolve the target. + pub resolution_supported: bool, + /// One-sided 95% Wilson upper confidence bound for the observed false-positive rate. + pub false_positive_rate_upper_95: f64, + /// Whether that upper confidence bound is at or below the requested target. + pub confidence_supported: bool, +} + +/// Repeated normalized fingerprint in the evaluated corpus. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NormalizedCluster { + /// Stable name of the normalization attack. + pub fingerprint_kind: String, + /// Keccak-256 cluster key. + pub fingerprint_keccak256: String, + /// Exact number of members, including omitted identifiers. + pub member_count: usize, + /// Retained member identifiers, bounded by [`DetectorConfig`]. + pub sample_ids: Vec, + /// Whether identifiers were truncated in the report. + pub sample_ids_truncated: bool, + /// Number of distinct supplied families represented by the cluster. + pub family_count: usize, + /// Labelled positive members. + pub positive_members: usize, + /// Labelled negative members. + pub negative_members: usize, + /// Unlabelled members. + pub unlabelled_members: usize, +} + +/// Pairwise linkability of one normalized fingerprint when family labels are supplied. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NormalizationLinkabilityMetrics { + /// Stable name of the normalization attack. + pub fingerprint_kind: String, + /// Pairs whose records have the same supplied family. + pub same_family_pairs: u64, + /// Same-family pairs sharing the fingerprint. + pub linked_same_family_pairs: u64, + /// Fraction of same-family pairs sharing the fingerprint. + pub same_family_pair_recall: Option, + /// Positive/negative pairs that share a supplied family label. + /// + /// Unlike all-pairs recall, this directly measures whether an Azoth output links to a + /// compiler-produced member of the same source family. + pub cross_label_same_family_pairs: u64, + /// Cross-label same-family pairs sharing the fingerprint. + pub linked_cross_label_same_family_pairs: u64, + /// Fraction of cross-label same-family pairs sharing the fingerprint. + pub cross_label_same_family_pair_recall: Option, + /// Pairs whose records have different supplied families. + pub different_family_pairs: u64, + /// Different-family pairs sharing the fingerprint. + pub linked_different_family_pairs: u64, + /// Fraction of different-family pairs sharing the fingerprint. + pub different_family_pair_collision_rate: Option, +} + +/// Label-dependent detector quality metrics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BinaryClassificationMetrics { + /// Number of labelled positives. + pub positive_count: usize, + /// Number of labelled negatives. + pub negative_count: usize, + /// Area under the ROC curve with ties receiving half credit. + pub auroc: f64, + /// Tie-invariant threshold-sweep average precision. + pub average_precision: f64, + /// Confusion metrics at stable report thresholds. + pub thresholds: Vec, + /// Best empirical points under several low-FPR ceilings. + pub low_fpr_operating_points: Vec, +} + +/// Repeated exact metadata suffix in the evaluated corpus. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MetadataCluster { + /// Keccak-256 cluster key. + pub suffix_keccak256: String, + /// Exact number of members, including omitted identifiers. + pub member_count: usize, + /// Retained member identifiers, bounded by [`DetectorConfig`]. + pub sample_ids: Vec, + /// Whether identifiers were truncated in the report. + pub sample_ids_truncated: bool, + /// Labelled positive members. + pub positive_members: usize, + /// Labelled negative members. + pub negative_members: usize, + /// Unlabelled members. + pub unlabelled_members: usize, +} + +/// Pairwise metadata linkage metrics when source-family labels are supplied. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MetadataLinkabilityMetrics { + /// Pairs whose records have the same supplied family. + pub same_family_pairs: u64, + /// Same-family pairs sharing an exact metadata suffix. + pub linked_same_family_pairs: u64, + /// Fraction of same-family pairs sharing an exact metadata suffix. + pub same_family_pair_recall: Option, + /// Pairs whose records have different supplied families. + pub different_family_pairs: u64, + /// Different-family pairs sharing an exact metadata suffix. + pub linked_different_family_pairs: u64, + /// Fraction of different-family pairs sharing an exact metadata suffix. + pub different_family_pair_collision_rate: Option, +} + +/// Full result of corpus scoring and optional labelled evaluation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CorpusReport { + /// Feature-definition version. + pub detector_version: String, + /// Number of evaluated samples. + pub sample_count: usize, + /// Score distribution across all records. + pub all_scores: ScoreDistribution, + /// Score distribution for labelled positives. + pub positive_scores: ScoreDistribution, + /// Score distribution for labelled negatives. + pub negative_scores: ScoreDistribution, + /// Presence counts for every exposed feature. + pub feature_prevalence: BTreeMap, + /// Per-record detector results. + pub samples: Vec, + /// Label-dependent metrics, absent unless both classes are supplied. + pub binary_classification: Option, + /// Exact metadata suffix clusters containing at least two records. + pub metadata_clusters: Vec, + /// Pairwise linkage metrics, absent when fewer than two family labels are supplied. + pub metadata_linkability: Option, + /// Repeated fingerprints exposed by cheap normalization attacks. + pub normalized_clusters: Vec, + /// Pairwise source-family linkability for each normalization attack. + pub normalization_linkability: Vec, + /// Limitations or data-quality conditions relevant to interpretation. + pub warnings: Vec, +} + +/// Corpus decoding or resource-limit error. +#[derive(Debug, Error)] +pub enum DetectorError { + /// Too many records were supplied. + #[error("corpus has {actual} records; configured maximum is {maximum}")] + TooManySamples { + /// Actual record count. + actual: usize, + /// Configured limit. + maximum: usize, + }, + /// Two records used the same stable identifier and would be double-counted. + #[error("corpus contains duplicate sample id `{sample_id}`")] + DuplicateSampleId { + /// Repeated identifier. + sample_id: String, + }, + /// A decoded bytecode blob would exceed the configured limit. + #[error( + "sample `{sample_id}` is approximately {actual} bytes; configured maximum is {maximum}" + )] + BytecodeTooLarge { + /// Sample identifier. + sample_id: String, + /// Approximate or actual decoded size. + actual: usize, + /// Configured limit. + maximum: usize, + }, + /// Hexadecimal bytecode was malformed. + #[error("sample `{sample_id}` has invalid bytecode hex: {source}")] + InvalidHex { + /// Sample identifier. + sample_id: String, + /// Decoder error. + #[source] + source: hex::FromHexError, + }, +} + +#[derive(Debug, Clone)] +struct Instruction { + opcode: u8, + immediate: Vec, + declared_push_width: usize, + truncated: bool, +} + +impl Instruction { + fn is_push(&self) -> bool { + self.opcode == 0x5f || (0x60..=0x7f).contains(&self.opcode) + } + + fn push_width(&self) -> Option { + self.is_push().then_some(self.declared_push_width) + } + + fn push_value_u64(&self) -> Option { + if !self.is_push() || self.truncated || self.immediate.len() > 8 { + return None; + } + Some( + self.immediate + .iter() + .fold(0u64, |value, byte| (value << 8) | u64::from(*byte)), + ) + } + + fn push_value_u128(&self) -> Option { + if !self.is_push() || self.truncated || self.immediate.len() > 16 { + return None; + } + Some( + self.immediate + .iter() + .fold(0u128, |value, byte| (value << 8) | u128::from(*byte)), + ) + } +} + +/// Extract features from raw EVM bytecode in time linear in the blob length. +#[must_use] +pub fn extract_signature_features(bytecode: &[u8]) -> SignatureFeatures { + let metadata = metadata_suffix(bytecode); + let code_len = metadata + .as_ref() + .map_or(bytecode.len(), |value| value.start); + let instructions = decode_instructions(&bytecode[..code_len]); + let malformed_pushes = instructions.iter().filter(|value| value.truncated).count(); + let raw_opcode_tokens = opcode_tokens(&instructions, false); + let folded_opcode_tokens = opcode_tokens(&instructions, true); + + let dispatcher_invalid_sinks = instructions + .windows(2) + .filter(|window| window[0].opcode == 0x5b && window[1].opcode == 0xfe) + .count(); + let dispatcher_constant_false_decoys = instructions + .windows(8) + .filter(|window| is_constant_false_decoy(window)) + .count(); + let dispatcher_storage_gates = instructions + .windows(7) + .filter(|window| is_storage_gate(window)) + .count(); + let dispatcher_byte_gates = instructions + .windows(10) + .filter(|window| is_byte_gate(window)) + .count(); + let (push_split_chains, push_split_terms) = count_push_split_chains(&instructions); + let arithmetic_codecopy_loads = instructions + .windows(6) + .filter(|window| is_arithmetic_codecopy_load(window)) + .count(); + let wide_constant_chain_starts = instructions + .windows(3) + .filter(|window| { + window[0].push_width().is_some_and(|width| width >= 16) + && window[1].push_width().is_some_and(|width| width >= 16) + && is_foldable_arithmetic(window[2].opcode) + }) + .count(); + let constructor_mask_decoder_chunks = count_constructor_mask_decoder_chunks(&instructions); + let tail_instruction_count = instructions.len().div_ceil(4); + let tail_dispatcher_opcode_density = if tail_instruction_count == 0 { + 0.0 + } else { + let dispatcher_opcodes = instructions[instructions.len() - tail_instruction_count..] + .iter() + .filter(|instruction| is_dispatcher_associated_opcode(instruction.opcode)) + .count(); + dispatcher_opcodes as f64 / tail_instruction_count as f64 + }; + + SignatureFeatures { + byte_len: bytecode.len(), + code_len, + instruction_count: instructions.len(), + malformed_pushes, + dispatcher_invalid_sinks, + dispatcher_constant_false_decoys, + dispatcher_storage_gates, + dispatcher_byte_gates, + push_split_chains, + push_split_terms, + arithmetic_codecopy_loads, + wide_constant_chain_starts, + constructor_mask_decoder_chunks, + tail_dispatcher_opcode_density, + tail_instruction_count, + metadata_suffix_len: metadata.as_ref().map(|value| bytecode.len() - value.start), + metadata_suffix_keccak256: metadata.map(|value| value.keccak256), + opcode_skeleton_keccak256: hash_tokens(b"ordered-opcodes-v1", &raw_opcode_tokens), + block_multiset_keccak256: hash_block_multiset(&raw_opcode_tokens), + folded_opcode_skeleton_keccak256: hash_tokens( + b"ordered-opcodes-push-split-fold-v1", + &folded_opcode_tokens, + ), + folded_block_multiset_keccak256: hash_block_multiset(&folded_opcode_tokens), + } +} + +/// Score raw EVM bytecode using the versioned, bounded heuristic. +#[must_use] +pub fn score_bytecode(bytecode: &[u8]) -> DetectorResult { + score_features(extract_signature_features(bytecode)) +} + +/// Decode and score a hexadecimal EVM bytecode string. +pub fn score_hex(bytecode: &str) -> Result { + let decoded = decode_hex("", bytecode, usize::MAX)?; + Ok(score_bytecode(&decoded)) +} + +/// Evaluate a corpus using [`DetectorConfig::default`]. +pub fn evaluate_corpus(samples: &[CorpusSample]) -> Result { + evaluate_corpus_with_config(samples, &DetectorConfig::default()) +} + +/// Evaluate a corpus with explicit resource limits. +pub fn evaluate_corpus_with_config( + samples: &[CorpusSample], + config: &DetectorConfig, +) -> Result { + if samples.len() > config.max_samples { + return Err(DetectorError::TooManySamples { + actual: samples.len(), + maximum: config.max_samples, + }); + } + + let mut sample_ids = HashSet::with_capacity(samples.len()); + let mut scored = Vec::with_capacity(samples.len()); + for sample in samples { + if !sample_ids.insert(sample.id.as_str()) { + return Err(DetectorError::DuplicateSampleId { + sample_id: sample.id.clone(), + }); + } + let decoded = decode_hex(&sample.id, &sample.bytecode, config.max_bytecode_bytes)?; + scored.push(ScoredSample { + id: sample.id.clone(), + label: sample.label, + family: sample.family.clone(), + detector: score_bytecode(&decoded), + }); + } + + let all_scores: Vec<_> = scored + .iter() + .map(|sample| sample.detector.heuristic_score) + .collect(); + let positive_scores: Vec<_> = scored + .iter() + .filter(|sample| sample.label == Some(true)) + .map(|sample| sample.detector.heuristic_score) + .collect(); + let negative_scores: Vec<_> = scored + .iter() + .filter(|sample| sample.label == Some(false)) + .map(|sample| sample.detector.heuristic_score) + .collect(); + + let mut warnings = Vec::new(); + let labelled_count = positive_scores.len() + negative_scores.len(); + if labelled_count == 0 { + warnings.push( + "No labels supplied: report contains descriptive features only; detection quality cannot be inferred." + .to_string(), + ); + } else if positive_scores.is_empty() || negative_scores.is_empty() { + warnings.push( + "Only one labelled class supplied: AUROC, average precision, and FPR metrics are undefined." + .to_string(), + ); + } + if labelled_count != scored.len() && labelled_count != 0 { + warnings.push(format!( + "{} of {} samples are labelled; classification metrics exclude unlabelled records.", + labelled_count, + scored.len() + )); + } + + let binary_classification = if positive_scores.is_empty() || negative_scores.is_empty() { + None + } else { + let labelled: Vec<_> = scored + .iter() + .filter_map(|sample| { + sample + .label + .map(|label| (sample.detector.heuristic_score, label)) + }) + .collect(); + Some(compute_binary_metrics(&labelled)) + }; + if let Some(metrics) = &binary_classification { + warnings.push( + "Operating points are selected and evaluated on the same supplied corpus; treat them as exploratory in-sample measurements, not out-of-sample performance." + .to_string(), + ); + for point in &metrics.low_fpr_operating_points { + if !point.resolution_supported { + warnings.push(format!( + "FPR target {:.4}% is below the empirical resolution of {} negatives.", + point.target_false_positive_rate * 100.0, + metrics.negative_count + )); + } + if !point.confidence_supported { + warnings.push(format!( + "At the {:.4}% FPR target, the one-sided 95% Wilson upper bound is {:.4}% ({} negatives, {} observed false positives); this corpus cannot support the target with 95% confidence.", + point.target_false_positive_rate * 100.0, + point.false_positive_rate_upper_95 * 100.0, + metrics.negative_count, + point.false_positives, + )); + } + } + } + + let labelled_families = scored + .iter() + .filter(|sample| sample.label.is_some()) + .filter_map(|sample| sample.family.as_deref()) + .collect::>(); + let distinct_labelled_families = labelled_families + .iter() + .copied() + .collect::>() + .len(); + if labelled_families.len() > distinct_labelled_families { + warnings.push(format!( + "The {} labelled records contain only {} distinct family labels. Sample-level AUROC/AP treat variants as separate observations and can overstate evidence when variants share source code.", + labelled_families.len(), + distinct_labelled_families, + )); + } + + let metadata_clusters = metadata_clusters(&scored, config.max_cluster_members_in_report); + if metadata_clusters + .iter() + .any(|cluster| cluster.sample_ids_truncated) + { + warnings.push(format!( + "Metadata cluster member identifiers are capped at {} per cluster.", + config.max_cluster_members_in_report + )); + } + + let normalization_specs: [(&str, FingerprintExtractor); 4] = [ + ("opcode_skeleton", |features| { + &features.opcode_skeleton_keccak256 + }), + ("block_multiset", |features| { + &features.block_multiset_keccak256 + }), + ("push_split_folded_opcode_skeleton", |features| { + &features.folded_opcode_skeleton_keccak256 + }), + ("push_split_folded_block_multiset", |features| { + &features.folded_block_multiset_keccak256 + }), + ]; + let normalized_clusters: Vec = normalization_specs + .iter() + .flat_map(|(kind, fingerprint)| { + normalized_clusters( + &scored, + kind, + *fingerprint, + config.max_cluster_members_in_report, + ) + }) + .collect(); + if normalized_clusters + .iter() + .any(|cluster| cluster.sample_ids_truncated) + { + warnings.push(format!( + "Normalized cluster member identifiers are capped at {} per cluster.", + config.max_cluster_members_in_report + )); + } + let normalization_linkability = normalization_specs + .iter() + .filter_map(|(kind, fingerprint)| normalization_linkability(&scored, kind, *fingerprint)) + .collect(); + + Ok(CorpusReport { + detector_version: DETECTOR_VERSION.to_string(), + sample_count: scored.len(), + all_scores: score_distribution(&all_scores), + positive_scores: score_distribution(&positive_scores), + negative_scores: score_distribution(&negative_scores), + feature_prevalence: feature_prevalence(&scored), + binary_classification, + metadata_clusters, + metadata_linkability: metadata_linkability(&scored), + normalized_clusters, + normalization_linkability, + samples: scored, + warnings, + }) +} + +fn decode_hex(sample_id: &str, bytecode: &str, maximum: usize) -> Result, DetectorError> { + let trimmed = bytecode.trim(); + let value = trimmed.strip_prefix("0x").unwrap_or(trimmed); + let approximate_len = value.len().div_ceil(2); + if approximate_len > maximum { + return Err(DetectorError::BytecodeTooLarge { + sample_id: sample_id.to_string(), + actual: approximate_len, + maximum, + }); + } + hex::decode(value).map_err(|source| DetectorError::InvalidHex { + sample_id: sample_id.to_string(), + source, + }) +} + +fn decode_instructions(bytecode: &[u8]) -> Vec { + let mut instructions = Vec::new(); + let mut pc = 0usize; + while pc < bytecode.len() { + let opcode = bytecode[pc]; + let width = if (0x60..=0x7f).contains(&opcode) { + usize::from(opcode - 0x5f) + } else { + 0 + }; + let available = bytecode.len().saturating_sub(pc + 1).min(width); + instructions.push(Instruction { + opcode, + immediate: bytecode[pc + 1..pc + 1 + available].to_vec(), + declared_push_width: width, + truncated: available != width, + }); + pc = pc.saturating_add(1 + width).min(bytecode.len()); + } + instructions +} + +fn opcode_tokens(instructions: &[Instruction], fold_push_split: bool) -> Vec { + let mut tokens = Vec::with_capacity(instructions.len()); + let mut index = 0usize; + while index < instructions.len() { + if fold_push_split && instructions[index].opcode == 0x5f { + let mut cursor = index + 1; + let mut terms = 0usize; + let mut accumulator = 0u128; + while cursor + 1 < instructions.len() { + let part = &instructions[cursor]; + let operation = instructions[cursor + 1].opcode; + if !part + .push_width() + .is_some_and(|width| (1..=16).contains(&width)) + || !matches!(operation, 0x01 | 0x03 | 0x18) + { + break; + } + let Some(value) = part.push_value_u128() else { + break; + }; + accumulator = match operation { + 0x01 => accumulator.wrapping_add(value), + 0x03 => accumulator.wrapping_sub(value), + 0x18 => accumulator ^ value, + _ => unreachable!("operation was checked above"), + }; + terms += 1; + cursor += 2; + } + if terms >= 2 { + let width = if accumulator == 0 { + 0 + } else { + usize::try_from((128 - accumulator.leading_zeros()).div_ceil(8)).unwrap_or(16) + }; + tokens.push(0x5f + u8::try_from(width).unwrap_or(16)); + index = cursor; + continue; + } + } + tokens.push(instructions[index].opcode); + index += 1; + } + tokens +} + +fn hash_tokens(domain: &[u8], tokens: &[u8]) -> String { + let mut hasher = Keccak256::new(); + hasher.update(domain); + hasher.update((tokens.len() as u64).to_be_bytes()); + hasher.update(tokens); + hex::encode(hasher.finalize()) +} + +fn hash_block_multiset(tokens: &[u8]) -> String { + let mut blocks = Vec::>::new(); + let mut current = Vec::new(); + for opcode in tokens.iter().copied() { + if opcode == 0x5b && !current.is_empty() { + blocks.push(std::mem::take(&mut current)); + } + current.push(opcode); + if is_basic_block_terminator(opcode) { + blocks.push(std::mem::take(&mut current)); + } + } + if !current.is_empty() { + blocks.push(current); + } + blocks.sort_unstable(); + + let mut hasher = Keccak256::new(); + hasher.update(b"opcode-block-multiset-v1"); + hasher.update((blocks.len() as u64).to_be_bytes()); + for block in blocks { + hasher.update((block.len() as u64).to_be_bytes()); + hasher.update(block); + } + hex::encode(hasher.finalize()) +} + +fn is_basic_block_terminator(opcode: u8) -> bool { + matches!(opcode, 0x00 | 0x56 | 0x57 | 0xf3 | 0xfd..=0xff) +} + +fn is_constant_false_decoy(window: &[Instruction]) -> bool { + window[0].opcode == 0x5b + && push_is(&window[1], 1, 1) + && push_is(&window[2], 1, 0) + && window[3].opcode == 0x14 + && window[4].is_push() + && window[5].opcode == 0x57 + && window[6].is_push() + && window[7].opcode == 0x56 +} + +fn is_storage_gate(window: &[Instruction]) -> bool { + window[0] + .push_value_u64() + .is_some_and(|slot| (0x1000..=0xffff).contains(&slot)) + && window[1].opcode == 0x54 + && window[2].opcode == 0x15 + && window[3].is_push() + && window[4].opcode == 0x57 + && window[5].is_push() + && window[6].opcode == 0x56 +} + +fn is_byte_gate(window: &[Instruction]) -> bool { + push_is(&window[0], 1, 0) + && window[1].opcode == 0x35 + && window[2].push_width() == Some(1) + && window[2].push_value_u64().is_some_and(|value| value <= 3) + && window[3].opcode == 0x1a + && window[4].push_width() == Some(1) + && window[5].opcode == 0x14 + && window[6].is_push() + && window[7].opcode == 0x57 + && window[8].is_push() + && window[9].opcode == 0x56 +} + +fn is_arithmetic_codecopy_load(window: &[Instruction]) -> bool { + push_is(&window[0], 1, 0x20) + && window[1] + .push_width() + .is_some_and(|width| (1..=4).contains(&width)) + && push_is(&window[2], 1, 0) + && window[3].opcode == 0x39 + && push_is(&window[4], 1, 0) + && window[5].opcode == 0x51 +} + +fn count_constructor_mask_decoder_chunks(instructions: &[Instruction]) -> usize { + let mut chunks = 0usize; + let mut index = 0usize; + while index + 1 < instructions.len() { + if instructions[index].opcode != 0x80 || instructions[index + 1].opcode != 0x51 { + index += 1; + continue; + } + + // ArithmeticChain's inline mask material consists of at least two PUSH32 values and up + // to three arithmetic operations. Bound the scan so ordinary, distant memory writes do + // not become evidence for the constructor pass. + let search_end = (index + 14).min(instructions.len()); + let mut cursor = index + 2; + let mut push32_count = 0usize; + let mut found_end = None; + while cursor < search_end { + push32_count += usize::from(instructions[cursor].opcode == 0x7f); + if cursor + 2 < search_end + && instructions[cursor].opcode == 0x18 + && instructions[cursor + 1].opcode == 0x90 + && instructions[cursor + 2].opcode == 0x52 + && push32_count >= 2 + { + found_end = Some(cursor + 3); + break; + } + cursor += 1; + } + if let Some(end) = found_end { + chunks += 1; + index = end; + } else { + index += 1; + } + } + chunks +} + +fn count_push_split_chains(instructions: &[Instruction]) -> (usize, usize) { + let mut chains = 0usize; + let mut terms = 0usize; + let mut index = 0usize; + while index < instructions.len() { + if instructions[index].opcode != 0x5f { + index += 1; + continue; + } + let mut cursor = index + 1; + let mut chain_terms = 0usize; + while cursor + 1 < instructions.len() + && instructions[cursor] + .push_width() + .is_some_and(|width| (1..=16).contains(&width)) + && matches!(instructions[cursor + 1].opcode, 0x01 | 0x03 | 0x18) + { + chain_terms += 1; + cursor += 2; + } + if chain_terms >= 2 { + chains += 1; + terms += chain_terms; + index = cursor; + } else { + index += 1; + } + } + (chains, terms) +} + +fn push_is(instruction: &Instruction, width: usize, value: u64) -> bool { + instruction.push_width() == Some(width) && instruction.push_value_u64() == Some(value) +} + +fn is_foldable_arithmetic(opcode: u8) -> bool { + matches!(opcode, 0x01..=0x07 | 0x10..=0x1d) +} + +fn is_dispatcher_associated_opcode(opcode: u8) -> bool { + matches!( + opcode, + 0x14 | 0x15 | 0x1a | 0x35 | 0x54 | 0x56 | 0x57 | 0x5b | 0xfe + ) +} + +#[derive(Debug)] +struct MetadataSuffix { + start: usize, + keccak256: String, +} + +fn metadata_suffix(bytecode: &[u8]) -> Option { + if bytecode.len() < 3 { + return None; + } + let metadata_len = usize::from(u16::from_be_bytes([ + bytecode[bytecode.len() - 2], + bytecode[bytecode.len() - 1], + ])); + let suffix_len = metadata_len.checked_add(2)?; + let start = bytecode.len().checked_sub(suffix_len)?; + let payload = bytecode.get(start..bytecode.len() - 2)?; + if !is_structurally_valid_compiler_cbor(payload) + || !is_evm_instruction_boundary(bytecode, start) + { + return None; + } + let digest = Keccak256::digest(&bytecode[start..]); + Some(MetadataSuffix { + start, + keccak256: hex::encode(digest), + }) +} + +/// Solidity compiler auxdata is a complete definite-length CBOR map containing at least one +/// compiler metadata key. A map prefix alone is insufficient: arbitrary executable tail bytes can +/// otherwise be mistaken for metadata and removed from every normalization fingerprint. +fn is_structurally_valid_compiler_cbor(payload: &[u8]) -> bool { + let mut cursor = 0usize; + let initial = match payload.get(cursor) { + Some(initial) if initial >> 5 == 5 => *initial, + _ => return false, + }; + cursor += 1; + let Some(pair_count) = consume_cbor_argument(payload, &mut cursor, initial & 0x1f) + .and_then(|count| usize::try_from(count).ok()) + else { + return false; + }; + if pair_count > payload.len().saturating_sub(cursor) / 2 { + return false; + } + + let mut has_compiler_key = false; + for _ in 0..pair_count { + let Some(key_initial) = payload.get(cursor).copied() else { + return false; + }; + if key_initial >> 5 != 3 { + return false; + } + cursor += 1; + let Some(key_len) = consume_cbor_argument(payload, &mut cursor, key_initial & 0x1f) + .and_then(|length| usize::try_from(length).ok()) + else { + return false; + }; + let Some(key_end) = cursor.checked_add(key_len) else { + return false; + }; + let Some(key) = payload.get(cursor..key_end) else { + return false; + }; + has_compiler_key |= key == b"ipfs" + || key == b"solc" + || key == b"bzzr0" + || key == b"bzzr1" + || key == b"vyper"; + cursor = key_end; + if consume_cbor_item(payload, &mut cursor, 1).is_none() { + return false; + } + } + + has_compiler_key && cursor == payload.len() +} + +fn consume_cbor_item(bytes: &[u8], cursor: &mut usize, depth: usize) -> Option<()> { + const MAX_CBOR_DEPTH: usize = 32; + if depth >= MAX_CBOR_DEPTH { + return None; + } + + let initial = *bytes.get(*cursor)?; + *cursor += 1; + let major = initial >> 5; + let additional = initial & 0x1f; + let argument = consume_cbor_argument(bytes, cursor, additional)?; + + match major { + 0 | 1 | 7 => Some(()), + 2 | 3 => { + let length = usize::try_from(argument).ok()?; + let end = cursor.checked_add(length)?; + if end > bytes.len() { + return None; + } + *cursor = end; + Some(()) + } + 4 => { + let items = usize::try_from(argument).ok()?; + if items > bytes.len().saturating_sub(*cursor) { + return None; + } + for _ in 0..items { + consume_cbor_item(bytes, cursor, depth + 1)?; + } + Some(()) + } + 5 => { + let pairs = usize::try_from(argument).ok()?; + let items = pairs.checked_mul(2)?; + if items > bytes.len().saturating_sub(*cursor) { + return None; + } + for _ in 0..items { + consume_cbor_item(bytes, cursor, depth + 1)?; + } + Some(()) + } + 6 => consume_cbor_item(bytes, cursor, depth + 1), + _ => None, + } +} + +fn consume_cbor_argument(bytes: &[u8], cursor: &mut usize, additional: u8) -> Option { + let width = match additional { + 0..=23 => return Some(u64::from(additional)), + 24 => 1, + 25 => 2, + 26 => 4, + 27 => 8, + _ => return None, + }; + let end = cursor.checked_add(width)?; + let encoded = bytes.get(*cursor..end)?; + *cursor = end; + Some( + encoded + .iter() + .fold(0u64, |value, byte| (value << 8) | u64::from(*byte)), + ) +} + +fn is_evm_instruction_boundary(bytecode: &[u8], boundary: usize) -> bool { + if boundary > bytecode.len() { + return false; + } + + let mut pc = 0usize; + while pc < boundary { + let immediate_width = match bytecode[pc] { + opcode @ 0x60..=0x7f => usize::from(opcode - 0x5f), + _ => 0, + }; + let Some(next_pc) = pc.checked_add(1 + immediate_width) else { + return false; + }; + if next_pc > boundary { + return false; + } + pc = next_pc; + } + pc == boundary +} + +fn score_features(features: SignatureFeatures) -> DetectorResult { + let mut evidence = Vec::new(); + add_evidence( + &mut evidence, + "dispatcher_constant_false_decoy", + features.dispatcher_constant_false_decoys, + 30, + ); + add_evidence( + &mut evidence, + "dispatcher_storage_gate", + features.dispatcher_storage_gates, + 25, + ); + add_evidence( + &mut evidence, + "dispatcher_byte_gate", + features.dispatcher_byte_gates, + 25, + ); + add_evidence( + &mut evidence, + "push_split_chain", + features.push_split_chains, + 30, + ); + add_evidence( + &mut evidence, + "arithmetic_codecopy_load", + features.arithmetic_codecopy_loads, + 30, + ); + add_evidence( + &mut evidence, + "wide_constant_chain_start", + features.wide_constant_chain_starts, + 15, + ); + if features.constructor_mask_decoder_chunks >= 2 { + add_evidence( + &mut evidence, + "constructor_mask_decoder_chunks", + features.constructor_mask_decoder_chunks, + 40, + ); + } + + let has_dispatcher_core = features.dispatcher_constant_false_decoys > 0 + || features.dispatcher_storage_gates > 0 + || features.dispatcher_byte_gates > 0; + if has_dispatcher_core { + add_evidence( + &mut evidence, + "dispatcher_invalid_sink_context", + features.dispatcher_invalid_sinks, + 10, + ); + if features.tail_instruction_count >= 8 && features.tail_dispatcher_opcode_density >= 0.45 { + add_evidence(&mut evidence, "dense_dispatcher_tail_context", 1, 10); + } + } + + let raw_score: u32 = evidence.iter().map(|item| item.weight).sum(); + DetectorResult { + detector_version: DETECTOR_VERSION.to_string(), + heuristic_score: raw_score.min(MAX_HEURISTIC_SCORE), + evidence, + features, + } +} + +fn add_evidence( + evidence: &mut Vec, + feature: &'static str, + count: usize, + weight: u32, +) { + if count > 0 { + evidence.push(SignatureEvidence { + feature: feature.to_string(), + count, + weight, + }); + } +} + +fn score_distribution(scores: &[u32]) -> ScoreDistribution { + if scores.is_empty() { + return ScoreDistribution::default(); + } + let mut sorted = scores.to_vec(); + sorted.sort_unstable(); + let count = sorted.len(); + let median = if count.is_multiple_of(2) { + (f64::from(sorted[count / 2 - 1]) + f64::from(sorted[count / 2])) / 2.0 + } else { + f64::from(sorted[count / 2]) + }; + ScoreDistribution { + count, + min: sorted.first().copied(), + max: sorted.last().copied(), + mean: Some(sorted.iter().map(|value| f64::from(*value)).sum::() / count as f64), + median: Some(median), + } +} + +fn feature_prevalence(samples: &[ScoredSample]) -> BTreeMap { + let mut prevalence = BTreeMap::new(); + for sample in samples { + let features = &sample.detector.features; + let present = [ + ( + "dispatcher_invalid_sink", + features.dispatcher_invalid_sinks > 0, + ), + ( + "dispatcher_constant_false_decoy", + features.dispatcher_constant_false_decoys > 0, + ), + ( + "dispatcher_storage_gate", + features.dispatcher_storage_gates > 0, + ), + ("dispatcher_byte_gate", features.dispatcher_byte_gates > 0), + ("push_split_chain", features.push_split_chains > 0), + ( + "arithmetic_codecopy_load", + features.arithmetic_codecopy_loads > 0, + ), + ( + "wide_constant_chain_start", + features.wide_constant_chain_starts > 0, + ), + ( + "constructor_mask_decoder_chunks", + features.constructor_mask_decoder_chunks >= 2, + ), + ( + "dense_dispatcher_tail", + features.tail_instruction_count >= 8 + && features.tail_dispatcher_opcode_density >= 0.45, + ), + ( + "terminal_solidity_metadata_candidate", + features.metadata_suffix_keccak256.is_some(), + ), + ("malformed_push", features.malformed_pushes > 0), + ]; + for (name, is_present) in present { + let entry = prevalence + .entry(name.to_string()) + .or_insert_with(FeaturePrevalence::default); + if is_present { + entry.all += 1; + match sample.label { + Some(true) => entry.positive += 1, + Some(false) => entry.negative += 1, + None => entry.unlabelled += 1, + } + } + } + } + prevalence +} + +fn compute_binary_metrics(labelled: &[(u32, bool)]) -> BinaryClassificationMetrics { + let positive_count = labelled.iter().filter(|(_, label)| *label).count(); + let negative_count = labelled.len() - positive_count; + BinaryClassificationMetrics { + positive_count, + negative_count, + auroc: auroc(labelled, positive_count, negative_count), + average_precision: average_precision(labelled, positive_count), + thresholds: REPORT_THRESHOLDS + .into_iter() + .map(|threshold| threshold_metrics(labelled, threshold)) + .collect(), + low_fpr_operating_points: LOW_FPR_TARGETS + .into_iter() + .map(|target| operating_point(labelled, target, negative_count)) + .collect(), + } +} + +fn auroc(labelled: &[(u32, bool)], positive_count: usize, negative_count: usize) -> f64 { + let mut sorted = labelled.to_vec(); + sorted.sort_unstable_by_key(|(score, _)| *score); + let mut index = 0usize; + let mut negatives_below = 0usize; + let mut winning_pairs = 0.0f64; + while index < sorted.len() { + let score = sorted[index].0; + let mut end = index; + let mut group_positives = 0usize; + let mut group_negatives = 0usize; + while end < sorted.len() && sorted[end].0 == score { + if sorted[end].1 { + group_positives += 1; + } else { + group_negatives += 1; + } + end += 1; + } + winning_pairs += group_positives as f64 * negatives_below as f64; + winning_pairs += group_positives as f64 * group_negatives as f64 * 0.5; + negatives_below += group_negatives; + index = end; + } + winning_pairs / (positive_count as f64 * negative_count as f64) +} + +fn average_precision(labelled: &[(u32, bool)], positive_count: usize) -> f64 { + let mut sorted = labelled.to_vec(); + sorted.sort_unstable_by_key(|item| Reverse(item.0)); + let mut index = 0usize; + let mut seen = 0usize; + let mut true_positives = 0usize; + let mut area = 0.0f64; + while index < sorted.len() { + let score = sorted[index].0; + let mut end = index; + let mut group_positives = 0usize; + while end < sorted.len() && sorted[end].0 == score { + group_positives += usize::from(sorted[end].1); + end += 1; + } + seen += end - index; + true_positives += group_positives; + let recall_delta = group_positives as f64 / positive_count as f64; + let precision = true_positives as f64 / seen as f64; + area += recall_delta * precision; + index = end; + } + area +} + +fn threshold_metrics(labelled: &[(u32, bool)], threshold: u32) -> ThresholdMetrics { + let mut true_positives = 0usize; + let mut false_positives = 0usize; + let mut true_negatives = 0usize; + let mut false_negatives = 0usize; + for (score, label) in labelled { + match (*score >= threshold, *label) { + (true, true) => true_positives += 1, + (true, false) => false_positives += 1, + (false, true) => false_negatives += 1, + (false, false) => true_negatives += 1, + } + } + let positives = true_positives + false_negatives; + let negatives = false_positives + true_negatives; + let predicted_positives = true_positives + false_positives; + ThresholdMetrics { + threshold, + true_positives, + false_positives, + true_negatives, + false_negatives, + true_positive_rate: true_positives as f64 / positives as f64, + false_positive_rate: false_positives as f64 / negatives as f64, + precision: (predicted_positives > 0) + .then_some(true_positives as f64 / predicted_positives as f64), + } +} + +fn operating_point(labelled: &[(u32, bool)], target: f64, negative_count: usize) -> OperatingPoint { + let mut thresholds: Vec<_> = labelled.iter().map(|(score, _)| *score).collect(); + thresholds.push(MAX_HEURISTIC_SCORE + 1); + thresholds.sort_unstable(); + thresholds.dedup(); + let mut best = threshold_metrics(labelled, MAX_HEURISTIC_SCORE + 1); + for metrics in thresholds + .into_iter() + .map(|threshold| threshold_metrics(labelled, threshold)) + .filter(|metrics| metrics.false_positive_rate <= target) + { + let better_tpr = metrics.true_positive_rate > best.true_positive_rate; + let equal_tpr = metrics.true_positive_rate == best.true_positive_rate; + let lower_fpr = metrics.false_positive_rate < best.false_positive_rate; + let equal_fpr = metrics.false_positive_rate == best.false_positive_rate; + if better_tpr + || (equal_tpr && lower_fpr) + || (equal_tpr && equal_fpr && metrics.threshold > best.threshold) + { + best = metrics; + } + } + let false_positive_rate_upper_95 = wilson_upper_bound_95(best.false_positives, negative_count); + OperatingPoint { + target_false_positive_rate: target, + threshold: best.threshold, + observed_false_positive_rate: best.false_positive_rate, + observed_true_positive_rate: best.true_positive_rate, + false_positives: best.false_positives, + true_positives: best.true_positives, + resolution_supported: (negative_count as f64) >= 1.0 / target, + false_positive_rate_upper_95, + confidence_supported: false_positive_rate_upper_95 <= target, + } +} + +fn wilson_upper_bound_95(successes: usize, trials: usize) -> f64 { + if trials == 0 { + return 1.0; + } + // One-sided 95% standard-normal quantile. Wilson is well behaved for zero observed events, + // unlike the naive plug-in estimate of zero. + const Z: f64 = 1.644_853_626_951_472_2; + let n = trials as f64; + let probability = successes as f64 / n; + let z_squared = Z * Z; + let numerator = probability + + z_squared / (2.0 * n) + + Z * (probability * (1.0 - probability) / n + z_squared / (4.0 * n * n)).sqrt(); + (numerator / (1.0 + z_squared / n)).min(1.0) +} + +fn metadata_clusters(samples: &[ScoredSample], member_limit: usize) -> Vec { + let mut groups: HashMap> = HashMap::new(); + for sample in samples { + if let Some(hash) = &sample.detector.features.metadata_suffix_keccak256 { + groups.entry(hash.clone()).or_default().push(sample); + } + } + let mut clusters: Vec<_> = groups + .into_iter() + .filter(|(_, members)| members.len() >= 2) + .map(|(hash, mut members)| { + members.sort_unstable_by(|left, right| left.id.cmp(&right.id)); + MetadataCluster { + suffix_keccak256: hash, + member_count: members.len(), + sample_ids: members + .iter() + .take(member_limit) + .map(|sample| sample.id.clone()) + .collect(), + sample_ids_truncated: members.len() > member_limit, + positive_members: members + .iter() + .filter(|sample| sample.label == Some(true)) + .count(), + negative_members: members + .iter() + .filter(|sample| sample.label == Some(false)) + .count(), + unlabelled_members: members + .iter() + .filter(|sample| sample.label.is_none()) + .count(), + } + }) + .collect(); + clusters.sort_unstable_by(|left, right| { + right + .member_count + .cmp(&left.member_count) + .then_with(|| left.suffix_keccak256.cmp(&right.suffix_keccak256)) + }); + clusters +} + +fn normalized_clusters( + samples: &[ScoredSample], + fingerprint_kind: &str, + fingerprint: fn(&SignatureFeatures) -> &str, + member_limit: usize, +) -> Vec { + let mut groups: HashMap> = HashMap::new(); + for sample in samples { + groups + .entry(fingerprint(&sample.detector.features).to_string()) + .or_default() + .push(sample); + } + let mut clusters: Vec<_> = groups + .into_iter() + .filter(|(_, members)| members.len() >= 2) + .map(|(hash, mut members)| { + members.sort_unstable_by(|left, right| left.id.cmp(&right.id)); + let family_count = members + .iter() + .filter_map(|sample| sample.family.as_deref()) + .collect::>() + .len(); + NormalizedCluster { + fingerprint_kind: fingerprint_kind.to_string(), + fingerprint_keccak256: hash, + member_count: members.len(), + sample_ids: members + .iter() + .take(member_limit) + .map(|sample| sample.id.clone()) + .collect(), + sample_ids_truncated: members.len() > member_limit, + family_count, + positive_members: members + .iter() + .filter(|sample| sample.label == Some(true)) + .count(), + negative_members: members + .iter() + .filter(|sample| sample.label == Some(false)) + .count(), + unlabelled_members: members + .iter() + .filter(|sample| sample.label.is_none()) + .count(), + } + }) + .collect(); + clusters.sort_unstable_by(|left, right| { + right + .member_count + .cmp(&left.member_count) + .then_with(|| left.fingerprint_kind.cmp(&right.fingerprint_kind)) + .then_with(|| left.fingerprint_keccak256.cmp(&right.fingerprint_keccak256)) + }); + clusters +} + +fn normalization_linkability( + samples: &[ScoredSample], + fingerprint_kind: &str, + fingerprint: fn(&SignatureFeatures) -> &str, +) -> Option { + let family_samples: Vec<_> = samples + .iter() + .filter(|sample| sample.family.is_some()) + .collect(); + if family_samples.len() < 2 { + return None; + } + + let mut family_counts: HashMap<&str, u64> = HashMap::new(); + let mut hash_counts: HashMap<&str, u64> = HashMap::new(); + let mut family_hash_counts: HashMap<(&str, &str), u64> = HashMap::new(); + let mut family_label_counts: HashMap<&str, (u64, u64)> = HashMap::new(); + let mut family_hash_label_counts: HashMap<(&str, &str), (u64, u64)> = HashMap::new(); + for sample in family_samples { + let family = sample.family.as_deref().expect("filtered above"); + let hash = fingerprint(&sample.detector.features); + *family_counts.entry(family).or_default() += 1; + *hash_counts.entry(hash).or_default() += 1; + *family_hash_counts.entry((family, hash)).or_default() += 1; + if let Some(label) = sample.label { + let counts = family_label_counts.entry(family).or_default(); + let hash_counts = family_hash_label_counts.entry((family, hash)).or_default(); + if label { + counts.0 += 1; + hash_counts.0 += 1; + } else { + counts.1 += 1; + hash_counts.1 += 1; + } + } + } + + let same_family_pairs = family_counts.values().copied().map(pair_count).sum(); + let all_pairs = pair_count( + samples + .iter() + .filter(|sample| sample.family.is_some()) + .count() as u64, + ); + let different_family_pairs = all_pairs.saturating_sub(same_family_pairs); + let linked_same_family_pairs = family_hash_counts.values().copied().map(pair_count).sum(); + let cross_label_same_family_pairs = family_label_counts + .values() + .map(|(positives, negatives)| positives.saturating_mul(*negatives)) + .sum(); + let linked_cross_label_same_family_pairs = family_hash_label_counts + .values() + .map(|(positives, negatives)| positives.saturating_mul(*negatives)) + .sum(); + let linked_all_pairs: u64 = hash_counts.values().copied().map(pair_count).sum(); + let linked_different_family_pairs = linked_all_pairs.saturating_sub(linked_same_family_pairs); + + Some(NormalizationLinkabilityMetrics { + fingerprint_kind: fingerprint_kind.to_string(), + same_family_pairs, + linked_same_family_pairs, + same_family_pair_recall: ratio(linked_same_family_pairs, same_family_pairs), + cross_label_same_family_pairs, + linked_cross_label_same_family_pairs, + cross_label_same_family_pair_recall: ratio( + linked_cross_label_same_family_pairs, + cross_label_same_family_pairs, + ), + different_family_pairs, + linked_different_family_pairs, + different_family_pair_collision_rate: ratio( + linked_different_family_pairs, + different_family_pairs, + ), + }) +} + +fn metadata_linkability(samples: &[ScoredSample]) -> Option { + let family_samples: Vec<_> = samples + .iter() + .filter(|sample| sample.family.is_some()) + .collect(); + if family_samples.len() < 2 { + return None; + } + + let mut family_counts: HashMap<&str, u64> = HashMap::new(); + let mut hash_counts: HashMap<&str, u64> = HashMap::new(); + let mut family_hash_counts: HashMap<(&str, &str), u64> = HashMap::new(); + for sample in &family_samples { + let Some(family) = sample.family.as_deref() else { + continue; + }; + *family_counts.entry(family).or_default() += 1; + if let Some(hash) = sample + .detector + .features + .metadata_suffix_keccak256 + .as_deref() + { + *hash_counts.entry(hash).or_default() += 1; + *family_hash_counts.entry((family, hash)).or_default() += 1; + } + } + + let same_family_pairs = family_counts.values().copied().map(pair_count).sum(); + let all_pairs = pair_count(family_samples.len() as u64); + let different_family_pairs = all_pairs.saturating_sub(same_family_pairs); + let linked_same_family_pairs = family_hash_counts.values().copied().map(pair_count).sum(); + let linked_all_pairs: u64 = hash_counts.values().copied().map(pair_count).sum(); + let linked_different_family_pairs = linked_all_pairs.saturating_sub(linked_same_family_pairs); + + Some(MetadataLinkabilityMetrics { + same_family_pairs, + linked_same_family_pairs, + same_family_pair_recall: ratio(linked_same_family_pairs, same_family_pairs), + different_family_pairs, + linked_different_family_pairs, + different_family_pair_collision_rate: ratio( + linked_different_family_pairs, + different_family_pairs, + ), + }) +} + +fn pair_count(count: u64) -> u64 { + count.saturating_mul(count.saturating_sub(1)) / 2 +} + +fn ratio(numerator: u64, denominator: u64) -> Option { + (denominator > 0).then_some(numerator as f64 / denominator as f64) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn labelled(score: u32, label: bool) -> (u32, bool) { + (score, label) + } + + fn metadata_blob(code: &[u8], tag: u8) -> String { + // A complete compiler-keyed CBOR map followed by Solidity's two-byte length. + let metadata = [0xa1, 0x64, b's', b'o', b'l', b'c', 0x43, 0x00, 0x08, tag]; + let mut bytes = code.to_vec(); + bytes.extend_from_slice(&metadata); + bytes.extend_from_slice(&(metadata.len() as u16).to_be_bytes()); + hex::encode(bytes) + } + + #[test] + fn decoder_does_not_scan_push_immediates_for_motifs() { + let motif = [0x5b, 0x60, 0x01, 0x60, 0x00, 0x14, 0x60, 0x00]; + let mut bytecode = vec![0x7f]; + bytecode.extend_from_slice(&motif); + bytecode.resize(33, 0); + let features = extract_signature_features(&bytecode); + assert_eq!(features.dispatcher_constant_false_decoys, 0); + assert_eq!(features.instruction_count, 1); + } + + #[test] + fn extracts_dispatcher_motifs() { + let bytecode = [ + 0x5b, 0xfe, // invalid sink + 0x5b, 0x60, 0x01, 0x60, 0x00, 0x14, 0x60, 0x00, 0x57, 0x60, 0x00, 0x56, 0x61, 0x10, + 0x00, 0x54, 0x15, 0x60, 0x00, 0x57, 0x60, 0x00, 0x56, 0x60, 0x00, 0x35, 0x60, 0x02, + 0x1a, 0x60, 0xab, 0x14, 0x60, 0x00, 0x57, 0x60, 0x00, 0x56, + ]; + let result = score_bytecode(&bytecode); + assert_eq!(result.features.dispatcher_invalid_sinks, 1); + assert_eq!(result.features.dispatcher_constant_false_decoys, 1); + assert_eq!(result.features.dispatcher_storage_gates, 1); + assert_eq!(result.features.dispatcher_byte_gates, 1); + assert_eq!(result.heuristic_score, 90); + } + + #[test] + fn extracts_push_split_and_codecopy_motifs() { + let bytecode = [ + 0x5f, 0x64, 1, 2, 3, 4, 5, 0x18, 0x64, 6, 7, 8, 9, 10, 0x01, // split + 0x60, 0x20, 0x61, 0x12, 0x34, 0x60, 0x00, 0x39, 0x60, 0x00, 0x51, + ]; + let features = extract_signature_features(&bytecode); + assert_eq!(features.push_split_chains, 1); + assert_eq!(features.push_split_terms, 2); + assert_eq!(features.arithmetic_codecopy_loads, 1); + } + + #[test] + fn extracts_repeated_constructor_mask_decoder_chunks() { + let mut bytecode = Vec::new(); + for salt in [0x11, 0x22] { + bytecode.extend_from_slice(&[0x80, 0x51, 0x7f]); + bytecode.extend_from_slice(&[salt; 32]); + bytecode.push(0x7f); + bytecode.extend_from_slice(&[salt ^ 0xff; 32]); + bytecode.extend_from_slice(&[0x01, 0x18, 0x90, 0x52]); + } + let result = score_bytecode(&bytecode); + assert_eq!(result.features.constructor_mask_decoder_chunks, 2); + assert!( + result + .evidence + .iter() + .any(|evidence| evidence.feature == "constructor_mask_decoder_chunks") + ); + } + + #[test] + fn marks_truncated_push_without_panicking() { + let features = extract_signature_features(&[0x62, 0xaa]); + assert_eq!(features.instruction_count, 1); + assert_eq!(features.malformed_pushes, 1); + } + + #[test] + fn excludes_terminal_metadata_from_instruction_scan() { + let hex = metadata_blob(&[0x00], 0x0a); + let result = score_hex(&hex).expect("valid hex"); + assert_eq!(result.features.code_len, 1); + assert_eq!(result.features.byte_len, 13); + assert_eq!(result.features.instruction_count, 1); + assert_eq!(result.features.metadata_suffix_len, Some(12)); + assert!(result.features.metadata_suffix_keccak256.is_some()); + } + + #[test] + fn malformed_map_like_executable_tail_is_not_metadata() { + let bytecode = [ + 0x00, 0xa1, 0x64, b's', b'o', b'l', b'c', 0x43, 0x00, 0x08, 0x1e, 0x00, 0x09, + ]; + let features = extract_signature_features(&bytecode); + assert_eq!(features.code_len, bytecode.len()); + assert_eq!(features.metadata_suffix_len, None); + assert_eq!(features.instruction_count, 8); + } + + #[test] + fn compiler_marker_in_cbor_value_is_not_treated_as_a_compiler_key() { + let payload = [ + 0xa1, 0x64, b'n', b'o', b't', b'e', 0x45, 0x64, b's', b'o', b'l', b'c', + ]; + let mut bytecode = vec![0x00]; + bytecode.extend_from_slice(&payload); + bytecode.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + + let features = extract_signature_features(&bytecode); + assert_eq!(features.code_len, bytecode.len()); + assert_eq!(features.metadata_suffix_len, None); + } + + #[test] + fn compiler_cbor_inside_push_immediate_is_not_metadata() { + let mut bytecode = vec![0x7f]; + bytecode.extend_from_slice(&[0u8; 20]); + bytecode.extend_from_slice(&[ + 0xa1, 0x64, b's', b'o', b'l', b'c', 0x43, 0x00, 0x08, 0x1e, 0x00, 0x0a, + ]); + assert_eq!(bytecode.len(), 33); + + let features = extract_signature_features(&bytecode); + assert_eq!(features.code_len, bytecode.len()); + assert_eq!(features.metadata_suffix_len, None); + assert_eq!(features.instruction_count, 1); + assert_eq!(features.malformed_pushes, 0); + } + + #[test] + fn binary_metrics_handle_perfect_ranking_and_ties() { + let perfect = [ + labelled(90, true), + labelled(80, true), + labelled(20, false), + labelled(10, false), + ]; + let metrics = compute_binary_metrics(&perfect); + assert_eq!(metrics.auroc, 1.0); + assert_eq!(metrics.average_precision, 1.0); + + let tied = [labelled(50, true), labelled(50, false)]; + let metrics = compute_binary_metrics(&tied); + assert_eq!(metrics.auroc, 0.5); + assert_eq!(metrics.average_precision, 0.5); + } + + #[test] + fn unlabelled_corpus_does_not_claim_classification_quality() { + let samples = [CorpusSample { + id: "unknown".to_string(), + bytecode: "00".to_string(), + label: None, + family: None, + }]; + let report = evaluate_corpus(&samples).expect("valid corpus"); + assert!(report.binary_classification.is_none()); + assert!( + report + .warnings + .iter() + .any(|warning| warning.contains("No labels")) + ); + } + + #[test] + fn duplicate_sample_ids_are_rejected_instead_of_double_counted() { + let samples = [ + CorpusSample { + id: "duplicate".to_string(), + bytecode: "00".to_string(), + label: Some(true), + family: None, + }, + CorpusSample { + id: "duplicate".to_string(), + bytecode: "00".to_string(), + label: Some(false), + family: None, + }, + ]; + assert!(matches!( + evaluate_corpus(&samples), + Err(DetectorError::DuplicateSampleId { .. }) + )); + } + + #[test] + fn reports_exact_metadata_linkability_by_family() { + let samples = [ + CorpusSample { + id: "a-1".to_string(), + bytecode: metadata_blob(&[0x00], 1), + label: Some(true), + family: Some("a".to_string()), + }, + CorpusSample { + id: "a-2".to_string(), + bytecode: metadata_blob(&[0x01], 1), + label: Some(true), + family: Some("a".to_string()), + }, + CorpusSample { + id: "b-1".to_string(), + bytecode: metadata_blob(&[0x00], 2), + label: Some(false), + family: Some("b".to_string()), + }, + ]; + let report = evaluate_corpus(&samples).expect("valid corpus"); + assert_eq!(report.metadata_clusters.len(), 1); + let linkage = report.metadata_linkability.expect("family metrics"); + assert_eq!(linkage.same_family_pairs, 1); + assert_eq!(linkage.linked_same_family_pairs, 1); + assert_eq!(linkage.same_family_pair_recall, Some(1.0)); + assert_eq!(linkage.linked_different_family_pairs, 0); + } + + #[test] + fn normalization_erases_immediates_and_block_order() { + let first = [ + 0x5b, 0x60, 0x01, 0x50, 0x00, // JUMPDEST PUSH1 1 POP STOP + 0x5b, 0x60, 0x02, 0x51, 0x00, // JUMPDEST PUSH1 2 MLOAD STOP + ]; + let changed_immediates = [0x5b, 0x60, 0xaa, 0x50, 0x00, 0x5b, 0x60, 0xbb, 0x51, 0x00]; + let reordered = [0x5b, 0x60, 0x02, 0x51, 0x00, 0x5b, 0x60, 0x01, 0x50, 0x00]; + + let first_features = extract_signature_features(&first); + let immediate_features = extract_signature_features(&changed_immediates); + let reordered_features = extract_signature_features(&reordered); + assert_eq!( + first_features.opcode_skeleton_keccak256, + immediate_features.opcode_skeleton_keccak256 + ); + assert_ne!( + first_features.opcode_skeleton_keccak256, + reordered_features.opcode_skeleton_keccak256 + ); + assert_eq!( + first_features.block_multiset_keccak256, + reordered_features.block_multiset_keccak256 + ); + } + + #[test] + fn normalization_folds_push_split_chain() { + let original = extract_signature_features(&[0x60, 0x03, 0x00]); + let split = extract_signature_features(&[0x5f, 0x60, 0x01, 0x01, 0x60, 0x02, 0x01, 0x00]); + assert_eq!(split.push_split_chains, 1); + assert_ne!( + original.opcode_skeleton_keccak256, + split.opcode_skeleton_keccak256 + ); + assert_eq!( + original.folded_opcode_skeleton_keccak256, + split.folded_opcode_skeleton_keccak256 + ); + } + + #[test] + fn low_fpr_requires_statistical_support_not_just_zero_observations() { + let small = wilson_upper_bound_95(0, 100); + let large = wilson_upper_bound_95(0, 1_000); + assert!(small > 0.01); + assert!(large < 0.01); + + let mut dataset = vec![labelled(100, true)]; + dataset.extend((0..100).map(|_| labelled(0, false))); + let point = operating_point(&dataset, 0.01, 100); + assert!(point.resolution_supported); + assert!(!point.confidence_supported); + } + + #[test] + fn enforces_configured_corpus_bounds() { + let samples = [CorpusSample { + id: "large".to_string(), + bytecode: "0000".to_string(), + label: None, + family: None, + }]; + let config = DetectorConfig { + max_samples: 1, + max_bytecode_bytes: 1, + max_cluster_members_in_report: 1, + }; + assert!(matches!( + evaluate_corpus_with_config(&samples, &config), + Err(DetectorError::BytecodeTooLarge { .. }) + )); + } +} diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index 7468457d..be7afd83 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -9,6 +9,7 @@ //! report. pub mod decompile_diff; +pub mod detector; pub mod metrics; pub use metrics::{Metrics, collect_metrics, compare}; diff --git a/crates/analysis/src/obfuscation.rs b/crates/analysis/src/obfuscation.rs index 9b449a53..8b111c10 100644 --- a/crates/analysis/src/obfuscation.rs +++ b/crates/analysis/src/obfuscation.rs @@ -1,10 +1,8 @@ use azoth_core::seed::Seed; use azoth_transform::{ Transform, - jump_address_transformer::JumpAddressTransformer, + cluster_shuffle::ClusterShuffle, obfuscator::{ObfuscationConfig, obfuscate_bytecode}, - opaque_predicate::OpaquePredicate, - shuffle::Shuffle, }; use chrono::{DateTime, Utc}; use hex::FromHexError; @@ -18,13 +16,11 @@ use thiserror::Error as ThisError; /// Default passes applied to each obfuscation run. /// -/// Leaving this empty means the analysis reuses the obfuscator's native defaults -/// (dispatcher when detected plus any user-specified transforms) instead of -/// forcing deprecated transforms such as Shuffle. -pub const DEFAULT_PASSES: &str = ""; +/// The analysis command measures exactly the production-admitted explicit CFG pass. +pub const DEFAULT_PASSES: &str = "cluster_shuffle"; /// Configuration for running an obfuscation analysis experiment. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct AnalysisConfig<'a> { /// Number of obfuscated samples to generate. pub iterations: usize, @@ -36,17 +32,25 @@ pub struct AnalysisConfig<'a> { pub report_path: PathBuf, /// Maximum attempts per iteration before giving up on a seed. pub max_attempts: usize, + /// Private root seed used to derive a stable per-iteration corpus. + pub root_seed: Seed, } impl<'a> AnalysisConfig<'a> { /// Create config with sensible defaults. - pub fn new(original_bytecode: &'a str, runtime_bytecode: &'a str, iterations: usize) -> Self { + pub fn new( + original_bytecode: &'a str, + runtime_bytecode: &'a str, + iterations: usize, + root_seed: Seed, + ) -> Self { Self { iterations, original_bytecode, runtime_bytecode, report_path: PathBuf::from("obfuscation_analysis_report.md"), max_attempts: 5, + root_seed, } } } @@ -88,7 +92,8 @@ pub struct AnalysisReport { pub iterations: usize, pub original_length: usize, pub transform_counts: BTreeMap, - pub seeds: Vec, + /// Commitments to the derived sample seeds. Raw private seeds are never written to reports. + pub seed_commitments: Vec, pub unique_seed_count: usize, pub sequence_lengths: Vec, pub top_sequences: Vec, @@ -202,13 +207,21 @@ impl AnalysisReport { writeln!(out)?; writeln!(out, "## Seed Summary")?; writeln!(out)?; - writeln!(out, "- **Total seeds generated:** {}", self.seeds.len())?; + writeln!( + out, + "- **Total seeds derived:** {}", + self.seed_commitments.len() + )?; writeln!(out, "- **Unique seeds:** {}", self.unique_seed_count)?; - if !self.seeds.is_empty() { - let preview: Vec<_> = self.seeds.iter().take(5).cloned().collect(); - writeln!(out, "- **Sample seeds:** {}", preview.join(", "))?; - if self.seeds.len() > 5 { - writeln!(out, "- _...and {} more_", self.seeds.len() - preview.len())?; + if !self.seed_commitments.is_empty() { + let preview: Vec<_> = self.seed_commitments.iter().take(5).cloned().collect(); + writeln!(out, "- **Sample seed commitments:** {}", preview.join(", "))?; + if self.seed_commitments.len() > 5 { + writeln!( + out, + "- _...and {} more_", + self.seed_commitments.len() - preview.len() + )?; } } writeln!(out)?; @@ -217,7 +230,7 @@ impl AnalysisReport { if self.transform_counts.is_empty() { writeln!( out, - "_No additional transforms were applied beyond dispatcher detection._" + "_No configured transform committed a bytecode change in these samples._" )?; } else { writeln!(out, "| Transform | Iterations | Coverage |")?; @@ -276,40 +289,16 @@ impl AnalysisReport { "Average longest common sequence covers **{:.2}%** of the original bytecode.", self.summary.preservation_ratio )?; - if self.summary.preservation_ratio < 10.0 { - writeln!( - out, - "This suggests strong obfuscation with minimal contiguous preservation." - )?; - } else if self.summary.preservation_ratio < 25.0 { - writeln!( - out, - "This suggests moderate obfuscation with noticeable contiguous preservation." - )?; - } else { - writeln!( - out, - "This suggests weaker obfuscation: significant contiguous blocks remain." - )?; - } + writeln!( + out, + "This is a byte-position statistic only. It does not measure semantic equivalence, \"stealth\", or resistance to CFG- or block-multiset normalization." + )?; writeln!(out)?; let diversity = self.ngram_diversity.get(&8).copied().unwrap_or(0.0); - if diversity > 90.0 { - writeln!( - out, - "High 8-byte diversity indicates obfuscation yields highly varied byte patterns." - )?; - } else if diversity > 70.0 { - writeln!( - out, - "Moderate 8-byte diversity indicates reasonable variation across seeds." - )?; - } else { - writeln!( - out, - "Low 8-byte diversity indicates many recurring patterns across outputs." - )?; - } + writeln!( + out, + "Observed 8-byte n-gram diversity is **{diversity:.2}%**. Treat it as a descriptive corpus statistic, not a security conclusion; a normalizer can discard ordering and recover much stronger links." + )?; writeln!(out)?; writeln!(out, "---")?; writeln!( @@ -331,6 +320,8 @@ impl AnalysisReport { pub enum AnalysisError { #[error("analysis requires at least one iteration")] EmptyIterations, + #[error("analysis requires at least one attempt per iteration")] + EmptyAttempts, #[error("bytecode decode error: {0}")] Decode(#[from] FromHexError), #[error("analysis aborted: obfuscation preserved {count} unknown opcode(s)")] @@ -356,22 +347,24 @@ pub async fn analyze_obfuscation( if config.iterations == 0 { return Err(AnalysisError::EmptyIterations); } + if config.max_attempts == 0 { + return Err(AnalysisError::EmptyAttempts); + } let passes = parse_passes(DEFAULT_PASSES)?; let original_bytes = hex_to_bytes(config.original_bytecode)?; let mut sequence_lengths = Vec::with_capacity(config.iterations); let mut sequence_counter: HashMap, usize> = HashMap::new(); let mut obfuscated_bytecodes: Vec> = Vec::with_capacity(config.iterations); - let mut seeds = Vec::with_capacity(config.iterations); + let mut seed_commitments = Vec::with_capacity(config.iterations); let mut transform_counts: BTreeMap = BTreeMap::new(); - transform_counts.insert("FunctionDispatcher".to_string(), 0); - for _ in 0..config.iterations { + for iteration in 0..config.iterations { let mut attempt = 0; loop { attempt += 1; - let seed = Seed::generate(); - let seed_hex = seed.to_hex(); + let seed = derive_analysis_seed(&config.root_seed, iteration, attempt - 1); + let seed_commitment = seed.hash_hex(); let mut obfuscation_config = ObfuscationConfig::with_seed(seed.clone()); obfuscation_config.preserve_unknown_opcodes = true; obfuscation_config.transforms = passes.iter().map(|p| p.build()).collect(); @@ -396,7 +389,7 @@ pub async fn analyze_obfuscation( } sequence_lengths.push(sequence.len()); obfuscated_bytecodes.push(obfuscated_bytes); - seeds.push(seed_hex); + seed_commitments.push(seed_commitment); break; } Err(_err) if attempt < config.max_attempts => continue, @@ -415,14 +408,14 @@ pub async fn analyze_obfuscation( let top_sequences = compute_top_sequences(sequence_counter); let ngram_diversity = compute_ngram_diversity(&obfuscated_bytecodes, &[2, 4, 8]); - let unique_seed_count = seeds.iter().collect::>().len(); + let unique_seed_count = seed_commitments.iter().collect::>().len(); let report = AnalysisReport { generated_at: Utc::now(), iterations: config.iterations, original_length: original_bytes.len(), transform_counts, - seeds, + seed_commitments, unique_seed_count, sequence_lengths, top_sequences, @@ -607,7 +600,12 @@ fn compute_top_sequences(counter: HashMap, usize>) -> Vec) -> Vec<(String, us fn summarize_transforms(counts: &BTreeMap, iterations: usize) -> String { if counts.is_empty() { - return "None detected (dispatcher skipped)".to_string(); + return "No configured transform committed a change".to_string(); } let entries = sorted_transform_entries(counts); let mut parts = Vec::new(); @@ -687,9 +685,7 @@ fn parse_passes(passes: &str) -> Result, AnalysisError> { continue; } let spec = match name { - "shuffle" => TransformSpec::Shuffle, - "opaque_pred" | "opaque_predicate" => TransformSpec::OpaquePredicate, - "jump_transform" | "jump_addr" => TransformSpec::JumpTransform, + "cluster_shuffle" => TransformSpec::ClusterShuffle, other => return Err(AnalysisError::InvalidPass(other.to_string())), }; specs.push(spec); @@ -698,21 +694,24 @@ fn parse_passes(passes: &str) -> Result, AnalysisError> { } enum TransformSpec { - Shuffle, - OpaquePredicate, - JumpTransform, + ClusterShuffle, } impl TransformSpec { fn build(&self) -> Box { match self { - TransformSpec::Shuffle => Box::new(Shuffle), - TransformSpec::OpaquePredicate => Box::new(OpaquePredicate::new()), - TransformSpec::JumpTransform => Box::new(JumpAddressTransformer::new()), + TransformSpec::ClusterShuffle => Box::new(ClusterShuffle::new()), } } } +fn derive_analysis_seed(root: &Seed, iteration: usize, attempt: usize) -> Seed { + let mut domain = b"azoth-analysis-sample-v1".to_vec(); + domain.extend_from_slice(&(iteration as u64).to_be_bytes()); + domain.extend_from_slice(&(attempt as u64).to_be_bytes()); + root.derive_seed(&domain) +} + #[cfg(test)] mod tests { use super::*; @@ -731,4 +730,42 @@ mod tests { assert_eq!(percentile(&values, 25.0), 17.5); assert_eq!(percentile(&values, 75.0), 32.5); } + + #[test] + fn analysis_seed_corpus_is_reproducible_and_domain_separated() { + let root = Seed::from_bytes([0x42; 32]); + assert_eq!( + derive_analysis_seed(&root, 4, 1).as_bytes(), + derive_analysis_seed(&root, 4, 1).as_bytes() + ); + assert_ne!( + derive_analysis_seed(&root, 4, 1).as_bytes(), + derive_analysis_seed(&root, 5, 1).as_bytes() + ); + assert_ne!( + derive_analysis_seed(&root, 4, 1).as_bytes(), + derive_analysis_seed(&root, 4, 2).as_bytes() + ); + } + + #[test] + fn tied_top_sequences_have_stable_ordering() { + let counter = HashMap::from([(vec![0x02], 3), (vec![0x01, 0x02], 3), (vec![0x01], 3)]); + let ordered = compute_top_sequences(counter); + let hex: Vec<_> = ordered + .iter() + .map(|sequence| sequence.sequence_hex.as_str()) + .collect(); + assert_eq!(hex, vec!["0102", "01", "02"]); + } + + #[tokio::test] + async fn zero_attempt_budget_is_rejected_before_running() { + let mut config = AnalysisConfig::new("00", "00", 1, Seed::from_bytes([0x42; 32])); + config.max_attempts = 0; + assert!(matches!( + analyze_obfuscation(config).await, + Err(AnalysisError::EmptyAttempts) + )); + } } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 46c57454..0f84da5d 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -26,6 +26,7 @@ thiserror.workspace = true # Fuzz command dependencies chrono = "0.4" parking_lot = "0.12" -rand = { workspace = true, features = ["small_rng"] } +rand.workspace = true revm.workspace = true sha3 = "0.10" +tempfile.workspace = true diff --git a/crates/cli/README.md b/crates/cli/README.md index a7dfd2af..f3499b9b 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -24,7 +24,8 @@ azoth decode --deployment 0x608060405234801561001057600080fd5b50 azoth decode -D path/to/bytecode.hex ``` -Outputs the raw assembly from Heimdall disassembler followed by a list of instructions with program counters and opcodes. +Outputs one deterministic assembly listing from Azoth's native decoder. Each line includes the +program counter, opcode, and any `PUSH` immediate. ### `azoth strip` Extracts runtime bytecode from deployment bytecode, removing init code and auxdata. @@ -58,38 +59,51 @@ Options: Applies obfuscation transformations to bytecode. ```bash -azoth obfuscate -D -R -azoth obfuscate --deployment 0x6080... --runtime 0x6080... --seed 12345 -azoth obfuscate -D path/to/deployment.hex -R path/to/runtime.hex --passes shuffle -azoth obfuscate -D path/to/deployment.hex -R path/to/runtime.hex --constructor-args 0x... +azoth obfuscate --deployment 0x6080... --runtime 0x6080... \ + --seed 0x0000000000000000000000000000000000000000000000000000000000000001 +azoth obfuscate -D path/to/deployment.hex -R path/to/runtime.hex \ + --seed-stdin --passes cluster_shuffle < /run/secrets/azoth-seed +azoth obfuscate -D path/to/deployment.hex -R path/to/runtime.hex \ + --seed 0x0000000000000000000000000000000000000000000000000000000000000001 \ + --passes cluster_shuffle ``` Options: - `-D, --deployment ` - Input deployment bytecode (required) - `-R, --runtime ` - Input runtime bytecode (required) -- `--constructor-args ` - ABI-encoded constructor suffix to append and obfuscate; omit when `-D` already contains it -- `--seed ` - Cryptographic seed for deterministic obfuscation -- `--passes ` - Comma-separated list of transforms (default: shuffle) +- `--constructor-args ` - ABI-encoded constructor suffix to append unchanged; omit when `-D` already contains it +- `--seed ` - Private 32-byte seed, with optional `0x` prefix; retained for compatibility but visible in process arguments and often shell history +- `--seed-stdin` - Read the private seed from standard input; exactly one of this option and `--seed` is required +- `--passes ` - Comma-separated list of admitted transforms (default: `cluster_shuffle`) - `--emit ` - Path to write gas/size report as JSON - `--emit-debug ` - Path to emit detailed CFG trace debug report as JSON +- `--emit-manifest ` - Path to emit the private interaction/integrity guide - `--tui` - Launch TUI to view debug trace after obfuscation -Note: `function_dispatcher` is always applied automatically. +Legacy transforms and function-selector rewriting are rejected or disabled by the safe CLI +profile. Library opt-ins remain experimental and are not a production endorsement. -The runtime is used as an exact, authoritative deployment boundary. A supplied runtime that is missing or occurs more than once is rejected. Constructor masking does not parse the ABI and is not cryptographic confidentiality; it removes the stable plaintext suffix while preserving constructor behavior. +The runtime is used as an exact, authoritative deployment boundary. A supplied runtime that is +missing or occurs more than once is rejected. Constructor arguments are not masked by the safe +profile. Private manifests/debug traces are fully written and synced in the destination directory +before no-clobber publication, use owner-only permissions on Unix, and refuse to overwrite an +existing destination. They are plaintext capabilities, not encrypted containers; protect them +like the seed. ### `azoth analyze` Generates multiple obfuscated variants and reports how much of the original bytecode survives unchanged. ```bash azoth analyze -D -R -azoth analyze 50 --deployment path/to/deployment.hex --runtime path/to/runtime.hex +azoth analyze 50 --deployment path/to/deployment.hex --runtime path/to/runtime.hex \ + --seed 0x0000000000000000000000000000000000000000000000000000000000000001 azoth analyze 25 -D 0x6080... -R 0x6080... --output reports/analysis.md ``` Options: - `-D, --deployment ` - Input deployment bytecode (default: examples/escrow-bytecode/artifacts/erc20_deployment.hex) - `-R, --runtime ` - Input runtime bytecode (default: examples/escrow-bytecode/artifacts/erc20_runtime.hex) +- `--seed ` - Required private 32-byte root seed for reproducible child seeds - `--output ` - Where to write the markdown report (default: ./obfuscation_analysis_report.md) - `--max-attempts ` - Retry budget per iteration when a seed fails (default: 5) diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs new file mode 100644 index 00000000..c7ea7461 --- /dev/null +++ b/crates/cli/src/commands.rs @@ -0,0 +1,2337 @@ +use async_trait::async_trait; +use clap::Subcommand; +use std::error::Error; + +use thiserror::Error; + +pub const DEFAULT_PASSES: &str = "cluster_shuffle"; + +/// Errors that can occur during obfuscation. +#[derive(Debug, Error)] +pub enum ObfuscateError { + /// The hex string has an odd length, making it invalid. + #[error("hex string has odd length: {0}")] + OddLength(usize), + /// Failed to decode hex string to bytes. + #[error("hex decode error: {0}")] + HexDecode(#[from] hex::FromHexError), + /// File read/write error. + #[error("file error: {0}")] + File(#[from] std::io::Error), + /// Transform application failed. + #[error("transform error: {0}")] + Transform(String), + /// Invalid transform pass specified. + #[error("invalid pass: {0}")] + InvalidPass(String), + /// A legacy pass is available to library developers but is not admitted to the production + /// profile because its semantic or detectability obligations are not yet proven. + #[error("pass is disabled in the production profile: {0}")] + UnsafePass(String), + /// JSON serialization error. + #[error("serialization error: {0}")] + Serialize(#[from] serde_json::Error), +} + +/// CLI subcommands for Azoth. +#[derive(Subcommand)] +pub enum Cmd { + /// Decode bytecode to annotated assembly. + Decode(decode::DecodeArgs), + /// Strip init/auxdata, dump runtime hex. + Strip(strip::StripArgs), + /// Write runtime CFG to stdout or a file. + Cfg(cfg::CfgArgs), + /// Obfuscate bytecode with specified transforms. + Obfuscate(obfuscate::ObfuscateArgs), + /// Run obfuscation analysis across multiple seeds. + Analyze(analyze::AnalyzeArgs), + /// Compare decompiled output before and after obfuscation. + DecompileDiff(decompile_diff::DecompileDiffArgs), + /// View obfuscation debug traces in a TUI. + Tui(tui::TuiArgs), + /// Fuzz test the obfuscation pipeline. + Fuzz(fuzz::FuzzArgs), +} + +/// Trait for executing CLI subcommands. +/// +/// Implementors define the logic for processing input bytecode and producing output (e.g., +/// assembly, stripped bytecode, CFG, or obfuscated bytecode). +#[async_trait] +pub trait Command { + /// Executes the subcommand. + /// + /// # Returns + /// A `Result` indicating success or an error if execution fails. + async fn execute(self) -> Result<(), Box>; +} + +#[async_trait] +impl Command for Cmd { + async fn execute(self) -> Result<(), Box> { + match self { + Cmd::Decode(args) => args.execute().await, + Cmd::Strip(args) => args.execute().await, + Cmd::Cfg(args) => args.execute().await, + Cmd::Obfuscate(args) => args.execute().await, + Cmd::Analyze(args) => args.execute().await, + Cmd::DecompileDiff(args) => args.execute().await, + Cmd::Tui(args) => args.execute().await, + Cmd::Fuzz(args) => args.execute().await, + } + } +} + +pub mod analyze { + use crate::commands::{obfuscate::read_input, ObfuscateError}; + use async_trait::async_trait; + use azoth_analysis::obfuscation::{analyze_obfuscation, AnalysisConfig, AnalysisError}; + use azoth_core::seed::Seed; + use clap::Args; + use std::{error::Error, path::PathBuf}; + const DEFAULT_DEPLOYMENT_PATH: &str = "examples/escrow-bytecode/artifacts/erc20_deployment.hex"; + const DEFAULT_RUNTIME_PATH: &str = "examples/escrow-bytecode/artifacts/erc20_runtime.hex"; + + /// Analyze how much bytecode survives obfuscation across multiple seeds. + #[derive(Args)] + pub struct AnalyzeArgs { + /// Number of obfuscated samples to generate. + pub iterations: usize, + /// Input deployment bytecode as hex, .hex file, or binary file. + #[arg(short = 'D', long = "deployment", value_name = "BYTECODE", default_value = DEFAULT_DEPLOYMENT_PATH)] + pub deployment_bytecode: String, + /// Input runtime bytecode as hex, .hex file, or binary file. + #[arg(short = 'R', long = "runtime", value_name = "RUNTIME", default_value = DEFAULT_RUNTIME_PATH)] + pub runtime_bytecode: String, + /// Where to write the markdown report (default: ./obfuscation_analysis_report.md). + #[arg(long, value_name = "PATH")] + output: Option, + /// Maximum attempts per iteration when an obfuscation fails. + #[arg(long, default_value_t = 5)] + max_attempts: usize, + /// Private 256-bit root seed used to derive the reproducible analysis corpus. + #[arg(long)] + seed: String, + } + + #[async_trait] + impl super::Command for AnalyzeArgs { + async fn execute(self) -> Result<(), Box> { + let AnalyzeArgs { + iterations, + deployment_bytecode, + runtime_bytecode, + output, + max_attempts, + seed, + } = self; + + let input_hex = read_input(&deployment_bytecode)?; + let runtime_hex = read_input(&runtime_bytecode)?; + + let root_seed = + Seed::from_hex(&seed).map_err(|error| format!("invalid seed hex: {error}"))?; + let mut config = AnalysisConfig::new(&input_hex, &runtime_hex, iterations, root_seed); + config.max_attempts = max_attempts; + if let Some(path) = output { + config.report_path = path; + } + + let report = match analyze_obfuscation(config).await { + Ok(report) => report, + Err(AnalysisError::UnknownOpcodes { count }) => { + println!( + "Analysis aborted: obfuscation preserved {count} unknown opcode(s).\nStrip or normalize the bytecode before running analysis." + ); + return Ok(()); + } + Err(err) => return Err(map_analysis_error(err)), + }; + + println!("============================================================"); + println!("SUMMARY"); + println!("============================================================"); + println!( + "Average longest sequence: {:.2} bytes ({:.2}% of original)", + report.summary.average_length, report.summary.preservation_ratio + ); + println!( + "Median longest sequence: {:.2} bytes", + report.summary.median_length + ); + println!( + "Standard deviation: {:.2} bytes", + report.summary.std_dev + ); + println!( + "Range: {}-{} bytes", + report.summary.min_length, report.summary.max_length + ); + println!( + "25th percentile: {:.2} bytes", + report.summary.percentile_25 + ); + println!( + "75th percentile: {:.2} bytes", + report.summary.percentile_75 + ); + println!( + "95th percentile: {:.2} bytes", + report.summary.percentile_95 + ); + println!( + "Seeds generated: {} (unique: {})", + report.seed_commitments.len(), + report.unique_seed_count + ); + println!("Transforms observed: {}", report.transform_summary()); + println!(); + for (n, value) in &report.ngram_diversity { + println!("{:>2}-byte n-gram diversity: {:>6.2}%", n, value); + } + println!("============================================================"); + println!( + "Analysis complete! Report saved to: {}", + report.markdown_path.display() + ); + + Ok(()) + } + } + + fn map_analysis_error(err: AnalysisError) -> Box { + match err { + AnalysisError::Decode(err) => Box::new(err), + AnalysisError::UnknownOpcodes { count } => Box::new(std::io::Error::other(format!( + "analysis aborted due to {count} unknown opcode(s)" + ))), + AnalysisError::InvalidPass(name) => Box::new(ObfuscateError::InvalidPass(name)), + AnalysisError::ObfuscationFailure { source, .. } => source, + AnalysisError::Io(err) => Box::new(err), + AnalysisError::Fmt(err) => Box::new(err), + AnalysisError::EmptyIterations => Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "iterations must be positive", + )), + AnalysisError::EmptyAttempts => Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "max attempts must be positive", + )), + } + } +} + +pub mod cfg { + //! This module processes input bytecode, constructs a CFG using the `cfg_ir` module, and + //! generates a Graphviz .dot file representing the CFG. The output can be written to a file or + //! printed to stdout. + + use async_trait::async_trait; + use azoth_core::cfg_ir::{Block, CfgIrBundle, EdgeType}; + use azoth_core::process_bytecode_to_cfg; + use clap::Args; + use std::error::Error; + use std::fs; + use std::path::Path; + + /// Arguments for the `cfg` subcommand. + #[derive(Args)] + pub struct CfgArgs { + /// Input deployment bytecode as a hex string (0x...) or file path containing EVM bytecode. + #[arg(short = 'D', long = "deployment")] + pub deployment_bytecode: String, + /// Input runtime bytecode as a hex string (0x...) or file path containing EVM bytecode. + #[arg(short = 'R', long = "runtime")] + pub runtime_bytecode: String, + /// Output file for Graphviz .dot (default: stdout) + #[arg(short, long)] + output: Option, + } + + /// Executes the `cfg` subcommand to generate a CFG visualization. + #[async_trait] + impl super::Command for CfgArgs { + async fn execute(self) -> Result<(), Box> { + let is_file = !self.deployment_bytecode.starts_with("0x") + && Path::new(&self.deployment_bytecode).is_file(); + let runtime_is_file = !self.runtime_bytecode.starts_with("0x") + && Path::new(&self.runtime_bytecode).is_file(); + let (cfg_ir, _, _, _) = process_bytecode_to_cfg( + &self.deployment_bytecode, + is_file, + &self.runtime_bytecode, + runtime_is_file, + ) + .await + .map_err(|error| -> Box { error })?; + + let dot = generate_dot(&cfg_ir); + if let Some(out_path) = self.output { + fs::write(out_path, &dot)?; + } else { + println!("{dot}"); + } + Ok(()) + } + } + + /// Generates a Graphviz .dot representation of the CFG. + /// + /// # Arguments + /// * `cfg_ir` - The `CfgIrBundle` containing the CFG to visualize. + /// + /// # Returns + /// A `String` containing the .dot file content. + fn generate_dot(cfg_ir: &CfgIrBundle) -> String { + let mut dot = String::from("digraph CFG {\n"); + + // Add nodes + for node in cfg_ir.cfg.node_indices() { + let block = cfg_ir.cfg.node_weight(node).unwrap(); + let label = match block { + Block::Entry => "Entry".to_string(), + Block::Exit => "Exit".to_string(), + Block::Body(body) => { + let instrs: Vec = + body.instructions.iter().map(|i| i.to_string()).collect(); + format!("Block_{}\\n{}", body.start_pc, instrs.join("\\n")) + } + }; + dot.push_str(&format!(" {} [label=\"{}\"];\n", node.index(), label)); + } + + // Add edges + for edge in cfg_ir.cfg.edge_indices() { + let (src, dst) = cfg_ir.cfg.edge_endpoints(edge).unwrap(); + let edge_type = cfg_ir.cfg.edge_weight(edge).unwrap(); + let label = match edge_type { + EdgeType::Fallthrough => "Fallthrough", + EdgeType::Jump => "Jump", + EdgeType::BranchTrue => "BranchTrue", + EdgeType::BranchFalse => "BranchFalse", + }; + dot.push_str(&format!( + " {} -> {} [label=\"{}\"];\n", + src.index(), + dst.index(), + label + )); + } + + dot.push_str("}\n"); + dot + } +} + +pub mod decode { + //! This module processes input bytecode and outputs Azoth's native assembly format. + + use async_trait::async_trait; + use azoth_core::decoder::decode_input; + use clap::Args; + use std::error::Error; + use std::path::Path; + + /// Arguments for the `decode` subcommand. + #[derive(Args)] + pub struct DecodeArgs { + /// Input bytecode as a hex string (0x...) or file path containing EVM bytecode. + #[arg(short = 'D', long = "deployment")] + pub deployment_bytecode: String, + } + + /// Executes the `decode` subcommand to decode bytecode. + #[async_trait] + impl super::Command for DecodeArgs { + async fn execute(self) -> Result<(), Box> { + let is_file = !self.deployment_bytecode.starts_with("0x") + && Path::new(&self.deployment_bytecode).is_file(); + let decoded = decode_input(&self.deployment_bytecode, is_file)?; + print!("{}", decoded.format_assembly()); + Ok(()) + } + } +} + +pub mod decompile_diff { + //! Decompile diff command for comparing decompiled bytecode before and after obfuscation. + //! + //! This module provides a CLI interface to the decompile diff analysis functionality, + //! which runs obfuscation on input bytecode, then uses Heimdall's decompiler to generate + //! human-readable Solidity-like output and computes structured diffs between the original + //! and obfuscated versions. + //! + //! The structured diff uses the selector mapping from obfuscation to pair functions, + //! enabling semantic comparison even when selectors are remapped. Supports running multiple + //! iterations with different seeds to generate statistical analysis. + + use async_trait::async_trait; + use azoth_analysis::decompile_diff::{self, DiffStats, StructureKind, StructuredDiffResult}; + use azoth_core::seed::Seed; + use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; + use clap::Args; + use owo_colors::OwoColorize; + use std::collections::HashMap; + use std::error::Error; + use std::fs; + use std::path::PathBuf; + use std::sync::Arc; + use tokio::task::JoinSet; + + use crate::commands::DEFAULT_PASSES; + + use super::obfuscate::{build_passes, read_input}; + + /// Arguments for the `decompile-diff` subcommand. + /// + /// This command obfuscates input bytecode and compares decompiled output of the + /// original vs obfuscated versions using structured diff that pairs functions + /// by their selector mapping. + #[derive(Args)] + pub struct DecompileDiffArgs { + /// Input deployment bytecode as a hex string (0x...), .hex file, or binary file. + #[arg(short = 'D', long = "deployment")] + pub deployment_bytecode: String, + + /// Input runtime bytecode as a hex string (0x...), .hex file, or binary file. + #[arg(short = 'R', long = "runtime")] + pub runtime_bytecode: String, + + /// Comma-separated list of production-admitted transforms (default: cluster shuffle). + #[arg(long, default_value = DEFAULT_PASSES)] + pub passes: String, + + /// Number of iterations to run with different seeds for statistical analysis. + #[arg(long, short = 'n', default_value = "10")] + pub iterations: usize, + + /// Private 256-bit root seed. Each iteration uses a deterministic domain-separated child. + #[arg(long)] + pub seed: String, + + /// Output file path for writing the diff from the first iteration. + #[arg(long, short = 'o')] + pub output: Option, + + /// Only show items that have changes. + #[arg(long)] + pub changed_only: bool, + } + + /// Aggregated statistics across multiple structured diff runs. + #[derive(Debug, Clone)] + struct AggregatedStructuredStats { + min: DiffStats, + max: DiffStats, + sum: DiffStats, + sample_count: usize, + } + + impl AggregatedStructuredStats { + fn new(first: &DiffStats) -> Self { + Self { + min: first.clone(), + max: first.clone(), + sum: first.clone(), + sample_count: 1, + } + } + + fn add(&mut self, stats: &DiffStats) { + self.min.hunk_count = self.min.hunk_count.min(stats.hunk_count); + self.min.lines_removed = self.min.lines_removed.min(stats.lines_removed); + self.min.lines_added = self.min.lines_added.min(stats.lines_added); + self.min.lines_unchanged = self.min.lines_unchanged.min(stats.lines_unchanged); + + self.max.hunk_count = self.max.hunk_count.max(stats.hunk_count); + self.max.lines_removed = self.max.lines_removed.max(stats.lines_removed); + self.max.lines_added = self.max.lines_added.max(stats.lines_added); + self.max.lines_unchanged = self.max.lines_unchanged.max(stats.lines_unchanged); + + self.sum.hunk_count += stats.hunk_count; + self.sum.lines_removed += stats.lines_removed; + self.sum.lines_added += stats.lines_added; + self.sum.lines_unchanged += stats.lines_unchanged; + + self.sample_count += 1; + } + + fn avg_hunks(&self) -> f64 { + self.sum.hunk_count as f64 / self.sample_count as f64 + } + + fn avg_removed(&self) -> f64 { + self.sum.lines_removed as f64 / self.sample_count as f64 + } + + fn avg_added(&self) -> f64 { + self.sum.lines_added as f64 / self.sample_count as f64 + } + + fn avg_unchanged(&self) -> f64 { + self.sum.lines_unchanged as f64 / self.sample_count as f64 + } + } + + /// Executes the `decompile-diff` subcommand. + #[async_trait] + impl super::Command for DecompileDiffArgs { + async fn execute(self) -> Result<(), Box> { + if self.iterations == 0 { + return Err("iterations must be at least one".into()); + } + let input_bytecode = read_input(&self.deployment_bytecode)?; + let runtime_bytecode = read_input(&self.runtime_bytecode)?; + let pre_bytes = hex::decode(runtime_bytecode.trim_start_matches("0x"))?; + let root_seed = Arc::new( + Seed::from_hex(&self.seed).map_err(|error| format!("invalid seed hex: {error}"))?, + ); + + // Run iterations in parallel with bounded concurrency + let max_concurrency = std::thread::available_parallelism() + .map(|p| p.get()) + .unwrap_or(4); + let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrency)); + + let input_bytecode = Arc::new(input_bytecode); + let runtime_bytecode = Arc::new(runtime_bytecode); + let pre_bytes = Arc::new(pre_bytes); + let passes = Arc::new(self.passes.clone()); + + let mut join_set: JoinSet> = + JoinSet::new(); + + for iteration in 0..self.iterations { + let input_bytecode = Arc::clone(&input_bytecode); + let runtime_bytecode = Arc::clone(&runtime_bytecode); + let pre_bytes = Arc::clone(&pre_bytes); + let passes = Arc::clone(&passes); + let semaphore = Arc::clone(&semaphore); + let root_seed = Arc::clone(&root_seed); + + join_set.spawn(async move { + let _permit = semaphore.acquire().await.unwrap(); + + let transforms = + build_passes(&passes).map_err(|e| format!("build_passes: {e}"))?; + + let mut config = + ObfuscationConfig::with_seed(derive_iteration_seed(&root_seed, iteration)); + config.transforms = transforms; + + let obf_result = obfuscate_bytecode(&input_bytecode, &runtime_bytecode, config) + .await + .map_err(|e| format!("obfuscate: {e}"))?; + + let post_bytes = + hex::decode(obf_result.obfuscated_runtime.trim_start_matches("0x")) + .map_err(|e| format!("hex decode: {e}"))?; + + let selector_mapping: HashMap> = + obf_result.selector_mapping.unwrap_or_default(); + + let diff_result = decompile_diff::compare_structured( + pre_bytes.as_ref().clone().into(), + post_bytes.into(), + selector_mapping, + ) + .await + .map_err(|e| format!("decompile: {e}"))?; + + Ok((iteration, diff_result)) + }); + } + + // Collect results + let mut results = Vec::with_capacity(self.iterations); + while let Some(result) = join_set.join_next().await { + results.push( + result + .map_err(|error| format!("join error: {error}"))? + .map_err(|error| format!("analysis iteration failed: {error}"))?, + ); + } + results.sort_by_key(|(iteration, _)| *iteration); + + // Aggregate statistics + let mut aggregated: Option = None; + let mut first_result: Option = None; + + for (i, (_, diff_result)) in results.into_iter().enumerate() { + let stats = diff_result.aggregate_stats(); + + if i == 0 { + first_result = Some(diff_result); + } + + match &mut aggregated { + None => aggregated = Some(AggregatedStructuredStats::new(&stats)), + Some(agg) => agg.add(&stats), + } + } + + let aggregated = aggregated.expect("at least one iteration"); + let first_result = first_result.expect("at least one iteration"); + + // Write diff to file if requested, otherwise print to terminal + if let Some(output_path) = &self.output { + let output = self.format_diff_output(&first_result); + fs::write(output_path, output)?; + } else { + self.print_structured_diff(&first_result); + } + + // Always print summary and statistics + self.print_statistics(&aggregated, &first_result); + + Ok(()) + } + } + + fn derive_iteration_seed(root: &Seed, iteration: usize) -> Seed { + let mut domain = b"azoth-decompile-diff-iteration-v1".to_vec(); + domain.extend_from_slice(&(iteration as u64).to_be_bytes()); + root.derive_seed(&domain) + } + + impl DecompileDiffArgs { + /// Formats the diff only (no stats) as plain text for file output. + fn format_diff_output(&self, result: &StructuredDiffResult) -> String { + let mut output = String::new(); + + for item in &result.items { + if self.changed_only && !item.has_changes() { + continue; + } + + output.push_str(&format!("─── {} ───\n", item.kind)); + + if item.has_changes() { + output.push_str(&item.diff.unified_diff); + } else { + output.push_str("(no changes)\n"); + } + output.push('\n'); + } + + output + } + + /// Prints the structured diff to stdout with colors (no summary, just diffs). + fn print_structured_diff(&self, result: &StructuredDiffResult) { + // Each item + for item in &result.items { + if self.changed_only && !item.has_changes() { + continue; + } + + // Section header + let header = match &item.kind { + StructureKind::Header => "Header".to_string(), + StructureKind::Storage => "Storage".to_string(), + StructureKind::Function { + original_selector, + obfuscated_selector, + name, + } => { + format!( + "Function {} ({} → {})", + name.bold(), + format!("0x{:08x}", original_selector).dimmed(), + format!("0x{:08x}", obfuscated_selector).cyan() + ) + } + StructureKind::UnmatchedOriginal { selector, name } => { + format!( + "{} {} ({})", + "Removed:".red(), + name, + format!("0x{:08x}", selector).dimmed() + ) + } + StructureKind::UnmatchedObfuscated { selector, name } => { + format!( + "{} {} ({})", + "Added:".green(), + name, + format!("0x{:08x}", selector).cyan() + ) + } + }; + + println!("─── {} ───", header); + + if item.has_changes() { + let stats = &item.diff.stats; + println!( + " {} hunks, {} {}, {} {}", + stats.hunk_count, + format!("-{}", stats.lines_removed).red(), + "removed".dimmed(), + format!("+{}", stats.lines_added).green(), + "added".dimmed() + ); + println!(); + print!("{}", item.diff.colored_diff); + } else { + println!(" {}", "(no changes)".dimmed()); + } + println!(); + } + } + + /// Prints summary and aggregated statistics to stdout as valid markdown. + fn print_statistics( + &self, + stats: &AggregatedStructuredStats, + result: &StructuredDiffResult, + ) { + println!( + "## Statistics ({} iteration{})\n", + stats.sample_count, + if stats.sample_count == 1 { "" } else { "s" } + ); + + // Summary from first result + let diff_stats = result.aggregate_stats(); + let changed_count = result.items.iter().filter(|i| i.has_changes()).count(); + + println!( + "- **Total:** {} hunks, -{} removed, +{} added", + diff_stats.hunk_count, diff_stats.lines_removed, diff_stats.lines_added + ); + println!( + "- **Items:** {} total, {} with changes", + result.items.len(), + changed_count + ); + + if !result.selector_mapping.is_empty() { + println!( + "- **Selectors:** {} remapped", + result.selector_mapping.len() + ); + } + println!(); + + // Markdown table with padding for readability + println!( + "| {:<15} | {:>10} | {:>10} | {:>10} |", + "Metric", "Min", "Avg", "Max" + ); + println!("|-{:-<15}-|-{:->10}:|-{:->10}:|-{:->10}:|", "", "", "", ""); + println!( + "| {:<15} | {:>10} | {:>10.1} | {:>10} |", + "Hunks", + stats.min.hunk_count, + stats.avg_hunks(), + stats.max.hunk_count + ); + println!( + "| {:<15} | {:>10} | {:>10.1} | {:>10} |", + "Lines removed", + stats.min.lines_removed, + stats.avg_removed(), + stats.max.lines_removed + ); + println!( + "| {:<15} | {:>10} | {:>10.1} | {:>10} |", + "Lines added", + stats.min.lines_added, + stats.avg_added(), + stats.max.lines_added + ); + println!( + "| {:<15} | {:>10} | {:>10.1} | {:>10} |", + "Lines unchanged", + stats.min.lines_unchanged, + stats.avg_unchanged(), + stats.max.lines_unchanged + ); + } + } + + #[cfg(test)] + mod tests { + use super::derive_iteration_seed; + use azoth_core::seed::Seed; + + #[test] + fn iteration_seed_corpus_is_deterministic_and_domain_separated() { + let root = Seed::from_bytes([0xa5; 32]); + assert_eq!( + derive_iteration_seed(&root, 7).as_bytes(), + derive_iteration_seed(&root, 7).as_bytes() + ); + assert_ne!( + derive_iteration_seed(&root, 7).as_bytes(), + derive_iteration_seed(&root, 8).as_bytes() + ); + } + } +} + +pub mod fuzz { + //! Fuzz testing subcommand for the Azoth CLI. + //! + //! Runs parallel fuzz testing against the obfuscation pipeline, saving + //! reproducible crash inputs with debug traces for TUI visualization. + + use std::collections::HashSet; + use std::error::Error; + use std::fmt; + use std::fs; + use std::io::Write; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + use std::time::Instant; + + use async_trait::async_trait; + use azoth_core::cfg_ir::TraceEvent; + use azoth_core::seed::{DeterministicRng, Seed}; + use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; + use clap::{Args, Subcommand}; + use parking_lot::Mutex; + use rand::{RngCore, SeedableRng}; + use revm::bytecode::Bytecode; + use revm::context::result::{ExecutionResult, Output}; + use revm::context::TxEnv; + use revm::database::InMemoryDB; + use revm::primitives::{Address, Bytes, TxKind, U256}; + use revm::state::AccountInfo; + use revm::{Context, ExecuteEvm, MainBuilder, MainContext}; + use serde::{Deserialize, Serialize}; + use sha3::{Digest, Sha3_256}; + use tracing_subscriber::fmt::MakeWriter; + use tracing_subscriber::layer::SubscriberExt; + + use super::obfuscate::build_passes; + use crate::commands::DEFAULT_PASSES; + + const FUZZ_CRASH_SCHEMA_VERSION: u32 = 2; + const EXPECTED_PIPELINE_PROFILE: &str = "azoth-foundation-v4"; + + fn num_cpus() -> usize { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) + } + + /// A writer that captures log output to a buffer. + /// Uses Arc because tracing's `with_default` requires Send + Sync. + /// parking_lot::Mutex is just a single atomic op for uncontended locks. + #[derive(Clone)] + struct LogCapture { + buffer: Arc>>, + } + + impl LogCapture { + fn new() -> Self { + Self { + buffer: Arc::new(Mutex::new(Vec::new())), + } + } + + fn clear(&self) { + self.buffer.lock().clear(); + } + + fn extract_lines(&self) -> Vec { + String::from_utf8_lossy(&self.buffer.lock()) + .lines() + .map(|s| s.to_string()) + .collect() + } + } + + impl Write for LogCapture { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buffer.lock().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> MakeWriter<'a> for LogCapture { + type Writer = LogCapture; + + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + // Contract bytecodes + const ESCROW_DEPLOYMENT: &str = + include_str!("../../../examples/escrow-bytecode/artifacts/erc20_deployment.hex"); + const ESCROW_RUNTIME: &str = + include_str!("../../../examples/escrow-bytecode/artifacts/erc20_runtime.hex"); + const COUNTER_DEPLOYMENT: &str = + include_str!("../../../tests/bytecode/counter/counter_deployment.hex"); + const COUNTER_RUNTIME: &str = + include_str!("../../../tests/bytecode/counter/counter_runtime.hex"); + + /// Fuzz testing for the obfuscation pipeline. + #[derive(Args)] + pub struct FuzzArgs { + #[command(subcommand)] + command: Option, + + /// Number of parallel fuzzing tasks (defaults to number of CPU cores) + #[arg(short = 'j', long, default_value_t = num_cpus())] + jobs: usize, + + /// Maximum iterations (0 = infinite) + #[arg(short, long, default_value = "0")] + iterations: u64, + + /// Duration in seconds (0 = infinite) + #[arg(short, long, default_value = "0")] + duration: u64, + + /// Directory to save crash inputs + #[arg(long, default_value = "crashes")] + crash_dir: PathBuf, + + /// Check that obfuscated bytecode deploys successfully + #[arg(long, default_value = "false")] + check_deploy: bool, + } + + #[derive(Subcommand)] + enum FuzzCommand { + /// Replay a saved crash file + Replay { + /// Path to crash JSON file + crash_file: PathBuf, + }, + /// List all saved crashes + List, + } + + /// Contract to fuzz test. + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] + enum Contract { + Escrow, + Counter, + } + + impl Contract { + const ALL: [Self; 2] = [Self::Escrow, Self::Counter]; + + fn name(self) -> &'static str { + match self { + Self::Escrow => "escrow", + Self::Counter => "counter", + } + } + + fn deployment_hex(self) -> &'static str { + match self { + Self::Escrow => ESCROW_DEPLOYMENT, + Self::Counter => COUNTER_DEPLOYMENT, + } + } + + fn runtime_hex(self) -> &'static str { + match self { + Self::Escrow => ESCROW_RUNTIME, + Self::Counter => COUNTER_RUNTIME, + } + } + } + + /// Number of comma-separated passes in `DEFAULT_PASSES`. + fn default_pass_count() -> u32 { + DEFAULT_PASSES.split(',').count() as u32 + } + + /// Exclusive upper bound on the pass-selection bitmask, covering every default pass. + fn pass_mask_limit() -> u32 { + 1u32 << default_pass_count() + } + + /// Generate a random comma-separated pass string from bits. + fn passes_from_bits(bits: u32) -> String { + DEFAULT_PASSES + .split(",") + .enumerate() + .filter(|(i, _)| bits & (1 << i) != 0) + .map(|(_, name)| name) + .collect::>() + .join(",") + } + + /// Reproducible fuzz input containing all parameters needed to replay a test case. + #[derive(Debug, Clone, Serialize, Deserialize)] + struct FuzzInput { + contract: Contract, + seed: String, + passes: String, + } + + impl FuzzInput { + fn new(contract: Contract, seed_bytes: [u8; 32], passes: String) -> Self { + Self { + contract, + seed: hex::encode(seed_bytes), + passes, + } + } + + fn seed_bytes(&self) -> Result<[u8; 32], String> { + let mut bytes = [0u8; 32]; + let decoded = + hex::decode(&self.seed).map_err(|error| format!("invalid seed hex: {error}"))?; + if decoded.len() != 32 { + return Err(format!( + "invalid seed length: expected 32 bytes, received {}", + decoded.len() + )); + } + bytes.copy_from_slice(&decoded); + Ok(bytes) + } + } + + /// Error categories for crash classification. + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + enum ErrorKind { + Obfuscation, + Validation, + DeploymentMismatch { original: usize, obfuscated: usize }, + } + + impl fmt::Display for ErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Obfuscation => write!(f, "obfuscation failed"), + Self::Validation => write!(f, "validation failed"), + Self::DeploymentMismatch { + original, + obfuscated, + } => { + write!( + f, + "deployment mismatch (orig={original}b, obf={obfuscated}b)" + ) + } + } + } + } + + /// Crash report saved to disk for reproduction and debugging. + #[derive(Debug, Clone, Serialize, Deserialize)] + struct CrashReport { + #[serde(default)] + schema_version: u32, + #[serde(default)] + pipeline_profile: String, + id: String, + timestamp: String, + input: FuzzInput, + check_deploy: bool, + error_kind: ErrorKind, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + obfuscated_bytecode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + trace_file: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + logs: Vec, + } + + /// Statistics tracked during fuzzing + struct FuzzStats { + iterations: AtomicU64, + successes: AtomicU64, + errors: AtomicU64, + deployment_mismatches: AtomicU64, + unique_crashes: Mutex>, + start_time: Instant, + } + + impl FuzzStats { + fn new() -> Self { + Self { + iterations: AtomicU64::new(0), + successes: AtomicU64::new(0), + errors: AtomicU64::new(0), + deployment_mismatches: AtomicU64::new(0), + unique_crashes: Mutex::new(HashSet::new()), + start_time: Instant::now(), + } + } + + fn print_summary(&self, check_deploy: bool) { + let elapsed = self.start_time.elapsed().as_secs_f64(); + let iters = self.iterations.load(Ordering::Relaxed); + let rate = if elapsed > 0.0 { + iters as f64 / elapsed + } else { + 0.0 + }; + + println!("\r\x1b[K=== Fuzzing Summary ==="); + println!("Duration: {:.1}s", elapsed); + println!("Iterations: {}", iters); + println!("Rate: {:.1} iter/sec", rate); + println!("Successes: {}", self.successes.load(Ordering::Relaxed)); + println!("Errors: {}", self.errors.load(Ordering::Relaxed)); + if check_deploy { + println!( + "Deployment-success regressions: {}", + self.deployment_mismatches.load(Ordering::Relaxed) + ); + } + println!( + "Unique failing inputs saved: {}", + self.unique_crashes.lock().len() + ); + } + } + + const MOCK_TOKEN_ADDR: Address = Address::new([0x11; 20]); + + fn prepare_escrow_bytecode(deployment_hex: &str, seed: [u8; 32]) -> Option> { + let normalized = deployment_hex.trim().trim_start_matches("0x"); + let mut bytecode = hex::decode(normalized).ok()?; + let mut rng = DeterministicRng::from_seed(seed); + let mut recipient = [0u8; 20]; + let mut expected_amount = [0u8; 32]; + let mut payment_amount = [0u8; 32]; + rng.fill_bytes(&mut recipient); + rng.fill_bytes(&mut expected_amount); + rng.fill_bytes(&mut payment_amount); + bytecode.extend_from_slice(&[0; 12]); + bytecode.extend_from_slice(MOCK_TOKEN_ADDR.as_slice()); + bytecode.extend_from_slice(&[0; 12]); + bytecode.extend_from_slice(&recipient); + bytecode.extend_from_slice(&expected_amount); + bytecode.extend_from_slice(&[0; 32]); + bytecode.extend_from_slice(&payment_amount); + Some(bytecode) + } + + fn prepare_counter_bytecode(deployment_hex: &str) -> Option> { + let normalized = deployment_hex.trim().trim_start_matches("0x"); + hex::decode(normalized).ok() + } + + fn prepare_bytecode( + contract: Contract, + deployment_hex: &str, + seed: [u8; 32], + ) -> Option> { + match contract { + Contract::Escrow => prepare_escrow_bytecode(deployment_hex, seed), + Contract::Counter => prepare_counter_bytecode(deployment_hex), + } + } + + fn deploy_to_revm(bytecode: &[u8], contract: Contract) -> Result { + let mut db = InMemoryDB::default(); + let deployer = Address::from([0x42u8; 20]); + + db.insert_account_info( + deployer, + AccountInfo { + balance: U256::from(1_000_000_000_000_000_000u128), + nonce: 0, + code_hash: revm::primitives::KECCAK_EMPTY, + code: None, + }, + ); + + if contract == Contract::Escrow { + db.insert_account_info( + MOCK_TOKEN_ADDR, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code_hash: revm::primitives::KECCAK_EMPTY, + code: Some(Bytecode::new_raw(Bytes::from_static(&[ + 0x60, 0x01, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3, + ]))), + }, + ); + } + + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + let tx = TxEnv { + caller: deployer, + gas_limit: 30_000_000, + kind: TxKind::Create, + data: bytecode.to_vec().into(), + value: U256::ZERO, + ..Default::default() + }; + + let result = evm + .transact(tx) + .map_err(|e| format!("EVM error: {:?}", e))?; + + match result.result { + ExecutionResult::Success { + output: Output::Create(_, Some(addr)), + .. + } => Ok(addr), + ExecutionResult::Success { .. } => Err("No address returned".into()), + ExecutionResult::Revert { output, .. } => Err(format!("Reverted: {:?}", output)), + ExecutionResult::Halt { reason, .. } => Err(format!("Halted: {:?}", reason)), + } + } + + fn hash_crash_field(hasher: &mut Sha3_256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + } + + fn hash_error_kind(hasher: &mut Sha3_256, kind: &ErrorKind) { + match kind { + ErrorKind::Obfuscation => hasher.update([0]), + ErrorKind::Validation => hasher.update([1]), + ErrorKind::DeploymentMismatch { + original, + obfuscated, + } => { + hasher.update([2]); + hasher.update((*original as u64).to_be_bytes()); + hasher.update((*obfuscated as u64).to_be_bytes()); + } + } + } + + fn crash_hash_parts( + input: &FuzzInput, + kind: &ErrorKind, + message: &str, + obfuscated_bytecode: Option<&str>, + check_deploy: bool, + ) -> String { + let mut hasher = Sha3_256::new(); + hasher.update(b"AZOTH_FUZZ_CRASH_ID_V2"); + hasher.update(FUZZ_CRASH_SCHEMA_VERSION.to_be_bytes()); + hash_crash_field(&mut hasher, EXPECTED_PIPELINE_PROFILE.as_bytes()); + hasher.update([match input.contract { + Contract::Escrow => 0, + Contract::Counter => 1, + }]); + hash_crash_field(&mut hasher, input.seed.as_bytes()); + hash_crash_field(&mut hasher, input.passes.as_bytes()); + hasher.update([u8::from(check_deploy)]); + hash_error_kind(&mut hasher, kind); + hash_crash_field(&mut hasher, message.as_bytes()); + match obfuscated_bytecode { + Some(bytecode) => { + hasher.update([1]); + hash_crash_field(&mut hasher, bytecode.as_bytes()); + } + None => hasher.update([0]), + } + hex::encode(hasher.finalize()) + } + + fn crash_hash(input: &FuzzInput, failure: &FuzzFailure, check_deploy: bool) -> String { + crash_hash_parts( + input, + &failure.kind, + &failure.message, + failure.obfuscated_bytecode.as_deref(), + check_deploy, + ) + } + + /// Failure from a fuzz run, containing error info and trace for debugging. + struct FuzzFailure { + kind: ErrorKind, + message: String, + trace: Vec, + obfuscated_bytecode: Option, + logs: Vec, + } + + fn save_crash( + crash_dir: &PathBuf, + input: &FuzzInput, + failure: &FuzzFailure, + check_deploy: bool, + ) -> std::io::Result { + fs::create_dir_all(crash_dir)?; + + let crash_id = crash_hash(input, failure, check_deploy); + + // Save trace file if we have trace events + let trace_file = if !failure.trace.is_empty() { + let filename = format!("trace_{crash_id}.json"); + let path = crash_dir.join(&filename); + fs::write(&path, serde_json::to_string_pretty(&failure.trace)?)?; + Some(filename) + } else { + None + }; + + let report = CrashReport { + schema_version: FUZZ_CRASH_SCHEMA_VERSION, + pipeline_profile: EXPECTED_PIPELINE_PROFILE.to_string(), + id: crash_id, + timestamp: chrono::Utc::now().to_rfc3339(), + input: input.clone(), + check_deploy, + error_kind: failure.kind.clone(), + message: failure.message.clone(), + obfuscated_bytecode: failure.obfuscated_bytecode.clone(), + trace_file, + logs: failure.logs.clone(), + }; + + let path = crash_dir.join(format!("crash_{}.json", report.id)); + fs::write(&path, serde_json::to_string_pretty(&report)?)?; + Ok(path) + } + + async fn run_fuzz_input(input: &FuzzInput, check_deploy: bool) -> Result<(), FuzzFailure> { + let deployment_hex = input.contract.deployment_hex(); + let runtime_hex = input.contract.runtime_hex(); + let seed_bytes = input.seed_bytes().map_err(|message| FuzzFailure { + kind: ErrorKind::Obfuscation, + message, + trace: Vec::new(), + obfuscated_bytecode: None, + logs: Vec::new(), + })?; + let seed = Seed::from_bytes(seed_bytes); + let original_bytes = prepare_bytecode(input.contract, deployment_hex, seed_bytes) + .ok_or_else(|| FuzzFailure { + kind: ErrorKind::Obfuscation, + message: "failed to prepare original bytecode".into(), + trace: Vec::new(), + obfuscated_bytecode: None, + logs: Vec::new(), + })?; + let full_deployment_hex = format!("0x{}", hex::encode(&original_bytes)); + + let transforms = build_passes(&input.passes).map_err(|e| FuzzFailure { + kind: ErrorKind::Obfuscation, + message: format!("invalid passes: {e}"), + trace: Vec::new(), + obfuscated_bytecode: None, + logs: Vec::new(), + })?; + + let config = ObfuscationConfig { + seed, + transforms, + preserve_unknown_opcodes: true, + rewrite_function_selectors: false, + obfuscate_constructor_arguments: false, + }; + + let result = obfuscate_bytecode(&full_deployment_hex, runtime_hex, config) + .await + .map_err(|e| { + let kind = if e.message.contains("validation") || e.message.contains("invalid jump") + { + ErrorKind::Validation + } else { + ErrorKind::Obfuscation + }; + FuzzFailure { + kind, + message: e.message, + trace: e.trace, + obfuscated_bytecode: None, + logs: Vec::new(), + } + })?; + + if result.integrity.pipeline_profile != EXPECTED_PIPELINE_PROFILE { + return Err(FuzzFailure { + kind: ErrorKind::Obfuscation, + message: format!( + "fuzz harness expects pipeline profile {}, but Azoth emitted {}", + EXPECTED_PIPELINE_PROFILE, result.integrity.pipeline_profile + ), + trace: result.trace, + obfuscated_bytecode: Some(result.obfuscated_bytecode), + logs: Vec::new(), + }); + } + + if !check_deploy { + return Ok(()); + } + + deploy_to_revm(&original_bytes, input.contract).map_err(|error| FuzzFailure { + kind: ErrorKind::Obfuscation, + message: format!("invalid fuzz baseline: original deployment failed: {error}"), + trace: result.trace.clone(), + obfuscated_bytecode: Some(result.obfuscated_bytecode.clone()), + logs: Vec::new(), + })?; + + let prepared_obfuscated = hex::decode(result.obfuscated_bytecode.trim_start_matches("0x")) + .map_err(|error| FuzzFailure { + kind: ErrorKind::Obfuscation, + message: format!("failed to decode obfuscated bytecode: {error}"), + trace: result.trace.clone(), + obfuscated_bytecode: Some(result.obfuscated_bytecode.clone()), + logs: Vec::new(), + })?; + + if let Err(error) = deploy_to_revm(&prepared_obfuscated, input.contract) { + return Err(FuzzFailure { + kind: ErrorKind::DeploymentMismatch { + original: original_bytes.len(), + obfuscated: prepared_obfuscated.len(), + }, + message: format!( + "original deployed but obfuscated failed ({}b vs {}b): {error}", + original_bytes.len(), + prepared_obfuscated.len() + ), + trace: result.trace, + obfuscated_bytecode: Some(result.obfuscated_bytecode), + logs: Vec::new(), + }); + } + + Ok(()) + } + + /// Runs a fuzz input using the provided log capture buffer. + /// Clears the buffer before running and extracts logs on failure. + async fn run_fuzz_input_capturing( + input: &FuzzInput, + log_capture: &LogCapture, + check_deploy: bool, + ) -> Result<(), FuzzFailure> { + log_capture.clear(); + let result = run_fuzz_input(input, check_deploy).await; + result.map_err(|mut failure| { + failure.logs = log_capture.extract_lines(); + failure + }) + } + + async fn replay_crash(crash_file: &PathBuf) -> Result<(), Box> { + let content = fs::read_to_string(crash_file)?; + let report: CrashReport = serde_json::from_str(&content)?; + if report.schema_version != FUZZ_CRASH_SCHEMA_VERSION + || report.pipeline_profile != EXPECTED_PIPELINE_PROFILE + { + return Err(format!( + "crash report targets schema/profile {}/{:?}, but this harness requires {}/{}", + report.schema_version, + report.pipeline_profile, + FUZZ_CRASH_SCHEMA_VERSION, + EXPECTED_PIPELINE_PROFILE + ) + .into()); + } + let expected_id = crash_hash_parts( + &report.input, + &report.error_kind, + &report.message, + report.obfuscated_bytecode.as_deref(), + report.check_deploy, + ); + if report.id != expected_id { + return Err(format!( + "crash report id mismatch: saved {}, recomputed {}", + report.id, expected_id + ) + .into()); + } + + println!("=== Replaying Crash ==="); + println!("ID: {}", report.id); + println!("Timestamp: {}", report.timestamp); + println!("Contract: {:?}", report.input.contract); + println!("Seed: {}", report.input.seed); + println!( + "Passes: {}", + if report.input.passes.is_empty() { + "none" + } else { + &report.input.passes + } + ); + println!("Original error: {}", report.message); + println!("Deployment check: {}", report.check_deploy); + if let Some(ref trace) = report.trace_file { + println!("Debug trace: {trace}"); + } + if !report.logs.is_empty() { + println!("Captured logs: {} lines", report.logs.len()); + } + println!(); + + if !report.logs.is_empty() { + println!("=== Captured Logs ==="); + for line in &report.logs { + println!("{line}"); + } + println!(); + } + + println!("Running..."); + + let log_capture = LogCapture::new(); + let subscriber = tracing_subscriber::registry().with( + tracing_subscriber::fmt::layer() + .with_writer(log_capture.clone()) + .with_ansi(false) + .without_time(), + ); + let dispatch = tracing::dispatcher::Dispatch::new(subscriber); + let _guard = tracing::dispatcher::set_default(&dispatch); + + let result = + run_fuzz_input_capturing(&report.input, &log_capture, report.check_deploy).await; + + match result { + Ok(()) => { + println!("NOT REPRODUCED - the saved failure no longer occurs"); + Err("saved crash did not reproduce".into()) + } + Err(failure) => { + let exact = failure.kind == report.error_kind + && failure.message == report.message + && failure.obfuscated_bytecode == report.obfuscated_bytecode; + println!( + "{}", + if exact { + "REPRODUCED EXACTLY" + } else { + "DIFFERENT FAILURE" + } + ); + println!("Error: {}", failure.kind); + println!("Message: {}", failure.message); + if !failure.logs.is_empty() { + println!(); + println!("=== New Logs ==="); + for line in &failure.logs { + println!("{line}"); + } + } + if exact { + return Err("saved crash reproduced exactly".into()); + } + Err("saved crash produced a different failure".into()) + } + } + } + + fn list_crashes(crash_dir: &PathBuf) -> Result<(), Box> { + if !crash_dir.exists() { + println!("No crashes directory found at {crash_dir:?}"); + return Ok(()); + } + + let mut crashes = Vec::new(); + for entry in fs::read_dir(crash_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().is_some_and(|e| e == "json") + && path + .file_name() + .is_some_and(|n| n.to_string_lossy().starts_with("crash_")) + { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(report) = serde_json::from_str::(&content) { + crashes.push((path, report)); + } + } + } + } + + if crashes.is_empty() { + println!("No crashes found in {crash_dir:?}"); + return Ok(()); + } + + println!("=== Saved Crashes ({}) ===\n", crashes.len()); + for (path, report) in crashes { + println!("File: {}", path.display()); + println!(" ID: {}", report.id); + println!(" Contract: {:?}", report.input.contract); + println!( + " Passes: {}", + if report.input.passes.is_empty() { + "none" + } else { + &report.input.passes + } + ); + println!(" Error: {}", report.error_kind); + if report.trace_file.is_some() { + println!(" Trace: available"); + } + if !report.logs.is_empty() { + println!(" Logs: {} lines", report.logs.len()); + } + println!(); + } + + Ok(()) + } + + async fn fuzzer_worker( + _worker_id: usize, + stats: Arc, + args: Arc, + crash_dir: PathBuf, + ) { + let log_capture = LogCapture::new(); + let subscriber = tracing_subscriber::registry().with( + tracing_subscriber::fmt::layer() + .with_writer(log_capture.clone()) + .with_ansi(false) + .without_time(), + ); + let dispatch = tracing::dispatcher::Dispatch::new(subscriber); + let _guard = tracing::dispatcher::set_default(&dispatch); + + loop { + if args.duration > 0 && stats.start_time.elapsed().as_secs() >= args.duration { + break; + } + let Some(iteration) = claim_iteration(&stats.iterations, args.iterations) else { + break; + }; + + let input = fuzz_input_for_iteration(iteration); + + match run_fuzz_input_capturing(&input, &log_capture, args.check_deploy).await { + Ok(()) => { + stats.successes.fetch_add(1, Ordering::Relaxed); + } + Err(failure) => { + let is_mismatch = matches!(failure.kind, ErrorKind::DeploymentMismatch { .. }); + + if is_mismatch { + stats.deployment_mismatches.fetch_add(1, Ordering::Relaxed); + } + stats.errors.fetch_add(1, Ordering::Relaxed); + + let crash_id = crash_hash(&input, &failure, args.check_deploy); + let mut crashes = stats.unique_crashes.lock(); + if !crashes.contains(&crash_id) { + match save_crash(&crash_dir, &input, &failure, args.check_deploy) { + Ok(path) => { + crashes.insert(crash_id); + let passes_display = if input.passes.is_empty() { + "none" + } else { + &input.passes + }; + eprintln!("\n[CRASH] Saved: {}", path.display()); + eprintln!( + " {:?} | {} | {}", + input.contract, passes_display, failure.kind + ); + } + Err(error) => { + eprintln!("\n[CRASH] Failed to save {crash_id}: {error}"); + } + } + } + } + } + } + } + + /// Atomically reserves and returns one fuzz-case index without allowing concurrent workers to + /// overrun a finite iteration budget. A zero limit means unbounded execution. + fn claim_iteration(counter: &AtomicU64, limit: u64) -> Option { + if limit == 0 { + return Some(counter.fetch_add(1, Ordering::Relaxed)); + } + + counter + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + (current < limit).then_some(current + 1) + }) + .ok() + } + + /// Derives a case solely from its global index. Fixed-iteration runs therefore exercise the same + /// corpus regardless of worker count or scheduler timing. + fn fuzz_input_for_iteration(iteration: u64) -> FuzzInput { + let mut rng = DeterministicRng::seed_from_u64(iteration ^ 0xdead_beef_5eed_cafe); + let contract = Contract::ALL[(rng.next_u32() as usize) % Contract::ALL.len()]; + let mut seed_bytes = [0u8; 32]; + rng.fill_bytes(&mut seed_bytes); + let passes = passes_from_bits(rng.next_u32() % pass_mask_limit()); + FuzzInput::new(contract, seed_bytes, passes) + } + + fn status_printer(stats: Arc, stop: Arc) { + while !stop.load(Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(250)); + let elapsed = stats.start_time.elapsed(); + let secs = elapsed.as_secs(); + let iters = stats.iterations.load(Ordering::Relaxed); + let rate = if elapsed.as_secs_f64() > 0.0 { + iters as f64 / elapsed.as_secs_f64() + } else { + 0.0 + }; + let ok = stats.successes.load(Ordering::Relaxed); + let mismatch = stats.deployment_mismatches.load(Ordering::Relaxed); + let crashes = stats.unique_crashes.lock().len(); + print!( + "\r\x1b[K[{:02}:{:02}] {:.1}/s iter={} ok={} mismatch={} failing_inputs={}", + secs / 60, + secs % 60, + rate, + iters, + ok, + mismatch, + crashes + ); + std::io::stdout().flush().ok(); + } + } + + #[async_trait] + impl super::Command for FuzzArgs { + async fn execute(self) -> Result<(), Box> { + match &self.command { + Some(FuzzCommand::Replay { crash_file }) => { + return replay_crash(crash_file).await; + } + Some(FuzzCommand::List) => { + return list_crashes(&self.crash_dir); + } + None => {} + } + + if self.jobs == 0 { + return Err("jobs must be at least one".into()); + } + + println!("Azoth Fuzzer"); + println!("============"); + println!("Pipeline profile: {}", EXPECTED_PIPELINE_PROFILE); + println!("Jobs: {}", self.jobs); + println!( + "Iterations: {}", + if self.iterations == 0 { + "infinite".to_string() + } else { + self.iterations.to_string() + } + ); + println!( + "Duration: {}", + if self.duration == 0 { + "infinite".to_string() + } else { + format!("{}s", self.duration) + } + ); + println!("Crash dir: {}", self.crash_dir.display()); + println!( + "Deployment check: creation success only; this is not a behavioral-equivalence proof" + ); + let contracts: Vec<_> = Contract::ALL.iter().map(|c| c.name()).collect(); + println!("Contracts: {}", contracts.join(", ")); + println!("Transforms: none, {}", DEFAULT_PASSES); + println!(); + + let args = Arc::new(self); + let stats = Arc::new(FuzzStats::new()); + let crash_dir = args.crash_dir.clone(); + + // Spawn status printer on dedicated thread + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let status_thread = { + let stats = stats.clone(); + let stop = stop.clone(); + std::thread::spawn(move || status_printer(stats, stop)) + }; + + let mut handles = Vec::new(); + for worker_id in 0..args.jobs { + let stats = stats.clone(); + let args = args.clone(); + let crash_dir = crash_dir.clone(); + + handles.push(tokio::spawn(fuzzer_worker( + worker_id, stats, args, crash_dir, + ))); + } + + let mut worker_error = None; + for handle in handles { + if let Err(error) = handle.await { + worker_error.get_or_insert(error); + } + } + + // Stop status printer and print summary + stop.store(true, Ordering::Relaxed); + let _ = status_thread.join(); + stats.print_summary(args.check_deploy); + if let Some(error) = worker_error { + return Err(format!("fuzzer worker terminated unexpectedly: {error}").into()); + } + let iterations = stats.iterations.load(Ordering::Relaxed); + let completed = + stats.successes.load(Ordering::Relaxed) + stats.errors.load(Ordering::Relaxed); + if completed != iterations { + return Err(format!( + "fuzzer accounting mismatch: claimed {iterations} cases but recorded {completed} outcomes" + ) + .into()); + } + let errors = stats.errors.load(Ordering::Relaxed); + if errors > 0 { + return Err(format!("fuzz run recorded {errors} failing case(s)").into()); + } + Ok(()) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn fuzz_pass_selection_can_reach_every_default_pass() { + let default_passes: Vec<&str> = DEFAULT_PASSES.split(',').map(str::trim).collect(); + + for expected in &default_passes { + let reachable = (0u32..pass_mask_limit()).any(|mask| { + passes_from_bits(mask) + .split(',') + .map(str::trim) + .any(|p| p == *expected) + }); + assert!( + reachable, + "default pass {expected} is not reachable by fuzz pass selection" + ); + } + } + + #[test] + fn pass_mask_limit_covers_all_default_passes() { + assert_eq!(pass_mask_limit(), 1u32 << default_pass_count()); + assert!(default_pass_count() >= 1); + } + + #[test] + fn finite_iteration_claims_never_overshoot() { + let counter = AtomicU64::new(0); + assert_eq!(claim_iteration(&counter, 2), Some(0)); + assert_eq!(claim_iteration(&counter, 2), Some(1)); + assert_eq!(claim_iteration(&counter, 2), None); + assert_eq!(counter.load(Ordering::Relaxed), 2); + } + + #[test] + fn fixed_iteration_corpus_is_reproducible() { + let first = fuzz_input_for_iteration(17); + let replay = fuzz_input_for_iteration(17); + let next = fuzz_input_for_iteration(18); + assert_eq!(first.contract, replay.contract); + assert_eq!(first.seed, replay.seed); + assert_eq!(first.passes, replay.passes); + assert_ne!(first.seed, next.seed); + } + + #[test] + fn first_hundred_cases_cover_every_contract_and_pass_subset() { + let cohorts: HashSet<_> = (0..100) + .map(fuzz_input_for_iteration) + .map(|input| (input.contract.name(), input.passes)) + .collect(); + let expected: HashSet<_> = Contract::ALL + .iter() + .flat_map(|contract| { + (0..pass_mask_limit()).map(|mask| (contract.name(), passes_from_bits(mask))) + }) + .collect(); + assert_eq!(cohorts, expected); + } + + #[test] + fn malformed_replay_seed_is_rejected_instead_of_becoming_zero() { + let input = FuzzInput { + contract: Contract::Counter, + seed: "not-hex".to_string(), + passes: String::new(), + }; + assert!(input.seed_bytes().is_err()); + } + + #[test] + fn crash_id_binds_seed_deployment_mode_and_failure_details() { + let input = FuzzInput::new(Contract::Counter, [1u8; 32], String::new()); + let changed_seed = FuzzInput::new(Contract::Counter, [2u8; 32], String::new()); + let failure = FuzzFailure { + kind: ErrorKind::Validation, + message: "first failure".to_string(), + trace: Vec::new(), + obfuscated_bytecode: None, + logs: Vec::new(), + }; + let changed_failure = FuzzFailure { + kind: ErrorKind::Obfuscation, + message: "second failure".to_string(), + trace: Vec::new(), + obfuscated_bytecode: None, + logs: Vec::new(), + }; + + let baseline = crash_hash(&input, &failure, false); + assert_eq!(baseline.len(), 64); + assert_eq!( + baseline, + "ae10a34b737fc531872b91fe045801f1cdedc93c8398c814e645eed1905d95c0" + ); + assert_ne!(baseline, crash_hash(&changed_seed, &failure, false)); + assert_ne!(baseline, crash_hash(&input, &failure, true)); + assert_ne!(baseline, crash_hash(&input, &changed_failure, false)); + + let mut changed_output = FuzzFailure { + kind: ErrorKind::Validation, + message: "first failure".to_string(), + trace: Vec::new(), + obfuscated_bytecode: Some("0x00".to_string()), + logs: Vec::new(), + }; + assert_ne!(baseline, crash_hash(&input, &changed_output, false)); + changed_output.obfuscated_bytecode = Some("0x01".to_string()); + assert_ne!( + crash_hash( + &input, + &FuzzFailure { + kind: ErrorKind::Validation, + message: "first failure".to_string(), + trace: Vec::new(), + obfuscated_bytecode: Some("0x00".to_string()), + logs: Vec::new(), + }, + false + ), + crash_hash(&input, &changed_output, false) + ); + } + } +} + +pub mod obfuscate { + //! Module for the `obfuscate` subcommand, which applies obfuscation transforms to EVM + //! bytecode. + //! + //! This module processes input bytecode and uses the unified obfuscation pipeline + //! from `azoth-transform` to apply transforms and output obfuscated bytecode. + + use crate::commands::{ObfuscateError, DEFAULT_PASSES}; + use async_trait::async_trait; + use azoth_core::seed::Seed; + use azoth_transform::obfuscator::{ + create_gas_report, obfuscate_bytecode, print_obfuscation_analysis, ObfuscationConfig, + }; + use azoth_transform::Transform; + use clap::Args; + use std::error::Error; + use std::fs::{self, File}; + use std::io::{self, Read, Write}; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use std::path::Path; + + /// Arguments for the `obfuscate` subcommand. + #[derive(Args)] + pub struct ObfuscateArgs { + /// Input deployment bytecode as a hex string, .hex file, or binary file containing EVM bytecode. + #[arg(short = 'D', long = "deployment")] + pub deployment_bytecode: String, + /// Input runtime bytecode as a hex string, .hex file, or binary file containing EVM bytecode. + #[arg(short = 'R', long = "runtime")] + pub runtime_bytecode: String, + /// ABI-encoded constructor argument suffix to append before obfuscation. + /// May be omitted when the deployment input already contains the suffix. + #[arg(long, value_name = "HEX")] + constructor_args: Option, + /// Cryptographic seed for deterministic obfuscation. This value is visible in process argv; + /// prefer --seed-stdin when the local execution environment is not fully trusted. + #[arg( + long, + conflicts_with = "seed_stdin", + required_unless_present = "seed_stdin" + )] + seed: Option, + /// Read the cryptographic seed from standard input instead of exposing it in process argv. + #[arg(long, conflicts_with = "seed")] + seed_stdin: bool, + /// Comma-separated list of transforms to apply. + /// The production CLI accepts only relationship-safe passes. + #[arg(long, default_value = DEFAULT_PASSES)] + passes: String, + /// Path to emit gas/size report as JSON (optional). + #[arg(long)] + emit: Option, + /// Path to emit a detailed CFG trace debug report as JSON. + #[arg(long, value_name = "PATH")] + emit_debug: Option, + /// Path to emit the private selector/interaction manifest as JSON. + /// Treat this file like the seed: do not publish it or store it on-chain. + #[arg(long, value_name = "PATH")] + emit_manifest: Option, + /// Launch TUI to view the debug trace after obfuscation. + #[arg(long)] + tui: bool, + } + + /// Executes the `obfuscate` subcommand using the unified obfuscation pipeline. + #[async_trait] + impl super::Command for ObfuscateArgs { + async fn execute(self) -> Result<(), Box> { + let ObfuscateArgs { + deployment_bytecode, + runtime_bytecode, + constructor_args, + seed, + seed_stdin, + passes, + emit, + emit_debug, + emit_manifest, + tui, + } = self; + + // Step 1: Read and normalize input + let mut input_bytecode = read_input(&deployment_bytecode)?; + let runtime_bytecode_hex = read_input(&runtime_bytecode)?; + if let Some(constructor_args) = constructor_args { + let deployment = normalise_hex(&input_bytecode)?; + let args = normalise_hex(&constructor_args)?; + input_bytecode = format!("0x{deployment}{args}"); + } + + // Step 2: Build transforms from CLI args + let transforms = build_passes(&passes)?; + + // Step 3: Configure obfuscation + // The seed is private protocol input, not hidden process state. Requiring it makes every + // CLI result replayable and prevents an internally generated seed from being lost. + let seed_hex = if seed_stdin { + let mut value = String::new(); + io::stdin().read_to_string(&mut value)?; + value.trim().to_string() + } else { + seed.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "either --seed or --seed-stdin is required", + ) + })? + }; + let seed = Seed::from_hex(&seed_hex).map_err(|e| format!("Invalid seed hex: {e}"))?; + let mut config = ObfuscationConfig::with_seed(seed); + + config.transforms = transforms; + config.preserve_unknown_opcodes = true; + + // Step 4: Run obfuscation pipeline + let result = + match obfuscate_bytecode(&input_bytecode, &runtime_bytecode_hex, config).await { + Ok(result) => result, + Err(e) => return Err(format!("{e}").into()), + }; + + // Step 5: Print analysis and results + print_obfuscation_analysis(&result); + + // Step 6: Check size limits + // Step 7: Write report if requested + if let Some(path) = emit.as_ref() { + let report = create_gas_report(&result); + fs::write(path, serde_json::to_string_pretty(&report)?)?; + println!("📊 Wrote gas/size report to {}", path); + } + + if let Some(path) = emit_debug.as_ref() { + let debug_payload = serde_json::to_string_pretty(&serde_json::json!({ + "metadata": &result.metadata, + "trace": &result.trace, + }))?; + // Experimental selector mappings can appear in CFG snapshots used by the TUI. Treat + // a debug trace as private interaction material too: create it atomically with + // owner-only permissions and never overwrite an existing file. + write_private_manifest(Path::new(path), debug_payload.as_bytes())?; + println!("Wrote CFG trace debug report to {}", path); + } + + if let Some(path) = emit_manifest.as_ref() { + let manifest = result.private_interaction_manifest(); + let payload = serde_json::to_vec_pretty(&manifest)?; + write_private_manifest(Path::new(path), &payload)?; + println!("Wrote private interaction manifest to {}", path); + } + + // Step 8: Output final bytecode + println!("{}", result.obfuscated_bytecode); + + // Step 9: Launch TUI if requested + if tui { + let debug = azoth_tui::DebugOutput { + metadata: azoth_tui::DebugMetadata { + transforms_applied: result.metadata.transforms_applied.clone(), + size_limit_exceeded: result.metadata.size_limit_exceeded, + unknown_opcodes_preserved: result.metadata.unknown_opcodes_preserved, + }, + trace: result.trace, + }; + azoth_tui::run(debug, Some(runtime_bytecode.clone()))?; + } + + Ok(()) + } + } + + /// Atomically publishes a private manifest and refuses to overwrite an existing path. + /// + /// The complete payload is written and synced to an owner-only temporary file in the target + /// directory before the final filename becomes visible. `persist_noclobber` provides the + /// create-new property without exposing a truncated destination after a crash. Selector mappings + /// are equivalent to seed-authorized interaction material. + fn write_private_manifest(path: &Path, payload: &[u8]) -> io::Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let mut output = tempfile::Builder::new() + .prefix(".azoth-private-") + .tempfile_in(parent)?; + + #[cfg(unix)] + fs::set_permissions(output.path(), std::fs::Permissions::from_mode(0o600))?; + + output.write_all(payload)?; + output.as_file().sync_all()?; + let persisted = output + .persist_noclobber(path) + .map_err(|error| error.error)?; + persisted.sync_all()?; + + // The file sync makes payload bytes durable; syncing the directory makes publication of the + // final name durable on Unix filesystems that implement directory fsync. + #[cfg(unix)] + File::open(parent)?.sync_all()?; + + Ok(()) + } + + /// Reads input from hex string, .hex file, or binary file + pub(crate) fn read_input(input: &str) -> Result> { + if input.trim_start().starts_with("0x") { + // Direct hex string input + Ok(input.to_string()) + } else if Path::new(input).extension().and_then(|s| s.to_str()) == Some("hex") { + // .hex file + let content = fs::read_to_string(input)?; + let normalized = normalise_hex(&content)?; + Ok(format!("0x{normalized}")) + } else { + // Binary file + let bytes = fs::read(input)?; + Ok(format!("0x{}", hex::encode(bytes))) + } + } + + /// Normalizes a hex string by removing prefixes and underscores. + pub(crate) fn normalise_hex(s: &str) -> Result { + let stripped = s.trim().trim_start_matches("0x").replace('_', ""); + if !stripped.len().is_multiple_of(2) { + return Err(ObfuscateError::OddLength(stripped.len())); + } + Ok(stripped) + } + + /// Builds a list of transform passes from a comma-separated string. + pub(crate) fn build_passes(list: &str) -> Result>, Box> { + list.split(',') + .filter(|s| !s.is_empty()) + .map(|name| match name.trim() { + "cluster_shuffle" => Ok(Box::new( + azoth_transform::cluster_shuffle::ClusterShuffle::new(), + ) as Box), + "shuffle" | "opaque_pred" | "opaque_predicate" | "jump_transform" | "jump_addr" + | "arithmetic_chain" | "push_split" | "storage_gates" | "slot_shuffle" + | "string_obfuscate" | "string_obf" | "splice" => { + Err(ObfuscateError::UnsafePass(name.trim().to_string()).into()) + } + _ => Err(ObfuscateError::InvalidPass(name.to_string()).into()), + }) + .collect() + } + + #[cfg(test)] + mod tests { + use super::{build_passes, write_private_manifest, ObfuscateArgs}; + use clap::Args as _; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + #[test] + fn production_profile_accepts_only_relationship_safe_passes() { + assert_eq!(build_passes("cluster_shuffle").unwrap().len(), 1); + for legacy in [ + "shuffle", + "opaque_predicate", + "jump_addr", + "arithmetic_chain", + "push_split", + "storage_gates", + "slot_shuffle", + "string_obfuscate", + "splice", + ] { + let error = build_passes(legacy).err().expect("legacy pass must fail"); + assert!( + error + .to_string() + .contains("disabled in the production profile"), + "unexpected error for {legacy}: {error}" + ); + } + } + + #[test] + fn exactly_one_seed_input_mechanism_is_required() { + let command = || ObfuscateArgs::augment_args(clap::Command::new("obfuscate")); + let base = ["obfuscate", "--deployment", "0x00", "--runtime", "0x00"]; + + assert!(command().try_get_matches_from(base).is_err()); + assert!(command() + .try_get_matches_from(base.into_iter().chain([ + "--seed", + "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ])) + .is_ok()); + assert!(command() + .try_get_matches_from(base.into_iter().chain(["--seed-stdin"])) + .is_ok()); + assert!(command() + .try_get_matches_from(base.into_iter().chain([ + "--seed-stdin", + "--seed", + "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ])) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn private_manifest_is_created_once_with_owner_only_permissions() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("interaction.json"); + + write_private_manifest(&path, b"secret mapping").unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(std::fs::read(&path).unwrap(), b"secret mapping"); + + let error = write_private_manifest(&path, b"replacement").unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&path).unwrap(), b"secret mapping"); + } + } +} + +pub mod strip { + //! This module processes input bytecode, removes non-runtime sections (e.g., init code, + //! auxdata), and outputs either the cleaned runtime bytecode as a hex string or a JSON report + //! detailing the stripping process. + + use async_trait::async_trait; + use azoth_core::decoder::decode_input; + use azoth_core::detection::locate_sections; + use azoth_core::input_to_bytes; + use azoth_core::strip::strip_bytecode; + use clap::Args; + use serde_json; + use std::error::Error; + use std::path::Path; + + /// Arguments for the `strip` subcommand. + #[derive(Args)] + pub struct StripArgs { + /// Input deployment bytecode as a hex string (0x...) or file path containing EVM bytecode. + #[arg(short = 'D', long = "deployment")] + pub deployment_bytecode: String, + /// Input runtime bytecode as a hex string (0x...) or file path containing EVM bytecode. + #[arg(short = 'R', long = "runtime")] + pub runtime_bytecode: String, + /// Output raw cleaned runtime hex instead of JSON report + #[arg(long)] + raw: bool, + } + + /// Executes the `strip` subcommand to extract runtime bytecode. + #[async_trait] + impl super::Command for StripArgs { + async fn execute(self) -> Result<(), Box> { + let is_file = !self.deployment_bytecode.starts_with("0x") + && Path::new(&self.deployment_bytecode).is_file(); + let runtime_is_file = !self.runtime_bytecode.starts_with("0x") + && Path::new(&self.runtime_bytecode).is_file(); + let decoded = decode_input(&self.deployment_bytecode, is_file)?; + let instructions = decoded.instructions; + let bytes = decoded.bytes; + let runtime_bytes = input_to_bytes(&self.runtime_bytecode, runtime_is_file)?; + let sections = locate_sections(&bytes, &instructions, &runtime_bytes)?; + let (clean_runtime, report) = strip_bytecode(&bytes, §ions)?; + + if self.raw { + println!("0x{}", hex::encode(&clean_runtime)); + } else { + let json = serde_json::to_string_pretty(&report)?; + println!("{json}"); + } + Ok(()) + } + } +} + +pub mod tui { + //! TUI subcommand for viewing debug traces. + + use std::path::PathBuf; + + use async_trait::async_trait; + use clap::Args; + + use super::Command; + + /// View obfuscation debug traces in a TUI. + #[derive(Args)] + pub struct TuiArgs { + /// Path to the debug JSON file. + #[arg(default_value = "debug.json")] + pub file: PathBuf, + } + + #[async_trait] + impl Command for TuiArgs { + async fn execute(self) -> Result<(), Box> { + let filename = self.file.display().to_string(); + let debug = azoth_tui::load_debug_file(&self.file)?; + azoth_tui::run(debug, Some(filename)) + } + } +} diff --git a/crates/cli/src/commands/analyze.rs b/crates/cli/src/commands/analyze.rs deleted file mode 100644 index 82ff8e03..00000000 --- a/crates/cli/src/commands/analyze.rs +++ /dev/null @@ -1,123 +0,0 @@ -use crate::commands::{obfuscate::read_input, ObfuscateError}; -use async_trait::async_trait; -use azoth_analysis::obfuscation::{analyze_obfuscation, AnalysisConfig, AnalysisError}; -use clap::Args; -use std::{error::Error, path::PathBuf}; -const DEFAULT_DEPLOYMENT_PATH: &str = "examples/escrow-bytecode/artifacts/erc20_deployment.hex"; -const DEFAULT_RUNTIME_PATH: &str = "examples/escrow-bytecode/artifacts/erc20_runtime.hex"; - -/// Analyze how much bytecode survives obfuscation across multiple seeds. -#[derive(Args)] -pub struct AnalyzeArgs { - /// Number of obfuscated samples to generate. - pub iterations: usize, - /// Input deployment bytecode as hex, .hex file, or binary file. - #[arg(short = 'D', long = "deployment", value_name = "BYTECODE", default_value = DEFAULT_DEPLOYMENT_PATH)] - pub deployment_bytecode: String, - /// Input runtime bytecode as hex, .hex file, or binary file. - #[arg(short = 'R', long = "runtime", value_name = "RUNTIME", default_value = DEFAULT_RUNTIME_PATH)] - pub runtime_bytecode: String, - /// Where to write the markdown report (default: ./obfuscation_analysis_report.md). - #[arg(long, value_name = "PATH")] - output: Option, - /// Maximum attempts per iteration when an obfuscation fails. - #[arg(long, default_value_t = 5)] - max_attempts: usize, -} - -#[async_trait] -impl super::Command for AnalyzeArgs { - async fn execute(self) -> Result<(), Box> { - let AnalyzeArgs { - iterations, - deployment_bytecode, - runtime_bytecode, - output, - max_attempts, - } = self; - - let input_hex = read_input(&deployment_bytecode)?; - let runtime_hex = read_input(&runtime_bytecode)?; - - let mut config = AnalysisConfig::new(&input_hex, &runtime_hex, iterations); - config.max_attempts = max_attempts; - if let Some(path) = output { - config.report_path = path; - } - - let report = match analyze_obfuscation(config).await { - Ok(report) => report, - Err(AnalysisError::UnknownOpcodes { count }) => { - println!("Analysis aborted: obfuscation preserved {count} unknown opcode(s).\nStrip or normalize the bytecode before running analysis."); - return Ok(()); - } - Err(err) => return Err(map_analysis_error(err)), - }; - - println!("============================================================"); - println!("SUMMARY"); - println!("============================================================"); - println!( - "Average longest sequence: {:.2} bytes ({:.2}% of original)", - report.summary.average_length, report.summary.preservation_ratio - ); - println!( - "Median longest sequence: {:.2} bytes", - report.summary.median_length - ); - println!( - "Standard deviation: {:.2} bytes", - report.summary.std_dev - ); - println!( - "Range: {}-{} bytes", - report.summary.min_length, report.summary.max_length - ); - println!( - "25th percentile: {:.2} bytes", - report.summary.percentile_25 - ); - println!( - "75th percentile: {:.2} bytes", - report.summary.percentile_75 - ); - println!( - "95th percentile: {:.2} bytes", - report.summary.percentile_95 - ); - println!( - "Seeds generated: {} (unique: {})", - report.seeds.len(), - report.unique_seed_count - ); - println!("Transforms observed: {}", report.transform_summary()); - println!(); - for (n, value) in &report.ngram_diversity { - println!("{:>2}-byte n-gram diversity: {:>6.2}%", n, value); - } - println!("============================================================"); - println!( - "Analysis complete! Report saved to: {}", - report.markdown_path.display() - ); - - Ok(()) - } -} - -fn map_analysis_error(err: AnalysisError) -> Box { - match err { - AnalysisError::Decode(err) => Box::new(err), - AnalysisError::UnknownOpcodes { count } => Box::new(std::io::Error::other(format!( - "analysis aborted due to {count} unknown opcode(s)" - ))), - AnalysisError::InvalidPass(name) => Box::new(ObfuscateError::InvalidPass(name)), - AnalysisError::ObfuscationFailure { source, .. } => source, - AnalysisError::Io(err) => Box::new(err), - AnalysisError::Fmt(err) => Box::new(err), - AnalysisError::EmptyIterations => Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "iterations must be positive", - )), - } -} diff --git a/crates/cli/src/commands/cfg.rs b/crates/cli/src/commands/cfg.rs deleted file mode 100644 index 1c065ae2..00000000 --- a/crates/cli/src/commands/cfg.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! This module processes input bytecode, constructs a CFG using the `cfg_ir` module, and -//! generates a Graphviz .dot file representing the CFG. The output can be written to a file or -//! printed to stdout. - -use async_trait::async_trait; -use azoth_core::cfg_ir::{build_cfg_ir, Block, CfgIrBundle, EdgeType}; -use azoth_core::decoder::decode_bytecode; -use azoth_core::detection::locate_sections; -use azoth_core::input_to_bytes; -use azoth_core::strip::strip_bytecode; -use clap::Args; -use std::error::Error; -use std::fs; -use std::path::Path; - -/// Arguments for the `cfg` subcommand. -#[derive(Args)] -pub struct CfgArgs { - /// Input deployment bytecode as a hex string (0x...) or file path containing EVM bytecode. - #[arg(short = 'D', long = "deployment")] - pub deployment_bytecode: String, - /// Input runtime bytecode as a hex string (0x...) or file path containing EVM bytecode. - #[arg(short = 'R', long = "runtime")] - pub runtime_bytecode: String, - /// Output file for Graphviz .dot (default: stdout) - #[arg(short, long)] - output: Option, -} - -/// Executes the `cfg` subcommand to generate a CFG visualization. -#[async_trait] -impl super::Command for CfgArgs { - async fn execute(self) -> Result<(), Box> { - let is_file = !self.deployment_bytecode.starts_with("0x") - && Path::new(&self.deployment_bytecode).is_file(); - let runtime_is_file = - !self.runtime_bytecode.starts_with("0x") && Path::new(&self.runtime_bytecode).is_file(); - let (instructions, _, _, bytes) = - decode_bytecode(&self.deployment_bytecode, is_file).await?; - let runtime_bytes = input_to_bytes(&self.runtime_bytecode, runtime_is_file)?; - let sections = locate_sections(&bytes, &instructions, &runtime_bytes)?; - let (_clean_runtime, clean_report) = strip_bytecode(&bytes, §ions)?; - let cfg_ir = build_cfg_ir(&instructions, §ions, clean_report, &bytes)?; - - let dot = generate_dot(&cfg_ir); - if let Some(out_path) = self.output { - fs::write(out_path, &dot)?; - } else { - println!("{dot}"); - } - Ok(()) - } -} - -/// Generates a Graphviz .dot representation of the CFG. -/// -/// # Arguments -/// * `cfg_ir` - The `CfgIrBundle` containing the CFG to visualize. -/// -/// # Returns -/// A `String` containing the .dot file content. -fn generate_dot(cfg_ir: &CfgIrBundle) -> String { - let mut dot = String::from("digraph CFG {\n"); - - // Add nodes - for node in cfg_ir.cfg.node_indices() { - let block = cfg_ir.cfg.node_weight(node).unwrap(); - let label = match block { - Block::Entry => "Entry".to_string(), - Block::Exit => "Exit".to_string(), - Block::Body(body) => { - let instrs: Vec = body.instructions.iter().map(|i| i.to_string()).collect(); - format!("Block_{}\\n{}", body.start_pc, instrs.join("\\n")) - } - }; - dot.push_str(&format!(" {} [label=\"{}\"];\n", node.index(), label)); - } - - // Add edges - for edge in cfg_ir.cfg.edge_indices() { - let (src, dst) = cfg_ir.cfg.edge_endpoints(edge).unwrap(); - let edge_type = cfg_ir.cfg.edge_weight(edge).unwrap(); - let label = match edge_type { - EdgeType::Fallthrough => "Fallthrough", - EdgeType::Jump => "Jump", - EdgeType::BranchTrue => "BranchTrue", - EdgeType::BranchFalse => "BranchFalse", - }; - dot.push_str(&format!( - " {} -> {} [label=\"{}\"];\n", - src.index(), - dst.index(), - label - )); - } - - dot.push_str("}\n"); - dot -} diff --git a/crates/cli/src/commands/decode.rs b/crates/cli/src/commands/decode.rs deleted file mode 100644 index 679bb622..00000000 --- a/crates/cli/src/commands/decode.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! This module processes input bytecode and outputs both the raw assembly from the Heimdall -//! disassembler and a structured list of instructions with program counters and opcodes. - -use async_trait::async_trait; -use azoth_core::decoder::decode_bytecode; -use clap::Args; -use std::error::Error; -use std::path::Path; - -/// Arguments for the `decode` subcommand. -#[derive(Args)] -pub struct DecodeArgs { - /// Input bytecode as a hex string (0x...) or file path containing EVM bytecode. - #[arg(short = 'D', long = "deployment")] - pub deployment_bytecode: String, -} - -/// Executes the `decode` subcommand to decode bytecode. -#[async_trait] -impl super::Command for DecodeArgs { - async fn execute(self) -> Result<(), Box> { - let is_file = !self.deployment_bytecode.starts_with("0x") - && Path::new(&self.deployment_bytecode).is_file(); - let (instructions, _, asm, _) = decode_bytecode(&self.deployment_bytecode, is_file).await?; - println!("{asm}"); - for instruction in instructions { - println!("{instruction}"); - } - Ok(()) - } -} diff --git a/crates/cli/src/commands/decompile_diff.rs b/crates/cli/src/commands/decompile_diff.rs deleted file mode 100644 index 7630e5a3..00000000 --- a/crates/cli/src/commands/decompile_diff.rs +++ /dev/null @@ -1,371 +0,0 @@ -//! Decompile diff command for comparing decompiled bytecode before and after obfuscation. -//! -//! This module provides a CLI interface to the decompile diff analysis functionality, -//! which runs obfuscation on input bytecode, then uses Heimdall's decompiler to generate -//! human-readable Solidity-like output and computes structured diffs between the original -//! and obfuscated versions. -//! -//! The structured diff uses the selector mapping from obfuscation to pair functions, -//! enabling semantic comparison even when selectors are remapped. Supports running multiple -//! iterations with different seeds to generate statistical analysis. - -use async_trait::async_trait; -use azoth_analysis::decompile_diff::{self, DiffStats, StructureKind, StructuredDiffResult}; -use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; -use clap::Args; -use owo_colors::OwoColorize; -use std::collections::HashMap; -use std::error::Error; -use std::fs; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::task::JoinSet; - -use crate::commands::DEFAULT_PASSES; - -use super::obfuscate::{build_passes, read_input}; - -/// Arguments for the `decompile-diff` subcommand. -/// -/// This command obfuscates input bytecode and compares decompiled output of the -/// original vs obfuscated versions using structured diff that pairs functions -/// by their selector mapping. -#[derive(Args)] -pub struct DecompileDiffArgs { - /// Input deployment bytecode as a hex string (0x...), .hex file, or binary file. - #[arg(short = 'D', long = "deployment")] - pub deployment_bytecode: String, - - /// Input runtime bytecode as a hex string (0x...), .hex file, or binary file. - #[arg(short = 'R', long = "runtime")] - pub runtime_bytecode: String, - - /// Comma-separated list of transforms (default: shuffle). - #[arg(long, default_value = DEFAULT_PASSES)] - pub passes: String, - - /// Number of iterations to run with different seeds for statistical analysis. - #[arg(long, short = 'n', default_value = "10")] - pub iterations: usize, - - /// Output file path for writing the diff from the first iteration. - #[arg(long, short = 'o')] - pub output: Option, - - /// Only show items that have changes. - #[arg(long)] - pub changed_only: bool, -} - -/// Aggregated statistics across multiple structured diff runs. -#[derive(Debug, Clone)] -struct AggregatedStructuredStats { - min: DiffStats, - max: DiffStats, - sum: DiffStats, - sample_count: usize, -} - -impl AggregatedStructuredStats { - fn new(first: &DiffStats) -> Self { - Self { - min: first.clone(), - max: first.clone(), - sum: first.clone(), - sample_count: 1, - } - } - - fn add(&mut self, stats: &DiffStats) { - self.min.hunk_count = self.min.hunk_count.min(stats.hunk_count); - self.min.lines_removed = self.min.lines_removed.min(stats.lines_removed); - self.min.lines_added = self.min.lines_added.min(stats.lines_added); - self.min.lines_unchanged = self.min.lines_unchanged.min(stats.lines_unchanged); - - self.max.hunk_count = self.max.hunk_count.max(stats.hunk_count); - self.max.lines_removed = self.max.lines_removed.max(stats.lines_removed); - self.max.lines_added = self.max.lines_added.max(stats.lines_added); - self.max.lines_unchanged = self.max.lines_unchanged.max(stats.lines_unchanged); - - self.sum.hunk_count += stats.hunk_count; - self.sum.lines_removed += stats.lines_removed; - self.sum.lines_added += stats.lines_added; - self.sum.lines_unchanged += stats.lines_unchanged; - - self.sample_count += 1; - } - - fn avg_hunks(&self) -> f64 { - self.sum.hunk_count as f64 / self.sample_count as f64 - } - - fn avg_removed(&self) -> f64 { - self.sum.lines_removed as f64 / self.sample_count as f64 - } - - fn avg_added(&self) -> f64 { - self.sum.lines_added as f64 / self.sample_count as f64 - } - - fn avg_unchanged(&self) -> f64 { - self.sum.lines_unchanged as f64 / self.sample_count as f64 - } -} - -/// Executes the `decompile-diff` subcommand. -#[async_trait] -impl super::Command for DecompileDiffArgs { - async fn execute(self) -> Result<(), Box> { - let input_bytecode = read_input(&self.deployment_bytecode)?; - let runtime_bytecode = read_input(&self.runtime_bytecode)?; - let pre_bytes = hex::decode(runtime_bytecode.trim_start_matches("0x"))?; - - // Run iterations in parallel with bounded concurrency - let max_concurrency = std::thread::available_parallelism() - .map(|p| p.get()) - .unwrap_or(4); - let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrency)); - - let input_bytecode = Arc::new(input_bytecode); - let runtime_bytecode = Arc::new(runtime_bytecode); - let pre_bytes = Arc::new(pre_bytes); - let passes = Arc::new(self.passes.clone()); - - let mut join_set: JoinSet> = JoinSet::new(); - - for _ in 0..self.iterations { - let input_bytecode = Arc::clone(&input_bytecode); - let runtime_bytecode = Arc::clone(&runtime_bytecode); - let pre_bytes = Arc::clone(&pre_bytes); - let passes = Arc::clone(&passes); - let semaphore = Arc::clone(&semaphore); - - join_set.spawn(async move { - let _permit = semaphore.acquire().await.unwrap(); - - let transforms = build_passes(&passes).map_err(|e| format!("build_passes: {e}"))?; - - // Each iteration uses a fresh random seed - let config = ObfuscationConfig { - transforms, - preserve_unknown_opcodes: true, - ..Default::default() - }; - - let obf_result = obfuscate_bytecode(&input_bytecode, &runtime_bytecode, config) - .await - .map_err(|e| format!("obfuscate: {e}"))?; - - let post_bytes = - hex::decode(obf_result.obfuscated_runtime.trim_start_matches("0x")) - .map_err(|e| format!("hex decode: {e}"))?; - - let selector_mapping: HashMap> = - obf_result.selector_mapping.unwrap_or_default(); - - let diff_result = decompile_diff::compare_structured( - pre_bytes.as_ref().clone().into(), - post_bytes.into(), - selector_mapping, - ) - .await - .map_err(|e| format!("decompile: {e}"))?; - - Ok(diff_result) - }); - } - - // Collect results - let mut results = Vec::with_capacity(self.iterations); - while let Some(result) = join_set.join_next().await { - results.push(result.map_err(|e| format!("join error: {e}"))?); - } - - // Aggregate statistics - let mut aggregated: Option = None; - let mut first_result: Option = None; - - for (i, result) in results.into_iter().enumerate() { - let diff_result = result?; - let stats = diff_result.aggregate_stats(); - - if i == 0 { - first_result = Some(diff_result); - } - - match &mut aggregated { - None => aggregated = Some(AggregatedStructuredStats::new(&stats)), - Some(agg) => agg.add(&stats), - } - } - - let aggregated = aggregated.expect("at least one iteration"); - let first_result = first_result.expect("at least one iteration"); - - // Write diff to file if requested, otherwise print to terminal - if let Some(output_path) = &self.output { - let output = self.format_diff_output(&first_result); - fs::write(output_path, output)?; - } else { - self.print_structured_diff(&first_result); - } - - // Always print summary and statistics - self.print_statistics(&aggregated, &first_result); - - Ok(()) - } -} - -impl DecompileDiffArgs { - /// Formats the diff only (no stats) as plain text for file output. - fn format_diff_output(&self, result: &StructuredDiffResult) -> String { - let mut output = String::new(); - - for item in &result.items { - if self.changed_only && !item.has_changes() { - continue; - } - - output.push_str(&format!("─── {} ───\n", item.kind)); - - if item.has_changes() { - output.push_str(&item.diff.unified_diff); - } else { - output.push_str("(no changes)\n"); - } - output.push('\n'); - } - - output - } - - /// Prints the structured diff to stdout with colors (no summary, just diffs). - fn print_structured_diff(&self, result: &StructuredDiffResult) { - // Each item - for item in &result.items { - if self.changed_only && !item.has_changes() { - continue; - } - - // Section header - let header = match &item.kind { - StructureKind::Header => "Header".to_string(), - StructureKind::Storage => "Storage".to_string(), - StructureKind::Function { - original_selector, - obfuscated_selector, - name, - } => { - format!( - "Function {} ({} → {})", - name.bold(), - format!("0x{:08x}", original_selector).dimmed(), - format!("0x{:08x}", obfuscated_selector).cyan() - ) - } - StructureKind::UnmatchedOriginal { selector, name } => { - format!( - "{} {} ({})", - "Removed:".red(), - name, - format!("0x{:08x}", selector).dimmed() - ) - } - StructureKind::UnmatchedObfuscated { selector, name } => { - format!( - "{} {} ({})", - "Added:".green(), - name, - format!("0x{:08x}", selector).cyan() - ) - } - }; - - println!("─── {} ───", header); - - if item.has_changes() { - let stats = &item.diff.stats; - println!( - " {} hunks, {} {}, {} {}", - stats.hunk_count, - format!("-{}", stats.lines_removed).red(), - "removed".dimmed(), - format!("+{}", stats.lines_added).green(), - "added".dimmed() - ); - println!(); - print!("{}", item.diff.colored_diff); - } else { - println!(" {}", "(no changes)".dimmed()); - } - println!(); - } - } - - /// Prints summary and aggregated statistics to stdout as valid markdown. - fn print_statistics(&self, stats: &AggregatedStructuredStats, result: &StructuredDiffResult) { - println!( - "## Statistics ({} iteration{})\n", - stats.sample_count, - if stats.sample_count == 1 { "" } else { "s" } - ); - - // Summary from first result - let diff_stats = result.aggregate_stats(); - let changed_count = result.items.iter().filter(|i| i.has_changes()).count(); - - println!( - "- **Total:** {} hunks, -{} removed, +{} added", - diff_stats.hunk_count, diff_stats.lines_removed, diff_stats.lines_added - ); - println!( - "- **Items:** {} total, {} with changes", - result.items.len(), - changed_count - ); - - if !result.selector_mapping.is_empty() { - println!( - "- **Selectors:** {} remapped", - result.selector_mapping.len() - ); - } - println!(); - - // Markdown table with padding for readability - println!( - "| {:<15} | {:>10} | {:>10} | {:>10} |", - "Metric", "Min", "Avg", "Max" - ); - println!("|-{:-<15}-|-{:->10}:|-{:->10}:|-{:->10}:|", "", "", "", ""); - println!( - "| {:<15} | {:>10} | {:>10.1} | {:>10} |", - "Hunks", - stats.min.hunk_count, - stats.avg_hunks(), - stats.max.hunk_count - ); - println!( - "| {:<15} | {:>10} | {:>10.1} | {:>10} |", - "Lines removed", - stats.min.lines_removed, - stats.avg_removed(), - stats.max.lines_removed - ); - println!( - "| {:<15} | {:>10} | {:>10.1} | {:>10} |", - "Lines added", - stats.min.lines_added, - stats.avg_added(), - stats.max.lines_added - ); - println!( - "| {:<15} | {:>10} | {:>10.1} | {:>10} |", - "Lines unchanged", - stats.min.lines_unchanged, - stats.avg_unchanged(), - stats.max.lines_unchanged - ); - } -} diff --git a/crates/cli/src/commands/fuzz.rs b/crates/cli/src/commands/fuzz.rs deleted file mode 100644 index 37ac6e4d..00000000 --- a/crates/cli/src/commands/fuzz.rs +++ /dev/null @@ -1,911 +0,0 @@ -//! Fuzz testing subcommand for the Azoth CLI. -//! -//! Runs parallel fuzz testing against the obfuscation pipeline, saving -//! reproducible crash inputs with debug traces for TUI visualization. - -use std::collections::HashSet; -use std::error::Error; -use std::fmt; -use std::fs; -use std::io::Write; -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Instant; - -use async_trait::async_trait; -use azoth_core::cfg_ir::TraceEvent; -use azoth_core::seed::Seed; -use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; -use clap::{Args, Subcommand}; -use parking_lot::Mutex; -use rand::rngs::SmallRng; -use rand::{RngCore, SeedableRng}; -use revm::bytecode::Bytecode; -use revm::context::result::{ExecutionResult, Output}; -use revm::context::TxEnv; -use revm::database::InMemoryDB; -use revm::primitives::{Address, Bytes, TxKind, U256}; -use revm::state::AccountInfo; -use revm::{Context, ExecuteEvm, MainBuilder, MainContext}; -use serde::{Deserialize, Serialize}; -use sha3::{Digest, Sha3_256}; -use tracing_subscriber::fmt::MakeWriter; -use tracing_subscriber::layer::SubscriberExt; - -use super::obfuscate::build_passes; -use crate::commands::DEFAULT_PASSES; - -fn num_cpus() -> usize { - std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(4) -} - -/// A writer that captures log output to a buffer. -/// Uses Arc because tracing's `with_default` requires Send + Sync. -/// parking_lot::Mutex is just a single atomic op for uncontended locks. -#[derive(Clone)] -struct LogCapture { - buffer: Arc>>, -} - -impl LogCapture { - fn new() -> Self { - Self { - buffer: Arc::new(Mutex::new(Vec::new())), - } - } - - fn clear(&self) { - self.buffer.lock().clear(); - } - - fn extract_lines(&self) -> Vec { - String::from_utf8_lossy(&self.buffer.lock()) - .lines() - .map(|s| s.to_string()) - .collect() - } -} - -impl Write for LogCapture { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.buffer.lock().extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -impl<'a> MakeWriter<'a> for LogCapture { - type Writer = LogCapture; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } -} - -// Contract bytecodes -const ESCROW_DEPLOYMENT: &str = - include_str!("../../../../examples/escrow-bytecode/artifacts/erc20_deployment.hex"); -const ESCROW_RUNTIME: &str = - include_str!("../../../../examples/escrow-bytecode/artifacts/erc20_runtime.hex"); -const COUNTER_DEPLOYMENT: &str = - include_str!("../../../../tests/bytecode/counter/counter_deployment.hex"); -const COUNTER_RUNTIME: &str = - include_str!("../../../../tests/bytecode/counter/counter_runtime.hex"); - -/// Fuzz testing for the obfuscation pipeline. -#[derive(Args)] -pub struct FuzzArgs { - #[command(subcommand)] - command: Option, - - /// Number of parallel fuzzing tasks (defaults to number of CPU cores) - #[arg(short = 'j', long, default_value_t = num_cpus())] - jobs: usize, - - /// Maximum iterations (0 = infinite) - #[arg(short, long, default_value = "0")] - iterations: u64, - - /// Duration in seconds (0 = infinite) - #[arg(short, long, default_value = "0")] - duration: u64, - - /// Directory to save crash inputs - #[arg(long, default_value = "crashes")] - crash_dir: PathBuf, - - /// Check that obfuscated bytecode deploys successfully - #[arg(long, default_value = "false")] - check_deploy: bool, -} - -#[derive(Subcommand)] -enum FuzzCommand { - /// Replay a saved crash file - Replay { - /// Path to crash JSON file - crash_file: PathBuf, - }, - /// List all saved crashes - List, -} - -/// Contract to fuzz test. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -enum Contract { - Escrow, - Counter, -} - -impl Contract { - const ALL: [Self; 2] = [Self::Escrow, Self::Counter]; - - fn name(self) -> &'static str { - match self { - Self::Escrow => "escrow", - Self::Counter => "counter", - } - } - - fn deployment_hex(self) -> &'static str { - match self { - Self::Escrow => ESCROW_DEPLOYMENT, - Self::Counter => COUNTER_DEPLOYMENT, - } - } - - fn runtime_hex(self) -> &'static str { - match self { - Self::Escrow => ESCROW_RUNTIME, - Self::Counter => COUNTER_RUNTIME, - } - } -} - -/// Number of comma-separated passes in `DEFAULT_PASSES`. -fn default_pass_count() -> u32 { - DEFAULT_PASSES.split(',').count() as u32 -} - -/// Exclusive upper bound on the pass-selection bitmask, covering every default pass. -fn pass_mask_limit() -> u32 { - 1u32 << default_pass_count() -} - -/// Generate a random comma-separated pass string from bits. -fn passes_from_bits(bits: u32) -> String { - DEFAULT_PASSES - .split(",") - .enumerate() - .filter(|(i, _)| bits & (1 << i) != 0) - .map(|(_, name)| name) - .collect::>() - .join(",") -} - -/// Reproducible fuzz input containing all parameters needed to replay a test case. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct FuzzInput { - contract: Contract, - seed: String, - passes: String, -} - -impl FuzzInput { - fn new(contract: Contract, seed_bytes: [u8; 32], passes: String) -> Self { - Self { - contract, - seed: hex::encode(seed_bytes), - passes, - } - } - - fn seed_bytes(&self) -> [u8; 32] { - let mut bytes = [0u8; 32]; - if let Ok(decoded) = hex::decode(&self.seed) { - if decoded.len() == 32 { - bytes.copy_from_slice(&decoded); - } - } - bytes - } -} - -/// Error categories for crash classification. -#[derive(Debug, Clone, Serialize, Deserialize)] -enum ErrorKind { - Obfuscation, - Validation, - ConstructorArgsVisible, - DeploymentMismatch { original: usize, obfuscated: usize }, -} - -impl fmt::Display for ErrorKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Obfuscation => write!(f, "obfuscation failed"), - Self::Validation => write!(f, "validation failed"), - Self::ConstructorArgsVisible => write!(f, "constructor arguments remain visible"), - Self::DeploymentMismatch { - original, - obfuscated, - } => { - write!( - f, - "deployment mismatch (orig={original}b, obf={obfuscated}b)" - ) - } - } - } -} - -/// Crash report saved to disk for reproduction and debugging. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct CrashReport { - id: String, - timestamp: String, - input: FuzzInput, - error_kind: ErrorKind, - message: String, - #[serde(skip_serializing_if = "Option::is_none")] - obfuscated_bytecode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - trace_file: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - logs: Vec, -} - -/// Statistics tracked during fuzzing -struct FuzzStats { - iterations: AtomicU64, - successes: AtomicU64, - errors: AtomicU64, - deployment_mismatches: AtomicU64, - unique_crashes: Mutex>, - start_time: Instant, -} - -impl FuzzStats { - fn new() -> Self { - Self { - iterations: AtomicU64::new(0), - successes: AtomicU64::new(0), - errors: AtomicU64::new(0), - deployment_mismatches: AtomicU64::new(0), - unique_crashes: Mutex::new(HashSet::new()), - start_time: Instant::now(), - } - } - - fn print_summary(&self, check_deploy: bool) { - let elapsed = self.start_time.elapsed().as_secs_f64(); - let iters = self.iterations.load(Ordering::Relaxed); - let rate = if elapsed > 0.0 { - iters as f64 / elapsed - } else { - 0.0 - }; - - println!("\r\x1b[K=== Fuzzing Summary ==="); - println!("Duration: {:.1}s", elapsed); - println!("Iterations: {}", iters); - println!("Rate: {:.1} iter/sec", rate); - println!("Successes: {}", self.successes.load(Ordering::Relaxed)); - println!("Errors: {}", self.errors.load(Ordering::Relaxed)); - if check_deploy { - println!( - "Deployment mismatches: {}", - self.deployment_mismatches.load(Ordering::Relaxed) - ); - } - println!("Unique crashes saved: {}", self.unique_crashes.lock().len()); - } -} - -const MOCK_TOKEN_ADDR: Address = Address::new([0x11; 20]); - -fn prepare_escrow_bytecode(deployment_hex: &str, seed: [u8; 32]) -> Option> { - let normalized = deployment_hex.trim().trim_start_matches("0x"); - let mut bytecode = hex::decode(normalized).ok()?; - let mut rng = SmallRng::from_seed(seed); - let mut recipient = [0u8; 20]; - let mut expected_amount = [0u8; 32]; - let mut payment_amount = [0u8; 32]; - rng.fill_bytes(&mut recipient); - rng.fill_bytes(&mut expected_amount); - rng.fill_bytes(&mut payment_amount); - bytecode.extend_from_slice(&[0; 12]); - bytecode.extend_from_slice(MOCK_TOKEN_ADDR.as_slice()); - bytecode.extend_from_slice(&[0; 12]); - bytecode.extend_from_slice(&recipient); - bytecode.extend_from_slice(&expected_amount); - bytecode.extend_from_slice(&[0; 32]); - bytecode.extend_from_slice(&payment_amount); - Some(bytecode) -} - -fn prepare_counter_bytecode(deployment_hex: &str) -> Option> { - let normalized = deployment_hex.trim().trim_start_matches("0x"); - hex::decode(normalized).ok() -} - -fn prepare_bytecode(contract: Contract, deployment_hex: &str, seed: [u8; 32]) -> Option> { - match contract { - Contract::Escrow => prepare_escrow_bytecode(deployment_hex, seed), - Contract::Counter => prepare_counter_bytecode(deployment_hex), - } -} - -fn deploy_to_revm(bytecode: &[u8], contract: Contract) -> Result { - let mut db = InMemoryDB::default(); - let deployer = Address::from([0x42u8; 20]); - - db.insert_account_info( - deployer, - AccountInfo { - balance: U256::from(1_000_000_000_000_000_000u128), - nonce: 0, - code_hash: revm::primitives::KECCAK_EMPTY, - code: None, - }, - ); - - if contract == Contract::Escrow { - db.insert_account_info( - MOCK_TOKEN_ADDR, - AccountInfo { - balance: U256::ZERO, - nonce: 1, - code_hash: revm::primitives::KECCAK_EMPTY, - code: Some(Bytecode::new_raw(Bytes::from_static(&[ - 0x60, 0x01, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3, - ]))), - }, - ); - } - - let mut evm = Context::mainnet().with_db(db).build_mainnet(); - let tx = TxEnv { - caller: deployer, - gas_limit: 30_000_000, - kind: TxKind::Create, - data: bytecode.to_vec().into(), - value: U256::ZERO, - ..Default::default() - }; - - let result = evm - .transact(tx) - .map_err(|e| format!("EVM error: {:?}", e))?; - - match result.result { - ExecutionResult::Success { - output: Output::Create(_, Some(addr)), - .. - } => Ok(addr), - ExecutionResult::Success { .. } => Err("No address returned".into()), - ExecutionResult::Revert { output, .. } => Err(format!("Reverted: {:?}", output)), - ExecutionResult::Halt { reason, .. } => Err(format!("Halted: {:?}", reason)), - } -} - -fn crash_hash(input: &FuzzInput, error: &str) -> String { - let mut hasher = Sha3_256::new(); - hasher.update(format!("{:?}", input.contract).as_bytes()); - hasher.update(input.passes.as_bytes()); - hasher.update(error.as_bytes()); - hex::encode(&hasher.finalize()[..8]) -} - -/// Failure from a fuzz run, containing error info and trace for debugging. -struct FuzzFailure { - kind: ErrorKind, - message: String, - trace: Vec, - obfuscated_bytecode: Option, - logs: Vec, -} - -fn save_crash( - crash_dir: &PathBuf, - input: &FuzzInput, - failure: &FuzzFailure, -) -> std::io::Result { - fs::create_dir_all(crash_dir)?; - - let crash_id = crash_hash(input, &failure.message); - - // Save trace file if we have trace events - let trace_file = if !failure.trace.is_empty() { - let filename = format!("trace_{crash_id}.json"); - let path = crash_dir.join(&filename); - fs::write(&path, serde_json::to_string_pretty(&failure.trace)?)?; - Some(filename) - } else { - None - }; - - let report = CrashReport { - id: crash_id, - timestamp: chrono::Utc::now().to_rfc3339(), - input: input.clone(), - error_kind: failure.kind.clone(), - message: failure.message.clone(), - obfuscated_bytecode: failure.obfuscated_bytecode.clone(), - trace_file, - logs: failure.logs.clone(), - }; - - let path = crash_dir.join(format!("crash_{}.json", report.id)); - fs::write(&path, serde_json::to_string_pretty(&report)?)?; - Ok(path) -} - -async fn run_fuzz_input(input: &FuzzInput, check_deploy: bool) -> Result<(), FuzzFailure> { - let deployment_hex = input.contract.deployment_hex(); - let runtime_hex = input.contract.runtime_hex(); - let seed = Seed::from_bytes(input.seed_bytes()); - let original_bytes = prepare_bytecode(input.contract, deployment_hex, input.seed_bytes()) - .ok_or_else(|| FuzzFailure { - kind: ErrorKind::Obfuscation, - message: "failed to prepare original bytecode".into(), - trace: Vec::new(), - obfuscated_bytecode: None, - logs: Vec::new(), - })?; - let full_deployment_hex = format!("0x{}", hex::encode(&original_bytes)); - - let transforms = build_passes(&input.passes).map_err(|e| FuzzFailure { - kind: ErrorKind::Obfuscation, - message: format!("invalid passes: {e}"), - trace: Vec::new(), - obfuscated_bytecode: None, - logs: Vec::new(), - })?; - - let config = ObfuscationConfig { - seed, - transforms, - preserve_unknown_opcodes: true, - }; - - let result = obfuscate_bytecode(&full_deployment_hex, runtime_hex, config) - .await - .map_err(|e| { - let kind = if e.message.contains("validation") || e.message.contains("invalid jump") { - ErrorKind::Validation - } else { - ErrorKind::Obfuscation - }; - FuzzFailure { - kind, - message: e.message, - trace: e.trace, - obfuscated_bytecode: None, - logs: Vec::new(), - } - })?; - - if input.contract == Contract::Escrow { - let base_len = hex::decode(deployment_hex.trim().trim_start_matches("0x")) - .map_err(|error| FuzzFailure { - kind: ErrorKind::Obfuscation, - message: format!("failed to decode base deployment: {error}"), - trace: result.trace.clone(), - obfuscated_bytecode: Some(result.obfuscated_bytecode.clone()), - logs: Vec::new(), - })? - .len(); - let args = &original_bytes[base_len..]; - let obfuscated = - hex::decode(result.obfuscated_bytecode.trim_start_matches("0x")).map_err(|error| { - FuzzFailure { - kind: ErrorKind::Obfuscation, - message: format!("failed to decode obfuscated deployment: {error}"), - trace: result.trace.clone(), - obfuscated_bytecode: Some(result.obfuscated_bytecode.clone()), - logs: Vec::new(), - } - })?; - if obfuscated.windows(args.len()).any(|window| window == args) { - return Err(FuzzFailure { - kind: ErrorKind::ConstructorArgsVisible, - message: "the complete ABI constructor suffix survived obfuscation".into(), - trace: result.trace, - obfuscated_bytecode: Some(result.obfuscated_bytecode), - logs: Vec::new(), - }); - } - for word_index in [0usize, 1, 2, 4] { - let word = &args[word_index * 32..(word_index + 1) * 32]; - if obfuscated.windows(32).any(|window| window == word) { - return Err(FuzzFailure { - kind: ErrorKind::ConstructorArgsVisible, - message: format!("constructor ABI word {word_index} survived obfuscation"), - trace: result.trace, - obfuscated_bytecode: Some(result.obfuscated_bytecode), - logs: Vec::new(), - }); - } - } - } - - if !check_deploy { - return Ok(()); - } - - let original_deployed = deploy_to_revm(&original_bytes, input.contract).is_ok(); - - let prepared_obfuscated = hex::decode(result.obfuscated_bytecode.trim_start_matches("0x")) - .map_err(|error| FuzzFailure { - kind: ErrorKind::Obfuscation, - message: format!("failed to decode obfuscated bytecode: {error}"), - trace: result.trace.clone(), - obfuscated_bytecode: Some(result.obfuscated_bytecode.clone()), - logs: Vec::new(), - })?; - - let obfuscated_deployed = deploy_to_revm(&prepared_obfuscated, input.contract).is_ok(); - - if original_deployed && !obfuscated_deployed { - return Err(FuzzFailure { - kind: ErrorKind::DeploymentMismatch { - original: original_bytes.len(), - obfuscated: prepared_obfuscated.len(), - }, - message: format!( - "original deployed but obfuscated failed ({}b vs {}b)", - original_bytes.len(), - prepared_obfuscated.len() - ), - trace: result.trace, - obfuscated_bytecode: Some(result.obfuscated_bytecode), - logs: Vec::new(), - }); - } - - Ok(()) -} - -/// Runs a fuzz input using the provided log capture buffer. -/// Clears the buffer before running and extracts logs on failure. -async fn run_fuzz_input_capturing( - input: &FuzzInput, - log_capture: &LogCapture, - check_deploy: bool, -) -> Result<(), FuzzFailure> { - log_capture.clear(); - let result = run_fuzz_input(input, check_deploy).await; - result.map_err(|mut failure| { - failure.logs = log_capture.extract_lines(); - failure - }) -} - -async fn replay_crash(crash_file: &PathBuf, check_deploy: bool) -> Result<(), Box> { - let content = fs::read_to_string(crash_file)?; - let report: CrashReport = serde_json::from_str(&content)?; - - println!("=== Replaying Crash ==="); - println!("ID: {}", report.id); - println!("Timestamp: {}", report.timestamp); - println!("Contract: {:?}", report.input.contract); - println!("Seed: {}", report.input.seed); - println!( - "Passes: {}", - if report.input.passes.is_empty() { - "none" - } else { - &report.input.passes - } - ); - println!("Original error: {}", report.message); - if let Some(ref trace) = report.trace_file { - println!("Debug trace: {trace}"); - } - if !report.logs.is_empty() { - println!("Captured logs: {} lines", report.logs.len()); - } - println!(); - - if !report.logs.is_empty() { - println!("=== Captured Logs ==="); - for line in &report.logs { - println!("{line}"); - } - println!(); - } - - println!("Running..."); - - let log_capture = LogCapture::new(); - let subscriber = tracing_subscriber::registry().with( - tracing_subscriber::fmt::layer() - .with_writer(log_capture.clone()) - .with_ansi(false) - .without_time(), - ); - let dispatch = tracing::dispatcher::Dispatch::new(subscriber); - let _guard = tracing::dispatcher::set_default(&dispatch); - - let result = run_fuzz_input_capturing(&report.input, &log_capture, check_deploy).await; - - match result { - Ok(()) => println!("SUCCESS - No error reproduced!"), - Err(failure) => { - println!("REPRODUCED!"); - println!("Error: {}", failure.kind); - println!("Message: {}", failure.message); - if !failure.logs.is_empty() { - println!(); - println!("=== New Logs ==="); - for line in &failure.logs { - println!("{line}"); - } - } - } - } - - Ok(()) -} - -fn list_crashes(crash_dir: &PathBuf) -> Result<(), Box> { - if !crash_dir.exists() { - println!("No crashes directory found at {crash_dir:?}"); - return Ok(()); - } - - let mut crashes = Vec::new(); - for entry in fs::read_dir(crash_dir)? { - let entry = entry?; - let path = entry.path(); - if path.extension().is_some_and(|e| e == "json") - && path - .file_name() - .is_some_and(|n| n.to_string_lossy().starts_with("crash_")) - { - if let Ok(content) = fs::read_to_string(&path) { - if let Ok(report) = serde_json::from_str::(&content) { - crashes.push((path, report)); - } - } - } - } - - if crashes.is_empty() { - println!("No crashes found in {crash_dir:?}"); - return Ok(()); - } - - println!("=== Saved Crashes ({}) ===\n", crashes.len()); - for (path, report) in crashes { - println!("File: {}", path.display()); - println!(" ID: {}", report.id); - println!(" Contract: {:?}", report.input.contract); - println!( - " Passes: {}", - if report.input.passes.is_empty() { - "none" - } else { - &report.input.passes - } - ); - println!(" Error: {}", report.error_kind); - if report.trace_file.is_some() { - println!(" Trace: available"); - } - if !report.logs.is_empty() { - println!(" Logs: {} lines", report.logs.len()); - } - println!(); - } - - Ok(()) -} - -async fn fuzzer_worker( - worker_id: usize, - stats: Arc, - args: Arc, - crash_dir: PathBuf, -) { - let log_capture = LogCapture::new(); - let subscriber = tracing_subscriber::registry().with( - tracing_subscriber::fmt::layer() - .with_writer(log_capture.clone()) - .with_ansi(false) - .without_time(), - ); - let dispatch = tracing::dispatcher::Dispatch::new(subscriber); - let _guard = tracing::dispatcher::set_default(&dispatch); - - let mut rng = SmallRng::seed_from_u64(worker_id as u64 ^ 0xdeadbeef); - - loop { - let iters = stats.iterations.fetch_add(1, Ordering::Relaxed); - if args.iterations > 0 && iters >= args.iterations { - break; - } - if args.duration > 0 && stats.start_time.elapsed().as_secs() >= args.duration { - break; - } - - let contract = Contract::ALL[(rng.next_u32() as usize) % Contract::ALL.len()]; - let mut seed_bytes = [0u8; 32]; - rng.fill_bytes(&mut seed_bytes); - let passes = passes_from_bits(rng.next_u32() % pass_mask_limit()); - - let input = FuzzInput::new(contract, seed_bytes, passes); - - match run_fuzz_input_capturing(&input, &log_capture, args.check_deploy).await { - Ok(()) => { - stats.successes.fetch_add(1, Ordering::Relaxed); - } - Err(failure) => { - let is_mismatch = matches!(failure.kind, ErrorKind::DeploymentMismatch { .. }); - let is_interesting = is_mismatch || matches!(failure.kind, ErrorKind::Validation); - - if is_mismatch { - stats.deployment_mismatches.fetch_add(1, Ordering::Relaxed); - } - stats.errors.fetch_add(1, Ordering::Relaxed); - - if is_interesting { - let crash_id = crash_hash(&input, &failure.message); - let mut crashes = stats.unique_crashes.lock(); - - if crashes.insert(crash_id.clone()) { - if let Ok(path) = save_crash(&crash_dir, &input, &failure) { - let passes_display = if input.passes.is_empty() { - "none" - } else { - &input.passes - }; - eprintln!("\n[CRASH] Saved: {}", path.display()); - eprintln!( - " {:?} | {} | {}", - input.contract, passes_display, failure.kind - ); - } - } - } - } - } - } -} - -fn status_printer(stats: Arc, stop: Arc) { - while !stop.load(Ordering::Relaxed) { - std::thread::sleep(std::time::Duration::from_millis(250)); - let elapsed = stats.start_time.elapsed(); - let secs = elapsed.as_secs(); - let iters = stats.iterations.load(Ordering::Relaxed); - let rate = if elapsed.as_secs_f64() > 0.0 { - iters as f64 / elapsed.as_secs_f64() - } else { - 0.0 - }; - let ok = stats.successes.load(Ordering::Relaxed); - let mismatch = stats.deployment_mismatches.load(Ordering::Relaxed); - let crashes = stats.unique_crashes.lock().len(); - print!( - "\r\x1b[K[{:02}:{:02}] {:.1}/s iter={} ok={} mismatch={} crashes={}", - secs / 60, - secs % 60, - rate, - iters, - ok, - mismatch, - crashes - ); - std::io::stdout().flush().ok(); - } -} - -#[async_trait] -impl super::Command for FuzzArgs { - async fn execute(self) -> Result<(), Box> { - match &self.command { - Some(FuzzCommand::Replay { crash_file }) => { - return replay_crash(crash_file, self.check_deploy).await; - } - Some(FuzzCommand::List) => { - return list_crashes(&self.crash_dir); - } - None => {} - } - - println!("Azoth Fuzzer"); - println!("============"); - println!("Jobs: {}", self.jobs); - println!( - "Iterations: {}", - if self.iterations == 0 { - "infinite".to_string() - } else { - self.iterations.to_string() - } - ); - println!( - "Duration: {}", - if self.duration == 0 { - "infinite".to_string() - } else { - format!("{}s", self.duration) - } - ); - println!("Crash dir: {}", self.crash_dir.display()); - let contracts: Vec<_> = Contract::ALL.iter().map(|c| c.name()).collect(); - println!("Contracts: {}", contracts.join(", ")); - println!("Transforms: none, {}", DEFAULT_PASSES); - println!(); - - let args = Arc::new(self); - let stats = Arc::new(FuzzStats::new()); - let crash_dir = args.crash_dir.clone(); - - // Spawn status printer on dedicated thread - let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let status_thread = { - let stats = stats.clone(); - let stop = stop.clone(); - std::thread::spawn(move || status_printer(stats, stop)) - }; - - let mut handles = Vec::new(); - for worker_id in 0..args.jobs { - let stats = stats.clone(); - let args = args.clone(); - let crash_dir = crash_dir.clone(); - - handles.push(tokio::spawn(fuzzer_worker( - worker_id, stats, args, crash_dir, - ))); - } - - for handle in handles { - let _ = handle.await; - } - - // Stop status printer and print summary - stop.store(true, Ordering::Relaxed); - let _ = status_thread.join(); - stats.print_summary(args.check_deploy); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fuzz_pass_selection_can_reach_every_default_pass() { - let default_passes: Vec<&str> = DEFAULT_PASSES.split(',').map(str::trim).collect(); - - for expected in &default_passes { - let reachable = (0u32..pass_mask_limit()).any(|mask| { - passes_from_bits(mask) - .split(',') - .map(str::trim) - .any(|p| p == *expected) - }); - assert!( - reachable, - "default pass {expected} is not reachable by fuzz pass selection" - ); - } - } - - #[test] - fn pass_mask_limit_covers_all_default_passes() { - assert_eq!(pass_mask_limit(), 1u32 << default_pass_count()); - assert!(default_pass_count() >= 1); - } -} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs deleted file mode 100644 index b7d5f5a9..00000000 --- a/crates/cli/src/commands/mod.rs +++ /dev/null @@ -1,89 +0,0 @@ -use async_trait::async_trait; -use clap::Subcommand; -use std::error::Error; - -pub mod analyze; -pub mod cfg; -pub mod decode; -pub mod decompile_diff; -pub mod fuzz; -pub mod obfuscate; -pub mod strip; -pub mod tui; - -use thiserror::Error; - -pub const DEFAULT_PASSES: &str = "arithmetic_chain, push_split, slot_shuffle, string_obfuscate"; - -/// Errors that can occur during obfuscation. -#[derive(Debug, Error)] -pub enum ObfuscateError { - /// The hex string has an odd length, making it invalid. - #[error("hex string has odd length: {0}")] - OddLength(usize), - /// Failed to decode hex string to bytes. - #[error("hex decode error: {0}")] - HexDecode(#[from] hex::FromHexError), - /// File read/write error. - #[error("file error: {0}")] - File(#[from] std::io::Error), - /// Transform application failed. - #[error("transform error: {0}")] - Transform(String), - /// Invalid transform pass specified. - #[error("invalid pass: {0}")] - InvalidPass(String), - /// JSON serialization error. - #[error("serialization error: {0}")] - Serialize(#[from] serde_json::Error), -} - -/// CLI subcommands for Azoth. -#[derive(Subcommand)] -pub enum Cmd { - /// Decode bytecode to annotated assembly. - Decode(decode::DecodeArgs), - /// Strip init/auxdata, dump runtime hex. - Strip(strip::StripArgs), - /// Write runtime CFG to stdout or a file. - Cfg(cfg::CfgArgs), - /// Obfuscate bytecode with specified transforms. - Obfuscate(obfuscate::ObfuscateArgs), - /// Run obfuscation analysis across multiple seeds. - Analyze(analyze::AnalyzeArgs), - /// Compare decompiled output before and after obfuscation. - DecompileDiff(decompile_diff::DecompileDiffArgs), - /// View obfuscation debug traces in a TUI. - Tui(tui::TuiArgs), - /// Fuzz test the obfuscation pipeline. - Fuzz(fuzz::FuzzArgs), -} - -/// Trait for executing CLI subcommands. -/// -/// Implementors define the logic for processing input bytecode and producing output (e.g., -/// assembly, stripped bytecode, CFG, or obfuscated bytecode). -#[async_trait] -pub trait Command { - /// Executes the subcommand. - /// - /// # Returns - /// A `Result` indicating success or an error if execution fails. - async fn execute(self) -> Result<(), Box>; -} - -#[async_trait] -impl Command for Cmd { - async fn execute(self) -> Result<(), Box> { - match self { - Cmd::Decode(args) => args.execute().await, - Cmd::Strip(args) => args.execute().await, - Cmd::Cfg(args) => args.execute().await, - Cmd::Obfuscate(args) => args.execute().await, - Cmd::Analyze(args) => args.execute().await, - Cmd::DecompileDiff(args) => args.execute().await, - Cmd::Tui(args) => args.execute().await, - Cmd::Fuzz(args) => args.execute().await, - } - } -} diff --git a/crates/cli/src/commands/obfuscate.rs b/crates/cli/src/commands/obfuscate.rs deleted file mode 100644 index 4681c35d..00000000 --- a/crates/cli/src/commands/obfuscate.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! Module for the `obfuscate` subcommand, which applies obfuscation transforms to EVM -//! bytecode. -//! -//! This module processes input bytecode and uses the unified obfuscation pipeline -//! from `azoth-transform` to apply transforms and output obfuscated bytecode. - -use crate::commands::{ObfuscateError, DEFAULT_PASSES}; -use async_trait::async_trait; -use azoth_core::seed::Seed; -use azoth_transform::obfuscator::{ - create_gas_report, obfuscate_bytecode, print_obfuscation_analysis, ObfuscationConfig, -}; -use azoth_transform::Transform; -use clap::Args; -use std::error::Error; -use std::fs; -use std::path::Path; - -/// Arguments for the `obfuscate` subcommand. -#[derive(Args)] -pub struct ObfuscateArgs { - /// Input deployment bytecode as a hex string, .hex file, or binary file containing EVM bytecode. - #[arg(short = 'D', long = "deployment")] - pub deployment_bytecode: String, - /// Input runtime bytecode as a hex string, .hex file, or binary file containing EVM bytecode. - #[arg(short = 'R', long = "runtime")] - pub runtime_bytecode: String, - /// ABI-encoded constructor argument suffix to append before obfuscation. - /// May be omitted when the deployment input already contains the suffix. - #[arg(long, value_name = "HEX")] - constructor_args: Option, - /// Cryptographic seed for deterministic obfuscation. - #[arg(long)] - seed: Option, - /// Comma-separated list of transforms to apply. - /// Note: function_dispatcher is ALWAYS applied and doesn't need to be specified. - #[arg(long, default_value = DEFAULT_PASSES)] - passes: String, - /// Path to emit gas/size report as JSON (optional). - #[arg(long)] - emit: Option, - /// Path to emit a detailed CFG trace debug report as JSON. - #[arg(long, value_name = "PATH")] - emit_debug: Option, - /// Launch TUI to view the debug trace after obfuscation. - #[arg(long)] - tui: bool, -} - -/// Executes the `obfuscate` subcommand using the unified obfuscation pipeline. -#[async_trait] -impl super::Command for ObfuscateArgs { - async fn execute(self) -> Result<(), Box> { - let ObfuscateArgs { - deployment_bytecode, - runtime_bytecode, - constructor_args, - seed, - passes, - emit, - emit_debug, - tui, - } = self; - - // Step 1: Read and normalize input - let mut input_bytecode = read_input(&deployment_bytecode)?; - let runtime_bytecode_hex = read_input(&runtime_bytecode)?; - if let Some(constructor_args) = constructor_args { - let deployment = normalise_hex(&input_bytecode)?; - let args = normalise_hex(&constructor_args)?; - input_bytecode = format!("0x{deployment}{args}"); - } - - // Step 2: Build transforms from CLI args - let transforms = build_passes(&passes)?; - - // Step 3: Configure obfuscation - let mut config = if let Some(seed_hex) = seed { - // Use provided seed - let seed = Seed::from_hex(&seed_hex).map_err(|e| format!("Invalid seed hex: {e}"))?; - ObfuscationConfig::with_seed(seed) - } else { - // Use random seed - ObfuscationConfig::default() - }; - - config.transforms = transforms; - config.preserve_unknown_opcodes = true; - - // Step 4: Run obfuscation pipeline - let result = match obfuscate_bytecode(&input_bytecode, &runtime_bytecode_hex, config).await - { - Ok(result) => result, - Err(e) => return Err(format!("{e}").into()), - }; - - // Step 5: Print analysis and results - print_obfuscation_analysis(&result); - - // Step 6: Check size limits - // Step 7: Write report if requested - if let Some(path) = emit.as_ref() { - let report = create_gas_report(&result); - fs::write(path, serde_json::to_string_pretty(&report)?)?; - println!("📊 Wrote gas/size report to {}", path); - } - - if let Some(path) = emit_debug.as_ref() { - let debug_payload = serde_json::to_string_pretty(&serde_json::json!({ - "metadata": &result.metadata, - "trace": &result.trace, - }))?; - fs::write(path, debug_payload)?; - println!("Wrote CFG trace debug report to {}", path); - } - - // Step 8: Output final bytecode - println!("{}", result.obfuscated_bytecode); - - // Step 9: Launch TUI if requested - if tui { - let debug = azoth_tui::DebugOutput { - metadata: azoth_tui::DebugMetadata { - transforms_applied: result.metadata.transforms_applied.clone(), - size_limit_exceeded: result.metadata.size_limit_exceeded, - unknown_opcodes_preserved: result.metadata.unknown_opcodes_preserved, - }, - trace: result.trace, - }; - azoth_tui::run(debug, Some(runtime_bytecode.clone()))?; - } - - Ok(()) - } -} - -/// Reads input from hex string, .hex file, or binary file -pub(crate) fn read_input(input: &str) -> Result> { - if input.trim_start().starts_with("0x") { - // Direct hex string input - Ok(input.to_string()) - } else if Path::new(input).extension().and_then(|s| s.to_str()) == Some("hex") { - // .hex file - let content = fs::read_to_string(input)?; - let normalized = normalise_hex(&content)?; - Ok(format!("0x{normalized}")) - } else { - // Binary file - let bytes = fs::read(input)?; - Ok(format!("0x{}", hex::encode(bytes))) - } -} - -/// Normalizes a hex string by removing prefixes and underscores. -pub(crate) fn normalise_hex(s: &str) -> Result { - let stripped = s.trim().trim_start_matches("0x").replace('_', ""); - if !stripped.len().is_multiple_of(2) { - return Err(ObfuscateError::OddLength(stripped.len())); - } - Ok(stripped) -} - -/// Builds a list of transform passes from a comma-separated string. -pub(crate) fn build_passes(list: &str) -> Result>, Box> { - list.split(',') - .filter(|s| !s.is_empty()) - .map(|name| match name.trim() { - "shuffle" => Ok(Box::new(azoth_transform::shuffle::Shuffle) as Box), - "opaque_pred" | "opaque_predicate" => Ok(Box::new( - azoth_transform::opaque_predicate::OpaquePredicate::new(), - ) as Box), - "jump_transform" | "jump_addr" => Ok(Box::new( - azoth_transform::jump_address_transformer::JumpAddressTransformer::new(), - ) as Box), - "arithmetic_chain" => Ok(Box::new( - azoth_transform::arithmetic_chain::ArithmeticChain::new(), - ) as Box), - "push_split" => { - Ok(Box::new(azoth_transform::push_split::PushSplit::new()) as Box) - } - "storage_gates" => Ok( - Box::new(azoth_transform::storage_gates::StorageGates::new()) as Box, - ), - "slot_shuffle" => { - Ok(Box::new(azoth_transform::slot_shuffle::SlotShuffle::new()) - as Box) - } - "string_obfuscate" | "string_obf" => Ok(Box::new( - azoth_transform::string_obfuscate::StringObfuscate::new(), - ) as Box), - "cluster_shuffle" => Ok( - Box::new(azoth_transform::cluster_shuffle::ClusterShuffle::new()) - as Box, - ), - "splice" => Ok(Box::new(azoth_transform::splice::Splice::new()) as Box), - _ => Err(ObfuscateError::InvalidPass(name.to_string()).into()), - }) - .collect() -} diff --git a/crates/cli/src/commands/strip.rs b/crates/cli/src/commands/strip.rs deleted file mode 100644 index 585865cb..00000000 --- a/crates/cli/src/commands/strip.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! This module processes input bytecode, removes non-runtime sections (e.g., init code, -//! auxdata), and outputs either the cleaned runtime bytecode as a hex string or a JSON report -//! detailing the stripping process. - -use async_trait::async_trait; -use azoth_core::decoder::decode_bytecode; -use azoth_core::detection::locate_sections; -use azoth_core::input_to_bytes; -use azoth_core::strip::strip_bytecode; -use clap::Args; -use serde_json; -use std::error::Error; -use std::path::Path; - -/// Arguments for the `strip` subcommand. -#[derive(Args)] -pub struct StripArgs { - /// Input deployment bytecode as a hex string (0x...) or file path containing EVM bytecode. - #[arg(short = 'D', long = "deployment")] - pub deployment_bytecode: String, - /// Input runtime bytecode as a hex string (0x...) or file path containing EVM bytecode. - #[arg(short = 'R', long = "runtime")] - pub runtime_bytecode: String, - /// Output raw cleaned runtime hex instead of JSON report - #[arg(long)] - raw: bool, -} - -/// Executes the `strip` subcommand to extract runtime bytecode. -#[async_trait] -impl super::Command for StripArgs { - async fn execute(self) -> Result<(), Box> { - let is_file = !self.deployment_bytecode.starts_with("0x") - && Path::new(&self.deployment_bytecode).is_file(); - let runtime_is_file = - !self.runtime_bytecode.starts_with("0x") && Path::new(&self.runtime_bytecode).is_file(); - let (instructions, _, _, bytes) = - decode_bytecode(&self.deployment_bytecode, is_file).await?; - let runtime_bytes = input_to_bytes(&self.runtime_bytecode, runtime_is_file)?; - let sections = locate_sections(&bytes, &instructions, &runtime_bytes)?; - let (clean_runtime, report) = strip_bytecode(&bytes, §ions)?; - - if self.raw { - println!("0x{}", hex::encode(&clean_runtime)); - } else { - let json = serde_json::to_string_pretty(&report)?; - println!("{json}"); - } - Ok(()) - } -} diff --git a/crates/cli/src/commands/tui.rs b/crates/cli/src/commands/tui.rs deleted file mode 100644 index 1ab19f06..00000000 --- a/crates/cli/src/commands/tui.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! TUI subcommand for viewing debug traces. - -use std::path::PathBuf; - -use async_trait::async_trait; -use clap::Args; - -use super::Command; - -/// View obfuscation debug traces in a TUI. -#[derive(Args)] -pub struct TuiArgs { - /// Path to the debug JSON file. - #[arg(default_value = "debug.json")] - pub file: PathBuf, -} - -#[async_trait] -impl Command for TuiArgs { - async fn execute(self) -> Result<(), Box> { - let filename = self.file.display().to_string(); - let debug = azoth_tui::load_debug_file(&self.file)?; - azoth_tui::run(debug, Some(filename)) - } -} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 82b6da3c..a62c0539 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1 +1,3 @@ +#![recursion_limit = "256"] + pub mod commands; diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 356d7c2c..a661f7d7 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -4,15 +4,16 @@ version = "0.1.0" edition = "2024" [dependencies] -eot.workspace = true hex.workspace = true -heimdall.workspace = true petgraph.workspace = true rand.workspace = true +rand_chacha.workspace = true serde.workspace = true sha3.workspace = true thiserror.workspace = true tiny-keccak.workspace = true tracing.workspace = true -tokio.workspace = true revm.workspace = true + +[dev-dependencies] +tokio.workspace = true diff --git a/crates/core/README.md b/crates/core/README.md index 8b87fdd2..81631441 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -11,3 +11,54 @@ The core crate implements a multi-stage pipeline for bytecode processing: 3. **Stripping** - Isolates runtime code from deployment artifacts 4. **CFG/IR Generation** - Builds control flow graphs with intermediate representation 5. **Encoding** - Reconstructs bytecode from transformed representations + +## Native bytecode boundary + +`azoth-core` owns its opcode model and decoder. The decoder walks bytes once, computes exact byte +offsets, and treats only `PUSH1` through `PUSH32` payloads as immediate data. It does not spawn an +async task, render assembly, or call Heimdall/EOT. Human-readable assembly is produced only when a +CLI or diagnostic caller explicitly requests it. + +There are two intentionally different entry points: + +- `decode_bytes` is total over arbitrary byte blobs and preserves every byte, including unknown + opcodes and a final short `PUSH`. This mode is appropriate while section boundaries and compiler + metadata are still being identified. +- `decode_executable_bytes` rejects a final `PUSH` whose declared immediate extends past the end of + its executable section. The EVM would read missing bytes as zero, but moving or appending code + would turn new bytes into part of that operand. Transform and validation callers therefore fail + closed. + +`INVALID` always means byte `0xfe`. Every other unassigned byte is represented as +`UNKNOWN(original_byte)` and re-encodes exactly; unknown executable operations are terminal +exceptional halts and have no invented stack effect. The opcode table is pinned to the current +Fusaka/Osaka legacy EVM, including `CLZ` at `0x1e`. Withdrawn EOF proposal bytes remain unknown in +legacy bytecode. See `docs/native-bytecode-decoder.md` for the complete maintenance contract. + +## Stable identity and physical layout + +`CfgIrBundle` deliberately separates a block's identity from its byte offset. `NodeIndex` remains +stable while `layout_order` describes physical emission order. A transform changes layout through +`set_layout_order`; `reindex_pcs` is the lowering step that assigns concrete offsets. This prevents +relationships from silently becoming stale every time a pass moves code. + +`RelationshipIndex` is rebuilt and validated transactionally. For every block it records section +and section-region membership, predecessors/successors, roles, cluster membership, unresolved +control, position-sensitive opcodes, and typed code-pointer relocations. Fallthrough and false +branch edges create adjacency constraints. Those constraints are unioned into ordered clusters so +a layout pass cannot separate instructions whose behavior depends on physical adjacency. + +Solidity legacy internal calls carry return addresses on the EVM stack. The relationship analyzer +tracks literal code pointers through reachable `PUSH`, `DUP`, `SWAP`, and modeled stack effects +across CFG edges. It keeps distinct caller contexts rather than erasing them at joins. Only origins +that resolve consistently become `CodePointerRelocation` records; unsupported operations, +conflicts, resource-limit exhaustion, or non-literal dynamic jumps stay unresolved. Layout-changing +passes must refuse such input. + +## Equivalence boundary + +Azoth rejects layout variation when runtime code observes code position, size, bytes, or hash via +`PC`, `CODESIZE`, `CODECOPY`, `EXTCODESIZE`, `EXTCODECOPY`, or `EXTCODEHASH`. Some external-code +operations might not target `address(this)`, but proving that requires value analysis the current +foundation does not yet provide. The conservative rejection is intentional: a changed bytecode +cannot preserve self-code observations exactly. diff --git a/crates/core/src/cfg_ir/mod.rs b/crates/core/src/cfg_ir/mod.rs index 4348761d..7a081086 100644 --- a/crates/core/src/cfg_ir/mod.rs +++ b/crates/core/src/cfg_ir/mod.rs @@ -14,8 +14,13 @@ use petgraph::visit::{EdgeRef, IntoNodeReferences}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; +mod relationships; mod trace; +pub use relationships::{ + BlockCluster, BlockLink, BlockRelationships, BlockRole, CodePointerRelocation, + RelationshipIndex, +}; pub use trace::{ BlockBodySnapshot, BlockChangeSet, BlockControlSnapshot, BlockModification, BlockPcDiff, BlockSnapshot, BlockSnapshotKind, CfgIrDiff, CfgIrSnapshot, EdgeChangeSet, EdgeSnapshot, @@ -51,6 +56,9 @@ pub struct BlockBody { pub instructions: Vec, pub max_stack: usize, pub control: BlockControl, + /// Code section that owns this block. Section ownership is semantic metadata and must not be + /// inferred from a mutable program counter after layout transforms begin. + pub section: SectionKind, } impl BlockBody { @@ -61,16 +69,17 @@ impl BlockBody { instructions: Vec::new(), max_stack: 0, control: BlockControl::Unknown, + section: SectionKind::Runtime, } } - /// Returns true when this block resides inside the runtime section described by - /// `runtime_start`. - fn is_runtime(&self, runtime_start: Option<(usize, usize)>) -> bool { - if let Some((start, end)) = runtime_start { - return self.start_pc >= start && self.start_pc < end; - } - false + /// Returns true when this block belongs to executable runtime code. + /// + /// Section ownership is stable while program counters are not: transforms may append or move + /// a runtime block before lowering assigns its final PC. The retained parameter keeps legacy + /// call sites source-compatible while those callers migrate to section-aware APIs. + fn is_runtime(&self, runtime_bounds: Option<(usize, usize)>) -> bool { + runtime_bounds.is_some() && self.section == SectionKind::Runtime } } @@ -111,7 +120,7 @@ pub enum JumpTarget { } /// Describes how to interpret the immediate used by a jump. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum JumpEncoding { /// Immediate stores an absolute PC. Absolute, @@ -123,7 +132,7 @@ pub enum JumpEncoding { } /// Edge types mirror the legacy representation to avoid touching downstream consumers. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum EdgeType { Fallthrough, Jump, @@ -165,9 +174,65 @@ pub struct CfgIrBundle { /// rewrite every AC-emitted offset PUSH so CODECOPY still points into /// the appended data section. pub ac_runtime_length_estimate: Option, + /// Intended physical order of body blocks. `NodeIndex` is stable identity; `start_pc` is only + /// a lowered address and is assigned from this order during finalization. + pub layout_order: Vec, + /// Cached structural relationships rebuilt at transform boundaries. + pub relationships: RelationshipIndex, } impl CfgIrBundle { + /// Returns body blocks in their intended physical bytecode order. + pub fn layout_order(&self) -> &[NodeIndex] { + &self.layout_order + } + + /// Replaces the intended physical layout while preserving stable graph identity. + /// + /// The proposed order must contain every body block exactly once, keep the entry block first, + /// and preserve every fallthrough or PC-relative cluster recorded in the current relationship + /// snapshot. Addresses are not changed until [`Self::reindex_pcs`] lowers this order. + pub fn set_layout_order(&mut self, order: Vec) -> Result<(), Error> { + let relationships = RelationshipIndex::build(self).map_err(Error::InvalidBlockStructure)?; + relationships + .validate_layout(&order) + .map_err(Error::InvalidBlockStructure)?; + let blocks_moved = self + .layout_order + .iter() + .zip(&order) + .filter(|(before, after)| before != after) + .count(); + self.layout_order = order; + self.refresh_relationships()?; + let snapshot = snapshot_bundle(self); + self.record_operation( + OperationKind::ReorderLayout { blocks_moved }, + CfgIrDiff::FullSnapshot(Box::new(snapshot)), + None, + ); + Ok(()) + } + + /// Rebuilds predecessor, successor, cluster, role, and relocation metadata. + pub fn refresh_relationships(&mut self) -> Result<(), Error> { + self.relationships = + RelationshipIndex::build(self).map_err(Error::InvalidBlockStructure)?; + Ok(()) + } + + /// Returns the most recently refreshed relationship snapshot. + pub fn relationships(&self) -> &RelationshipIndex { + &self.relationships + } + + /// Re-derives and validates all structural relationships without mutating bytecode. + pub fn validate_relationships(&self) -> Result<(), Error> { + RelationshipIndex::build(self) + .map(|_| ()) + .map_err(Error::InvalidBlockStructure) + } + /// Returns cached runtime bounds (start inclusive, end exclusive) if the bytecode contains a /// runtime section. pub fn runtime_bounds(&self) -> Option<(usize, usize)> { @@ -176,14 +241,11 @@ impl CfgIrBundle { /// Returns true when the block referenced by `node` sits inside the runtime section. fn block_runtime_status(&self, node: NodeIndex) -> bool { - self.runtime_bounds - .and_then(|(start, end)| { - self.cfg.node_weight(node).map(|block| match block { - Block::Body(body) => body.start_pc >= start && body.start_pc < end, - _ => false, - }) - }) - .unwrap_or(false) + self.runtime_bounds.is_some() + && matches!( + self.cfg.node_weight(node), + Some(Block::Body(body)) if body.section == SectionKind::Runtime + ) } /// Returns a copy of the block control descriptor, if the node is a body block. @@ -251,6 +313,9 @@ impl CfgIrBundle { Block::Entry | Block::Exit => 0, }; let node = self.cfg.add_node(block); + if matches!(self.cfg.node_weight(node), Some(Block::Body(_))) { + self.layout_order.push(node); + } // Snapshot the new block for the diff let after = snapshot_block_body(self, node); @@ -571,21 +636,10 @@ impl CfgIrBundle { /// Finds the next block in program counter order, if any. fn find_next_body(&self, node: NodeIndex) -> Option { - let mut nodes: Vec<_> = self - .cfg - .node_references() - .filter_map(|(idx, block)| match block { - Block::Body(body) => Some((idx, body.start_pc)), - _ => None, - }) - .collect(); - nodes.sort_by_key(|(_, pc)| *pc); - for (i, (idx, _)) in nodes.iter().enumerate() { - if *idx == node { - return nodes.get(i + 1).map(|(next_idx, _)| *next_idx); - } - } - None + self.layout_order + .iter() + .position(|candidate| *candidate == node) + .and_then(|position| self.layout_order.get(position + 1).copied()) } /// Reindexes PCs and refreshes the start_pc mapping. Unlike the legacy implementation this also @@ -593,20 +647,22 @@ impl CfgIrBundle { /// for blocks using the new API. /// Renumbers program counters and returns a mapping from old PCs to their new positions. pub fn reindex_pcs(&mut self) -> Result { + // Lowering touches every instruction and can fail late if a relocated target no longer + // fits its original PUSH width. Work on an isolated bundle so callers never observe a + // half-reindexed graph after an error. + let mut candidate = self.clone(); + let outcome = candidate.reindex_pcs_in_place()?; + *self = candidate; + Ok(outcome) + } + + fn reindex_pcs_in_place(&mut self) -> Result { let before_blocks = block_start_pcs(self); let mut mapping = HashMap::new(); let old_runtime_bounds = self.runtime_bounds; - let mut blocks: Vec<_> = self - .cfg - .node_indices() - .filter_map(|idx| { - self.cfg.node_weight(idx).and_then(|block| match block { - Block::Body(body) => Some((idx, body.start_pc)), - _ => None, - }) - }) - .collect(); - blocks.sort_by_key(|(_, start_pc)| *start_pc); + self.refresh_relationships()?; + let code_pointer_relocations = self.relationships.code_pointer_relocations.clone(); + let blocks = self.layout_order.clone(); let mut next_pc = 0usize; let mut new_pc_to_block = HashMap::new(); @@ -614,22 +670,15 @@ impl CfgIrBundle { let mut runtime_first_new_pc: Option = None; let mut runtime_last_new_pc: Option = None; - for (idx, _) in blocks { + for idx in blocks { if let Some(Block::Body(body)) = self.cfg.node_weight_mut(idx) { let old_block_pc = body.start_pc; - let in_runtime = body.is_runtime(runtime_bounds); + let in_runtime = body.section == SectionKind::Runtime; body.start_pc = next_pc; new_pc_to_block.insert(body.start_pc, idx); for instr in &mut body.instructions { mapping.insert(instr.pc, next_pc); - // Preserve INVALID opcode bytes before we erase the original PC. - if matches!(instr.op, Opcode::INVALID) - && instr.imm.is_none() - && instr.pc < self.original_bytecode.len() - { - instr.imm = Some(format!("{:02x}", self.original_bytecode[instr.pc])); - } instr.pc = next_pc; next_pc += instr.byte_size(); } @@ -689,7 +738,7 @@ impl CfgIrBundle { } } - let instruction_diffs: Vec = mapping + let mut instruction_diffs: Vec = mapping .iter() .filter_map(|(old_pc, new_pc)| { if old_pc != new_pc { @@ -702,15 +751,96 @@ impl CfgIrBundle { } }) .collect(); + instruction_diffs.sort_by_key(|diff| (diff.old_pc, diff.new_pc)); let diff = diff_from_pc_remap(block_diffs, instruction_diffs); self.write_symbolic_immediates()?; + self.resolve_code_pointer_relocations(&code_pointer_relocations)?; + self.refresh_relationships()?; self.record_operation(OperationKind::ReindexPcs, diff, Some(mapping.clone())); Ok((mapping, old_runtime_bounds)) } + /// Resolves stack-proven literal code pointers after layout has assigned concrete PCs. + /// + /// This covers Solidity internal-call return addresses that are not adjacent to the dynamic + /// `JUMP` consuming them. The relationship analysis records stable source/target identities; + /// lowering is the only phase that converts those identities back to numeric immediates. + fn resolve_code_pointer_relocations( + &mut self, + relocations: &[CodePointerRelocation], + ) -> Result<(), Error> { + let runtime_start = self.runtime_bounds.map(|(start, _)| start); + let mut before = HashMap::new(); + let mut resolved = 0usize; + + for relocation in relocations { + let target_pc = match self.cfg.node_weight(relocation.target) { + Some(Block::Body(target)) => target.start_pc, + _ => { + return Err(Error::InvalidBlockStructure(format!( + "relocation target {} is not a body block", + relocation.target.index() + ))); + } + }; + let value = match relocation.encoding { + JumpEncoding::Absolute => target_pc, + JumpEncoding::RuntimeRelative => target_pc + .checked_sub(runtime_start.unwrap_or(0)) + .ok_or_else(|| { + Error::InvalidBlockStructure(format!( + "runtime relocation target 0x{target_pc:x} precedes runtime" + )) + })?, + JumpEncoding::PcRelative => { + return Err(Error::InvalidBlockStructure( + "literal code-pointer relocation cannot use PC-relative encoding".into(), + )); + } + }; + + before + .entry(relocation.source) + .or_insert_with(|| snapshot_block_body(self, relocation.source)); + let Some(Block::Body(source)) = self.cfg.node_weight_mut(relocation.source) else { + return Err(Error::InvalidBlockStructure(format!( + "relocation source {} is not a body block", + relocation.source.index() + ))); + }; + let instruction = source + .instructions + .get_mut(relocation.instruction_index) + .ok_or_else(|| { + Error::InvalidBlockStructure(format!( + "relocation source {} has no instruction {}", + relocation.source.index(), + relocation.instruction_index + )) + })?; + apply_immediate(instruction, value)?; + resolved += 1; + } + + let mut before: Vec<_> = before.into_iter().collect(); + before.sort_by_key(|(node, _)| node.index()); + let changes = before + .into_iter() + .filter_map(|(node, before)| { + block_modification(node, before, snapshot_block_body(self, node)) + }) + .collect(); + self.record_operation( + OperationKind::ResolveRelocations { count: resolved }, + diff_from_block_changes(changes), + None, + ); + Ok(()) + } + /// Rewrite jump immediates using the supplied PC mapping. This keeps the method signature used /// by older transforms, but the heavy lifting now happens during `reindex_pcs`. We only patch /// legacy blocks that still rely on raw immediates. @@ -1621,7 +1751,7 @@ pub fn build_cfg_ir( ); let runtime_bounds = runtime_bounds(sections); - let blocks = split_blocks(instructions)?; + let blocks = split_blocks(instructions, sections)?; let mut cfg = StableDiGraph::new(); let entry = cfg.add_node(Block::Entry); @@ -1679,7 +1809,10 @@ pub fn build_cfg_ir( dispatcher_blocks: HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + layout_order: ordered_nodes, + relationships: RelationshipIndex::default(), }; + bundle.refresh_relationships()?; let body_blocks = bundle .cfg .node_indices() @@ -1709,7 +1842,7 @@ fn runtime_bounds(sections: &[Section]) -> Option<(usize, usize)> { } /// Breaks the instruction stream into basic blocks and ensures branch boundaries are respected. -fn split_blocks(instructions: &[Instruction]) -> Result, Error> { +fn split_blocks(instructions: &[Instruction], sections: &[Section]) -> Result, Error> { let mut blocks = Vec::new(); let mut current = BlockBody::new(0); @@ -1720,6 +1853,15 @@ fn split_blocks(instructions: &[Instruction]) -> Result, Error> { .collect(); for ins in instructions { + let instruction_section = section_for_pc(ins.pc, sections); + if !current.instructions.is_empty() && current.section != instruction_section { + // A basic block cannot straddle code-section ownership. In particular, init and + // deployed runtime are separate EVM executions with independent empty operand stacks. + blocks.push(Block::Body(current)); + current = BlockBody::new(ins.pc); + current.section = instruction_section; + } + if matches!(ins.op, Opcode::JUMPDEST) { if !current.instructions.is_empty() { blocks.push(Block::Body(current.clone())); @@ -1729,12 +1871,14 @@ fn split_blocks(instructions: &[Instruction]) -> Result, Error> { instructions: vec![ins.clone()], max_stack: 0, control: BlockControl::Unknown, + section: instruction_section, }; continue; } if current.instructions.is_empty() { current.start_pc = ins.pc; + current.section = instruction_section; } current.instructions.push(ins.clone()); @@ -1753,6 +1897,14 @@ fn split_blocks(instructions: &[Instruction]) -> Result, Error> { Ok(blocks) } +fn section_for_pc(pc: usize, sections: &[Section]) -> SectionKind { + sections + .iter() + .find(|section| pc >= section.offset && pc < section.offset + section.len) + .map(|section| section.kind) + .unwrap_or(SectionKind::Runtime) +} + /// Ensures every `JUMPDEST` discovered in the bytecode starts a corresponding block. fn validate_jumpdests(blocks: &[Block], jumpdest_pcs: &HashSet) -> Result<(), Error> { let mut block_starts = HashSet::new(); @@ -1999,7 +2151,7 @@ fn analyse_jump_target( JumpPattern::PcRelative { push_idx, pc_idx } => { let delta = parse_immediate(&body.instructions[push_idx])?; let pc_value = body.instructions[pc_idx].pc; - let absolute_pc = pc_value + delta; + let absolute_pc = pc_value.checked_add(delta)?; let target_node = node_by_pc.get(&absolute_pc).copied(); target_node.map(|node| JumpTarget::Block { node, @@ -2022,7 +2174,9 @@ fn absolute_target_from_value( }; let absolute_pc = match encoding { - JumpEncoding::RuntimeRelative => runtime_bounds.map(|(start, _)| start + immediate)?, + JumpEncoding::RuntimeRelative => { + runtime_bounds.and_then(|(start, _)| start.checked_add(immediate))? + } JumpEncoding::Absolute => immediate, JumpEncoding::PcRelative => unreachable!(), }; @@ -2094,9 +2248,9 @@ fn detect_jump_pattern(instructions: &[Instruction]) -> Option { /// Stack position is tracked as the PUSH's distance from top: 0 right /// after the PUSH. DUP of the tracked slot is handled conservatively — /// returns `true` immediately because either the copy or the original -/// could still reach a downstream JUMP. Unknown opcodes also return -/// `true` so new opcodes added to future hardforks don't silently -/// introduce false negatives. +/// could still reach a downstream JUMP. Ordinary operations use the native +/// opcode table's stack metadata, while an unmodelled non-terminal operation returns `true` +/// conservatively so future hard-fork additions cannot silently introduce false negatives. #[doc(hidden)] pub fn push_reaches_jump(instructions: &[Instruction], push_idx: usize) -> bool { // `pos` is the tracked value's distance from stack top; 0 = top. @@ -2105,39 +2259,7 @@ pub fn push_reaches_jump(instructions: &[Instruction], push_idx: usize) -> bool for instr in instructions.iter().skip(push_idx + 1) { let op = &instr.op; match op { - // pop 0, push 1 — every value above us shifts us down by 1 - Opcode::PUSH(_) - | Opcode::PUSH0 - | Opcode::ADDRESS - | Opcode::ORIGIN - | Opcode::CALLER - | Opcode::CALLVALUE - | Opcode::CALLDATASIZE - | Opcode::CODESIZE - | Opcode::GASPRICE - | Opcode::COINBASE - | Opcode::TIMESTAMP - | Opcode::NUMBER - | Opcode::DIFFICULTY - | Opcode::GASLIMIT - | Opcode::CHAINID - | Opcode::SELFBALANCE - | Opcode::BASEFEE - | Opcode::GAS - | Opcode::RETURNDATASIZE - | Opcode::PC - | Opcode::MSIZE => { - pos += 1; - } - - Opcode::POP => { - if pos == 0 { - return false; - } - pos -= 1; - } - - Opcode::DUP(n) => { + Opcode::DUP(n) if (1..=16).contains(n) => { let source_pos = (*n as isize) - 1; if pos == source_pos { // We're the source of the DUP. The copy goes to stack @@ -2150,7 +2272,7 @@ pub fn push_reaches_jump(instructions: &[Instruction], push_idx: usize) -> bool pos += 1; } - Opcode::SWAP(n) => { + Opcode::SWAP(n) if (1..=16).contains(n) => { let n = *n as isize; if pos == 0 { pos = n; @@ -2159,134 +2281,6 @@ pub fn push_reaches_jump(instructions: &[Instruction], push_idx: usize) -> bool } } - // pop 1, push 1 — net 0, but consumed if our pos was the input - Opcode::ISZERO - | Opcode::NOT - | Opcode::BALANCE - | Opcode::CALLDATALOAD - | Opcode::EXTCODESIZE - | Opcode::BLOCKHASH - | Opcode::MLOAD - | Opcode::SLOAD - | Opcode::EXTCODEHASH => { - if pos == 0 { - return false; - } - } - - // pop 2, push 1 — net -1 - Opcode::ADD - | Opcode::SUB - | Opcode::MUL - | Opcode::DIV - | Opcode::SDIV - | Opcode::MOD - | Opcode::SMOD - | Opcode::EXP - | Opcode::SIGNEXTEND - | Opcode::LT - | Opcode::GT - | Opcode::SLT - | Opcode::SGT - | Opcode::EQ - | Opcode::AND - | Opcode::OR - | Opcode::XOR - | Opcode::BYTE - | Opcode::SHL - | Opcode::SHR - | Opcode::SAR - | Opcode::KECCAK256 => { - if pos < 2 { - return false; - } - pos -= 1; - } - - // pop 3, push 1 — net -2 - Opcode::ADDMOD | Opcode::MULMOD => { - if pos < 3 { - return false; - } - pos -= 2; - } - - // pop 2, push 0 — net -2 - Opcode::MSTORE | Opcode::MSTORE8 | Opcode::SSTORE => { - if pos < 2 { - return false; - } - pos -= 2; - } - - // pop 3, push 0 — net -3 - Opcode::CODECOPY - | Opcode::CALLDATACOPY - | Opcode::EXTCODECOPY - | Opcode::RETURNDATACOPY => { - if pos < 3 { - return false; - } - pos -= 3; - } - - Opcode::LOG0 => { - if pos < 2 { - return false; - } - pos -= 2; - } - Opcode::LOG1 => { - if pos < 3 { - return false; - } - pos -= 3; - } - Opcode::LOG2 => { - if pos < 4 { - return false; - } - pos -= 4; - } - Opcode::LOG3 => { - if pos < 5 { - return false; - } - pos -= 5; - } - Opcode::LOG4 => { - if pos < 6 { - return false; - } - pos -= 6; - } - - // External calls: treat as consuming the target region - Opcode::CALL | Opcode::CALLCODE => { - if pos < 7 { - return false; - } - pos -= 6; - } - Opcode::DELEGATECALL | Opcode::STATICCALL => { - if pos < 6 { - return false; - } - pos -= 5; - } - Opcode::CREATE => { - if pos < 3 { - return false; - } - pos -= 2; - } - Opcode::CREATE2 => { - if pos < 4 { - return false; - } - pos -= 3; - } - Opcode::JUMP => { // If our value is the JUMP target (pos == 0), return true. // Otherwise the JUMP consumes a different value and our @@ -2315,26 +2309,25 @@ pub fn push_reaches_jump(instructions: &[Instruction], push_idx: usize) -> bool return true; } - // Block-terminating ops other than JUMP/JUMPI: execution ends - // in this block without ever branching on our value. - Opcode::STOP - | Opcode::RETURN - | Opcode::REVERT - | Opcode::INVALID - | Opcode::SELFDESTRUCT => { + // No value can reach a later branch after a terminal instruction. This also covers + // canonical UNKNOWN bytes, which are exceptional halts in legacy EVM code. + op if op.is_terminal() => { return false; } - Opcode::JUMPDEST => { - // no stack effect - } - - // Unknown / unhandled opcodes (e.g. new hardfork instructions - // we don't yet model): be conservative and assume the value - // reaches a JUMP so new opcodes never silently introduce - // false negatives. _ => { - return true; + // The native opcode table is the single source of truth for ordinary stack + // effects. This automatically covers newly modelled non-control opcodes such as + // CLZ, TLOAD, MCOPY, BLOBHASH, and BLOBBASEFEE. If the opcode or a manually + // constructed parameter is not modelled, retain the conservative answer. + let Some(info) = op.info() else { + return true; + }; + let inputs = isize::from(info.inputs); + if pos < inputs { + return false; + } + pos = pos - inputs + isize::from(info.outputs); } } } @@ -2449,7 +2442,7 @@ fn apply_immediate(instr: &mut Instruction, value: usize) -> Result<(), Error> { value ))); } - instr.imm = Some("00".into()); + instr.imm = None; } Opcode::PUSH(width) => { let width = width as usize; @@ -2482,6 +2475,22 @@ fn apply_split_add_immediate( push_b_idx: usize, total: usize, ) -> Result<(), Error> { + // Lowering runs even when no transform committed a layout change. Preserve an existing + // split verbatim when it already encodes the resolved target: re-canonicalising (for example, + // `1 + 5` as `6 + 0`) would make an identity pipeline change bytecode while reporting that no + // transform ran. + let current_push_value = |instruction: &Instruction| match instruction.op { + Opcode::PUSH0 => Some(0), + Opcode::PUSH(_) => parse_immediate(instruction), + _ => None, + }; + let current_a = current_push_value(&instructions[push_a_idx]); + let current_b = current_push_value(&instructions[push_b_idx]); + if current_a.and_then(|left| current_b.and_then(|right| left.checked_add(right))) == Some(total) + { + return Ok(()); + } + let max_a = push_capacity(&instructions[push_a_idx].op) .ok_or_else(|| Error::InvalidImmediate("expected PUSH opcode before ADD".into()))?; let max_b = push_capacity(&instructions[push_b_idx].op) @@ -2502,8 +2511,37 @@ fn apply_split_add_immediate( ))); } - let part_a = total.min(max_a); - let part_b = total.saturating_sub(part_a); + // When the target moved, retain one source operand exactly whenever the other PUSH can absorb + // the displacement. Besides minimizing the edit, this preserves the input compiler's chosen + // arithmetic shape instead of emitting a stable `target + 0` fingerprint. + if let Some(part_a) = current_a + && let Some(part_b) = total.checked_sub(part_a) + && part_b <= max_b + { + apply_immediate(&mut instructions[push_b_idx], part_b)?; + return Ok(()); + } + if let Some(part_b) = current_b + && let Some(part_a) = total.checked_sub(part_b) + && part_a <= max_a + { + apply_immediate(&mut instructions[push_a_idx], part_a)?; + return Ok(()); + } + + // Neither original operand can survive. Choose a deterministic value from the feasible + // interval, biased toward its midpoint so both operands remain non-zero whenever widths and + // the target permit it. + let minimum_a = total.saturating_sub(max_b); + let maximum_a = total.min(max_a); + if minimum_a > maximum_a { + return Err(Error::InvalidImmediate(format!( + "value 0x{:x} cannot be represented by the supplied PUSH widths", + total + ))); + } + let part_a = minimum_a + (maximum_a - minimum_a) / 2; + let part_b = total - part_a; apply_immediate(&mut instructions[push_a_idx], part_a)?; apply_immediate(&mut instructions[push_b_idx], part_b)?; @@ -2619,6 +2657,120 @@ mod tests { assert_eq!(instr.imm.as_deref(), Some("12ab")); } + #[test] + fn unchanged_split_add_target_preserves_exact_operands() { + let mut instructions = vec![ + Instruction { + pc: 0, + op: Opcode::PUSH(1), + imm: Some("01".into()), + }, + Instruction { + pc: 2, + op: Opcode::PUSH(1), + imm: Some("05".into()), + }, + Instruction { + pc: 4, + op: Opcode::ADD, + imm: None, + }, + Instruction { + pc: 5, + op: Opcode::JUMP, + imm: None, + }, + ]; + let before = instructions.clone(); + + apply_split_add_immediate(&mut instructions, 0, 1, 6).unwrap(); + + assert_eq!(instructions, before); + } + + #[test] + fn changed_split_add_target_preserves_one_existing_operand() { + let mut instructions = vec![ + Instruction { + pc: 0, + op: Opcode::PUSH(1), + imm: Some("01".into()), + }, + Instruction { + pc: 2, + op: Opcode::PUSH(1), + imm: Some("05".into()), + }, + ]; + + apply_split_add_immediate(&mut instructions, 0, 1, 7).unwrap(); + + assert_eq!(instructions[0].imm.as_deref(), Some("01")); + assert_eq!(instructions[1].imm.as_deref(), Some("06")); + } + + #[test] + fn changed_split_add_target_uses_other_operand_when_width_requires_it() { + let mut instructions = vec![ + Instruction { + pc: 0, + op: Opcode::PUSH(1), + imm: Some("fa".into()), + }, + Instruction { + pc: 2, + op: Opcode::PUSH(1), + imm: Some("01".into()), + }, + ]; + + apply_split_add_immediate(&mut instructions, 0, 1, 2).unwrap(); + + assert_eq!(instructions[0].imm.as_deref(), Some("01")); + assert_eq!(instructions[1].imm.as_deref(), Some("01")); + } + + #[test] + fn changed_split_add_target_fallback_avoids_target_plus_zero() { + let mut instructions = vec![ + Instruction { + pc: 0, + op: Opcode::PUSH(1), + imm: Some("fa".into()), + }, + Instruction { + pc: 2, + op: Opcode::PUSH(1), + imm: Some("fa".into()), + }, + ]; + + apply_split_add_immediate(&mut instructions, 0, 1, 100).unwrap(); + + assert_eq!(instructions[0].imm.as_deref(), Some("32")); + assert_eq!(instructions[1].imm.as_deref(), Some("32")); + } + + #[test] + fn changed_split_add_target_rejects_combined_width_overflow() { + let mut instructions = vec![ + Instruction { + pc: 0, + op: Opcode::PUSH(1), + imm: Some("ff".into()), + }, + Instruction { + pc: 2, + op: Opcode::PUSH(1), + imm: Some("ff".into()), + }, + ]; + let before = instructions.clone(); + + assert!(apply_split_add_immediate(&mut instructions, 0, 1, 511).is_err()); + assert_eq!(instructions, before); + } + #[test] fn build_cfg_ir_creates_basic_blocks() { let instructions = vec![ @@ -2673,6 +2825,78 @@ mod tests { } } + #[test] + fn oversized_jump_address_arithmetic_fails_closed_without_panicking() { + let direct = vec![ + Instruction { + pc: 0, + op: Opcode::STOP, + imm: None, + }, + Instruction { + pc: 1, + op: Opcode::PUSH(8), + imm: Some("ffffffffffffffff".into()), + }, + Instruction { + pc: 10, + op: Opcode::JUMP, + imm: None, + }, + ]; + let sections = vec![ + Section { + kind: SectionKind::Init, + offset: 0, + len: 1, + }, + Section { + kind: SectionKind::Runtime, + offset: 1, + len: 10, + }, + ]; + let direct_bundle = build_cfg_ir( + &direct, + §ions, + sample_clean_report(11), + &sample_bytecode(11), + ) + .expect("overflowing direct target is represented as unresolved, not panicked"); + assert!(!direct_bundle.relationships().is_relocatable()); + + let pc_relative = vec![ + Instruction { + pc: 0, + op: Opcode::PUSH(8), + imm: Some("ffffffffffffffff".into()), + }, + Instruction { + pc: 9, + op: Opcode::PC, + imm: None, + }, + Instruction { + pc: 10, + op: Opcode::ADD, + imm: None, + }, + Instruction { + pc: 11, + op: Opcode::JUMP, + imm: None, + }, + ]; + let pc_relative_bundle = build_cfg_ir( + &pc_relative, + &[sample_runtime_section(12)], + sample_clean_report(12), + &sample_bytecode(12), + ) + .expect("overflowing PC-relative target is represented as unresolved, not panicked"); + assert!(!pc_relative_bundle.relationships().is_relocatable()); + } + #[test] fn reindex_pcs_returns_mapping() { let instructions = vec![ diff --git a/crates/core/src/cfg_ir/relationships.rs b/crates/core/src/cfg_ir/relationships.rs new file mode 100644 index 00000000..bc7827e1 --- /dev/null +++ b/crates/core/src/cfg_ir/relationships.rs @@ -0,0 +1,1616 @@ +//! Relationship index for the EVM control-flow intermediate representation. +//! +//! The CFG stores control-flow edges, while this module records the relationships that a +//! transform needs in order to change physical layout safely. In particular, it distinguishes +//! stable block identity (`NodeIndex`) from byte offsets and groups blocks that must move together +//! because execution falls through between them or because they use PC-relative addressing. + +use super::{Block, BlockControl, CfgIrBundle, EdgeType, JumpEncoding, JumpTarget}; +use crate::Opcode; +use crate::detection::SectionKind; +use petgraph::Direction::{Incoming, Outgoing}; +use petgraph::graph::NodeIndex; +use petgraph::visit::EdgeRef; +use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; + +/// Semantic role assigned to a body block. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BlockRole { + /// First executable body block. + Entry, + /// Block detected or synthesized as part of the function dispatcher. + Dispatcher, + /// Block begins with `JUMPDEST` and can therefore receive a dynamic jump. + JumpDestination, + /// Block ends execution. + Terminal, + /// Block ends in `JUMP` or `JUMPI`. + Control, + /// Block has no more specific role. + Ordinary, +} + +/// One typed relationship to another body block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlockLink { + /// Related body block. + pub block: NodeIndex, + /// Control-flow relationship. + pub kind: EdgeType, +} + +/// A literal code pointer whose value must be relinked when physical layout changes. +/// +/// The source is expressed as stable block identity plus instruction index. Concrete program +/// counters are deliberately absent: they are products of lowering, not object identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CodePointerRelocation { + /// Block containing the PUSH instruction. + pub source: NodeIndex, + /// Instruction index within `source`. + pub instruction_index: usize, + /// Body block whose `JUMPDEST` address is pushed. + pub target: NodeIndex, + /// Coordinate system used by the encoded immediate. + pub encoding: JumpEncoding, +} + +/// Relationship data retained for one body block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockRelationships { + /// Position in the intended physical bytecode layout. + pub layout_position: usize, + /// Code section that owns this block. + pub section: SectionKind, + /// Contiguous section region that owns this block in the source layout. + /// + /// The region is intentionally stronger than [`Self::section`]: two runtime spans separated + /// by another section have distinct regions even though both have `SectionKind::Runtime`. + pub layout_region: usize, + /// Incoming control-flow links, sorted deterministically. + pub predecessors: Vec, + /// Outgoing control-flow links, sorted deterministically. + pub successors: Vec, + /// Cluster that must be laid out as one unit. + pub cluster: usize, + /// High-level semantic roles. + pub roles: BTreeSet, +} + +/// A maximal set of blocks that must preserve internal physical order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockCluster { + /// Stable index within this relationship snapshot. + pub id: usize, + /// Members in physical order. + pub members: Vec, + /// Control-flow links entering from another cluster. + pub incoming: Vec, + /// Control-flow links leaving for another cluster. + pub outgoing: Vec, + /// Whether this cluster contains an externally entered section-region start. + pub anchors_entry: bool, + /// Whether this cluster must remain last because it falls off the end of code. + pub anchors_exit: bool, + /// Contiguous section region for movable clusters. Mixed-region clusters are immovable. + pub layout_region: Option, +} + +/// Complete relationship snapshot derived from a CFG bundle. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RelationshipIndex { + /// Per-block relationships. + pub blocks: HashMap, + /// Layout-safe clusters. + pub clusters: Vec, + /// Physical adjacency requirements expressed as `(before, after)`. + pub adjacency: Vec<(NodeIndex, NodeIndex)>, + /// Blocks whose control target could not be resolved symbolically. + pub unresolved_control: Vec, + /// Blocks containing code-introspection operations whose meaning may depend on byte offsets. + pub position_sensitive: Vec, + /// Stack-proven literal code pointers, including internal-call return addresses. + pub code_pointer_relocations: Vec, + /// Blocks whose jump destination may depend on a constructor-materialized runtime word. + /// + /// Solidity represents an unlinked immutable as `PUSH32 0x00..00` in the creation + /// artifact's runtime template and overwrites those 32 bytes during construction. Treating + /// that zero as a literal program counter would make the CFG valid only for the template, + /// not for the code that is actually deployed. + pub constructor_materialized_control: Vec, + /// First executable body block. + pub entry_block: Option, + /// Empty-stack external entry of each executable section region. + pub region_entry_blocks: Vec, + /// Last block when reaching the end of its instructions is a semantic halt. + pub exit_fallthrough_block: Option, +} + +impl RelationshipIndex { + /// Derives a deterministic relationship snapshot from `bundle`. + pub fn build(bundle: &CfgIrBundle) -> Result { + validate_layout_members(bundle, bundle.layout_order())?; + + let layout = bundle.layout_order(); + let positions: HashMap<_, _> = layout + .iter() + .enumerate() + .map(|(position, node)| (*node, position)) + .collect(); + let entry_block = find_entry_body(bundle).or_else(|| layout.first().copied()); + let region_entry_blocks = find_analysis_entries(bundle); + let mut region_by_node = HashMap::new(); + let mut previous_section = None; + let mut layout_region = 0usize; + for node in layout.iter().copied() { + let Some(Block::Body(body)) = bundle.cfg.node_weight(node) else { + return Err(format!("layout contains non-body node {}", node.index())); + }; + if previous_section.is_some_and(|section| section != body.section) { + layout_region += 1; + } + previous_section = Some(body.section); + region_by_node.insert(node, layout_region); + } + let exit_fallthrough_block = layout.last().copied().filter(|node| { + matches!( + bundle.cfg.node_weight(*node), + Some(Block::Body(body)) + if matches!(body.control, BlockControl::Fallthrough) + || body.instructions.last().is_some_and(|instruction| { + matches!(instruction.op, Opcode::JUMPI) + }) + ) + }); + + let mut adjacency = Vec::new(); + let mut unresolved_control = Vec::new(); + let mut dynamically_resolvable_control = HashSet::new(); + let mut position_sensitive = Vec::new(); + let mut union = UnionFind::new(layout.len()); + + let dynamic_control = analyze_dynamic_control(bundle, &positions); + position_sensitive.extend(dynamic_control.observed_code_pointer_data.iter().copied()); + + for (position, node) in layout.iter().copied().enumerate() { + let Some(Block::Body(body)) = bundle.cfg.node_weight(node) else { + return Err(format!("layout contains non-body node {}", node.index())); + }; + + // JUMPI always has a physical false path, even when its true destination is carried + // through the stack and the cached BlockControl therefore remains Unknown. Preserve + // that adjacency independently of target resolution and cached graph edges. + if body + .instructions + .last() + .is_some_and(|instruction| matches!(instruction.op, Opcode::JUMPI)) + && let Some(target) = layout.get(position + 1).copied() + { + adjacency.push((node, target)); + union.union(position, position + 1); + } + + if body.instructions.iter().any(|instruction| { + matches!( + instruction.op, + Opcode::PC + | Opcode::CODESIZE + | Opcode::CODECOPY + | Opcode::EXTCODESIZE + | Opcode::EXTCODECOPY + | Opcode::EXTCODEHASH + ) + }) { + position_sensitive.push(node); + } + + match &body.control { + BlockControl::Unknown => { + unresolved_control.push(node); + if body.instructions.last().is_some_and(|instruction| { + matches!(instruction.op, Opcode::JUMP | Opcode::JUMPI) + }) { + dynamically_resolvable_control.insert(node); + } + } + BlockControl::Fallthrough => { + if let Some(target) = layout.get(position + 1).copied() { + adjacency.push((node, target)); + union.union(position, position + 1); + } + } + BlockControl::Jump { target } => { + register_target_constraint( + target, + node, + position, + &positions, + &mut union, + &mut unresolved_control, + ); + } + BlockControl::Branch { + true_target, + false_target, + } => { + register_target_constraint( + true_target, + node, + position, + &positions, + &mut union, + &mut unresolved_control, + ); + register_target_constraint( + false_target, + node, + position, + &positions, + &mut union, + &mut unresolved_control, + ); + } + _ => {} + } + + for edge in bundle.cfg.edges_directed(node, Outgoing) { + let target = edge.target(); + if !positions.contains_key(&target) { + continue; + } + if matches!(edge.weight(), EdgeType::Fallthrough | EdgeType::BranchFalse) { + adjacency.push((node, target)); + let target_position = positions[&target]; + union.union(position, target_position); + } + } + } + + adjacency.sort_by_key(|(before, after)| (positions[before], positions[after])); + adjacency.dedup(); + unresolved_control.sort_by_key(|node| positions.get(node).copied().unwrap_or(usize::MAX)); + unresolved_control.dedup(); + unresolved_control.retain(|node| { + !dynamically_resolvable_control.contains(node) + || !dynamic_control.resolved_sources.contains(node) + }); + position_sensitive.sort_by_key(|node| positions.get(node).copied().unwrap_or(usize::MAX)); + position_sensitive.dedup(); + let mut constructor_materialized_control: Vec<_> = dynamic_control + .constructor_materialized_control + .into_iter() + .collect(); + constructor_materialized_control + .sort_by_key(|node| positions.get(node).copied().unwrap_or(usize::MAX)); + + let mut members_by_root: HashMap> = HashMap::new(); + for (position, node) in layout.iter().copied().enumerate() { + members_by_root + .entry(union.find(position)) + .or_default() + .push(node); + } + let mut grouped: Vec<_> = members_by_root.into_values().collect(); + grouped.sort_by_key(|members| positions[&members[0]]); + + let mut cluster_by_node = HashMap::new(); + for (cluster_id, members) in grouped.iter().enumerate() { + for node in members { + cluster_by_node.insert(*node, cluster_id); + } + } + + let mut blocks = HashMap::new(); + for node in layout.iter().copied() { + let Block::Body(body) = &bundle.cfg[node] else { + unreachable!("layout membership was validated"); + }; + let mut predecessors = body_links(bundle, node, Incoming, &positions); + let mut successors = body_links(bundle, node, Outgoing, &positions); + for (source, target, kind) in &dynamic_control.links { + if *target == node { + predecessors.push(BlockLink { + block: *source, + kind: *kind, + }); + } + if *source == node { + successors.push(BlockLink { + block: *target, + kind: *kind, + }); + } + } + sort_links(&mut predecessors, &positions); + sort_links(&mut successors, &positions); + predecessors.dedup(); + successors.dedup(); + + let mut roles = BTreeSet::new(); + if entry_block == Some(node) || region_entry_blocks.contains(&node) { + roles.insert(BlockRole::Entry); + } + if bundle.dispatcher_blocks.contains(&node.index()) { + roles.insert(BlockRole::Dispatcher); + } + if body + .instructions + .first() + .is_some_and(|instruction| matches!(instruction.op, Opcode::JUMPDEST)) + { + roles.insert(BlockRole::JumpDestination); + } + match body.control { + BlockControl::Terminal => { + roles.insert(BlockRole::Terminal); + } + BlockControl::Jump { .. } | BlockControl::Branch { .. } => { + roles.insert(BlockRole::Control); + } + _ => {} + } + if roles.is_empty() { + roles.insert(BlockRole::Ordinary); + } + + blocks.insert( + node, + BlockRelationships { + layout_position: positions[&node], + section: body.section, + layout_region: region_by_node[&node], + predecessors, + successors, + cluster: cluster_by_node[&node], + roles, + }, + ); + } + + let mut clusters = Vec::with_capacity(grouped.len()); + for (id, members) in grouped.into_iter().enumerate() { + let member_set: HashSet<_> = members.iter().copied().collect(); + let member_regions: BTreeSet<_> = members + .iter() + .map(|member| region_by_node[member]) + .collect(); + let mut incoming = Vec::new(); + let mut outgoing = Vec::new(); + for member in &members { + for link in &blocks[member].predecessors { + if !member_set.contains(&link.block) { + incoming.push(*link); + } + } + for link in &blocks[member].successors { + if !member_set.contains(&link.block) { + outgoing.push(*link); + } + } + } + sort_links(&mut incoming, &positions); + sort_links(&mut outgoing, &positions); + incoming.dedup(); + outgoing.dedup(); + clusters.push(BlockCluster { + id, + anchors_entry: entry_block.is_some_and(|entry| member_set.contains(&entry)) + || region_entry_blocks + .iter() + .any(|entry| member_set.contains(entry)), + anchors_exit: exit_fallthrough_block.is_some_and(|exit| member_set.contains(&exit)), + layout_region: (member_regions.len() == 1) + .then(|| *member_regions.first().expect("one member region")), + members, + incoming, + outgoing, + }); + } + + let index = Self { + blocks, + clusters, + adjacency, + unresolved_control, + position_sensitive, + code_pointer_relocations: dynamic_control.relocations, + constructor_materialized_control, + entry_block, + region_entry_blocks, + exit_fallthrough_block, + }; + index.validate_layout(bundle.layout_order())?; + Ok(index) + } + + /// Returns true when all address-sensitive control flow was resolved and no code + /// introspection operation prevents safe relocation. + pub fn is_relocatable(&self) -> bool { + self.unresolved_control.is_empty() + && self.position_sensitive.is_empty() + && self.constructor_materialized_control.is_empty() + } + + /// Validates a proposed physical body-block order against this snapshot. + pub fn validate_layout(&self, order: &[NodeIndex]) -> Result<(), String> { + if order.len() != self.blocks.len() { + return Err(format!( + "layout has {} body blocks but relationship index has {}", + order.len(), + self.blocks.len() + )); + } + let positions: HashMap<_, _> = order + .iter() + .enumerate() + .map(|(position, node)| (*node, position)) + .collect(); + if positions.len() != order.len() + || self.blocks.keys().any(|node| !positions.contains_key(node)) + { + return Err("layout must contain every body block exactly once".to_string()); + } + if let Some(entry) = self.entry_block + && order.first().copied() != Some(entry) + { + return Err(format!( + "entry block {} must remain first in physical layout", + entry.index() + )); + } + for entry in &self.region_entry_blocks { + let entry_position = self.blocks[entry].layout_position; + if order.get(entry_position).copied() != Some(*entry) { + return Err(format!( + "section-region entry block {} must remain at layout position {}", + entry.index(), + entry_position + )); + } + } + if let Some(exit) = self.exit_fallthrough_block + && order.last().copied() != Some(exit) + { + return Err(format!( + "fallthrough-to-end block {} must remain last in physical layout", + exit.index() + )); + } + let mut expected_regions = vec![usize::MAX; self.blocks.len()]; + for relationships in self.blocks.values() { + expected_regions[relationships.layout_position] = relationships.layout_region; + } + for (position, node) in order.iter().enumerate() { + if self.blocks[node].layout_region != expected_regions[position] { + return Err(format!( + "layout moves block {} across a section-region boundary", + node.index() + )); + } + } + for (before, after) in &self.adjacency { + let before_position = positions[before]; + let after_position = positions[after]; + if after_position != before_position + 1 { + return Err(format!( + "layout separates required adjacency {} -> {}", + before.index(), + after.index() + )); + } + } + for cluster in &self.clusters { + let actual: Vec<_> = order + .iter() + .copied() + .filter(|node| cluster.members.contains(node)) + .collect(); + if actual != cluster.members { + return Err(format!("layout reorders members of cluster {}", cluster.id)); + } + if let (Some(first), Some(last)) = (cluster.members.first(), cluster.members.last()) { + let expected_len = positions[last] - positions[first] + 1; + if expected_len != cluster.members.len() { + return Err(format!("layout splits cluster {}", cluster.id)); + } + } + } + Ok(()) + } +} + +fn validate_layout_members(bundle: &CfgIrBundle, order: &[NodeIndex]) -> Result<(), String> { + let body_nodes: HashSet<_> = bundle + .cfg + .node_indices() + .filter(|node| matches!(bundle.cfg[*node], Block::Body(_))) + .collect(); + let layout_nodes: HashSet<_> = order.iter().copied().collect(); + if body_nodes != layout_nodes || layout_nodes.len() != order.len() { + return Err("layout must contain every body block exactly once".to_string()); + } + + // Stable node identity is only meaningful when every decoded instruction owns one unique, + // non-overlapping byte span. Check this independently of physical layout order: a shuffled + // bundle intentionally retains its pre-lowering PCs until the atomic reindex step. + let mut spans = Vec::new(); + for node in &body_nodes { + let Block::Body(body) = &bundle.cfg[*node] else { + unreachable!("body_nodes contains only body blocks"); + }; + let mut expected_pc = body.start_pc; + for instruction in &body.instructions { + if instruction.pc != expected_pc { + return Err(format!( + "block {} has a gap or overlap before instruction 0x{:x}", + node.index(), + instruction.pc + )); + } + let end = instruction + .pc + .checked_add(instruction.byte_size()) + .ok_or_else(|| format!("instruction span overflows at 0x{:x}", instruction.pc))?; + spans.push((instruction.pc, end, *node)); + expected_pc = end; + } + } + spans.sort_by_key(|(start, end, node)| (*start, *end, node.index())); + for pair in spans.windows(2) { + let (_, previous_end, previous_node) = pair[0]; + let (next_start, _, next_node) = pair[1]; + if next_start < previous_end { + return Err(format!( + "instruction spans overlap between blocks {} and {} at 0x{next_start:x}", + previous_node.index(), + next_node.index() + )); + } + } + Ok(()) +} + +fn find_entry_body(bundle: &CfgIrBundle) -> Option { + let entry = bundle + .cfg + .node_indices() + .find(|node| matches!(bundle.cfg[*node], Block::Entry))?; + bundle + .cfg + .edges_directed(entry, Outgoing) + .map(|edge| edge.target()) + .find(|target| matches!(bundle.cfg[*target], Block::Body(_))) +} + +fn body_links( + bundle: &CfgIrBundle, + node: NodeIndex, + direction: petgraph::Direction, + positions: &HashMap, +) -> Vec { + bundle + .cfg + .edges_directed(node, direction) + .filter_map(|edge| { + let other = if direction == Outgoing { + edge.target() + } else { + edge.source() + }; + positions.contains_key(&other).then_some(BlockLink { + block: other, + kind: *edge.weight(), + }) + }) + .collect() +} + +fn sort_links(links: &mut [BlockLink], positions: &HashMap) { + links.sort_by_key(|link| { + ( + positions.get(&link.block).copied().unwrap_or(usize::MAX), + edge_rank(&link.kind), + ) + }); +} + +fn edge_rank(kind: &EdgeType) -> u8 { + match kind { + EdgeType::Fallthrough => 0, + EdgeType::BranchFalse => 1, + EdgeType::BranchTrue => 2, + EdgeType::Jump => 3, + } +} + +fn register_target_constraint( + target: &JumpTarget, + source: NodeIndex, + source_position: usize, + positions: &HashMap, + union: &mut UnionFind, + unresolved: &mut Vec, +) { + match target { + JumpTarget::Raw { .. } => unresolved.push(source), + JumpTarget::Block { + node, + encoding: JumpEncoding::PcRelative, + } => { + let Some(&target_position) = positions.get(node) else { + unresolved.push(source); + return; + }; + // Preserving a PC-relative delta requires preserving every byte between source and + // target, so the whole physical interval becomes one cluster. + let (start, end) = if source_position <= target_position { + (source_position, target_position) + } else { + (target_position, source_position) + }; + for position in start..end { + union.union(position, position + 1); + } + } + JumpTarget::Block { node, .. } if !positions.contains_key(node) => unresolved.push(source), + JumpTarget::Block { .. } => {} + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum AbstractValue { + Unknown, + ConstructorMaterialized { + origins: BTreeSet<(NodeIndex, usize)>, + }, + CodePointer { + origins: BTreeSet<(NodeIndex, usize)>, + targets: BTreeSet, + }, +} + +#[derive(Debug, Clone)] +struct JumpResolution { + kind: EdgeType, + value: AbstractValue, +} + +#[derive(Debug, Clone)] +struct BlockTransfer { + output: Vec, + jumps: Vec, + observed_code_pointer_origins: BTreeSet<(NodeIndex, usize)>, +} + +#[derive(Debug, Clone, Default)] +struct DynamicControlAnalysis { + resolved_sources: HashSet, + links: Vec<(NodeIndex, NodeIndex, EdgeType)>, + relocations: Vec, + observed_code_pointer_data: HashSet, + constructor_materialized_control: HashSet, +} + +/// Tracks literal code pointers through the cross-block EVM stack. +/// +/// Solidity's legacy internal-call convention carries a return `JUMPDEST` on the stack. Those +/// jumps look dynamic if each block is inspected in isolation, but the value still originates in +/// a concrete PUSH. This analysis follows PUSH/DUP/SWAP and exact CFG joins. Any unsupported +/// instruction, stack underflow, mismatched join height, or non-literal destination leaves the +/// jump unresolved, which in turn prevents layout transformation. +fn analyze_dynamic_control( + bundle: &CfgIrBundle, + positions: &HashMap, +) -> DynamicControlAnalysis { + const MAX_ABSTRACT_CONTEXTS: usize = 8_192; + + let entries = find_analysis_entries(bundle); + if entries.is_empty() { + return DynamicControlAnalysis::default(); + } + + // A legacy EVM block may intentionally be shared by callers with different return addresses + // or unrelated values below its arguments. Keep those abstract contexts separate: joining a + // code pointer with arbitrary data would erase exactly the relationship we need to prove. + let mut states: HashMap>> = HashMap::new(); + let mut worklist = VecDeque::new(); + for entry in entries { + states.entry(entry).or_default().insert(Vec::new()); + worklist.push_back((entry, Vec::new())); + } + let mut state_count = worklist.len(); + let mut analysis_sound = true; + let mut observed_origins_by_node: HashMap> = + HashMap::new(); + let mut constructor_materialized_control = HashSet::new(); + + while let Some((node, input)) = worklist.pop_front() { + let Some(transfer) = transfer_block(bundle, node, input) else { + tracing::debug!( + node = node.index(), + "code-pointer analysis could not model a reachable block" + ); + analysis_sound = false; + continue; + }; + if !transfer.observed_code_pointer_origins.is_empty() { + observed_origins_by_node + .entry(node) + .or_default() + .extend(transfer.observed_code_pointer_origins.iter().copied()); + } + if transfer + .jumps + .iter() + .any(|jump| matches!(&jump.value, AbstractValue::ConstructorMaterialized { .. })) + { + constructor_materialized_control.insert(node); + } + + let mut successors: Vec = bundle + .cfg + .edges_directed(node, Outgoing) + .map(|edge| edge.target()) + .filter(|target| positions.contains_key(target)) + .collect(); + // A stack-carried destination leaves BlockControl as Unknown, so the CFG has no cached + // BranchFalse edge. JUMPI nevertheless always propagates its post-pop stack to the next + // physical block when the condition is false. Analyze that context before deciding whether + // a relocated code pointer is also observed as ordinary data on the false path. + if matches!( + bundle.cfg.node_weight(node), + Some(Block::Body(body)) + if body.instructions.last().is_some_and(|instruction| { + matches!(instruction.op, Opcode::JUMPI) + }) + ) && let Some(false_successor) = positions + .get(&node) + .and_then(|position| bundle.layout_order().get(position + 1)) + .copied() + { + successors.push(false_successor); + } + for jump in &transfer.jumps { + if let AbstractValue::CodePointer { targets, .. } = &jump.value { + successors.extend(targets.iter().copied()); + } + } + successors.sort_by_key(|target| positions.get(target).copied().unwrap_or(usize::MAX)); + successors.dedup(); + + for successor in successors { + let contexts = states.entry(successor).or_default(); + if contexts.insert(transfer.output.clone()) { + worklist.push_back((successor, transfer.output.clone())); + state_count += 1; + } + if state_count > MAX_ABSTRACT_CONTEXTS { + tracing::debug!("code-pointer analysis exceeded its context limit"); + analysis_sound = false; + worklist.clear(); + break; + } + } + } + + if !analysis_sound { + return DynamicControlAnalysis { + constructor_materialized_control, + ..DynamicControlAnalysis::default() + }; + } + + // Re-evaluate every reachable abstract context. A source is considered resolved only when + // every context reaching its jump carries a finite set of literal code pointers. + let mut resolved_sources = HashSet::new(); + let mut sources_with_jumps = HashSet::new(); + let mut unresolved_sources = HashSet::new(); + let mut links = Vec::new(); + let mut used_origins = BTreeSet::new(); + let mut origins_to_targets = HashMap::new(); + + let mut final_nodes: Vec<_> = states.into_iter().collect(); + final_nodes.sort_by_key(|(node, _)| positions.get(node).copied().unwrap_or(usize::MAX)); + for (node, contexts) in final_nodes { + for input in contexts { + let Some(transfer) = transfer_block(bundle, node, input) else { + continue; + }; + if !transfer.jumps.is_empty() { + sources_with_jumps.insert(node); + } + for jump in transfer.jumps { + if matches!(&jump.value, AbstractValue::ConstructorMaterialized { .. }) { + constructor_materialized_control.insert(node); + unresolved_sources.insert(node); + continue; + } + let AbstractValue::CodePointer { origins, targets } = jump.value else { + let (start_pc, tail) = match &bundle.cfg[node] { + Block::Body(body) => ( + body.start_pc, + body.instructions + .iter() + .rev() + .take(8) + .map(|instruction| instruction.op.name()) + .collect::>(), + ), + _ => (0, Vec::new()), + }; + tracing::debug!( + node = node.index(), + start_pc = format_args!("0x{start_pc:x}"), + value = ?jump.value, + tail = ?tail, + "code-pointer analysis could not resolve a reachable jump destination" + ); + unresolved_sources.insert(node); + continue; + }; + if origins.is_empty() || targets.is_empty() { + unresolved_sources.insert(node); + continue; + } + used_origins.extend(origins.iter().copied()); + for origin in origins { + if let Some(target) = code_pointer_target(bundle, origin.0, origin.1) { + origins_to_targets.insert(origin, target); + } else { + unresolved_sources.insert(node); + } + } + for target in targets { + links.push((node, target, jump.kind)); + } + } + } + } + resolved_sources.extend(sources_with_jumps.difference(&unresolved_sources).copied()); + + links.sort_by_key(|(source, target, kind)| { + ( + positions.get(source).copied().unwrap_or(usize::MAX), + positions.get(target).copied().unwrap_or(usize::MAX), + edge_rank(kind), + ) + }); + links.dedup(); + + let observed_code_pointer_data = observed_origins_by_node + .into_iter() + .filter_map(|(node, origins)| { + origins + .iter() + .any(|origin| used_origins.contains(origin)) + .then_some(node) + }) + .collect(); + + let mut relocations: Vec<_> = used_origins + .into_iter() + .filter_map(|(source, instruction_index)| { + origins_to_targets + .get(&(source, instruction_index)) + .copied() + .map(|target| CodePointerRelocation { + source, + instruction_index, + target, + encoding: match &bundle.cfg[source] { + Block::Body(body) if body.section == SectionKind::Runtime => { + JumpEncoding::RuntimeRelative + } + _ => JumpEncoding::Absolute, + }, + }) + }) + .collect(); + relocations.sort(); + relocations.dedup(); + + DynamicControlAnalysis { + resolved_sources, + links, + relocations, + observed_code_pointer_data, + constructor_materialized_control, + } +} + +fn transfer_block( + bundle: &CfgIrBundle, + node: NodeIndex, + mut stack: Vec, +) -> Option { + const EVM_STACK_LIMIT: usize = 1_024; + + let Block::Body(body) = &bundle.cfg[node] else { + return None; + }; + if stack.len() > EVM_STACK_LIMIT { + return None; + } + let mut jumps = Vec::new(); + let mut observed_code_pointer_origins = BTreeSet::new(); + + for (instruction_index, instruction) in body.instructions.iter().enumerate() { + match instruction.op { + Opcode::PUSH(width) => { + let value = instruction + .imm + .as_deref() + .and_then(|immediate| usize::from_str_radix(immediate, 16).ok()); + let value = if is_constructor_materialized_placeholder(bundle, body, instruction) { + AbstractValue::ConstructorMaterialized { + origins: BTreeSet::from([(node, instruction_index)]), + } + } else { + value.map_or(AbstractValue::Unknown, |value| { + code_pointer_target_for_value(bundle, body.section, value).map_or( + AbstractValue::Unknown, + |target| AbstractValue::CodePointer { + origins: BTreeSet::from([(node, instruction_index)]), + targets: BTreeSet::from([target]), + }, + ) + }) + }; + let _ = width; + if stack.len() == EVM_STACK_LIMIT { + return None; + } + stack.push(value); + continue; + } + Opcode::PUSH0 => { + if stack.len() == EVM_STACK_LIMIT { + return None; + } + stack.push(AbstractValue::Unknown); + continue; + } + Opcode::DUP(depth) => { + let depth = depth as usize; + if depth == 0 || stack.len() < depth { + return None; + } + if stack.len() == EVM_STACK_LIMIT { + return None; + } + stack.push(stack[stack.len() - depth].clone()); + continue; + } + Opcode::SWAP(depth) => { + let depth = depth as usize; + if depth == 0 || stack.len() <= depth { + return None; + } + let top = stack.len() - 1; + stack.swap(top, top - depth); + continue; + } + Opcode::JUMP | Opcode::JUMPI => { + let destination = stack.last().cloned().unwrap_or(AbstractValue::Unknown); + jumps.push(JumpResolution { + kind: if matches!(instruction.op, Opcode::JUMPI) { + EdgeType::BranchTrue + } else { + EdgeType::Jump + }, + value: destination, + }); + } + _ => {} + } + + let info = instruction.op.info()?; + if stack.len() < info.inputs as usize { + return None; + } + let inputs = info.inputs as usize; + let consumed = &stack[stack.len() - inputs..]; + let data_inputs = match instruction.op { + // The stack top is the jump destination. JUMPI's remaining input is its condition and + // therefore an ordinary data use if it carries a relocatable code pointer. + Opcode::JUMP | Opcode::JUMPI => &consumed[..consumed.len().saturating_sub(1)], + // Throwing away a duplicate address is not externally observable. + Opcode::POP => &consumed[0..0], + _ => consumed, + }; + for value in data_inputs { + if let AbstractValue::CodePointer { origins, .. } = value { + observed_code_pointer_origins.extend(origins.iter().copied()); + } + } + let constructor_materialized_origins: BTreeSet<_> = consumed + .iter() + .filter_map(|value| match value { + AbstractValue::ConstructorMaterialized { origins } => Some(origins), + _ => None, + }) + .flatten() + .copied() + .collect(); + stack.truncate(stack.len() - inputs); + if stack.len() + info.outputs as usize > EVM_STACK_LIMIT { + return None; + } + let output = if constructor_materialized_origins.is_empty() { + AbstractValue::Unknown + } else { + AbstractValue::ConstructorMaterialized { + origins: constructor_materialized_origins, + } + }; + stack.extend((0..info.outputs).map(|_| output.clone())); + } + + Some(BlockTransfer { + output: stack, + jumps, + observed_code_pointer_origins, + }) +} + +/// True for an unlinked Solidity immutable word in an init-bearing artifact. +/// +/// A standalone deployed-runtime artifact may intentionally contain `PUSH32 0`; without init +/// code there is no constructor capable of replacing the immediate, so it remains an ordinary +/// literal. With init code, however, the exact all-zero PUSH32 shape is the compiler's immutable +/// placeholder contract and must retain constructor provenance through control-flow analysis. +fn is_constructor_materialized_placeholder( + bundle: &CfgIrBundle, + body: &super::BlockBody, + instruction: &crate::decoder::Instruction, +) -> bool { + body.section == SectionKind::Runtime + && bundle + .clean_report + .removed + .iter() + .any(|removed| removed.kind == SectionKind::Init) + && matches!(instruction.op, Opcode::PUSH(32)) + && instruction.imm.as_deref().is_some_and(|immediate| { + immediate.len() == 64 && immediate.bytes().all(|byte| byte == b'0') + }) +} + +/// Returns the external empty-stack entry of each executable section region. +/// +/// Init and deployed runtime are separate EVM executions. Seeding only the graph's global Entry +/// successor leaves runtime unanalyzed when a caller builds a combined init/runtime CFG. We seed +/// the first block of each contiguous executable region, but never arbitrary blocks in the middle +/// of a region (which could incorrectly invent empty-stack paths into internal functions). +fn find_analysis_entries(bundle: &CfgIrBundle) -> Vec { + let mut entries = Vec::new(); + let mut previous_section = None; + for node in bundle.layout_order().iter().copied() { + let Block::Body(body) = &bundle.cfg[node] else { + continue; + }; + let starts_region = previous_section != Some(body.section); + if starts_region && matches!(body.section, SectionKind::Init | SectionKind::Runtime) { + entries.push(node); + } + previous_section = Some(body.section); + } + entries +} + +fn code_pointer_target( + bundle: &CfgIrBundle, + source: NodeIndex, + instruction_index: usize, +) -> Option { + let Block::Body(body) = &bundle.cfg[source] else { + return None; + }; + let instruction = body.instructions.get(instruction_index)?; + let value = instruction + .imm + .as_deref() + .and_then(|immediate| usize::from_str_radix(immediate, 16).ok())?; + code_pointer_target_for_value(bundle, body.section, value) +} + +fn code_pointer_target_for_value( + bundle: &CfgIrBundle, + source_section: SectionKind, + value: usize, +) -> Option { + let absolute = if source_section == SectionKind::Runtime { + bundle + .runtime_bounds + .map(|(runtime_start, _)| runtime_start.saturating_add(value)) + .unwrap_or(value) + } else { + value + }; + let node = *bundle.pc_to_block.get(&absolute)?; + let Block::Body(target) = &bundle.cfg[node] else { + return None; + }; + target + .instructions + .first() + .is_some_and(|instruction| matches!(instruction.op, Opcode::JUMPDEST)) + .then_some(node) +} + +#[derive(Debug, Clone)] +struct UnionFind { + parent: Vec, + rank: Vec, +} + +impl UnionFind { + fn new(len: usize) -> Self { + Self { + parent: (0..len).collect(), + rank: vec![0; len], + } + } + + fn find(&mut self, value: usize) -> usize { + if self.parent[value] != value { + self.parent[value] = self.find(self.parent[value]); + } + self.parent[value] + } + + fn union(&mut self, left: usize, right: usize) { + let left_root = self.find(left); + let right_root = self.find(right); + if left_root == right_root { + return; + } + match self.rank[left_root].cmp(&self.rank[right_root]) { + std::cmp::Ordering::Less => self.parent[left_root] = right_root, + std::cmp::Ordering::Greater => self.parent[right_root] = left_root, + std::cmp::Ordering::Equal => { + self.parent[right_root] = left_root; + self.rank[left_root] += 1; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cfg_ir::build_cfg_ir; + use crate::decoder::Instruction; + use crate::detection::Section; + use crate::strip::{CleanReport, RuntimeSpan}; + use revm::primitives::B256; + + fn instruction(pc: usize, op: Opcode, immediate: Option<&str>) -> Instruction { + Instruction { + pc, + op, + imm: immediate.map(str::to_string), + } + } + + fn clean_report(len: usize) -> CleanReport { + CleanReport { + runtime_layout: vec![RuntimeSpan { offset: 0, len }], + removed: Vec::new(), + swarm_hash: None, + bytes_saved: 0, + clean_len: len, + clean_keccak: B256::ZERO, + program_counter_mapping: Vec::new(), + } + } + + fn build(instructions: &[Instruction], sections: &[Section], code_len: usize) -> CfgIrBundle { + build_cfg_ir( + instructions, + sections, + clean_report(code_len), + &vec![0; code_len], + ) + .expect("test CFG builds") + } + + #[test] + fn fallthrough_to_end_is_an_explicit_exit_anchor() { + let instructions = vec![ + instruction(0, Opcode::JUMPDEST, None), + instruction(1, Opcode::PUSH(1), Some("04")), + instruction(3, Opcode::JUMP, None), + instruction(4, Opcode::JUMPDEST, None), + instruction(5, Opcode::STOP, None), + instruction(6, Opcode::JUMPDEST, None), + ]; + let sections = [Section { + kind: SectionKind::Runtime, + offset: 0, + len: 7, + }]; + let bundle = build(&instructions, §ions, 7); + let order = bundle.layout_order(); + assert_eq!( + bundle.relationships.exit_fallthrough_block, + order.last().copied() + ); + + let invalid = vec![order[0], order[2], order[1]]; + let error = bundle + .relationships + .validate_layout(&invalid) + .expect_err("moving the end-of-code fallthrough must fail closed"); + assert!(error.contains("must remain last")); + } + + #[test] + fn fallthrough_constraint_does_not_depend_on_cached_edges() { + let instructions = vec![ + instruction(0, Opcode::PUSH0, None), + instruction(1, Opcode::POP, None), + instruction(2, Opcode::JUMPDEST, None), + instruction(3, Opcode::STOP, None), + ]; + let sections = [Section { + kind: SectionKind::Runtime, + offset: 0, + len: 4, + }]; + let mut bundle = build(&instructions, §ions, 4); + let source = bundle.layout_order()[0]; + let target = bundle.layout_order()[1]; + let edges: Vec<_> = bundle + .cfg + .edges_directed(source, Outgoing) + .map(|edge| edge.id()) + .collect(); + for edge in edges { + bundle.cfg.remove_edge(edge); + } + + bundle + .refresh_relationships() + .expect("relationships rebuild"); + assert!(bundle.relationships.adjacency.contains(&(source, target))); + } + + #[test] + fn runtime_region_is_seeded_and_return_addresses_are_relocated() { + // Init and runtime are separate executions. Runtime performs a legacy internal call: + // PUSH return; PUSH callee; JUMP, with the callee dynamically jumping to the return block. + let instructions = vec![ + instruction(0, Opcode::STOP, None), + instruction(1, Opcode::PUSH(1), Some("05")), + instruction(3, Opcode::PUSH(1), Some("08")), + instruction(5, Opcode::JUMP, None), + instruction(6, Opcode::JUMPDEST, None), + instruction(7, Opcode::STOP, None), + instruction(8, Opcode::STOP, None), + instruction(9, Opcode::JUMPDEST, None), + instruction(10, Opcode::JUMP, None), + ]; + let sections = [ + Section { + kind: SectionKind::Init, + offset: 0, + len: 1, + }, + Section { + kind: SectionKind::Runtime, + offset: 1, + len: 10, + }, + ]; + let mut bundle = build(&instructions, §ions, 11); + let original = bundle.layout_order().to_vec(); + assert_eq!(original.len(), 5, "the section boundary must split blocks"); + assert_eq!( + bundle.relationships.region_entry_blocks, + vec![original[0], original[1]] + ); + assert!(bundle.relationships.unresolved_control.is_empty()); + assert_eq!(bundle.relationships.code_pointer_relocations.len(), 2); + + // Both executable-region entries remain fixed. Move the return, filler, and callee + // clusters so both literal code pointers require new immediates. + let reordered = vec![ + original[0], + original[1], + original[3], + original[4], + original[2], + ]; + bundle + .set_layout_order(reordered) + .expect("intra-runtime cluster order is valid"); + bundle.reindex_pcs().expect("typed relocation succeeds"); + + let Block::Body(call_site) = &bundle.cfg[original[1]] else { + panic!("runtime entry is a body block"); + }; + assert_eq!(call_site.instructions[0].imm.as_deref(), Some("08")); + assert_eq!(call_site.instructions[1].imm.as_deref(), Some("06")); + } + + #[test] + fn executable_region_entry_cannot_move_within_its_section() { + let instructions = vec![ + instruction(0, Opcode::STOP, None), + instruction(1, Opcode::JUMPDEST, None), + instruction(2, Opcode::STOP, None), + instruction(3, Opcode::JUMPDEST, None), + instruction(4, Opcode::STOP, None), + ]; + let sections = [ + Section { + kind: SectionKind::Init, + offset: 0, + len: 1, + }, + Section { + kind: SectionKind::Runtime, + offset: 1, + len: 4, + }, + ]; + let bundle = build(&instructions, §ions, 5); + let order = bundle.layout_order(); + let invalid = vec![order[0], order[2], order[1]]; + let error = bundle + .relationships + .validate_layout(&invalid) + .expect_err("runtime entry must remain first in its region"); + assert!(error.contains("section-region entry")); + } + + #[test] + fn resolved_true_jump_does_not_hide_unresolved_false_fallthrough() { + let instructions = vec![ + instruction(0, Opcode::JUMPDEST, None), + instruction(1, Opcode::PUSH(1), Some("01")), + instruction(3, Opcode::PUSH(1), Some("00")), + instruction(5, Opcode::JUMPI, None), + ]; + let sections = [Section { + kind: SectionKind::Runtime, + offset: 0, + len: 6, + }]; + let bundle = build(&instructions, §ions, 6); + let source = bundle.layout_order()[0]; + + assert_eq!( + bundle.relationships.unresolved_control, + vec![source], + "resolving JUMPI's true destination must not clear its raw false path" + ); + assert!(!bundle.relationships.is_relocatable()); + } + + #[test] + fn resolved_stack_carried_jumpi_preserves_physical_false_successor() { + // The SWAP keeps the destination stack-carried, so the local jump-pattern recognizer + // leaves BlockControl as Unknown. The abstract analysis can still resolve the true target, + // but JUMPI's false path must remain adjacent to the source block. + let instructions = vec![ + instruction(0, Opcode::PUSH(1), Some("09")), + instruction(2, Opcode::PUSH(1), Some("00")), + instruction(4, Opcode::SWAP(1), None), + instruction(5, Opcode::JUMPI, None), + instruction(6, Opcode::PUSH0, None), + instruction(7, Opcode::PUSH0, None), + instruction(8, Opcode::RETURN, None), + instruction(9, Opcode::JUMPDEST, None), + instruction(10, Opcode::PUSH0, None), + instruction(11, Opcode::PUSH0, None), + instruction(12, Opcode::REVERT, None), + ]; + let sections = [Section { + kind: SectionKind::Runtime, + offset: 0, + len: 13, + }]; + let bundle = build(&instructions, §ions, 13); + let order = bundle.layout_order(); + let source = order[0]; + let false_successor = order[1]; + + assert!( + matches!(bundle.cfg[source], Block::Body(ref body) if body.control == BlockControl::Unknown) + ); + assert!(bundle.relationships.unresolved_control.is_empty()); + assert!( + bundle + .relationships + .adjacency + .contains(&(source, false_successor)) + ); + assert_eq!( + bundle.relationships.blocks[&source].cluster, + bundle.relationships.blocks[&false_successor].cluster + ); + + let invalid = vec![source, order[2], false_successor]; + let error = bundle + .relationships + .validate_layout(&invalid) + .expect_err("dynamic JUMPI must remain next to its false successor"); + assert!(error.contains("required adjacency")); + } + + #[test] + fn stack_carried_jumpi_pointer_observed_on_false_path_is_position_sensitive() { + // One copy is the resolved true destination; the other survives JUMPI and is returned as + // data on the false path. Relocating the literal would therefore change observable output. + let instructions = vec![ + instruction(0, Opcode::PUSH(1), Some("0c")), + instruction(2, Opcode::DUP(1), None), + instruction(3, Opcode::PUSH0, None), + instruction(4, Opcode::SWAP(1), None), + instruction(5, Opcode::JUMPI, None), + instruction(6, Opcode::PUSH0, None), + instruction(7, Opcode::MSTORE, None), + instruction(8, Opcode::PUSH(1), Some("20")), + instruction(10, Opcode::PUSH0, None), + instruction(11, Opcode::RETURN, None), + instruction(12, Opcode::JUMPDEST, None), + instruction(13, Opcode::STOP, None), + instruction(14, Opcode::JUMPDEST, None), + instruction(15, Opcode::STOP, None), + ]; + let sections = [Section { + kind: SectionKind::Runtime, + offset: 0, + len: 16, + }]; + let bundle = build(&instructions, §ions, 16); + let false_successor = bundle.layout_order()[1]; + + assert!(bundle.relationships.unresolved_control.is_empty()); + assert_eq!(bundle.relationships.code_pointer_relocations.len(), 1); + assert!( + bundle + .relationships + .position_sensitive + .contains(&false_successor) + ); + assert!(!bundle.relationships.is_relocatable()); + } + + #[test] + fn relocation_overflow_leaves_bundle_unmodified() { + let mut instructions = vec![ + instruction(0, Opcode::PUSH(1), Some("05")), + instruction(2, Opcode::PUSH(1), Some("07")), + instruction(4, Opcode::JUMP, None), + instruction(5, Opcode::JUMPDEST, None), + instruction(6, Opcode::STOP, None), + instruction(7, Opcode::JUMPDEST, None), + instruction(8, Opcode::JUMP, None), + ]; + // Moving this 251-byte terminal block before the two PUSH1 targets shifts the return + // address from 0x05 to 0x100, which cannot be represented without changing code shape. + for pc in 9..259 { + instructions.push(instruction(pc, Opcode::PUSH0, None)); + } + instructions.push(instruction(259, Opcode::STOP, None)); + let sections = [Section { + kind: SectionKind::Runtime, + offset: 0, + len: 260, + }]; + let mut bundle = build(&instructions, §ions, 260); + let original = bundle.layout_order().to_vec(); + let reordered = vec![original[0], original[3], original[1], original[2]]; + bundle + .set_layout_order(reordered) + .expect("layout itself is relationship-safe"); + + let before_layout = bundle.layout_order().to_vec(); + let before_bounds = bundle.runtime_bounds; + let before_trace_len = bundle.trace.len(); + let before_instructions: Vec<_> = before_layout + .iter() + .map(|node| { + let Block::Body(body) = &bundle.cfg[*node] else { + unreachable!(); + }; + ( + *node, + body.start_pc, + body.instructions + .iter() + .map(|instruction| (instruction.pc, instruction.imm.clone())) + .collect::>(), + ) + }) + .collect(); + + let error = bundle + .reindex_pcs() + .expect_err("PUSH1 relocation must fail instead of widening silently"); + assert!(matches!(error, crate::result::Error::InvalidImmediate(_))); + assert_eq!(bundle.layout_order(), before_layout); + assert_eq!(bundle.runtime_bounds, before_bounds); + assert_eq!(bundle.trace.len(), before_trace_len); + let after_instructions: Vec<_> = before_layout + .iter() + .map(|node| { + let Block::Body(body) = &bundle.cfg[*node] else { + unreachable!(); + }; + ( + *node, + body.start_pc, + body.instructions + .iter() + .map(|instruction| (instruction.pc, instruction.imm.clone())) + .collect::>(), + ) + }) + .collect(); + assert_eq!(after_instructions, before_instructions); + } + + #[test] + fn code_pointer_reused_as_data_is_position_sensitive() { + // The return address is duplicated. The callee consumes one copy as a JUMP destination; + // the return block stores the other copy in memory and returns it to the caller. Moving the + // return block would necessarily change that observable value, so relocation must refuse. + let instructions = vec![ + instruction(0, Opcode::PUSH(1), Some("07")), + instruction(2, Opcode::DUP(1), None), + instruction(3, Opcode::PUSH(1), Some("0e")), + instruction(5, Opcode::JUMP, None), + instruction(6, Opcode::STOP, None), + instruction(7, Opcode::JUMPDEST, None), + instruction(8, Opcode::PUSH0, None), + instruction(9, Opcode::MSTORE, None), + instruction(10, Opcode::PUSH(1), Some("20")), + instruction(12, Opcode::PUSH0, None), + instruction(13, Opcode::RETURN, None), + instruction(14, Opcode::JUMPDEST, None), + instruction(15, Opcode::JUMP, None), + ]; + let sections = [Section { + kind: SectionKind::Runtime, + offset: 0, + len: 16, + }]; + let bundle = build(&instructions, §ions, 16); + let return_block = bundle.layout_order()[2]; + + assert!(bundle.relationships.unresolved_control.is_empty()); + assert!( + bundle + .relationships + .position_sensitive + .contains(&return_block) + ); + assert!(!bundle.relationships.is_relocatable()); + } + + #[test] + fn coincidental_jumpdest_literal_used_only_as_data_is_not_relocated() { + let instructions = vec![ + instruction(0, Opcode::PUSH(1), Some("05")), + instruction(2, Opcode::PUSH0, None), + instruction(3, Opcode::MSTORE, None), + instruction(4, Opcode::STOP, None), + instruction(5, Opcode::JUMPDEST, None), + instruction(6, Opcode::STOP, None), + ]; + let sections = [Section { + kind: SectionKind::Runtime, + offset: 0, + len: 7, + }]; + let bundle = build(&instructions, §ions, 7); + + assert!(bundle.relationships.code_pointer_relocations.is_empty()); + assert!(bundle.relationships.position_sensitive.is_empty()); + assert!(bundle.relationships.is_relocatable()); + } +} diff --git a/crates/core/src/cfg_ir/trace.rs b/crates/core/src/cfg_ir/trace.rs index cf348212..8cab77e7 100644 --- a/crates/core/src/cfg_ir/trace.rs +++ b/crates/core/src/cfg_ir/trace.rs @@ -8,8 +8,8 @@ use petgraph::Direction; use petgraph::graph::NodeIndex; use petgraph::visit::{EdgeRef, IntoEdgeReferences}; use revm::primitives::Bytes; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use serde::{Deserialize, Serialize, Serializer}; +use std::collections::{BTreeMap, HashMap}; /// Operations recorded in the CFG trace. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -57,6 +57,14 @@ pub enum OperationKind { }, ReindexPcs, PatchJumpImmediates, + /// Physical body-block order changed without changing stable graph identity. + ReorderLayout { + blocks_moved: usize, + }, + /// Stack-proven literal code pointers resolved after physical layout was assigned. + ResolveRelocations { + count: usize, + }, /// Dispatcher selector tokens patched across multiple blocks. PatchDispatcher { blocks_modified: usize, @@ -72,6 +80,7 @@ pub enum OperationKind { pub struct TraceEvent { pub kind: OperationKind, pub diff: CfgIrDiff, + #[serde(serialize_with = "serialize_optional_usize_map")] pub remapped_pcs: Option>, } @@ -80,9 +89,11 @@ pub struct TraceEvent { pub struct CfgIrSnapshot { pub blocks: Vec, pub edges: Vec, + #[serde(serialize_with = "serialize_usize_map")] pub pc_to_block: HashMap, pub clean_report: CleanReport, pub sections: Vec, + #[serde(serialize_with = "serialize_optional_selector_map")] pub selector_mapping: Option>>, pub original_bytecode: Bytes, pub runtime_bounds: Option<(usize, usize)>, @@ -98,6 +109,9 @@ pub struct CfgIrSnapshot { /// PCs that should not be modified by transforms (dispatcher/controller metadata). #[serde(default)] pub protected_pcs: Vec, + /// Intended physical body-block order by stable node index. + #[serde(default)] + pub layout_order: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -206,6 +220,42 @@ pub struct SectionSnapshot { pub len: usize, } +fn serialize_usize_map(value: &HashMap, serializer: S) -> Result +where + S: Serializer, +{ + value + .iter() + .collect::>() + .serialize(serializer) +} + +fn serialize_optional_usize_map( + value: &Option>, + serializer: S, +) -> Result +where + S: Serializer, +{ + value + .as_ref() + .map(|map| map.iter().collect::>()) + .serialize(serializer) +} + +fn serialize_optional_selector_map( + value: &Option>>, + serializer: S, +) -> Result +where + S: Serializer, +{ + value + .as_ref() + .map(|map| map.iter().collect::>()) + .serialize(serializer) +} + /// Captures a complete snapshot of the current CFG bundle. pub fn snapshot_bundle(bundle: &CfgIrBundle) -> CfgIrSnapshot { let blocks = bundle @@ -228,7 +278,7 @@ pub fn snapshot_bundle(bundle: &CfgIrBundle) -> CfgIrSnapshot { id: edge.id().index(), source: edge.source().index(), target: edge.target().index(), - kind: edge.weight().clone(), + kind: *edge.weight(), }) .collect(); @@ -257,6 +307,9 @@ pub fn snapshot_bundle(bundle: &CfgIrBundle) -> CfgIrSnapshot { protected_pcs.sort_unstable(); protected_pcs.dedup(); + let mut dispatcher_blocks: Vec<_> = bundle.dispatcher_blocks.iter().copied().collect(); + dispatcher_blocks.sort_unstable(); + CfgIrSnapshot { blocks, edges, @@ -268,8 +321,13 @@ pub fn snapshot_bundle(bundle: &CfgIrBundle) -> CfgIrSnapshot { runtime_bounds: bundle.runtime_bounds, encoded_runtime: None, dispatcher_info: bundle.dispatcher_info.clone(), - dispatcher_blocks: bundle.dispatcher_blocks.iter().copied().collect(), + dispatcher_blocks, protected_pcs, + layout_order: bundle + .layout_order() + .iter() + .map(|node| node.index()) + .collect(), } } @@ -300,7 +358,7 @@ pub fn snapshot_edges(bundle: &CfgIrBundle, node: NodeIndex) -> Vec, } -/// Decoded bytecode metadata (length, hash, source). -#[derive(Debug)] +/// Metadata calculated while accepting bytecode input. +#[derive(Clone, Debug, PartialEq, Eq)] pub struct DecodeInfo { - /// Bytecode length in bytes + /// Bytecode length in bytes. pub byte_length: usize, - /// Keccak-256 hash + /// Keccak-256 hash of the exact input bytes. pub keccak_hash: [u8; 32], - /// Input source type + /// Input source type. pub source: SourceType, } +/// Native decode result without eagerly rendered assembly text. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DecodedBytecode { + /// Decoded instruction stream. + pub instructions: Vec, + /// Length, hash, and source metadata. + pub info: DecodeInfo, + /// Exact input bytes. + pub bytes: Vec, +} + +impl DecodedBytecode { + /// Render this result as deterministic, human-readable assembly. + pub fn format_assembly(&self) -> String { + format_assembly(&self.instructions) + } +} + /// Bytecode input source type. -#[derive(Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SourceType { + /// Hexadecimal supplied directly by the caller. HexString, + /// Hexadecimal read from a file. File, } -/// Decodes raw EVM bytecode into an instruction stream with metadata and raw assembly. -pub async fn decode_bytecode( - input: &str, - is_file: bool, -) -> Result<(Vec, DecodeInfo, String, Vec), Error> { +/// Decode a raw legacy-EVM byte slice in one pass, preserving every physical byte. +/// +/// Only `PUSH1` through `PUSH32` consume following bytes. Unknown opcode bytes are retained as +/// [`Opcode::UNKNOWN`]. The EVM defines absent bytes at the end of a PUSH immediate as zero, so a +/// final truncated PUSH is represented losslessly by an immediate shorter than its declared +/// width. Transformation callers must use [`decode_executable_bytes`] or explicitly reject +/// [`Instruction::is_truncated_push`] before relocating or appending code. +pub fn decode_bytes(bytes: &[u8]) -> Result, Error> { + // Most bytecode contains many one-byte operations, while PUSH-dense data can contain only one + // instruction per 33 bytes. Start modestly and let Vec grow so multi-megabyte offline blobs do + // not reserve tens of times more instruction storage than they use. + let initial_capacity = bytes.len().div_ceil(8).min(4_096); + let mut instructions = Vec::with_capacity(initial_capacity); + let mut pc = 0usize; + + while pc < bytes.len() { + let instruction_pc = pc; + let (op, immediate_size) = Opcode::parse(bytes[pc]); + pc += 1; + + let imm = if immediate_size == 0 { + None + } else { + let available = immediate_size.min(bytes.len() - pc); + let end = pc + available; + let encoded = hex::encode(&bytes[pc..end]); + pc = end; + Some(encoded) + }; + + instructions.push(Instruction { + pc: instruction_pc, + op, + imm, + }); + } + + Ok(instructions) +} + +/// Decode executable code and reject a final PUSH with physically missing immediate bytes. +/// +/// Such bytecode is executable because the EVM reads zeros beyond the end, but Azoth cannot move +/// or append code after it without changing which bytes the PUSH consumes. Failing closed at the +/// transformation boundary preserves semantics. +pub fn decode_executable_bytes(bytes: &[u8]) -> Result, Error> { + let instructions = decode_bytes(bytes)?; + reject_truncated_final_push(&instructions)?; + Ok(instructions) +} + +/// Decode one independently executable byte range and report absolute program counters. +/// +/// Creation code and deployed runtime are separate EVM executions and can have overlapping linear +/// interpretations at their boundary. Callers must decode a proven runtime range from its own +/// first byte instead of filtering instruction boundaries decoded from the complete deployment +/// payload. +pub fn decode_executable_range( + bytes: &[u8], + offset: usize, + len: usize, +) -> Result, Error> { + let end = offset + .checked_add(len) + .ok_or(Error::SectionOutOfBounds(offset))?; + let executable = bytes + .get(offset..end) + .ok_or(Error::SectionOutOfBounds(end))?; + let mut instructions = decode_bytes(executable)?; + for instruction in &mut instructions { + instruction.pc += offset; + } + reject_truncated_final_push(&instructions)?; + Ok(instructions) +} + +fn reject_truncated_final_push(instructions: &[Instruction]) -> Result<(), Error> { + if let Some(instruction) = instructions.last() + && let Some((width, available)) = instruction.truncated_push_widths() + { + return Err(Error::TruncatedPush { + pc: instruction.pc, + width, + available, + }); + } + Ok(()) +} + +/// Read and decode hexadecimal bytecode without eagerly rendering assembly text. +pub fn decode_input(input: &str, is_file: bool) -> Result { let bytes = crate::input_to_bytes(input, is_file)?; let source = if is_file { SourceType::File @@ -49,133 +160,187 @@ pub async fn decode_bytecode( SourceType::HexString }; - let byte_length = bytes.len(); let mut keccak = Keccak::v256(); keccak.update(&bytes); - let mut hash = [0u8; 32]; - keccak.finalize(&mut hash); - - let target_arg = format!("0x{}", hex::encode(&bytes)); - let args = DisassemblerArgsBuilder::new() - .target(target_arg) - .output("print".into()) - .decimal_counter(false) - .build() - .map_err(|e| Error::Heimdall(e.to_string()))?; - - let asm = disassemble(args) - .await - .map_err(|e| Error::Heimdall(e.to_string()))?; - - let instructions = parse_assembly(&asm)?; + let mut keccak_hash = [0u8; 32]; + keccak.finalize(&mut keccak_hash); + + let instructions = decode_bytes(&bytes)?; + let info = DecodeInfo { + byte_length: bytes.len(), + keccak_hash, + source, + }; - Ok(( + Ok(DecodedBytecode { instructions, - DecodeInfo { - byte_length, - keccak_hash: hash, - source, - }, - asm, + info, bytes, - )) + }) } -/// Parses Heimdall assembly output into structured instructions. -pub fn parse_assembly(asm: &str) -> Result, Error> { - // Fail on empty assembly - if asm.trim().is_empty() { +/// Compatibility wrapper returning the historical tuple, now backed entirely by native decode. +/// +/// New hot-path callers should use [`decode_input`] so assembly is rendered only when requested. +pub async fn decode_bytecode( + input: &str, + is_file: bool, +) -> Result<(Vec, DecodeInfo, String, Vec), Error> { + let decoded = decode_input(input, is_file)?; + let assembly = decoded.format_assembly(); + Ok((decoded.instructions, decoded.info, assembly, decoded.bytes)) +} + +/// Render instructions in Azoth's stable assembly format. +pub fn format_assembly(instructions: &[Instruction]) -> String { + let mut assembly = String::with_capacity(instructions.len().saturating_mul(18)); + for instruction in instructions { + // Writing to a String cannot fail. + writeln!(&mut assembly, "{instruction}").expect("String writes are infallible"); + } + assembly +} + +/// Parse Azoth's textual assembly format. +/// +/// This is a diagnostics/import helper. Production bytecode decoding uses [`decode_bytes`] +/// directly and never round-trips through text. +pub fn parse_assembly(assembly: &str) -> Result, Error> { + if assembly.trim().is_empty() { return Err(Error::ParseError { line: 0, msg: "empty assembly".into(), - raw: asm.to_string(), + raw: assembly.to_string(), }); } - let mut instructions = Vec::new(); - for (line_no, raw) in asm.lines().enumerate() { + let mut instructions: Vec = Vec::new(); + for (line_no, raw) in assembly.lines().enumerate() { let line = raw.split('#').next().unwrap_or("").trim(); if line.is_empty() || line.starts_with("label_") { - continue; // Skip blank lines and label lines + continue; } let mut parts = line.split_whitespace(); - let pc_hex = parts.next().ok_or_else(|| Error::ParseError { - line: line_no, - msg: "missing PC".to_string(), - raw: raw.to_string(), - })?; - let opcode = parts.next().ok_or_else(|| Error::ParseError { - line: line_no, - msg: "missing opcode".to_string(), - raw: raw.to_string(), - })?; + let pc_hex = parse_part(parts.next(), line_no, raw, "missing PC")?; + let opcode_name = parse_part(parts.next(), line_no, raw, "missing opcode")?; let immediate = parts .next() - .map(|s| s.trim_start_matches("0x").to_ascii_lowercase()); + .map(|value| value.trim_start_matches("0x").to_ascii_lowercase()); + if parts.next().is_some() { + return parse_error(line_no, raw, "unexpected trailing assembly fields"); + } let pc = usize::from_str_radix(pc_hex.trim_start_matches("0x"), 16).map_err(|_| { Error::ParseError { line: line_no, - msg: "invalid PC".to_string(), + msg: "invalid PC".into(), raw: raw.to_string(), } })?; + let op = parse_opcode_name(opcode_name).ok_or_else(|| Error::ParseError { + line: line_no, + msg: format!("unknown opcode '{opcode_name}'"), + raw: raw.to_string(), + })?; - if opcode.is_empty() || opcode.chars().all(|c| !c.is_alphanumeric()) { - return Err(Error::ParseError { - line: line_no, - msg: "invalid opcode".to_string(), - raw: raw.to_string(), - }); - } - - // Parse opcode once during decoding for efficient access - let parsed_opcode = Opcode::from_str(opcode).unwrap_or_else(|_| { - // Try to extract byte value from UNKNOWN_0x?? format - if let Some(hex_part) = opcode.strip_prefix("UNKNOWN_0x") - && let Ok(byte_val) = u8::from_str_radix(hex_part, 16) - { - tracing::debug!( - "Unknown opcode '{}' at PC 0x{:x}, storing as UNKNOWN(0x{:02x})", - opcode, - pc, - byte_val + match op { + Opcode::PUSH(width) => { + let Some(value) = immediate.as_deref() else { + return parse_error(line_no, raw, "PUSH is missing its immediate"); + }; + if value.len() % 2 != 0 + || value.len() > usize::from(width) * 2 + || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return parse_error( + line_no, + raw, + "PUSH immediate exceeds its width or is not whole-byte hexadecimal", + ); + } + } + _ if immediate.is_some() => { + return parse_error( + line_no, + raw, + "only PUSH instructions may have immediate data", ); - return Opcode::UNKNOWN(byte_val); } + _ => {} + } - // For generic "unknown" or other unrecognized opcodes, use INVALID as a placeholder. - // - // Note: INVALID here does NOT mean the opcode is actually 0xFE (the INVALID opcode). - // It's used as a marker for "we don't know what byte this is from the disassembly alone". - // The encoder will recover the actual byte value from the original bytecode using the PC. - // This is the least-bad choice when heimdall gives us "unknown" without a hex byte value. - // - // If we can't recover the byte during encoding (e.g., PC out of bounds), the instruction - // will be skipped rather than encoded as 0xFE. - tracing::warn!( - "Unrecognized opcode '{}' at PC 0x{:x}, using INVALID as placeholder (byte will be recovered from original during encode)", - opcode, - pc - ); - Opcode::INVALID - }); - - instructions.push(Instruction { + let instruction = Instruction { pc, - op: parsed_opcode, + op, imm: immediate, + }; + if let Some(previous) = instructions.last() { + if previous.is_truncated_push() { + return parse_error( + line_no, + raw, + "only the final instruction may be a truncated PUSH", + ); + } + let expected_pc = previous + .pc + .checked_add(previous.byte_size()) + .ok_or_else(|| Error::ParseError { + line: line_no, + msg: "instruction PC overflows usize".into(), + raw: raw.to_string(), + })?; + if pc != expected_pc { + return parse_error( + line_no, + raw, + &format!("non-contiguous PC: expected 0x{expected_pc:x}, found 0x{pc:x}"), + ); + } + } + instructions.push(instruction); + } + + if instructions.is_empty() { + return Err(Error::ParseError { + line: 0, + msg: "assembly contains no instructions".into(), + raw: assembly.to_string(), }); } Ok(instructions) } +fn parse_part<'a>( + part: Option<&'a str>, + line: usize, + raw: &str, + message: &str, +) -> Result<&'a str, Error> { + part.ok_or_else(|| Error::ParseError { + line, + msg: message.to_string(), + raw: raw.to_string(), + }) +} + +fn parse_opcode_name(name: &str) -> Option { + Opcode::from_str(name).ok() +} + +fn parse_error(line: usize, raw: &str, message: &str) -> Result { + Err(Error::ParseError { + line, + msg: message.to_string(), + raw: raw.to_string(), + }) +} + impl fmt::Display for Instruction { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // pc: six-digit hex, opcode left-padded to 8 chars, then optional imm if let Some(immediate) = &self.imm { - write!(f, "{:06x} {:<8} {}", self.pc, self.op, immediate) + write!(f, "{:06x} {:<8} 0x{immediate}", self.pc, self.op) } else { write!(f, "{:06x} {}", self.pc, self.op) } @@ -183,12 +348,34 @@ impl fmt::Display for Instruction { } impl Instruction { - /// Returns the byte size of this instruction (1 for most opcodes, 1+N for PUSH(N)). + /// Return the number of immediate bytes physically represented by this instruction. + pub fn immediate_byte_len(&self) -> Option { + self.imm + .as_deref() + .filter(|value| value.len() % 2 == 0) + .map(|value| value.len() / 2) + } + + /// Return true when a final PUSH has fewer physical immediate bytes than its declared width. + pub fn is_truncated_push(&self) -> bool { + self.truncated_push_widths().is_some() + } + + fn truncated_push_widths(&self) -> Option<(usize, usize)> { + let Opcode::PUSH(width) = self.op else { + return None; + }; + let available = self.immediate_byte_len()?; + (available < usize::from(width)).then_some((usize::from(width), available)) + } + + /// Return the number of physical bytes emitted for this instruction. #[inline] pub fn byte_size(&self) -> usize { match self.op { - Opcode::PUSH0 => 1, - Opcode::PUSH(n) => 1 + n as usize, + Opcode::PUSH(width) => self + .immediate_byte_len() + .map_or(1 + usize::from(width), |available| 1 + available), _ => 1, } } @@ -196,6 +383,7 @@ impl Instruction { /// Trait for computing the encoded byte size of instructions or collections of instructions. pub trait EncodedSize { + /// Return the encoded size in bytes. fn size(&self) -> usize; } @@ -214,88 +402,112 @@ impl EncodedSize for [T] { #[cfg(test)] mod tests { - use super::{SourceType, decode_bytecode, parse_assembly}; + use super::{ + SourceType, decode_bytes, decode_executable_bytes, decode_input, format_assembly, + parse_assembly, + }; use crate::Opcode; use crate::encoder; use crate::result::Error; - const SAMPLE_ASM: &str = " -000000 PUSH1 0x01 -000002 PUSH1 0xff -000004 ADD -000005 STOP -"; - #[test] - fn parse_basic_assembly_stream() { - let instructions = parse_assembly(SAMPLE_ASM).expect("parse sample asm"); - assert_eq!(instructions.len(), 4); - - assert_eq!(instructions[0].pc, 0); - assert!(matches!(instructions[0].op, Opcode::PUSH(1))); - assert_eq!(instructions[0].imm.as_deref(), Some("01")); - - assert_eq!(instructions[2].pc, 4); - assert_eq!(instructions[2].op, Opcode::ADD); - assert!(instructions[2].imm.is_none()); + fn native_decode_produces_metadata_and_roundtrips() { + let bytecode = include_str!("../../../tests/bytecode/storage.hex"); + let decoded = decode_input(bytecode, false).expect("decode bytecode"); + + assert!(!decoded.instructions.is_empty()); + assert_eq!(decoded.info.byte_length, decoded.bytes.len()); + assert_eq!(decoded.info.source, SourceType::HexString); + assert_eq!(decoded.instructions[0].pc, 0); + assert_eq!(decoded.instructions[0].op, Opcode::PUSH(1)); + assert_eq!(decoded.instructions[0].imm.as_deref(), Some("80")); + + let assembly = format_assembly(&decoded.instructions); + assert_eq!(parse_assembly(&assembly).unwrap(), decoded.instructions); + assert_eq!( + encoder::encode(&decoded.instructions, &decoded.bytes).unwrap(), + decoded.bytes + ); } #[test] - fn parse_unknown_opcode_with_hex_suffix() { - let asm = "\ -000000 UNKNOWN_0xaa\n"; - let instructions = parse_assembly(asm).expect("parse unknown hex"); - assert_eq!(instructions.len(), 1); - assert_eq!(instructions[0].pc, 0); - assert_eq!(instructions[0].op, Opcode::UNKNOWN(0xaa)); - assert!(instructions[0].imm.is_none()); + fn empty_bytecode_is_a_valid_empty_stream() { + let decoded = decode_input("0x", false).expect("empty code decodes"); + assert!(decoded.instructions.is_empty()); + assert!(decoded.bytes.is_empty()); + assert!(decoded.format_assembly().is_empty()); } #[test] - fn unknown_opcode_without_hex_becomes_invalid() { - let asm = "\ -000000 unknown\n"; - let instructions = parse_assembly(asm).expect("parse generic unknown"); - assert_eq!(instructions.len(), 1); - assert_eq!(instructions[0].op, Opcode::INVALID); + fn executable_decode_rejects_truncated_push_without_partial_success() { + let lossless = decode_bytes(&[0x00, 0x61, 0xaa]).unwrap(); + assert_eq!(lossless.len(), 2); + assert!(lossless[1].is_truncated_push()); + let err = decode_executable_bytes(&[0x00, 0x61, 0xaa]).unwrap_err(); + assert!(matches!( + err, + Error::TruncatedPush { + pc: 1, + width: 2, + available: 1 + } + )); } #[test] - fn parse_assembly_rejects_empty_input() { - let err = parse_assembly("").unwrap_err(); - assert!(matches!(err, Error::ParseError { .. })); + fn executable_range_decodes_from_its_own_boundary_and_rebases_pcs() { + // At deployment PC 2, PUSH2 consumes the byte at PC 4 in a whole-payload linear view. + // The independent runtime beginning at PC 4 must nevertheless decode that byte as PUSH1. + let bytes = [0x00, 0x00, 0x61, 0xaa, 0x60, 0x00, 0x00]; + let instructions = super::decode_executable_range(&bytes, 4, 3).unwrap(); + assert_eq!(instructions.len(), 2); + assert_eq!(instructions[0].pc, 4); + assert_eq!(instructions[0].op, Opcode::PUSH(1)); + assert_eq!(instructions[0].imm.as_deref(), Some("00")); + assert_eq!(instructions[1].pc, 6); + assert_eq!(instructions[1].op, Opcode::STOP); + + let error = super::decode_executable_range(&[0x00, 0x61, 0xaa], 1, 2).unwrap_err(); + assert!(matches!( + error, + Error::TruncatedPush { + pc: 1, + width: 2, + available: 1 + } + )); } #[test] - fn parse_assembly_errors_on_malformed_line() { - // Missing opcode after PC - let err = parse_assembly("000000").unwrap_err(); - assert!(matches!(err, Error::ParseError { .. })); + fn immediate_opcode_bytes_are_data() { + let instructions = decode_bytes(&[0x63, 0x5b, 0x60, 0xfe, 0xff, 0x00]).unwrap(); + assert_eq!(instructions.len(), 2); + assert_eq!(instructions[0].imm.as_deref(), Some("5b60feff")); + assert_eq!(instructions[1].op, Opcode::STOP); } - #[tokio::test] - async fn decode_bytecode_produces_instructions_and_metadata() { - let bytecode = include_str!("../../../tests/bytecode/storage.hex"); - let (instructions, info, asm, bytes) = decode_bytecode(bytecode, false) - .await - .expect("decode bytecode"); - - assert!(!instructions.is_empty()); - assert!(!asm.is_empty()); - assert_eq!(info.byte_length, bytes.len()); - assert_eq!(info.source, SourceType::HexString); - - let reparsed = parse_assembly(&asm).expect("parse decoded assembly"); - assert_eq!(reparsed, instructions); - - let reencoded = - encoder::encode(&instructions, &bytes).expect("encode decoded instructions"); - assert_eq!(reencoded, bytes); - - // storage hex starts with PUSH1 0x80 at pc=0 - let first = &instructions[0]; - assert_eq!(first.pc, 0); - assert!(matches!(first.op, Opcode::PUSH(1))); - assert_eq!(first.imm.as_deref(), Some("80")); + #[test] + fn text_parser_rejects_ambiguous_or_malformed_input() { + assert!(matches!(parse_assembly(""), Err(Error::ParseError { .. }))); + assert!(matches!( + parse_assembly("000000"), + Err(Error::ParseError { .. }) + )); + assert!(matches!( + parse_assembly("000000 unknown"), + Err(Error::ParseError { .. }) + )); + assert!(matches!( + parse_assembly("000000 PUSH2 0xaaaaaa"), + Err(Error::ParseError { .. }) + )); + assert!(matches!( + parse_assembly("000001 STOP\n000000 ADD"), + Err(Error::ParseError { .. }) + )); + assert!(matches!( + parse_assembly("000000 STOP\n000002 ADD"), + Err(Error::ParseError { .. }) + )); } } diff --git a/crates/core/src/detection/dispatcher.rs b/crates/core/src/detection/dispatcher.rs index 89bcea87..0516217a 100644 --- a/crates/core/src/detection/dispatcher.rs +++ b/crates/core/src/detection/dispatcher.rs @@ -122,40 +122,36 @@ pub fn detect_function_dispatcher(instructions: &[Instruction]) -> Option { - if stack.len() >= 2 { - // JUMPI pops: [condition, destination] - // Stack before JUMPI: [..., destination, condition] - // We want stack[len-1] which is the destination - let target_val = stack[stack.len() - 1]; - stack.truncate(stack.len() - 2); - - if let StackValue::Const { - addr: address, - def_pc, - } = target_val - && let Some((selector, sel_idx)) = current_selector - { - selectors.push(FunctionSelector { - selector, - target_address: address as u64, - instruction_index: sel_idx, - }); - tracing::debug!( - "Paired selector 0x{:08x} -> target 0x{:x} (PUSH at PC 0x{:x})", - selector, - address, - def_pc - ); - current_selector = None; - } + Opcode::JUMPI if stack.len() >= 2 => { + // JUMPI pops: [condition, destination] + // Stack before JUMPI: [..., destination, condition] + // We want stack[len-1] which is the destination + let target_val = stack[stack.len() - 1]; + stack.truncate(stack.len() - 2); + + if let StackValue::Const { + addr: address, + def_pc, + } = target_val + && let Some((selector, sel_idx)) = current_selector + { + selectors.push(FunctionSelector { + selector, + target_address: address as u64, + instruction_index: sel_idx, + }); + tracing::debug!( + "Paired selector 0x{:08x} -> target 0x{:x} (PUSH at PC 0x{:x})", + selector, + address, + def_pc + ); + current_selector = None; } } - Opcode::JUMP => { - if !stack.is_empty() { - stack.pop(); - } + Opcode::JUMP if !stack.is_empty() => { + stack.pop(); } Opcode::POP if !stack.is_empty() => { @@ -170,18 +166,16 @@ pub fn detect_function_dispatcher(instructions: &[Instruction]) -> Option { - if stack.len() >= 2 { - stack.truncate(stack.len() - 2); - stack.push(StackValue::Unknown); - } + | Opcode::SGT + if stack.len() >= 2 => + { + stack.truncate(stack.len() - 2); + stack.push(StackValue::Unknown); } - Opcode::ISZERO | Opcode::NOT => { - if !stack.is_empty() { - stack.pop(); - stack.push(StackValue::Unknown); - } + Opcode::ISZERO | Opcode::NOT if !stack.is_empty() => { + stack.pop(); + stack.push(StackValue::Unknown); } Opcode::REVERT if selectors.len() >= 3 => { @@ -302,7 +296,7 @@ fn find_dispatcher_preamble(instructions: &[Instruction], extraction_start: usiz // If no clear preamble pattern found at start, scan backwards from extraction // looking for CALLVALUE check or CALLDATASIZE check - let search_start = extraction_start.saturating_sub(20).max(0); + let search_start = extraction_start.saturating_sub(20); for i in search_start..extraction_start { if i + 2 < instructions.len() { diff --git a/crates/core/src/detection/sections.rs b/crates/core/src/detection/sections.rs index 9025a50d..28e0433a 100644 --- a/crates/core/src/detection/sections.rs +++ b/crates/core/src/detection/sections.rs @@ -518,8 +518,7 @@ pub fn validate_sections(sections: &[Section], total_len: usize) -> Result<(), E Ok(()) } -/// Detects Auxdata (CBOR) section from the end of the bytecode, using a canonical length check -/// and a fallback scan for invalid lengths. +/// Detects a compiler CBOR auxdata section at an EVM instruction boundary. /// /// # Arguments /// * `bytes` - Raw bytecode bytes. @@ -545,7 +544,134 @@ fn detect_auxdata(bytes: &[u8]) -> Option<(usize, usize)> { return None; } - Some((len - 2 - auxdata_cbor_length, auxdata_cbor_length + 2)) + let offset = len - 2 - auxdata_cbor_length; + let payload = bytes.get(offset..len - 2)?; + if !is_structurally_valid_compiler_cbor(payload) { + tracing::debug!("Ignoring terminal length marker: claimed payload is not compiler CBOR"); + return None; + } + if !is_evm_instruction_boundary(bytes, offset) { + tracing::debug!( + "Ignoring terminal CBOR candidate: offset {} splits an EVM instruction", + offset + ); + return None; + } + + Some((offset, auxdata_cbor_length + 2)) +} + +/// Solidity emits a definite-length CBOR map containing at least one compiler metadata key. +/// Parsing the complete payload prevents arbitrary final bytes from becoming a section boundary +/// solely because they happen to encode a small two-byte length. +fn is_structurally_valid_compiler_cbor(payload: &[u8]) -> bool { + if payload.first().is_none_or(|byte| byte >> 5 != 5) { + return false; + } + + let has_compiler_key = payload.windows(5).any(|window| window == b"\x64ipfs") + || payload.windows(5).any(|window| window == b"\x64solc") + || payload.windows(6).any(|window| { + window == b"\x65bzzr0" || window == b"\x65bzzr1" || window == b"\x65vyper" + }); + if !has_compiler_key { + return false; + } + + let mut cursor = 0usize; + consume_cbor_item(payload, &mut cursor, 0).is_some() && cursor == payload.len() +} + +fn consume_cbor_item(bytes: &[u8], cursor: &mut usize, depth: usize) -> Option<()> { + const MAX_CBOR_DEPTH: usize = 32; + if depth >= MAX_CBOR_DEPTH { + return None; + } + + let initial = *bytes.get(*cursor)?; + *cursor += 1; + let major = initial >> 5; + let additional = initial & 0x1f; + let argument = consume_cbor_argument(bytes, cursor, additional)?; + + match major { + 0 | 1 | 7 => Some(()), + 2 | 3 => { + let length = usize::try_from(argument).ok()?; + let end = cursor.checked_add(length)?; + if end > bytes.len() { + return None; + } + *cursor = end; + Some(()) + } + 4 => { + let items = usize::try_from(argument).ok()?; + if items > bytes.len().saturating_sub(*cursor) { + return None; + } + for _ in 0..items { + consume_cbor_item(bytes, cursor, depth + 1)?; + } + Some(()) + } + 5 => { + let pairs = usize::try_from(argument).ok()?; + let items = pairs.checked_mul(2)?; + if items > bytes.len().saturating_sub(*cursor) { + return None; + } + for _ in 0..items { + consume_cbor_item(bytes, cursor, depth + 1)?; + } + Some(()) + } + 6 => consume_cbor_item(bytes, cursor, depth + 1), + _ => None, + } +} + +fn consume_cbor_argument(bytes: &[u8], cursor: &mut usize, additional: u8) -> Option { + let width = match additional { + 0..=23 => return Some(u64::from(additional)), + 24 => 1, + 25 => 2, + 26 => 4, + 27 => 8, + _ => return None, + }; + let end = cursor.checked_add(width)?; + let encoded = bytes.get(*cursor..end)?; + *cursor = end; + Some( + encoded + .iter() + .fold(0u64, |value, byte| (value << 8) | u64::from(*byte)), + ) +} + +fn is_evm_instruction_boundary(bytes: &[u8], boundary: usize) -> bool { + if boundary > bytes.len() { + return false; + } + + let mut pc = 0usize; + while pc < boundary { + let opcode = bytes[pc]; + let immediate_width = if (0x60..=0x7f).contains(&opcode) { + usize::from(opcode - 0x5f) + } else { + 0 + }; + let Some(next_pc) = pc.checked_add(1 + immediate_width) else { + return false; + }; + if next_pc > boundary { + return false; + } + pc = next_pc; + } + pc == boundary } /// Detects Padding section before Auxdata. @@ -674,7 +800,11 @@ mod tests { #[test] fn exact_runtime_places_constructor_args_after_auxdata() { - let runtime = vec![0x60, 0x00, 0x00, 0xa1, 0x01, 0x02, 0x00, 0x03]; + let runtime = vec![ + 0x60, 0x00, 0x00, // runtime code + 0xa1, 0x64, b's', b'o', b'l', b'c', 0x43, 0x00, 0x08, 0x1e, // CBOR + 0x00, 0x0a, // CBOR payload length + ]; let mut deployment = vec![0x60, 0x00, 0xf3]; deployment.extend_from_slice(&runtime); deployment.extend_from_slice(&[0xabu8; 64]); @@ -696,17 +826,53 @@ mod tests { Section { kind: SectionKind::Auxdata, offset: 6, - len: 5, + len: 12, }, Section { kind: SectionKind::ConstructorArgs, - offset: 11, + offset: 18, len: 64, }, ] ); } + #[test] + fn auxdata_candidate_must_start_on_an_instruction_boundary() { + let mut runtime = vec![0x7f]; + runtime.extend_from_slice(&[0u8; 20]); + runtime.extend_from_slice(&[ + 0xa1, 0x64, b's', b'o', b'l', b'c', 0x43, 0x00, 0x08, 0x1e, 0x00, 0x0a, + ]); + + assert_eq!(runtime.len(), 33); + assert_eq!(detect_auxdata(&runtime), None); + assert!(is_evm_instruction_boundary(&runtime, runtime.len())); + assert!(!is_evm_instruction_boundary(&runtime, 21)); + + let sections = locate_sections_from_exact_runtime(&runtime, &runtime).unwrap(); + assert_eq!( + sections, + vec![Section { + kind: SectionKind::Runtime, + offset: 0, + len: runtime.len(), + }] + ); + } + + #[test] + fn auxdata_candidate_must_be_complete_compiler_cbor() { + let runtime = [ + &[0x00][..], + &[0xa1, 0x64, b's', b'o', b'l', b'c', 0x43, 0x00, 0x08][..], + &[0x00, 0x09][..], + ] + .concat(); + + assert_eq!(detect_auxdata(&runtime), None); + } + #[test] fn exact_runtime_rejects_ambiguous_boundaries() { let runtime = vec![0x60, 0x00, 0x00]; diff --git a/crates/core/src/encoder.rs b/crates/core/src/encoder.rs index 934046ed..226967d8 100644 --- a/crates/core/src/encoder.rs +++ b/crates/core/src/encoder.rs @@ -11,7 +11,8 @@ use hex; /// # Arguments /// * `instructions` - A slice of `Instruction` structs, each containing an opcode and optional /// immediate data. -/// * `bytecode` - Reference bytecode to extract unknown opcode bytes from using PC. +/// * `bytecode` - Retained for source compatibility; native opcodes are self-contained and this +/// reference is never consulted. /// /// # Returns /// A `Result` containing the encoded bytecode as a `Vec` or an `Error` if encoding fails. @@ -27,9 +28,9 @@ use hex; /// let bytes = encode(&[ins], &[0x60, 0xaa]).unwrap(); /// assert_eq!(bytes, vec![0x60, 0xaa]); /// ``` -pub fn encode(instructions: &[Instruction], bytecode: &[u8]) -> Result, Error> { - let mut bytes = Vec::with_capacity(instructions.len() * 3); - let mut unknown_count = 0; +pub fn encode(instructions: &[Instruction], _bytecode: &[u8]) -> Result, Error> { + let capacity = validate_instruction_stream(instructions)?; + let mut bytes = Vec::with_capacity(capacity); for ins in instructions { tracing::debug!( @@ -39,116 +40,106 @@ pub fn encode(instructions: &[Instruction], bytecode: &[u8]) -> Result, ins.imm ); - // Handle INVALID opcodes by attempting to preserve the original byte. - // - // Note: INVALID here is often a placeholder from the decoder, not the actual 0xFE opcode. - // When heimdall outputs "unknown" without a hex byte, the decoder uses INVALID as a marker. - // We recover the actual byte value from the original bytecode using PC, or skip if unavailable. - if matches!(ins.op, Opcode::INVALID) { - unknown_count += 1; - tracing::warn!("Encoding INVALID opcode at pc={}", ins.pc); - - // First try immediate data (might contain the original byte value) - if let Some(immediate) = &ins.imm - && let Ok(byte_val) = u8::from_str_radix(immediate, 16) - { - bytes.push(byte_val); - tracing::debug!( - "Preserved INVALID opcode from immediate as byte 0x{:02x}", - byte_val - ); - continue; - } + let opcode = ins.op; + let opcode_byte = opcode + .try_to_byte() + .map_err(|error| Error::UnsupportedOpcode(error.to_string()))?; - // Then try bytecode lookup - if ins.pc < bytecode.len() { - let byte_val = bytecode[ins.pc]; - bytes.push(byte_val); - tracing::debug!( - "Preserved INVALID opcode from bytecode as byte 0x{:02x} at pc={}", - byte_val, - ins.pc - ); - continue; - } + tracing::debug!("Encoding opcode '{}' -> byte 0x{:02x}", opcode, opcode_byte); + bytes.push(opcode_byte); - // Last resort: SKIP the instruction (cannot determine byte value) - tracing::error!( - "Cannot determine byte value for INVALID opcode at pc={}, skipping (this may break functionality)", - ins.pc - ); - continue; // Skip this instruction instead of encoding 0xFE + if let Opcode::PUSH(_) = opcode { + let immediate = ins.imm.as_deref().ok_or_else(|| { + Error::InvalidImmediate(format!("{opcode} missing immediate at pc={}", ins.pc)) + })?; + let imm_bytes = hex::decode(immediate).map_err(|error| { + Error::InvalidImmediate(format!( + "invalid hex immediate '{immediate}' for {opcode} at pc={}: {error}", + ins.pc + )) + })?; + bytes.extend_from_slice(&imm_bytes); + tracing::debug!("Added {} immediate bytes for {}", imm_bytes.len(), opcode); } + } - let opcode = ins.op; + tracing::debug!( + "Successfully encoded {} instructions into {} bytes", + instructions.len(), + bytes.len() + ); + Ok(bytes) +} - tracing::debug!( - "Encoding opcode '{}' -> byte 0x{:02x}", - opcode, - opcode.to_byte() - ); - bytes.push(opcode.to_byte()); - - // Handle immediate data for PUSH opcodes - if let Opcode::PUSH(n) = opcode { - if let Some(immediate) = &ins.imm { - let imm_bytes = match hex::decode(immediate) { - Ok(bytes) => bytes, - Err(e) => { - tracing::error!( - "Failed to decode immediate '{}' (len {}) for {} at pc={}: {:?}", - immediate, - immediate.len(), - opcode, - ins.pc, - e - ); - return Err(Error::InvalidImmediate(format!( - "invalid hex immediate '{}' for {} at pc={}: {:?}", - immediate, opcode, ins.pc, e - ))); - } - }; - if imm_bytes.len() != n as usize { - tracing::error!( - "Invalid immediate length for {}: expected {} bytes, got {} bytes", - opcode, - n, - imm_bytes.len() - ); +fn validate_instruction_stream(instructions: &[Instruction]) -> Result { + let mut capacity = 0usize; + let mut expected_pc = instructions.first().map_or(0, |instruction| instruction.pc); + + for (index, instruction) in instructions.iter().enumerate() { + if instruction.pc != expected_pc { + return Err(Error::InvalidBlockStructure(format!( + "non-contiguous instruction PCs: expected 0x{expected_pc:x}, found 0x{:x}", + instruction.pc + ))); + } + instruction + .op + .try_to_byte() + .map_err(|error| Error::UnsupportedOpcode(error.to_string()))?; + + let encoded_size = match instruction.op { + Opcode::PUSH(width) => { + let immediate = instruction.imm.as_deref().ok_or_else(|| { + Error::InvalidImmediate(format!( + "PUSH{width} missing immediate at pc={}", + instruction.pc + )) + })?; + let maximum_hex_len = usize::from(width) * 2; + if immediate.len() > maximum_hex_len { return Err(Error::InvalidImmediate(format!( - "PUSH{} requires {}-byte immediate, got {} bytes at pc={}", - n, - n, - imm_bytes.len(), - ins.pc + "PUSH{width} accepts at most {width} immediate bytes, got {} bytes at pc={}", + immediate.len().div_ceil(2), + instruction.pc ))); } - bytes.extend_from_slice(&imm_bytes); - tracing::debug!("Added {} immediate bytes for {}", imm_bytes.len(), opcode); - } else { - tracing::error!("Missing immediate for {} at pc={}", opcode, ins.pc); + if immediate.len() % 2 != 0 + || !immediate.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(Error::InvalidImmediate(format!( + "PUSH{width} immediate at pc={} must be whole-byte hexadecimal", + instruction.pc + ))); + } + let immediate_bytes = immediate.len() / 2; + if immediate_bytes < usize::from(width) && index + 1 != instructions.len() { + return Err(Error::InvalidImmediate(format!( + "truncated PUSH{width} at pc={} must be the final instruction", + instruction.pc + ))); + } + 1usize.checked_add(immediate_bytes).ok_or_else(|| { + Error::InvalidBlockStructure("encoded instruction size overflow".into()) + })? + } + _ if instruction.imm.is_some() => { return Err(Error::InvalidImmediate(format!( - "PUSH{} missing immediate at pc={}", - n, ins.pc + "{} at pc={} cannot carry immediate data", + instruction.op, instruction.pc ))); } - } - } + _ => 1, + }; - if unknown_count > 0 { - tracing::warn!( - "Encoded {} unknown opcodes as raw bytes. The resulting bytecode preserves the original bytes but these may represent invalid EVM instructions.", - unknown_count - ); + capacity = capacity.checked_add(encoded_size).ok_or_else(|| { + Error::InvalidBlockStructure("encoded bytecode length overflow".into()) + })?; + expected_pc = expected_pc + .checked_add(encoded_size) + .ok_or_else(|| Error::InvalidBlockStructure("instruction PC overflow".into()))?; } - tracing::debug!( - "Successfully encoded {} instructions into {} bytes", - instructions.len(), - bytes.len() - ); - Ok(bytes) + Ok(capacity) } /// Reassembles the original bytecode by combining runtime bytecode with non-runtime sections. @@ -161,9 +152,9 @@ pub fn encode(instructions: &[Instruction], bytecode: &[u8]) -> Result, /// * `report` - The `CleanReport` containing metadata about removed sections (mutable to update init code). /// /// # Returns -/// The reassembled bytecode as a `Vec`. -pub fn rebuild(runtime: &[u8], report: &mut CleanReport) -> Vec { - report.reassemble(runtime) +/// The reassembled bytecode, or an error when init-code relocation cannot be proven safe. +pub fn rebuild(runtime: &[u8], report: &mut CleanReport) -> Result, Error> { + report.reassemble(runtime).map_err(Error::ObfuscationFailed) } #[cfg(test)] @@ -198,19 +189,66 @@ mod tests { } #[test] - fn preserves_invalid_from_immediate() { + fn invalid_always_encodes_as_fe() { let instructions = vec![Instruction { pc: 5, op: Opcode::INVALID, - imm: Some("fe".into()), + imm: None, }]; - let bytes = encode(&instructions, &[]).expect("encodes invalid from imm"); + let bytes = encode(&instructions, &[]).expect("encodes invalid"); assert_eq!(bytes, vec![0xfe]); } #[test] - fn preserves_invalid_from_bytecode_fallback() { + fn rejects_immediate_on_non_push_opcode() { + let instructions = vec![Instruction { + pc: 5, + op: Opcode::JUMP, + imm: Some("1234".into()), + }]; + + let error = encode(&instructions, &[]).unwrap_err(); + assert!(matches!(error, Error::InvalidImmediate(_))); + assert!(error.to_string().contains("cannot carry immediate data")); + } + + #[test] + fn rejects_non_contiguous_program_counters() { + let instructions = vec![ + Instruction { + pc: 4, + op: Opcode::STOP, + imm: None, + }, + Instruction { + pc: 6, + op: Opcode::ADD, + imm: None, + }, + ]; + + let error = encode(&instructions, &[]).unwrap_err(); + assert!(matches!(error, Error::InvalidBlockStructure(_))); + assert!(error.to_string().contains("non-contiguous instruction PCs")); + } + + #[test] + fn rejects_oversized_immediate_before_hex_decoding() { + let instructions = vec![Instruction { + pc: 0, + op: Opcode::PUSH(1), + imm: Some("z".repeat(1_000_000)), + }]; + + let error = encode(&instructions, &[]).unwrap_err(); + assert!(matches!(error, Error::InvalidImmediate(_))); + assert!(error.to_string().contains("at most 1 immediate bytes")); + assert!(error.to_string().len() < 200); + } + + #[test] + fn invalid_never_uses_reference_bytecode() { let instructions = vec![Instruction { pc: 2, op: Opcode::INVALID, @@ -219,7 +257,20 @@ mod tests { let reference = [0xaa, 0xbb, 0xcc, 0xdd]; let bytes = encode(&instructions, &reference).expect("encodes invalid from bytecode"); - assert_eq!(bytes, vec![reference[2]]); + assert_eq!(bytes, vec![0xfe]); + } + + #[test] + fn rejects_assigned_byte_disguised_as_unknown() { + let instructions = vec![Instruction { + pc: 0, + op: Opcode::UNKNOWN(0x56), + imm: None, + }]; + + let error = encode(&instructions, &[]).unwrap_err(); + assert!(matches!(error, Error::UnsupportedOpcode(_))); + assert!(error.to_string().contains("cannot be marked UNKNOWN")); } #[test] @@ -238,17 +289,22 @@ mod tests { } #[test] - fn errors_on_wrong_immediate_length() { + fn encodes_truncated_push_only_at_end() { let instructions = vec![Instruction { pc: 0, op: Opcode::PUSH(2), imm: Some("aa".into()), }]; - let err = encode(&instructions, &[]).unwrap_err(); - assert!( - matches!(err, Error::InvalidImmediate(_)), - "unexpected error: {err:?}" - ); + assert_eq!(encode(&instructions, &[]).unwrap(), vec![0x61, 0xaa]); + + let mut followed = instructions; + followed.push(Instruction { + pc: 2, + op: Opcode::STOP, + imm: None, + }); + let err = encode(&followed, &[]).unwrap_err(); + assert!(matches!(err, Error::InvalidImmediate(_))); } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9c9a72b8..521ebb20 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,6 +2,7 @@ pub mod cfg_ir; pub mod decoder; pub mod detection; pub mod encoder; +pub mod opcode; pub mod result; pub mod seed; pub mod strip; @@ -12,18 +13,15 @@ pub use result::{Error, Result}; use std::fs; use std::path::Path; -pub use eot::UnifiedOpcode as Opcode; +pub use opcode::Opcode; /// Returns true if the opcode terminates execution. /// /// Terminal opcodes are those that end the execution of a program or transaction, /// such as STOP, RETURN, REVERT, SELFDESTRUCT, and INVALID. #[inline] -pub fn is_terminal_opcode(opcode: Opcode) -> bool { - matches!( - opcode, - Opcode::STOP | Opcode::RETURN | Opcode::REVERT | Opcode::SELFDESTRUCT | Opcode::INVALID - ) +pub const fn is_terminal_opcode(opcode: Opcode) -> bool { + opcode.is_terminal() } /// Returns true if the opcode ends a basic block. @@ -31,17 +29,8 @@ pub fn is_terminal_opcode(opcode: Opcode) -> bool { /// Block-ending opcodes include terminal opcodes as well as control flow opcodes /// like JUMP and JUMPI that transfer control to different parts of the program. #[inline] -pub fn is_block_ending_opcode(opcode: Opcode) -> bool { - matches!( - opcode, - Opcode::STOP - | Opcode::RETURN - | Opcode::REVERT - | Opcode::SELFDESTRUCT - | Opcode::INVALID - | Opcode::JUMP - | Opcode::JUMPI - ) +pub const fn is_block_ending_opcode(opcode: Opcode) -> bool { + opcode.is_block_ending() } /// Normalizes hex strings by removing whitespace, 0x prefix, and ensuring even length. @@ -106,8 +95,10 @@ pub async fn process_bytecode_to_cfg( ), Box, > { - let (instructions, _, _, bytes) = - decoder::decode_bytecode(deployment_bytecode, deployment_is_file).await?; + // CFG construction does not consume display/hash metadata, so keep the production hot path + // to one input normalization and one native byte walk. + let bytes = input_to_bytes(deployment_bytecode, deployment_is_file)?; + let instructions = decoder::decode_bytes(&bytes)?; let runtime_bytes = input_to_bytes(runtime_bytecode, runtime_is_file)?; let sections = detection::locate_sections(&bytes, &instructions, &runtime_bytes)?; let (_, report) = strip::strip_bytecode(&bytes, §ions)?; @@ -120,7 +111,10 @@ pub async fn process_bytecode_to_cfg( .ok_or("No Runtime section found in bytecode")?; let runtime_start_pc = runtime_section.offset; - let runtime_end_pc = runtime_section.offset + runtime_section.len; + let runtime_end_pc = runtime_section + .offset + .checked_add(runtime_section.len) + .ok_or(Error::SectionOutOfBounds(runtime_section.offset))?; tracing::debug!( "Filtering instructions to runtime section: PC range [{}, {})", @@ -128,11 +122,8 @@ pub async fn process_bytecode_to_cfg( runtime_end_pc ); - let runtime_instructions: Vec = instructions - .iter() - .filter(|instr| instr.pc >= runtime_start_pc && instr.pc < runtime_end_pc) - .cloned() - .collect(); + let runtime_instructions = + decoder::decode_executable_range(&bytes, runtime_section.offset, runtime_section.len)?; tracing::debug!( "Filtered from {} total instructions to {} runtime instructions", @@ -200,4 +191,45 @@ mod tests { assert!(bundle.cfg.node_count() > 0, "cfg contains blocks"); assert_eq!(bundle.original_bytecode, bytes); } + + #[tokio::test] + async fn process_bytecode_rejects_runtime_with_truncated_push() { + let error = process_bytecode_to_cfg("0x0061aa", false, "0x0061aa", false) + .await + .expect_err("moving code after a short final PUSH must fail closed"); + assert!( + error.to_string().contains("truncated PUSH2 at byte 0x1"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn process_bytecode_decodes_runtime_independently_of_init_data() { + // Init returns the three bytes at 0x0c. Dead init data at 0x0a begins PUSH2 and consumes + // byte 0x60 at 0x0c in the creation-code linear view; deployed execution starts fresh at + // 0x0c and must see that byte as PUSH1. + let deployment = "0x6003600c5f3960035ff361aa600000"; + let runtime = "0x600000"; + let (bundle, _, sections, _) = process_bytecode_to_cfg(deployment, false, runtime, false) + .await + .expect("valid overlapping init/runtime interpretations must be accepted"); + + let runtime_section = sections + .iter() + .find(|section| section.kind == SectionKind::Runtime) + .unwrap(); + assert_eq!((runtime_section.offset, runtime_section.len), (0x0c, 3)); + let runtime_instructions: Vec<_> = bundle + .cfg + .node_weights() + .filter_map(|block| match block { + cfg_ir::Block::Body(body) => Some(body.instructions.as_slice()), + _ => None, + }) + .flatten() + .collect(); + assert_eq!(runtime_instructions[0].pc, 0x0c); + assert_eq!(runtime_instructions[0].op, Opcode::PUSH(1)); + assert_eq!(runtime_instructions[0].imm.as_deref(), Some("00")); + } } diff --git a/crates/core/src/opcode.rs b/crates/core/src/opcode.rs new file mode 100644 index 00000000..bb264223 --- /dev/null +++ b/crates/core/src/opcode.rs @@ -0,0 +1,751 @@ +//! Native opcode definitions for the active Ethereum legacy EVM. +//! +//! This module deliberately fixes Azoth's opcode vocabulary to the Fulu/Osaka +//! execution-layer revision activated by [EIP-7607]. In particular, `CLZ` +//! (`0x1e`) is active, while the withdrawn EOF proposal opcodes remain unknown +//! bytes in legacy bytecode. Keeping the revision explicit and local makes +//! decoding deterministic and prevents a dependency update from silently +//! changing CFG semantics. +//! +//! [EIP-7607]: https://eips.ethereum.org/EIPS/eip-7607 + +#![allow(non_camel_case_types, clippy::upper_case_acronyms)] + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +/// The stack effect of a known opcode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct OpcodeInfo { + /// Number of stack values consumed by the opcode. + pub inputs: u8, + /// Number of stack values present in place of the consumed values. + pub outputs: u8, +} + +impl OpcodeInfo { + const fn new(inputs: u8, outputs: u8) -> Self { + Self { inputs, outputs } + } + + /// Returns the signed change in stack height. + #[must_use] + pub const fn stack_delta(self) -> i16 { + self.outputs as i16 - self.inputs as i16 + } +} + +/// Failure to encode a non-canonical parameterized opcode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum OpcodeEncodingError { + /// `PUSH(n)` is valid only for widths 1 through 32; zero uses `PUSH0`. + InvalidPushWidth(u8), + /// `DUP(n)` is valid only for depths 1 through 16. + InvalidDupDepth(u8), + /// `SWAP(n)` is valid only for depths 1 through 16. + InvalidSwapDepth(u8), + /// `UNKNOWN(byte)` may contain only a byte that is actually unassigned. + AssignedByteMarkedUnknown(u8), +} + +impl fmt::Display for OpcodeEncodingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPushWidth(width) => { + write!(f, "PUSH width must be in 1..=32, got {width}") + } + Self::InvalidDupDepth(depth) => { + write!(f, "DUP depth must be in 1..=16, got {depth}") + } + Self::InvalidSwapDepth(depth) => { + write!(f, "SWAP depth must be in 1..=16, got {depth}") + } + Self::AssignedByteMarkedUnknown(byte) => { + write!( + f, + "assigned opcode byte 0x{byte:02x} cannot be marked UNKNOWN" + ) + } + } + } +} + +impl std::error::Error for OpcodeEncodingError {} + +// The fixed entries below are the single source of truth for byte mapping, +// names, and stack effects. Parameterized PUSH/DUP/SWAP families are handled +// separately because their metadata is derived from the encoded byte. +macro_rules! define_opcodes { + ($( + $variant:ident = $byte:literal => ($inputs:literal, $outputs:literal); + )+) => { + /// A pattern-matchable opcode from the active Osaka legacy EVM. + /// + /// Unassigned bytes are represented losslessly as [`Opcode::UNKNOWN`]. + /// Parameterized families retain their natural one-based width/depth. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum Opcode { + $( + #[doc = concat!("The `", stringify!($variant), "` opcode (`", stringify!($byte), "`).")] + $variant, + )+ + /// Pushes 1 through 32 immediate bytes. + PUSH(#[serde(deserialize_with = "deserialize_push_width")] u8), + /// Duplicates stack item 1 through 16. + DUP(#[serde(deserialize_with = "deserialize_dup_depth")] u8), + /// Swaps the top with stack item 2 through 17 (`SWAP1` through `SWAP16`). + SWAP(#[serde(deserialize_with = "deserialize_swap_depth")] u8), + /// An unassigned legacy opcode byte, preserved exactly. + UNKNOWN(#[serde(deserialize_with = "deserialize_unknown_byte")] u8), + } + + impl Opcode { + /// Converts a raw legacy-EVM byte into its lossless opcode representation. + #[must_use] + pub const fn from_byte(byte: u8) -> Self { + match byte { + $($byte => Self::$variant,)+ + 0x60..=0x7f => Self::PUSH(byte - 0x5f), + 0x80..=0x8f => Self::DUP(byte - 0x7f), + 0x90..=0x9f => Self::SWAP(byte - 0x8f), + _ => Self::UNKNOWN(byte), + } + } + + /// Returns the encoded byte, or `None` for an invalid PUSH/DUP/SWAP parameter. + #[must_use] + pub const fn encoded_byte(self) -> Option { + match self { + $(Self::$variant => Some($byte),)+ + Self::PUSH(width) if matches!(width, 1..=32) => Some(0x5f + width), + Self::DUP(depth) if matches!(depth, 1..=16) => Some(0x7f + depth), + Self::SWAP(depth) if matches!(depth, 1..=16) => Some(0x8f + depth), + Self::UNKNOWN(byte) if matches!(Self::from_byte(byte), Self::UNKNOWN(_)) => { + Some(byte) + } + Self::PUSH(_) | Self::DUP(_) | Self::SWAP(_) | Self::UNKNOWN(_) => None, + } + } + + /// Returns the encoded byte with a precise error for an invalid parameter. + pub const fn try_to_byte(self) -> Result { + match self { + $(Self::$variant => Ok($byte),)+ + Self::PUSH(width) if matches!(width, 1..=32) => Ok(0x5f + width), + Self::DUP(depth) if matches!(depth, 1..=16) => Ok(0x7f + depth), + Self::SWAP(depth) if matches!(depth, 1..=16) => Ok(0x8f + depth), + Self::UNKNOWN(byte) if matches!(Self::from_byte(byte), Self::UNKNOWN(_)) => { + Ok(byte) + } + Self::PUSH(width) => Err(OpcodeEncodingError::InvalidPushWidth(width)), + Self::DUP(depth) => Err(OpcodeEncodingError::InvalidDupDepth(depth)), + Self::SWAP(depth) => Err(OpcodeEncodingError::InvalidSwapDepth(depth)), + Self::UNKNOWN(byte) => { + Err(OpcodeEncodingError::AssignedByteMarkedUnknown(byte)) + } + } + } + + /// Converts the opcode to its encoded byte for compatibility with the former API. + /// + /// New encoding code should use [`Opcode::try_to_byte`] so malformed IR is returned + /// as an error instead of panicking. + /// + /// # Panics + /// + /// Panics when a manually constructed PUSH/DUP/SWAP has an out-of-range parameter. + #[must_use] + pub fn to_byte(self) -> u8 { + match self.try_to_byte() { + Ok(byte) => byte, + Err(error) => panic!("cannot encode opcode: {error}"), + } + } + + /// Returns stack metadata for a known opcode. + /// + /// Unknown bytes deliberately return `None`; treating an unassigned byte as a + /// zero-effect instruction would make control-flow and stack analyses unsound. + #[must_use] + pub const fn info(self) -> Option { + match self { + $(Self::$variant => Some(OpcodeInfo::new($inputs, $outputs)),)+ + Self::PUSH(width) if matches!(width, 1..=32) => { + Some(OpcodeInfo::new(0, 1)) + } + Self::DUP(depth) if matches!(depth, 1..=16) => { + Some(OpcodeInfo::new(depth, depth + 1)) + } + Self::SWAP(depth) if matches!(depth, 1..=16) => { + Some(OpcodeInfo::new(depth + 1, depth + 1)) + } + Self::UNKNOWN(_) | Self::PUSH(_) | Self::DUP(_) | Self::SWAP(_) => None, + } + } + + /// Returns the PUSH immediate width, excluding `PUSH0`. + #[must_use] + pub const fn push_width(self) -> Option { + match self { + Self::PUSH(width) if matches!(width, 1..=32) => Some(width), + _ => None, + } + } + + /// Returns the number of immediate bytes consumed in legacy bytecode. + #[must_use] + pub const fn immediate_size(self) -> usize { + match self.push_width() { + Some(width) => width as usize, + None => 0, + } + } + + /// Decodes an opcode byte and reports its fixed immediate width. + #[must_use] + pub const fn parse(byte: u8) -> (Self, usize) { + let opcode = Self::from_byte(byte); + let immediate_size = opcode.immediate_size(); + (opcode, immediate_size) + } + + /// Returns whether this value represents an unassigned byte. + #[must_use] + pub const fn is_unknown(&self) -> bool { + matches!(self, Self::UNKNOWN(_)) + } + + /// Returns whether execution ends at this opcode in the active legacy EVM. + /// + /// Unassigned bytes terminate exceptionally and are therefore terminal too. + #[must_use] + pub const fn is_terminal(&self) -> bool { + matches!( + self, + Self::STOP + | Self::RETURN + | Self::REVERT + | Self::INVALID + | Self::SELFDESTRUCT + | Self::UNKNOWN(_) + ) + } + + /// Returns whether this opcode ends a legacy basic block. + #[must_use] + pub const fn is_block_ending(&self) -> bool { + self.is_terminal() || matches!(self, Self::JUMP | Self::JUMPI) + } + + /// Returns whether this opcode directly marks or changes control flow. + #[must_use] + pub const fn is_control_flow(&self) -> bool { + self.is_block_ending() || matches!(self, Self::JUMPDEST) + } + + /// Returns the canonical mnemonic, including parameters and unknown-byte values. + #[must_use] + pub fn name(&self) -> String { + self.to_string() + } + + fn fmt_name(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + $(Self::$variant => f.write_str(stringify!($variant)),)+ + Self::PUSH(width) => write!(f, "PUSH{width}"), + Self::DUP(depth) => write!(f, "DUP{depth}"), + Self::SWAP(depth) => write!(f, "SWAP{depth}"), + Self::UNKNOWN(byte) => write!(f, "UNKNOWN(0x{byte:02x})"), + } + } + } + + impl fmt::Display for Opcode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.fmt_name(f) + } + } + + impl FromStr for Opcode { + type Err = String; + + fn from_str(input: &str) -> Result { + let normalized = input.trim().to_ascii_uppercase(); + + if let Some(byte) = parse_unknown_byte(&normalized) { + let opcode = Self::from_byte(byte); + return if opcode.is_unknown() { + Ok(opcode) + } else { + Err(format!( + "assigned opcode byte 0x{byte:02x} is canonically {opcode}" + )) + }; + } + + match normalized.as_str() { + $(stringify!($variant) => Ok(Self::$variant),)+ + "SHA3" => Ok(Self::KECCAK256), + "PREVRANDAO" => Ok(Self::DIFFICULTY), + name => parse_parameterized(name) + .ok_or_else(|| format!("unknown opcode: {input}")), + } + } + } + }; +} + +define_opcodes! { + STOP = 0x00 => (0, 0); + ADD = 0x01 => (2, 1); + MUL = 0x02 => (2, 1); + SUB = 0x03 => (2, 1); + DIV = 0x04 => (2, 1); + SDIV = 0x05 => (2, 1); + MOD = 0x06 => (2, 1); + SMOD = 0x07 => (2, 1); + ADDMOD = 0x08 => (3, 1); + MULMOD = 0x09 => (3, 1); + EXP = 0x0a => (2, 1); + SIGNEXTEND = 0x0b => (2, 1); + + LT = 0x10 => (2, 1); + GT = 0x11 => (2, 1); + SLT = 0x12 => (2, 1); + SGT = 0x13 => (2, 1); + EQ = 0x14 => (2, 1); + ISZERO = 0x15 => (1, 1); + AND = 0x16 => (2, 1); + OR = 0x17 => (2, 1); + XOR = 0x18 => (2, 1); + NOT = 0x19 => (1, 1); + BYTE = 0x1a => (2, 1); + SHL = 0x1b => (2, 1); + SHR = 0x1c => (2, 1); + SAR = 0x1d => (2, 1); + CLZ = 0x1e => (1, 1); + + KECCAK256 = 0x20 => (2, 1); + + ADDRESS = 0x30 => (0, 1); + BALANCE = 0x31 => (1, 1); + ORIGIN = 0x32 => (0, 1); + CALLER = 0x33 => (0, 1); + CALLVALUE = 0x34 => (0, 1); + CALLDATALOAD = 0x35 => (1, 1); + CALLDATASIZE = 0x36 => (0, 1); + CALLDATACOPY = 0x37 => (3, 0); + CODESIZE = 0x38 => (0, 1); + CODECOPY = 0x39 => (3, 0); + GASPRICE = 0x3a => (0, 1); + EXTCODESIZE = 0x3b => (1, 1); + EXTCODECOPY = 0x3c => (4, 0); + RETURNDATASIZE = 0x3d => (0, 1); + RETURNDATACOPY = 0x3e => (3, 0); + EXTCODEHASH = 0x3f => (1, 1); + + BLOCKHASH = 0x40 => (1, 1); + COINBASE = 0x41 => (0, 1); + TIMESTAMP = 0x42 => (0, 1); + NUMBER = 0x43 => (0, 1); + DIFFICULTY = 0x44 => (0, 1); + GASLIMIT = 0x45 => (0, 1); + CHAINID = 0x46 => (0, 1); + SELFBALANCE = 0x47 => (0, 1); + BASEFEE = 0x48 => (0, 1); + BLOBHASH = 0x49 => (1, 1); + BLOBBASEFEE = 0x4a => (0, 1); + + POP = 0x50 => (1, 0); + MLOAD = 0x51 => (1, 1); + MSTORE = 0x52 => (2, 0); + MSTORE8 = 0x53 => (2, 0); + SLOAD = 0x54 => (1, 1); + SSTORE = 0x55 => (2, 0); + JUMP = 0x56 => (1, 0); + JUMPI = 0x57 => (2, 0); + PC = 0x58 => (0, 1); + MSIZE = 0x59 => (0, 1); + GAS = 0x5a => (0, 1); + JUMPDEST = 0x5b => (0, 0); + TLOAD = 0x5c => (1, 1); + TSTORE = 0x5d => (2, 0); + MCOPY = 0x5e => (3, 0); + PUSH0 = 0x5f => (0, 1); + + LOG0 = 0xa0 => (2, 0); + LOG1 = 0xa1 => (3, 0); + LOG2 = 0xa2 => (4, 0); + LOG3 = 0xa3 => (5, 0); + LOG4 = 0xa4 => (6, 0); + + CREATE = 0xf0 => (3, 1); + CALL = 0xf1 => (7, 1); + CALLCODE = 0xf2 => (7, 1); + RETURN = 0xf3 => (2, 0); + DELEGATECALL = 0xf4 => (6, 1); + CREATE2 = 0xf5 => (4, 1); + STATICCALL = 0xfa => (6, 1); + REVERT = 0xfd => (2, 0); + INVALID = 0xfe => (0, 0); + SELFDESTRUCT = 0xff => (1, 0); +} + +impl Opcode { + /// Post-Merge name for opcode `0x44`. + pub const PREVRANDAO: Self = Self::DIFFICULTY; + /// Historical alias for `KECCAK256` (`0x20`). + pub const SHA3: Self = Self::KECCAK256; +} + +impl From for Opcode { + fn from(byte: u8) -> Self { + Self::from_byte(byte) + } +} + +impl From for u8 { + /// Converts a canonical opcode to a byte. + /// + /// This compatibility conversion panics for an invalid parameterized opcode. Code handling + /// untrusted or transform-produced IR should call [`Opcode::try_to_byte`] instead. + fn from(opcode: Opcode) -> Self { + opcode.to_byte() + } +} + +fn parse_parameterized(name: &str) -> Option { + if let Some(width) = parse_decimal_suffix(name, "PUSH") + && (1..=32).contains(&width) + { + return Some(Opcode::PUSH(width)); + } + if let Some(depth) = parse_decimal_suffix(name, "DUP") + && (1..=16).contains(&depth) + { + return Some(Opcode::DUP(depth)); + } + if let Some(depth) = parse_decimal_suffix(name, "SWAP") + && (1..=16).contains(&depth) + { + return Some(Opcode::SWAP(depth)); + } + None +} + +fn parse_decimal_suffix(name: &str, prefix: &str) -> Option { + let suffix = name.strip_prefix(prefix)?; + if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + suffix.parse().ok() +} + +fn parse_unknown_byte(name: &str) -> Option { + let hex = name.strip_prefix("UNKNOWN_0X").or_else(|| { + name.strip_prefix("UNKNOWN(0X") + .and_then(|suffix| suffix.strip_suffix(')')) + })?; + if hex.is_empty() || hex.len() > 2 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + u8::from_str_radix(hex, 16).ok() +} + +fn deserialize_push_width<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let width = u8::deserialize(deserializer)?; + if (1..=32).contains(&width) { + Ok(width) + } else { + Err(serde::de::Error::custom(format_args!( + "PUSH width must be in 1..=32, got {width}" + ))) + } +} + +fn deserialize_dup_depth<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let depth = u8::deserialize(deserializer)?; + if (1..=16).contains(&depth) { + Ok(depth) + } else { + Err(serde::de::Error::custom(format_args!( + "DUP depth must be in 1..=16, got {depth}" + ))) + } +} + +fn deserialize_swap_depth<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let depth = u8::deserialize(deserializer)?; + if (1..=16).contains(&depth) { + Ok(depth) + } else { + Err(serde::de::Error::custom(format_args!( + "SWAP depth must be in 1..=16, got {depth}" + ))) + } +} + +fn deserialize_unknown_byte<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let byte = u8::deserialize(deserializer)?; + let opcode = Opcode::from_byte(byte); + if opcode.is_unknown() { + Ok(byte) + } else { + Err(serde::de::Error::custom(format_args!( + "assigned opcode byte 0x{byte:02x} is canonically {opcode}" + ))) + } +} + +#[cfg(test)] +mod tests { + use super::{Opcode, OpcodeEncodingError, OpcodeInfo}; + use std::str::FromStr; + + #[test] + fn every_byte_round_trips_losslessly() { + for byte in u8::MIN..=u8::MAX { + let opcode = Opcode::from_byte(byte); + assert_eq!(opcode.encoded_byte(), Some(byte), "byte 0x{byte:02x}"); + assert_eq!(opcode.try_to_byte(), Ok(byte), "byte 0x{byte:02x}"); + assert_eq!(u8::from(opcode), byte, "byte 0x{byte:02x}"); + } + } + + #[test] + fn known_byte_set_matches_osaka_legacy_evm() { + for byte in u8::MIN..=u8::MAX { + let expected_known = matches!( + byte, + 0x00..=0x0b + | 0x10..=0x1e + | 0x20 + | 0x30..=0x4a + | 0x50..=0xa4 + | 0xf0..=0xf5 + | 0xfa + | 0xfd..=0xff + ); + assert_eq!( + !Opcode::from_byte(byte).is_unknown(), + expected_known, + "known-byte classification differs at 0x{byte:02x}" + ); + } + } + + #[test] + fn current_fork_opcodes_are_modelled() { + let cases = [ + (0x1e, Opcode::CLZ, OpcodeInfo::new(1, 1)), + (0x49, Opcode::BLOBHASH, OpcodeInfo::new(1, 1)), + (0x4a, Opcode::BLOBBASEFEE, OpcodeInfo::new(0, 1)), + (0x5c, Opcode::TLOAD, OpcodeInfo::new(1, 1)), + (0x5d, Opcode::TSTORE, OpcodeInfo::new(2, 0)), + (0x5e, Opcode::MCOPY, OpcodeInfo::new(3, 0)), + (0x5f, Opcode::PUSH0, OpcodeInfo::new(0, 1)), + ]; + + for (byte, opcode, info) in cases { + assert_eq!(Opcode::from_byte(byte), opcode); + assert_eq!(opcode.info(), Some(info)); + } + } + + #[test] + fn withdrawn_eof_proposal_bytes_remain_unknown_in_legacy_code() { + const EOF_PROPOSAL_BYTES: &[u8] = &[ + 0xd0, 0xd1, 0xd2, 0xd3, 0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xec, + 0xee, 0xf7, 0xf8, 0xf9, 0xfb, + ]; + + for &byte in EOF_PROPOSAL_BYTES { + let opcode = Opcode::from_byte(byte); + assert_eq!(opcode, Opcode::UNKNOWN(byte)); + assert_eq!(opcode.info(), None); + assert!(opcode.is_terminal()); + } + } + + #[test] + fn stack_effects_are_exhaustive_for_known_bytes() { + for byte in u8::MIN..=u8::MAX { + assert_eq!( + Opcode::from_byte(byte).info(), + expected_stack_info(byte), + "stack metadata differs at 0x{byte:02x}" + ); + } + } + + #[test] + fn parameterized_opcode_boundaries_are_checked() { + assert_eq!(Opcode::PUSH(1).try_to_byte(), Ok(0x60)); + assert_eq!(Opcode::PUSH(32).try_to_byte(), Ok(0x7f)); + assert_eq!(Opcode::DUP(1).try_to_byte(), Ok(0x80)); + assert_eq!(Opcode::DUP(16).try_to_byte(), Ok(0x8f)); + assert_eq!(Opcode::SWAP(1).try_to_byte(), Ok(0x90)); + assert_eq!(Opcode::SWAP(16).try_to_byte(), Ok(0x9f)); + + for width in [0, 33, u8::MAX] { + assert_eq!(Opcode::PUSH(width).encoded_byte(), None); + assert_eq!( + Opcode::PUSH(width).try_to_byte(), + Err(OpcodeEncodingError::InvalidPushWidth(width)) + ); + assert_eq!(Opcode::PUSH(width).info(), None); + } + for depth in [0, 17, u8::MAX] { + assert_eq!(Opcode::DUP(depth).encoded_byte(), None); + assert_eq!( + Opcode::DUP(depth).try_to_byte(), + Err(OpcodeEncodingError::InvalidDupDepth(depth)) + ); + assert_eq!(Opcode::DUP(depth).info(), None); + + assert_eq!(Opcode::SWAP(depth).encoded_byte(), None); + assert_eq!( + Opcode::SWAP(depth).try_to_byte(), + Err(OpcodeEncodingError::InvalidSwapDepth(depth)) + ); + assert_eq!(Opcode::SWAP(depth).info(), None); + } + + for byte in [0x00, 0x56, 0x60, 0xfe, 0xff] { + assert_eq!(Opcode::UNKNOWN(byte).encoded_byte(), None); + assert_eq!( + Opcode::UNKNOWN(byte).try_to_byte(), + Err(OpcodeEncodingError::AssignedByteMarkedUnknown(byte)) + ); + } + + assert_eq!(Opcode::UNKNOWN(0x0c).encoded_byte(), Some(0x0c)); + assert_eq!(Opcode::UNKNOWN(0xd0).try_to_byte(), Ok(0xd0)); + } + + #[test] + fn parse_reports_only_push_immediate_widths() { + assert_eq!(Opcode::parse(0x5f), (Opcode::PUSH0, 0)); + for width in 1..=32 { + let byte = 0x5f + width; + assert_eq!(Opcode::parse(byte), (Opcode::PUSH(width), width as usize)); + } + assert_eq!(Opcode::parse(0xd1), (Opcode::UNKNOWN(0xd1), 0)); + } + + #[test] + fn names_parse_with_required_aliases_and_unknown_formats() { + assert_eq!(Opcode::from_str("sha3"), Ok(Opcode::KECCAK256)); + assert_eq!(Opcode::from_str("PREVRANDAO"), Ok(Opcode::DIFFICULTY)); + assert_eq!(Opcode::from_str("push32"), Ok(Opcode::PUSH(32))); + assert_eq!(Opcode::from_str("dup16"), Ok(Opcode::DUP(16))); + assert_eq!(Opcode::from_str("swap16"), Ok(Opcode::SWAP(16))); + assert_eq!(Opcode::from_str("UNKNOWN_0xaa"), Ok(Opcode::UNKNOWN(0xaa))); + assert_eq!(Opcode::from_str("UNKNOWN(0x0c)"), Ok(Opcode::UNKNOWN(0x0c))); + + assert!(Opcode::from_str("unknown").is_err()); + assert!(Opcode::from_str("PUSH00").is_err()); + assert!(Opcode::from_str("PUSH33").is_err()); + assert!(Opcode::from_str("DUP0").is_err()); + assert!(Opcode::from_str("SWAP17").is_err()); + assert!(Opcode::from_str("UNKNOWN(0x56)").is_err()); + assert!(Opcode::from_str("UNKNOWN_0xfe").is_err()); + } + + #[test] + fn canonical_names_round_trip() { + for byte in u8::MIN..=u8::MAX { + let opcode = Opcode::from_byte(byte); + let rendered = opcode.to_string(); + assert_eq!(Opcode::from_str(&rendered), Ok(opcode), "byte 0x{byte:02x}"); + assert_eq!(opcode.name(), rendered); + } + } + + #[test] + fn terminal_and_block_helpers_follow_legacy_execution() { + for opcode in [ + Opcode::STOP, + Opcode::RETURN, + Opcode::REVERT, + Opcode::INVALID, + Opcode::SELFDESTRUCT, + Opcode::UNKNOWN(0x0c), + ] { + assert!(opcode.is_terminal()); + assert!(opcode.is_block_ending()); + assert!(opcode.is_control_flow()); + } + + for opcode in [Opcode::JUMP, Opcode::JUMPI] { + assert!(!opcode.is_terminal()); + assert!(opcode.is_block_ending()); + assert!(opcode.is_control_flow()); + } + + assert!(!Opcode::JUMPDEST.is_terminal()); + assert!(!Opcode::JUMPDEST.is_block_ending()); + assert!(Opcode::JUMPDEST.is_control_flow()); + assert!(!Opcode::ADD.is_control_flow()); + } + + fn expected_stack_info(byte: u8) -> Option { + let info = match byte { + 0x00 | 0x5b | 0xfe => (0, 0), + + 0x30 + | 0x32..=0x34 + | 0x36 + | 0x38 + | 0x3a + | 0x3d + | 0x41..=0x48 + | 0x4a + | 0x58..=0x5a + | 0x5f + | 0x60..=0x7f => (0, 1), + + 0x50 | 0x56 | 0xff => (1, 0), + + 0x15 | 0x19 | 0x1e | 0x31 | 0x35 | 0x3b | 0x3f | 0x40 | 0x49 | 0x51 | 0x54 | 0x5c => { + (1, 1) + } + + 0x52 | 0x53 | 0x55 | 0x57 | 0x5d | 0xf3 | 0xfd => (2, 0), + + 0x01..=0x07 | 0x0a..=0x0b | 0x10..=0x14 | 0x16..=0x18 | 0x1a..=0x1d | 0x20 => (2, 1), + + 0x37 | 0x39 | 0x3e | 0x5e => (3, 0), + 0x3c => (4, 0), + 0x08 | 0x09 | 0xf0 => (3, 1), + 0xf5 => (4, 1), + 0xf1 | 0xf2 => (7, 1), + 0xf4 | 0xfa => (6, 1), + + 0x80..=0x8f => { + let depth = byte - 0x7f; + (depth, depth + 1) + } + 0x90..=0x9f => { + let depth = byte - 0x8f; + (depth + 1, depth + 1) + } + 0xa0..=0xa4 => (byte - 0x9e, 0), + _ => return None, + }; + Some(OpcodeInfo::new(info.0, info.1)) + } +} diff --git a/crates/core/src/result.rs b/crates/core/src/result.rs index b19dddc7..bf1763f5 100644 --- a/crates/core/src/result.rs +++ b/crates/core/src/result.rs @@ -19,10 +19,6 @@ pub enum Error { source: std::io::Error, }, - /// Heimdall disassembly operation failed. - #[error("heimdall disassembly failed: {0}")] - Heimdall(String), - /// Failed to decode hex string. #[error("hex decode failed: {0}")] HexDecode(#[from] hex::FromHexError), @@ -39,6 +35,19 @@ pub enum Error { #[error("invalid immediate: {0}")] InvalidImmediate(String), + /// A final PUSH has fewer physical immediate bytes than its declared width. + #[error( + "truncated PUSH{width} at byte 0x{pc:x}: expected {width} immediate bytes, found {available}" + )] + TruncatedPush { + /// Byte offset of the PUSH opcode. + pc: usize, + /// Declared PUSH immediate width. + width: usize, + /// Number of immediate bytes physically present. + available: usize, + }, + /// Invalid hexadecimal in seed. #[error("invalid hexadecimal in seed")] InvalidSeedHex, diff --git a/crates/core/src/seed.rs b/crates/core/src/seed.rs index b1cc5747..83a6a239 100644 --- a/crates/core/src/seed.rs +++ b/crates/core/src/seed.rs @@ -1,8 +1,17 @@ use crate::result::Error; -use rand::{RngCore, SeedableRng, rngs::StdRng}; +use rand::{RngCore, SeedableRng}; +use rand_chacha::ChaCha20Rng; use serde::{Deserialize, Serialize}; use sha3::{Digest, Sha3_256}; +/// The deterministic byte stream used by Azoth's transformation protocol. +/// +/// This alias deliberately names a pinned algorithm instead of `rand::rngs::StdRng`, whose +/// implementation is allowed to change between `rand` releases. Changing this algorithm or the +/// pinned `rand_chacha` or `rand` version is a protocol change: bump the pipeline profile and +/// update the golden vectors in this module. +pub type DeterministicRng = ChaCha20Rng; + /// A 256-bit cryptographic seed #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Seed { @@ -41,19 +50,34 @@ impl Seed { /// /// Basically, it uses whatever bytes are already stored in that Seed, regardless of how those /// bytes were created (randomly via generate(), from hex, from legacy u64, etc.). - pub fn create_deterministic_rng(&self) -> StdRng { - // Hash the seed to create RNG seed + pub fn create_deterministic_rng(&self) -> DeterministicRng { + self.derive_rng(b"AZOTH_DEFAULT_STREAM_V3") + } + + /// Creates an independent deterministic RNG stream for a domain label. + /// + /// All 256 derived bits seed the generator. Callers should include the normalized input hash, + /// profile version, transform identifier, occurrence, and decision label in `domain` when + /// choices must remain isolated from unrelated passes. + pub fn derive_rng(&self, domain: &[u8]) -> DeterministicRng { let mut hasher = Sha3_256::new(); - hasher.update(b"AZOTH_BYTECODE_OBFUSCATION"); + hasher.update(b"AZOTH_RNG_STREAM_V3_CHACHA20"); + hasher.update((domain.len() as u64).to_be_bytes()); + hasher.update(domain); hasher.update(self.inner); - let seed_hash = hasher.finalize(); - - // Convert first 8 bytes to u64 for StdRng - let mut seed_bytes = [0u8; 8]; - seed_bytes.copy_from_slice(&seed_hash[..8]); - let rng_seed = u64::from_le_bytes(seed_bytes); + DeterministicRng::from_seed(hasher.finalize().into()) + } - StdRng::seed_from_u64(rng_seed) + /// Derives a domain-separated 256-bit child seed. + pub fn derive_seed(&self, domain: &[u8]) -> Self { + let mut hasher = Sha3_256::new(); + hasher.update(b"AZOTH_CHILD_SEED_V2"); + hasher.update((domain.len() as u64).to_be_bytes()); + hasher.update(domain); + hasher.update(self.inner); + Self { + inner: hasher.finalize().into(), + } } /// Get a hash of this seed for integrity/identification purposes @@ -81,9 +105,9 @@ impl Seed { #[cfg(test)] mod tests { - use super::Seed; + use super::{DeterministicRng, Seed}; use crate::result::Error; - use rand::{RngCore, SeedableRng, rngs::StdRng}; + use rand::{RngCore, SeedableRng}; use sha3::{Digest, Sha3_256}; const SAMPLE_HEX: &str = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; @@ -114,16 +138,46 @@ mod tests { let mut rng = seed.create_deterministic_rng(); let mut hasher = Sha3_256::new(); - hasher.update(b"AZOTH_BYTECODE_OBFUSCATION"); + let domain = b"AZOTH_DEFAULT_STREAM_V3"; + hasher.update(b"AZOTH_RNG_STREAM_V3_CHACHA20"); + hasher.update((domain.len() as u64).to_be_bytes()); + hasher.update(domain); hasher.update(seed.inner); - let hash = hasher.finalize(); - let mut seed_bytes = [0u8; 8]; - seed_bytes.copy_from_slice(&hash[..8]); - let derived = u64::from_le_bytes(seed_bytes); - let mut manual_rng = StdRng::seed_from_u64(derived); + let mut manual_rng = DeterministicRng::from_seed(hasher.finalize().into()); for _ in 0..4 { assert_eq!(rng.next_u64(), manual_rng.next_u64()); } } + + #[test] + fn rng_domains_and_high_seed_bits_are_effective() { + let low = Seed::from_bytes([0u8; 32]); + let mut high_bytes = [0u8; 32]; + high_bytes[31] = 1; + let high = Seed::from_bytes(high_bytes); + + assert_ne!( + low.derive_rng(b"pass-a").next_u64(), + high.derive_rng(b"pass-a").next_u64() + ); + assert_ne!( + low.derive_rng(b"pass-a").next_u64(), + low.derive_rng(b"pass-b").next_u64() + ); + } + + #[test] + fn rng_stream_has_a_fixed_golden_vector() { + let seed = Seed::from_hex(SAMPLE_HEX).expect("valid sample seed"); + let mut rng = seed.derive_rng(b"azoth/golden/domain/v1"); + let mut output = [0u8; 64]; + rng.fill_bytes(&mut output); + + assert_eq!( + hex::encode(output), + "12d541e3dd62ec6e96e5ce09b90eccc3be2de3f566877362afabdb66d4c5532a\ + e09d49b187c3d31cdcfd2961a0540ecf6907017e8721c2e1a130fd7c7809f664" + ); + } } diff --git a/crates/core/src/strip.rs b/crates/core/src/strip.rs index 82cb115e..61345eea 100644 --- a/crates/core/src/strip.rs +++ b/crates/core/src/strip.rs @@ -2,13 +2,15 @@ //! obfuscation. use crate::{ + Opcode, detection::{Section, SectionKind}, result::Error, }; use hex::encode; -use revm::primitives::{B256, Bytes}; +use revm::primitives::{B256, Bytes, U256}; use serde::{Deserialize, Serialize}; use sha3::{Digest, Keccak256}; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; /// Represents a runtime section with its original offset and length. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -127,28 +129,1114 @@ pub fn strip_bytecode(bytes: &[u8], sections: &[Section]) -> Result<(Vec, Cl Ok((clean_runtime, report)) } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] struct PushInfo { pos: usize, width: usize, value: usize, } -fn opcode_positions(bytes: &[u8], target: u8) -> Vec { - let mut positions = Vec::new(); +#[derive(Clone, Debug)] +struct RuntimeCopyContract { + copy_index: usize, + return_index: usize, + length_pushes: Vec, + source_push: PushInfo, + memory_reads: Vec<(usize, InitStackValue)>, + free_memory_lower_bound: Option, + allocator_store_indices: BTreeSet, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum InitStackValue { + Unknown, + Preexisting(usize), + Constant(usize), + RuntimeLength(PushInfo), + RuntimeLengthCandidate(PushInfo), + RuntimeSource(PushInfo), + RuntimeAddress(usize), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum SymbolicWord { + Unknown, + Constant(U256), + AtLeast(usize), + DerivedFromFreeMemory(usize), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct SymbolicState { + instruction_index: usize, + stack: Vec, + memory: BTreeMap, + authoritative_copy_seen: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct CodeCopyObservation { + instruction_index: usize, + destination: Option, + destination_lower_bound: Option, + source: Option, + length: Option, +} + +#[derive(Clone, Debug)] +struct ParsedInstruction { + pos: usize, + opcode: u8, + push_width: Option, +} + +fn parse_instructions(bytes: &[u8], section_name: &str) -> Result, String> { + let mut instructions = Vec::new(); let mut pc = 0usize; while pc < bytes.len() { let opcode = bytes[pc]; - if opcode == target { - positions.push(pc); + let push_width = (0x60..=0x7f) + .contains(&opcode) + .then(|| (opcode - 0x5f) as usize); + let byte_size = 1 + push_width.unwrap_or(0); + let end = pc.checked_add(byte_size).ok_or_else(|| { + format!("{section_name} instruction length overflow at byte 0x{pc:x}") + })?; + if end > bytes.len() { + return Err(format!( + "truncated PUSH{} in {section_name} at byte 0x{pc:x}", + push_width.expect("only PUSH instructions have a multi-byte size") + )); + } + instructions.push(ParsedInstruction { + pos: pc, + opcode, + push_width, + }); + pc = end; + } + Ok(instructions) +} + +fn parsed_push_value(bytes: &[u8], instruction: &ParsedInstruction) -> Option { + let width = instruction.push_width?; + let immediate = bytes.get(instruction.pos + 1..instruction.pos + 1 + width)?; + let usize_bytes = std::mem::size_of::(); + if width > usize_bytes + && immediate[..width - usize_bytes] + .iter() + .any(|byte| *byte != 0) + { + return None; + } + Some( + immediate[width.saturating_sub(usize_bytes)..] + .iter() + .fold(0usize, |value, byte| (value << 8) | usize::from(*byte)), + ) +} + +fn parsed_push_info(bytes: &[u8], instruction: &ParsedInstruction) -> Option { + Some(PushInfo { + pos: instruction.pos, + width: instruction.push_width?, + value: parsed_push_value(bytes, instruction)?, + }) +} + +fn parsed_push_word(bytes: &[u8], instruction: &ParsedInstruction) -> Option { + let width = instruction.push_width?; + let immediate = bytes.get(instruction.pos + 1..instruction.pos + 1 + width)?; + Some(U256::from_be_slice(immediate)) +} + +fn is_zero_push(bytes: &[u8], instruction: &ParsedInstruction) -> bool { + instruction.opcode == 0x5f || parsed_push_value(bytes, instruction) == Some(0) +} + +fn write_push_value(bytes: &mut [u8], info: &PushInfo, new_value: usize) -> Result<(), String> { + if info.pos + 1 + info.width > bytes.len() { + return Err("push immediate out of bounds".into()); + } + if info.width < std::mem::size_of::() { + let max = (1usize << (info.width * 8)) - 1; + if new_value > max { + return Err(format!( + "value 0x{:x} does not fit in PUSH{}", + new_value, info.width + )); + } + } + let bit_width = usize::BITS as usize; + for idx in 0..info.width { + let shift = idx * 8; + let byte = if shift >= bit_width { + 0 + } else { + ((new_value >> shift) & 0xff) as u8 + }; + bytes[info.pos + 1 + info.width - 1 - idx] = byte; + } + Ok(()) +} + +fn is_solidity_allocator_store( + init: &[u8], + instructions: &[ParsedInstruction], + store_index: usize, +) -> bool { + let Some(start) = store_index.checked_sub(25) else { + return false; + }; + let expected = [ + 0x5b, 0x60, 0x90, 0x91, 0x01, 0x60, 0x19, 0x16, 0x81, 0x01, 0x90, 0x60, 0x60, 0x60, 0x1b, + 0x03, 0x82, 0x11, 0x90, 0x82, 0x10, 0x17, 0x61, 0x57, 0x60, 0x52, + ]; + if instructions.get(start..=store_index).is_none_or(|window| { + window + .iter() + .map(|instruction| instruction.opcode) + .ne(expected) + }) { + return false; + } + if parsed_push_value(init, &instructions[start + 1]) != Some(0x1f) + || parsed_push_value(init, &instructions[start + 5]) != Some(0x1f) + || parsed_push_value(init, &instructions[start + 11]) != Some(1) + || parsed_push_value(init, &instructions[start + 12]) != Some(1) + || parsed_push_value(init, &instructions[start + 13]) != Some(0x40) + || parsed_push_value(init, &instructions[start + 24]) != Some(0x40) + { + return false; + } + let Some(overflow_target) = parsed_push_value(init, &instructions[start + 22]) else { + return false; + }; + let Some(target_index) = instructions + .iter() + .position(|instruction| instruction.pos == overflow_target) + else { + return false; + }; + instructions[target_index].opcode == 0x5b + && instructions + .iter() + .skip(target_index + 1) + .take_while(|instruction| { + !matches!( + instruction.opcode, + 0x00 | 0x56 | 0x57 | 0xf3 | 0xfd | 0xfe | 0xff + ) + }) + .count() + <= 10 + && instructions + .iter() + .skip(target_index + 1) + .take(11) + .any(|instruction| instruction.opcode == 0xfd) +} + +fn locate_runtime_copy( + init: &[u8], + instructions: &[ParsedInstruction], + runtime_start: usize, + runtime_len: usize, + immutable_placeholders: Option<&std::collections::BTreeSet>, +) -> Result { + let mut candidates = Vec::new(); + for copy_index in 0..instructions.len() { + if instructions[copy_index].opcode != 0x39 { + continue; + } + + // solc's current terminal copy shape when it retains both length and memory base: + // PUSH len; SWAP1; DUP2; PUSH source; DUP3; CODECOPY + if copy_index >= 5 + && parsed_push_value(init, &instructions[copy_index - 5]) == Some(runtime_len) + && instructions[copy_index - 4].opcode == 0x90 + && instructions[copy_index - 3].opcode == 0x81 + && parsed_push_value(init, &instructions[copy_index - 2]) == Some(runtime_start) + && instructions[copy_index - 1].opcode == 0x82 + { + candidates.push((copy_index - 5, copy_index, copy_index - 5, copy_index - 2)); + } + + // Common compact compiler shape retaining only the length: + // PUSH len; DUP1; PUSH source; PUSH 0; CODECOPY + if copy_index >= 4 + && parsed_push_value(init, &instructions[copy_index - 4]) == Some(runtime_len) + && instructions[copy_index - 3].opcode == 0x80 + && parsed_push_value(init, &instructions[copy_index - 2]) == Some(runtime_start) + && is_zero_push(init, &instructions[copy_index - 1]) + { + candidates.push((copy_index - 4, copy_index, copy_index - 4, copy_index - 2)); + } + + // Minimal compiler/test shape with an explicit second length PUSH before RETURN: + // PUSH len; PUSH source; PUSH 0; CODECOPY + if copy_index >= 3 + && parsed_push_value(init, &instructions[copy_index - 3]) == Some(runtime_len) + && parsed_push_value(init, &instructions[copy_index - 2]) == Some(runtime_start) + && is_zero_push(init, &instructions[copy_index - 1]) + { + candidates.push((copy_index - 3, copy_index, copy_index - 3, copy_index - 2)); + } + } + candidates.sort_unstable(); + candidates.dedup(); + let (pattern_start, copy_index, length_index, source_index) = match candidates.as_slice() { + [candidate] => *candidate, + [] => { + return Err(format!( + "no proven runtime CODECOPY found for source 0x{runtime_start:x} and length 0x{runtime_len:x}" + )); + } + candidates => { + return Err(format!( + "ambiguous runtime CODECOPY: found {} proven candidates", + candidates.len() + )); + } + }; + + let selected_length = parsed_push_info(init, &instructions[length_index]) + .ok_or("runtime CODECOPY length is not a representable PUSH")?; + let selected_source = parsed_push_info(init, &instructions[source_index]) + .ok_or("runtime CODECOPY source is not a representable PUSH")?; + let free_memory_lower_bound = (copy_index - pattern_start == 5 + && pattern_start >= 2 + && parsed_push_value(init, &instructions[pattern_start - 2]) == Some(0x40) + && instructions[pattern_start - 1].opcode == 0x51 + && instructions + .get(1) + .is_some_and(|instruction| instruction.opcode == 0x80) + && instructions + .get(2) + .is_some_and(|instruction| parsed_push_value(init, instruction) == Some(0x40)) + && instructions + .get(3) + .is_some_and(|instruction| instruction.opcode == 0x52)) + .then(|| parsed_push_value(init, &instructions[0])) + .flatten(); + let allocator_store_indices = if free_memory_lower_bound.is_some() { + instructions + .iter() + .enumerate() + .filter_map(|(index, instruction)| { + (instruction.opcode == 0x52 + && is_solidity_allocator_store(init, instructions, index)) + .then_some(index) + }) + .collect() + } else { + BTreeSet::new() + }; + + // Unique sentinels below the recognized pattern let DUP/SWAP prove that solc's retained + // memory-base value is the same value ultimately passed to RETURN without pretending to know + // its concrete address. + let mut stack: Vec<_> = (0..32).map(InitStackValue::Preexisting).collect(); + if let Some(previous) = pattern_start.checked_sub(1).and_then(|index| { + if instructions[index].opcode == 0x5f { + Some(0) + } else { + parsed_push_value(init, &instructions[index]) + } + }) { + *stack.last_mut().expect("the sentinel stack is non-empty") = + InitStackValue::Constant(previous); + } + let mut copy_destination = None; + let mut length_pushes = Vec::new(); + let mut memory_reads = Vec::new(); + let mut immutable_write_counts = std::collections::BTreeMap::new(); + let mut consumed_length_candidate_positions = BTreeSet::new(); + + for index in pattern_start..instructions.len() { + let instruction = &instructions[index]; + match instruction.opcode { + 0x5f => { + stack.push(InitStackValue::Constant(0)); + continue; + } + 0x60..=0x7f => { + let info = parsed_push_info(init, instruction).ok_or_else(|| { + format!("unrepresentable init PUSH at 0x{:x}", instruction.pos) + })?; + let value = if info.pos == selected_length.pos { + InitStackValue::RuntimeLength(info) + } else if index > copy_index && info.value == runtime_len { + // Equal-valued literals after CODECOPY are only candidates: the PUSH becomes + // a relocation site if dataflow proves that RETURN consumes it. Treating every + // equal literal as the runtime length would incorrectly taint unrelated values + // such as an MLOAD address at the old runtime boundary. + InitStackValue::RuntimeLengthCandidate(info) + } else if info.pos == selected_source.pos { + InitStackValue::RuntimeSource(info) + } else { + InitStackValue::Constant(info.value) + }; + stack.push(value); + continue; + } + 0x80..=0x8f => { + let depth = usize::from(instruction.opcode - 0x7f); + if stack.len() < depth { + return Err(format!( + "init stack underflow at DUP in 0x{:x}", + instruction.pos + )); + } + stack.push(stack[stack.len() - depth].clone()); + continue; + } + 0x90..=0x9f => { + let depth = usize::from(instruction.opcode - 0x8f); + if stack.len() <= depth { + return Err(format!( + "init stack underflow at SWAP in 0x{:x}", + instruction.pos + )); + } + let top = stack.len() - 1; + stack.swap(top, top - depth); + continue; + } + 0x39 => { + if index != copy_index { + return Err(format!( + "a second CODECOPY follows the proven runtime copy at 0x{:x}", + instruction.pos + )); + } + if stack.len() < 3 { + return Err("runtime CODECOPY stack underflow".into()); + } + let consumed = &stack[stack.len() - 3..]; + if !matches!(consumed[0], InitStackValue::RuntimeLength(_)) + || !matches!(consumed[1], InitStackValue::RuntimeSource(_)) + { + return Err("runtime CODECOPY arguments lack exact PUSH provenance".into()); + } + copy_destination = Some(consumed[2].clone()); + stack.truncate(stack.len() - 3); + continue; + } + 0xf3 => { + if index <= copy_index { + return Err("RETURN occurs before the proven runtime CODECOPY".into()); + } + if stack.len() < 2 { + return Err("runtime RETURN stack underflow".into()); + } + let consumed = &stack[stack.len() - 2..]; + let return_length = match &consumed[0] { + InitStackValue::RuntimeLength(return_length) + | InitStackValue::RuntimeLengthCandidate(return_length) => return_length, + _ => { + return Err( + "runtime RETURN length does not derive from an exact length PUSH" + .into(), + ); + } + }; + if consumed_length_candidate_positions.contains(&return_length.pos) { + return Err(format!( + "runtime RETURN length PUSH at 0x{:x} also has a non-RETURN use", + return_length.pos + )); + } + if Some(&consumed[1]) != copy_destination.as_ref() { + return Err("runtime RETURN offset does not match CODECOPY destination".into()); + } + length_pushes.push(selected_length.clone()); + length_pushes.push(return_length.clone()); + length_pushes.sort_by_key(|push| push.pos); + length_pushes.dedup_by_key(|push| push.pos); + + if let Some(placeholders) = immutable_placeholders { + for placeholder in placeholders { + if immutable_write_counts + .get(placeholder) + .copied() + .unwrap_or(0) + != 1 + { + return Err(format!( + "immutable placeholder at runtime offset 0x{placeholder:x} is not written exactly once" + )); + } + } + if immutable_write_counts.len() != placeholders.len() { + return Err( + "runtime-copy window writes a non-placeholder memory word".into() + ); + } + } + return Ok(RuntimeCopyContract { + copy_index, + return_index: index, + length_pushes, + source_push: selected_source, + memory_reads, + free_memory_lower_bound, + allocator_store_indices, + }); + } + // These operations can read, overwrite, expose, or externally act on the copied + // runtime. The sole admitted write is the exact Solidity immutable-word shape below. + 0x20 + | 0x37 + | 0x3c + | 0x3e + | 0x53 + | 0x5e + | 0xa0..=0xa4 + | 0xf0..=0xf2 + | 0xf4..=0xf5 + | 0xfa => { + return Err(format!( + "opcode 0x{:02x} may observe or mutate copied runtime before RETURN at 0x{:x}", + instruction.opcode, instruction.pos + )); + } + 0x51 => { + let address = stack.last().cloned().ok_or_else(|| { + format!("init stack underflow at MLOAD in 0x{:x}", instruction.pos) + })?; + memory_reads.push(( + instruction.pos, + match address { + InitStackValue::RuntimeLengthCandidate(info) => { + InitStackValue::Constant(info.value) + } + address => address, + }, + )); + } + 0x01 => { + if stack.len() < 2 { + return Err(format!( + "init stack underflow at ADD in 0x{:x}", + instruction.pos + )); + } + let consumed = &stack[stack.len() - 2..]; + consumed_length_candidate_positions.extend(consumed.iter().filter_map(|value| { + match value { + InitStackValue::RuntimeLengthCandidate(info) => Some(info.pos), + _ => None, + } + })); + let runtime_offset = match (copy_destination.as_ref(), &consumed[0], &consumed[1]) { + (Some(base), candidate_base, InitStackValue::Constant(offset)) + if candidate_base == base => + { + Some(*offset) + } + (Some(base), InitStackValue::Constant(offset), candidate_base) + if candidate_base == base => + { + Some(*offset) + } + _ => None, + }; + stack.truncate(stack.len() - 2); + stack.push( + runtime_offset + .map(InitStackValue::RuntimeAddress) + .unwrap_or(InitStackValue::Unknown), + ); + continue; + } + 0x52 => { + let syntactic_offset = index + .checked_sub(2) + .filter(|previous| instructions[*previous + 1].opcode == 0x01) + .and_then(|previous| parsed_push_value(init, &instructions[previous])); + let stack_offset = stack.last().and_then(|value| match value { + InitStackValue::RuntimeAddress(offset) => Some(*offset), + _ => None, + }); + let Some(immutable_offset) = + syntactic_offset.filter(|offset| Some(*offset) == stack_offset) + else { + return Err(format!( + "MSTORE after runtime CODECOPY does not derive its address from the proven copy destination at 0x{:x}", + instruction.pos + )); + }; + if let Some(placeholders) = immutable_placeholders { + if !placeholders.contains(&immutable_offset) { + return Err(format!( + "MSTORE after runtime CODECOPY targets non-placeholder offset 0x{immutable_offset:x}" + )); + } + *immutable_write_counts + .entry(immutable_offset) + .or_insert(0usize) += 1; + } + } + 0x00 | 0x56 | 0x57 | 0xfd | 0xfe | 0xff => { + return Err(format!( + "runtime-copy path reaches opcode 0x{:02x} before proven RETURN at 0x{:x}", + instruction.opcode, instruction.pos + )); + } + // MSIZE changes when the copied runtime grows; GAS changes with every transformed + // copy/write sequence. Either can leak into constructor state or the returned code. + 0x38 | 0x58..=0x5a => { + return Err(format!( + "opcode 0x{:02x} observes init/runtime layout or gas after CODECOPY at 0x{:x}", + instruction.opcode, instruction.pos + )); + } + _ => {} + } + + let opcode = Opcode::from_byte(instruction.opcode); + let info = opcode.info().ok_or_else(|| { + format!( + "unknown init opcode 0x{:02x} after runtime CODECOPY at 0x{:x}", + instruction.opcode, instruction.pos + ) + })?; + let inputs = usize::from(info.inputs); + if stack.len() < inputs { + return Err(format!("init stack underflow at 0x{:x}", instruction.pos)); + } + let consumed = &stack[stack.len() - inputs..]; + if instruction.opcode != 0x50 { + consumed_length_candidate_positions.extend(consumed.iter().filter_map(|value| { + match value { + InitStackValue::RuntimeLengthCandidate(info) => Some(info.pos), + _ => None, + } + })); } - pc += if (0x60..=0x7f).contains(&opcode) { - 1 + (opcode - 0x5f) as usize + if instruction.opcode != 0x50 + && consumed.iter().any(|value| { + matches!( + value, + InitStackValue::RuntimeLength(_) | InitStackValue::RuntimeSource(_) + ) + }) + { + return Err(format!( + "runtime copy parameter is consumed by opcode 0x{:02x} at 0x{:x}", + instruction.opcode, instruction.pos + )); + } + stack.truncate(stack.len() - inputs); + stack.extend((0..info.outputs).map(|_| InitStackValue::Unknown)); + } + + Err("proven runtime CODECOPY has no matching RETURN".into()) +} + +fn invalidate_symbolic_memory( + memory: &mut BTreeMap, + destination: Option, + destination_lower_bound: Option, + length: Option, +) { + if length == Some(0) { + return; + } + let Some((destination, copy_end)) = + destination.zip(length).and_then(|(destination, length)| { + destination + .checked_add(length) + .map(|end| (destination, end)) + }) + else { + if let Some(lower_bound) = destination_lower_bound { + memory.retain(|word_start, _| { + word_start + .checked_add(32) + .is_some_and(|word_end| word_end <= lower_bound) + }); } else { - 1 + memory.clear(); + } + return; + }; + memory.retain(|word_start, _| { + let Some(word_end) = word_start.checked_add(32) else { + return false; + }; + word_end <= destination || *word_start >= copy_end + }); +} + +/// Symbolically execute reachable init-code control flow far enough to prove every CODECOPY +/// source range. This is deliberately a small constant-propagation engine, not a general EVM: +/// an unknown jump destination, unknown copy source/length, stack error, or state explosion is a +/// validation failure. That fail-closed boundary prevents a compiler-shape marker from being used +/// as a decoy in front of a malicious secondary runtime read. +fn reachable_codecopies( + init: &[u8], + instructions: &[ParsedInstruction], + full_creation_input_len: usize, + authoritative_copy_index: usize, + authoritative_return_index: usize, + free_memory_lower_bound: Option, + allocator_store_indices: &BTreeSet, +) -> Result, String> { + const MAX_SYMBOLIC_STATES: usize = 32_768; + const EVM_STACK_LIMIT: usize = 1_024; + + let index_by_pc: HashMap<_, _> = instructions + .iter() + .enumerate() + .map(|(index, instruction)| (instruction.pos, index)) + .collect(); + let mut seen = BTreeSet::new(); + let mut queue = VecDeque::new(); + let initial = SymbolicState { + instruction_index: 0, + stack: Vec::new(), + memory: BTreeMap::new(), + authoritative_copy_seen: false, + }; + seen.insert(initial.clone()); + queue.push_back(initial); + let mut observations = BTreeSet::new(); + + let constant = |value: &SymbolicWord| match value { + SymbolicWord::Constant(value) => Some(*value), + SymbolicWord::Unknown + | SymbolicWord::AtLeast(_) + | SymbolicWord::DerivedFromFreeMemory(_) => None, + }; + let constant_usize = + |value: &SymbolicWord| constant(value).and_then(|value| usize::try_from(value).ok()); + let lower_bound = |value: &SymbolicWord| match value { + SymbolicWord::Constant(value) => usize::try_from(*value).ok(), + SymbolicWord::AtLeast(value) => Some(*value), + SymbolicWord::Unknown | SymbolicWord::DerivedFromFreeMemory(_) => None, + }; + + while let Some(mut state) = queue.pop_front() { + let Some(instruction) = instructions.get(state.instruction_index) else { + return Err("reachable init path falls off the end without terminating".into()); }; + let index = state.instruction_index; + let mut successors = Vec::new(); + + match instruction.opcode { + 0x5f => state.stack.push(SymbolicWord::Constant(U256::ZERO)), + 0x60..=0x7f => { + let value = if index == 0 + && free_memory_lower_bound.is_some_and(|minimum| { + parsed_push_value(init, instruction) == Some(minimum) + }) { + // The compiler prologue's literal is an exact EVM value. Keeping it exact is + // both stronger and safer than prematurely widening it to a lower bound: it + // proves that constant-offset constructor writes cannot wrap onto memory 0x40. + // A recognized, overflow-guarded allocator store widens to AtLeast below only + // when its computed value is genuinely dynamic. + SymbolicWord::Constant(U256::from( + free_memory_lower_bound.expect("checked above"), + )) + } else { + parsed_push_word(init, instruction) + .map(SymbolicWord::Constant) + .unwrap_or(SymbolicWord::Unknown) + }; + state.stack.push(value); + } + 0x38 => state + .stack + .push(SymbolicWord::Constant(U256::from(full_creation_input_len))), + // PC is exact in the decoded init stream. This preserves proof through the trusted + // constructor-argument transform's `PC + delta` trampoline without admitting an + // unknown dynamic jump. + 0x58 => state + .stack + .push(SymbolicWord::Constant(U256::from(instruction.pos))), + 0x80..=0x8f => { + let depth = usize::from(instruction.opcode - 0x7f); + if depth == 0 || state.stack.len() < depth { + return Err(format!( + "reachable init stack underflow at DUP in 0x{:x}", + instruction.pos + )); + } + state + .stack + .push(state.stack[state.stack.len() - depth].clone()); + } + 0x90..=0x9f => { + let depth = usize::from(instruction.opcode - 0x8f); + if depth == 0 || state.stack.len() <= depth { + return Err(format!( + "reachable init stack underflow at SWAP in 0x{:x}", + instruction.pos + )); + } + let top = state.stack.len() - 1; + state.stack.swap(top, top - depth); + } + 0x01 | 0x02 | 0x03 | 0x04 | 0x06 | 0x10 | 0x11 | 0x14 | 0x16 | 0x17 | 0x18 | 0x1b + | 0x1c => { + if state.stack.len() < 2 { + return Err(format!( + "reachable init stack underflow at 0x{:x}", + instruction.pos + )); + } + let right = state.stack.pop().expect("length checked"); + let left = state.stack.pop().expect("length checked"); + let free_memory_output = if instruction.opcode == 0x01 { + match (&left, &right) { + (SymbolicWord::AtLeast(minimum), other) + | (other, SymbolicWord::AtLeast(minimum)) => { + // A nonzero ADD to an abstract lower bound can wrap modulo 2^256. + // Keep only its free-memory provenance until an admitted Solidity + // overflow guard proves and stores the rounded pointer. + if constant_usize(other) == Some(0) { + Some(SymbolicWord::AtLeast(*minimum)) + } else { + Some(SymbolicWord::DerivedFromFreeMemory(*minimum)) + } + } + (SymbolicWord::DerivedFromFreeMemory(minimum), _) + | (_, SymbolicWord::DerivedFromFreeMemory(minimum)) => { + Some(SymbolicWord::DerivedFromFreeMemory(*minimum)) + } + _ => None, + } + } else { + None + }; + let output = match (constant(&left), constant(&right)) { + (Some(left), Some(right)) => match instruction.opcode { + 0x01 => Some(left.wrapping_add(right)), + 0x02 => Some(left.wrapping_mul(right)), + // EVM's top stack item is the first operand for non-commutative ops. + 0x03 => Some(right.wrapping_sub(left)), + 0x04 => (left != 0).then(|| right / left), + 0x06 => (left != 0).then(|| right % left), + 0x10 => Some(U256::from(right < left)), + 0x11 => Some(U256::from(right > left)), + 0x14 => Some(U256::from(left == right)), + 0x16 => Some(left & right), + 0x17 => Some(left | right), + 0x18 => Some(left ^ right), + // EVM shifts take the shift amount from the top of stack and the value + // from the next word (unlike SUB/LT, the operands have different roles). + 0x1b => Some(if right < U256::from(256) { + left.wrapping_shl(right.to::()) + } else { + U256::ZERO + }), + 0x1c => Some(if right < U256::from(256) { + left.wrapping_shr(right.to::()) + } else { + U256::ZERO + }), + _ => unreachable!(), + }, + _ => None, + }; + state.stack.push(free_memory_output.unwrap_or_else(|| { + output + .map(SymbolicWord::Constant) + .unwrap_or(SymbolicWord::Unknown) + })); + } + 0x19 => { + let Some(value) = state.stack.pop() else { + return Err(format!( + "reachable init stack underflow at NOT in 0x{:x}", + instruction.pos + )); + }; + state.stack.push(match constant(&value) { + Some(value) => SymbolicWord::Constant(!value), + None => SymbolicWord::Unknown, + }); + } + 0x51 => { + let Some(address) = state.stack.pop() else { + return Err(format!( + "reachable init stack underflow at MLOAD in 0x{:x}", + instruction.pos + )); + }; + let value = constant_usize(&address) + .and_then(|address| state.memory.get(&address).cloned()) + .unwrap_or(SymbolicWord::Unknown); + state.stack.push(value); + } + 0x52 => { + if state.stack.len() < 2 { + return Err(format!( + "reachable init stack underflow at MSTORE in 0x{:x}", + instruction.pos + )); + } + let address = state.stack.pop().expect("length checked"); + let mut value = state.stack.pop().expect("length checked"); + let exact_address = constant_usize(&address); + let address_lower_bound = lower_bound(&address); + let prior_free_memory_lower_bound = state.memory.get(&0x40).and_then(&lower_bound); + if let Some(address) = exact_address { + if address == 0x40 + && matches!( + value, + SymbolicWord::Unknown | SymbolicWord::DerivedFromFreeMemory(_) + ) + && allocator_store_indices.contains(&index) + && free_memory_lower_bound.is_some_and(|minimum| { + prior_free_memory_lower_bound.is_some_and(|current| current >= minimum) + }) + { + value = + SymbolicWord::AtLeast(free_memory_lower_bound.expect("checked above")); + } + invalidate_symbolic_memory( + &mut state.memory, + exact_address, + address_lower_bound, + Some(32), + ); + state.memory.insert(address, value); + } else { + invalidate_symbolic_memory( + &mut state.memory, + None, + address_lower_bound, + Some(32), + ); + } + } + 0x53 => { + if state.stack.len() < 2 { + return Err(format!( + "reachable init stack underflow at MSTORE8 in 0x{:x}", + instruction.pos + )); + } + let address = state.stack.pop().expect("length checked"); + state.stack.pop(); + invalidate_symbolic_memory( + &mut state.memory, + constant_usize(&address), + lower_bound(&address), + Some(1), + ); + } + 0x39 => { + if state.stack.len() < 3 { + return Err(format!( + "reachable CODECOPY stack underflow at 0x{:x}", + instruction.pos + )); + } + let destination = state.stack.pop().expect("length checked"); + let source = state.stack.pop().expect("length checked"); + let length = state.stack.pop().expect("length checked"); + let exact_length = constant_usize(&length); + // CODECOPY with an exact zero length neither reads code nor touches memory. Its + // other operands are therefore semantically inert; canonicalizing them prevents + // harmless CODESIZE-dependent constructor-argument expressions from appearing to + // change when the creation artifact moves. + let inert = exact_length == Some(0); + observations.insert(CodeCopyObservation { + instruction_index: index, + destination: inert.then_some(0).or_else(|| constant_usize(&destination)), + destination_lower_bound: inert + .then_some(0) + .or_else(|| lower_bound(&destination)), + source: inert.then_some(0).or_else(|| constant_usize(&source)), + length: exact_length, + }); + invalidate_symbolic_memory( + &mut state.memory, + constant_usize(&destination), + lower_bound(&destination), + constant_usize(&length), + ); + if index == authoritative_copy_index { + state.authoritative_copy_seen = true; + } + } + 0xf1 | 0xf2 => { + if state.stack.len() < 7 { + return Err(format!( + "reachable init call stack underflow at 0x{:x}", + instruction.pos + )); + } + let args_start = state.stack.len() - 7; + let output_size = constant_usize(&state.stack[args_start]); + let output_offset = constant_usize(&state.stack[args_start + 1]); + let output_offset_lower_bound = lower_bound(&state.stack[args_start + 1]); + invalidate_symbolic_memory( + &mut state.memory, + output_offset, + output_offset_lower_bound, + output_size, + ); + state.stack.truncate(args_start); + state.stack.push(SymbolicWord::Unknown); + } + 0xf4 | 0xfa => { + if state.stack.len() < 6 { + return Err(format!( + "reachable init call stack underflow at 0x{:x}", + instruction.pos + )); + } + let args_start = state.stack.len() - 6; + let output_size = constant_usize(&state.stack[args_start]); + let output_offset = constant_usize(&state.stack[args_start + 1]); + let output_offset_lower_bound = lower_bound(&state.stack[args_start + 1]); + invalidate_symbolic_memory( + &mut state.memory, + output_offset, + output_offset_lower_bound, + output_size, + ); + state.stack.truncate(args_start); + state.stack.push(SymbolicWord::Unknown); + } + 0x56 | 0x57 => { + let inputs = if instruction.opcode == 0x57 { 2 } else { 1 }; + if state.stack.len() < inputs { + return Err(format!( + "reachable init jump stack underflow at 0x{:x}", + instruction.pos + )); + } + let destination = state.stack.pop().expect("length checked"); + let condition = (instruction.opcode == 0x57) + .then(|| state.stack.pop().expect("length checked")); + let can_take_jump = condition + .as_ref() + .and_then(constant) + .is_none_or(|condition| condition != U256::ZERO); + let can_fall_through = instruction.opcode == 0x57 + && condition + .as_ref() + .and_then(constant) + .is_none_or(|condition| condition == U256::ZERO); + if can_take_jump { + let target_pc = constant_usize(&destination).ok_or_else(|| { + format!( + "reachable init jump at 0x{:x} has an unknown destination", + instruction.pos + ) + })?; + let target = index_by_pc.get(&target_pc).copied().ok_or_else(|| { + format!( + "reachable init jump at 0x{:x} targets non-instruction 0x{target_pc:x}", + instruction.pos + ) + })?; + if instructions[target].opcode != 0x5b { + return Err(format!( + "reachable init jump at 0x{:x} targets non-JUMPDEST 0x{target_pc:x}", + instruction.pos + )); + } + successors.push(target); + } + if can_fall_through { + successors.push(index + 1); + } + } + 0xf3 => { + if !state.authoritative_copy_seen { + return Err(format!( + "reachable successful RETURN at 0x{:x} bypasses the proven runtime CODECOPY", + instruction.pos + )); + } + if index != authoritative_return_index { + return Err(format!( + "reachable successful RETURN at 0x{:x} is not the proven runtime RETURN", + instruction.pos + )); + } + continue; + } + 0x00 | 0xfd | 0xfe | 0xff => continue, + _ => { + if matches!(instruction.opcode, 0x37 | 0x3c | 0x3e | 0x5e) { + state.memory.clear(); + } + let opcode = Opcode::from_byte(instruction.opcode); + let info = opcode.info().ok_or_else(|| { + format!( + "unknown reachable init opcode 0x{:02x} at 0x{:x}", + instruction.opcode, instruction.pos + ) + })?; + let inputs = usize::from(info.inputs); + if state.stack.len() < inputs { + return Err(format!( + "reachable init stack underflow at 0x{:x}", + instruction.pos + )); + } + state.stack.truncate(state.stack.len() - inputs); + state + .stack + .extend((0..info.outputs).map(|_| SymbolicWord::Unknown)); + } + } + + if state.stack.len() > EVM_STACK_LIMIT { + return Err("reachable init path exceeds the EVM stack limit".into()); + } + if successors.is_empty() { + successors.push(index + 1); + } + for successor in successors { + let successor_state = SymbolicState { + instruction_index: successor, + stack: state.stack.clone(), + memory: state.memory.clone(), + authoritative_copy_seen: state.authoritative_copy_seen, + }; + if seen.insert(successor_state.clone()) { + if seen.len() > MAX_SYMBOLIC_STATES { + return Err("init CODECOPY proof exceeded its symbolic-state limit".into()); + } + queue.push_back(successor_state); + } + } + } + + Ok(observations) +} + +/// Returns the byte offsets of Solidity immutable words within an unlinked runtime template. +/// +/// Solidity represents every immutable use as `PUSH32` followed by exactly 32 zero bytes in the +/// creation artifact's runtime template. Decoding instruction boundaries is important: a run of +/// zeros inside another PUSH immediate or metadata is not an immutable placeholder. +fn immutable_placeholder_offsets(runtime: &[u8]) -> Result, String> { + let instructions = parse_instructions(runtime, "runtime template")?; + let mut offsets = Vec::new(); + for instruction in instructions { + if instruction.opcode != 0x7f { + continue; + } + let immediate_start = instruction.pos + 1; + let immediate_end = immediate_start + 32; + if runtime[immediate_start..immediate_end] + .iter() + .all(|byte| *byte == 0) + { + offsets.push(immediate_start); + } } - positions + Ok(offsets) } fn patch_constructor_arg_base( @@ -192,6 +1280,188 @@ fn patch_constructor_arg_base( } impl CleanReport { + /// Authenticate how init code constructs the supplied deployed-runtime template. + /// + /// Runtime layout is safe to change only when the creation program has one proven copy of + /// the exact reported runtime range and returns that same memory range without inspecting or + /// mutating it. The only admitted mutation is Solidity's exact immutable materialization + /// (`PUSH ; ADD; MSTORE`) for every PUSH32-zero carrier in the template. + /// + /// This check is intentionally required even for size-neutral transforms: block shuffling + /// changes which bytes an init-time hash, storage write, or arbitrary memory patch observes. + pub fn validate_init_runtime_contract( + &self, + original_clean_runtime: &[u8], + ) -> Result<(), String> { + let Some(init_section) = self + .removed + .iter() + .find(|removed| removed.kind == SectionKind::Init) + else { + return Ok(()); + }; + if original_clean_runtime.len() != self.clean_len { + return Err(format!( + "original clean runtime length mismatch: report={}, supplied={}", + self.clean_len, + original_clean_runtime.len() + )); + } + if self.runtime_layout.len() != 1 || self.runtime_layout[0].len != self.clean_len { + return Err("init/runtime validation requires one contiguous runtime template".into()); + } + + let runtime_start = self.runtime_layout[0].offset; + let deployed_suffix_len = self + .removed + .iter() + .filter(|removed| { + removed.offset >= runtime_start + && !matches!(removed.kind, SectionKind::ConstructorArgs) + }) + .try_fold(0usize, |total, removed| { + total.checked_add(removed.data.len()) + }) + .ok_or("deployed runtime suffix length overflow")?; + let deployed_runtime_len = self + .clean_len + .checked_add(deployed_suffix_len) + .ok_or("deployed runtime length overflow")?; + let creation_len = runtime_start + .checked_add(deployed_runtime_len) + .ok_or("creation bytecode length overflow")?; + let init = init_section.data.as_ref(); + let instructions = parse_instructions(init, "init code")?; + let placeholders: std::collections::BTreeSet<_> = + immutable_placeholder_offsets(original_clean_runtime)? + .into_iter() + .collect(); + let contract = locate_runtime_copy( + init, + &instructions, + runtime_start, + deployed_runtime_len, + Some(&placeholders), + )?; + + let full_creation_input_len = self + .runtime_layout + .iter() + .map(|span| span.offset.checked_add(span.len)) + .chain( + self.removed + .iter() + .map(|removed| removed.offset.checked_add(removed.data.len())), + ) + .try_fold(0usize, |largest, end| end.map(|end| largest.max(end))) + .ok_or("creation input layout overflow")?; + let copy_observations = reachable_codecopies( + init, + &instructions, + full_creation_input_len, + contract.copy_index, + contract.return_index, + contract.free_memory_lower_bound, + &contract.allocator_store_indices, + )?; + let mut observed_copy_indices = BTreeSet::new(); + let mut authoritative_destinations = BTreeSet::new(); + for observation in ©_observations { + observed_copy_indices.insert(observation.instruction_index); + if observation.instruction_index == contract.copy_index { + if observation.source != Some(runtime_start) + || observation.length != Some(deployed_runtime_len) + { + return Err("reachable runtime CODECOPY arguments do not match the reported runtime span".into()); + } + let lower_bound = observation.destination_lower_bound.ok_or_else(|| { + format!( + "reachable runtime CODECOPY destination has no proven lower bound: {copy_observations:?}" + ) + })?; + authoritative_destinations.insert((observation.destination, lower_bound)); + continue; + } + + let length = observation.length.ok_or_else(|| { + format!( + "secondary CODECOPY at 0x{:x} has an unproven length", + instructions[observation.instruction_index].pos + ) + })?; + if length == 0 { + continue; + } + let source = observation.source.ok_or_else(|| { + format!( + "secondary CODECOPY at 0x{:x} has an unproven source", + instructions[observation.instruction_index].pos + ) + })?; + let end = source + .checked_add(length) + .ok_or("secondary CODECOPY range overflow")?; + if source < creation_len || end > full_creation_input_len { + return Err(format!( + "secondary CODECOPY at 0x{:x} is not wholly within constructor arguments 0x{creation_len:x}..0x{full_creation_input_len:x}", + instructions[observation.instruction_index].pos + )); + } + } + if authoritative_destinations.is_empty() { + return Err("the proven runtime CODECOPY is unreachable".into()); + } + for (pc, read) in &contract.memory_reads { + let InitStackValue::Constant(read_start) = read else { + return Err(format!( + "MLOAD at 0x{pc:x} has no proven address disjoint from copied runtime" + )); + }; + let read_end = read_start.checked_add(32).ok_or("MLOAD range overflow")?; + for (exact_start, lower_bound) in &authoritative_destinations { + if let Some(runtime_memory_start) = exact_start { + let runtime_memory_end = runtime_memory_start + .checked_add(deployed_runtime_len) + .ok_or("runtime memory range overflow")?; + if *read_start < runtime_memory_end && read_end > *runtime_memory_start { + return Err(format!( + "MLOAD at 0x{pc:x} intersects copied runtime memory 0x{runtime_memory_start:x}..0x{runtime_memory_end:x}" + )); + } + } else if read_end > *lower_bound { + return Err(format!( + "MLOAD at 0x{pc:x} is not below the proven runtime-memory lower bound 0x{lower_bound:x}" + )); + } + } + } + for (index, instruction) in instructions.iter().enumerate() { + if instruction.opcode == 0x39 && !observed_copy_indices.contains(&index) { + return Err(format!( + "CODECOPY at 0x{:x} has no proven reachable argument state", + instruction.pos + )); + } + } + + for (index, instruction) in instructions.iter().enumerate() { + if instruction.opcode == 0xf3 && index != contract.return_index { + return Err(format!( + "init contains an alternative RETURN at 0x{:x}", + instruction.pos + )); + } + if matches!(instruction.opcode, 0x3b | 0x3c | 0x3f | 0x58) { + return Err(format!( + "init contains code-observation opcode 0x{:02x} at 0x{:x}", + instruction.opcode, instruction.pos + )); + } + } + + Ok(()) + } + /// Updates init code CODECOPY and RETURN parameters to reflect new runtime length and offset. /// /// After obfuscation modifies the runtime bytecode, the init code's CODECOPY instruction @@ -243,6 +1513,12 @@ impl CleanReport { .removed .iter() .any(|removed| matches!(removed.kind, SectionKind::ConstructorArgs)); + let constructor_args_len: usize = self + .removed + .iter() + .filter(|removed| matches!(removed.kind, SectionKind::ConstructorArgs)) + .map(|removed| removed.data.len()) + .sum(); tracing::debug!( "Calculated values: runtime_offset={} -> {}, deployed_runtime_len={} -> {}, creation_len={} -> {}", @@ -260,132 +1536,95 @@ impl CleanReport { .find(|r| matches!(r.kind, SectionKind::Init)) .ok_or("No Init section found")?; - let mut init_bytes = init_section.data.clone().to_vec(); - - tracing::debug!( - "Init code size: {} bytes, runtime offset: {}", - init_bytes.len(), - new_runtime_offset - ); - - tracing::debug!("Full init code (hex): {}", encode(&init_bytes)); - if init_bytes.len() > 24 { - tracing::debug!( - "Init code structure: offsets 16-24: {:02x?}", - &init_bytes[16..=24] - ); - } - - fn collect_previous_pushes(bytes: &[u8], start: usize, max: usize) -> Vec { - let mut pushes = Vec::new(); - let mut idx = start; - while idx > 0 && pushes.len() < max { - idx -= 1; - let opcode = bytes[idx]; - if !(0x60..=0x7f).contains(&opcode) { - continue; - } - let width = (opcode - 0x60 + 1) as usize; - if idx + 1 + width > bytes.len() || idx + 1 + width > start { - continue; - } - let mut value = 0usize; - for &byte in &bytes[idx + 1..idx + 1 + width] { - value = (value << 8) | byte as usize; - } - pushes.push(PushInfo { - pos: idx, - width, - value, - }); - if idx < width + 1 { - break; - } - idx = idx.saturating_sub(width); - } - pushes - } - - fn write_push_value( - bytes: &mut [u8], - info: &PushInfo, - new_value: usize, - ) -> Result<(), String> { - if info.pos + 1 + info.width > bytes.len() { - return Err("push immediate out of bounds".into()); - } - if info.width < std::mem::size_of::() { - let max = (1usize << (info.width * 8)) - 1; - if new_value > max { - return Err(format!( - "value 0x{:x} does not fit in PUSH{}", - new_value, info.width - )); - } - } - let bit_width = usize::BITS as usize; - for idx in 0..info.width { - let shift = idx * 8; - let byte = if shift >= bit_width { - 0 - } else { - ((new_value >> shift) & 0xff) as u8 - }; - bytes[info.pos + 1 + info.width - 1 - idx] = byte; - } - Ok(()) - } - - let codecopy_positions = opcode_positions(&init_bytes, 0x39); - - let mut codecopy_patched = original_runtime_offset == new_runtime_offset - && original_deployed_runtime_len == new_deployed_runtime_len; - for pos in codecopy_positions { - let pushes = collect_previous_pushes(&init_bytes, pos, 6); - let has_len = pushes - .iter() - .any(|info| info.value == original_deployed_runtime_len); - let has_offset = pushes - .iter() - .any(|info| info.value == original_runtime_offset); - if !(has_len && has_offset) { - continue; - } - - for info in &pushes { - if info.value == original_deployed_runtime_len { - write_push_value(&mut init_bytes, info, new_deployed_runtime_len)?; - codecopy_patched = true; - tracing::debug!( - "Updated CODECOPY length PUSH at 0x{:x} to 0x{:x}", - info.pos, - new_deployed_runtime_len - ); - break; - } - } + let mut init_bytes = init_section.data.clone().to_vec(); - for info in &pushes { - if info.value == original_runtime_offset - && new_runtime_offset != original_runtime_offset - { - write_push_value(&mut init_bytes, info, new_runtime_offset)?; - tracing::debug!( - "Updated CODECOPY offset PUSH at 0x{:x} to 0x{:x}", - info.pos, - new_runtime_offset - ); - } - } + tracing::debug!( + "Init code size: {} bytes, runtime offset: {}", + init_bytes.len(), + new_runtime_offset + ); - break; + tracing::debug!("Full init code (hex): {}", encode(&init_bytes)); + if init_bytes.len() > 24 { + tracing::debug!( + "Init code structure: offsets 16-24: {:02x?}", + &init_bytes[16..=24] + ); } - if !codecopy_patched { + let parsed_init = parse_instructions(&init_bytes, "init code")?; + let copy_contract = locate_runtime_copy( + &init_bytes, + &parsed_init, + original_runtime_offset, + original_deployed_runtime_len, + None, + )?; + let layout_changed = new_runtime_offset != original_runtime_offset + || new_deployed_runtime_len != original_deployed_runtime_len; + // Even Solidity's `PUSH base; DUP1; CODESIZE; SUB` idiom leaves the relocated base below + // the invariant argument length. Without complete downstream taint proof, that retained + // value could reach storage, a log, or a call. Reject every size-changing rewrite that can + // observe CODESIZE; patching a familiar syntax alone is not a semantic proof. + if layout_changed + && parsed_init + .iter() + .any(|instruction| instruction.opcode == 0x38) + { return Err( - "Could not locate CODECOPY arguments matching runtime offset and length".into(), + "runtime-size relocation is unsupported when init code observes CODESIZE".into(), + ); + } + let has_secondary_codecopy = parsed_init.iter().enumerate().any(|(index, instruction)| { + instruction.opcode == 0x39 && index != copy_contract.copy_index + }); + // Snapshot every proven secondary constructor copy before changing layout. A later proof + // requires the same copy to retain its destination and length while its source moves by + // exactly the creation-length delta. This prevents a decoy canonical base marker from + // authorizing an unrelated stale CODECOPY source. + let old_secondary_copies = if layout_changed && has_secondary_codecopy { + let old_full_input_len = original_creation_len + .checked_add(constructor_args_len) + .ok_or("original creation input length overflow")?; + Some( + reachable_codecopies( + &init_bytes, + &parsed_init, + old_full_input_len, + copy_contract.copy_index, + copy_contract.return_index, + copy_contract.free_memory_lower_bound, + ©_contract.allocator_store_indices, + )? + .into_iter() + .filter(|observation| observation.instruction_index != copy_contract.copy_index) + .collect::>(), + ) + } else { + None + }; + + // Every edit below names a decoded PUSH instruction that the stack proof established as + // either CODECOPY's size/source or RETURN's size. No byte inside another immediate can be + // selected, and a missing independent RETURN length is an error rather than false success. + for info in ©_contract.length_pushes { + write_push_value(&mut init_bytes, info, new_deployed_runtime_len)?; + tracing::debug!( + "Updated proven runtime length PUSH at 0x{:x} to 0x{:x}", + info.pos, + new_deployed_runtime_len ); } + write_push_value( + &mut init_bytes, + ©_contract.source_push, + new_runtime_offset, + )?; + tracing::debug!( + "Updated proven runtime source PUSH at 0x{:x} to 0x{:x}", + copy_contract.source_push.pos, + new_runtime_offset + ); if original_creation_len != new_creation_len { let patched = patch_constructor_arg_base( @@ -396,7 +1635,7 @@ impl CleanReport { // A caller may obfuscate bare creation bytecode and append its constructor // arguments afterwards. Patch a supported constructor-copy base whenever it is // present, but only require it when this payload already contains arguments. - if patched == 0 && has_constructor_args { + if patched == 0 && (has_constructor_args || has_secondary_codecopy) { return Err(format!( "Could not locate constructor argument base 0x{:x} before CODESIZE/SUB", original_creation_len @@ -404,32 +1643,121 @@ impl CleanReport { } } - let return_positions = opcode_positions(&init_bytes, 0xf3); - - let mut return_patched = original_deployed_runtime_len == new_deployed_runtime_len; - for pos in return_positions { - let pushes = collect_previous_pushes(&init_bytes, pos, 4); - if let Some(info) = pushes.iter().find(|info| { - info.value == original_deployed_runtime_len - || info.value == new_deployed_runtime_len - }) { - if info.value == original_deployed_runtime_len { - write_push_value(&mut init_bytes, info, new_deployed_runtime_len)?; + if layout_changed { + let updated_instructions = parse_instructions(&init_bytes, "updated init code")?; + let updated_contract = locate_runtime_copy( + &init_bytes, + &updated_instructions, + new_runtime_offset, + new_deployed_runtime_len, + None, + )?; + if updated_contract.copy_index != copy_contract.copy_index { + return Err("runtime CODECOPY identity changed while patching init code".into()); + } + let new_full_input_len = new_creation_len + .checked_add(constructor_args_len) + .ok_or("updated creation input length overflow")?; + let updated_copy_observations = reachable_codecopies( + &init_bytes, + &updated_instructions, + new_full_input_len, + updated_contract.copy_index, + updated_contract.return_index, + updated_contract.free_memory_lower_bound, + &updated_contract.allocator_store_indices, + )?; + let mut updated_destinations = BTreeSet::new(); + for observation in &updated_copy_observations { + if observation.instruction_index != updated_contract.copy_index { + continue; + } + if observation.source != Some(new_runtime_offset) + || observation.length != Some(new_deployed_runtime_len) + { + return Err( + "updated runtime CODECOPY arguments do not match the rewritten runtime span" + .into(), + ); + } + let lower_bound = observation + .destination_lower_bound + .ok_or("updated runtime CODECOPY destination has no proven lower bound")?; + updated_destinations.insert((observation.destination, lower_bound)); + } + if updated_destinations.is_empty() { + return Err("updated proven runtime CODECOPY is unreachable".into()); + } + for (pc, read) in &updated_contract.memory_reads { + let InitStackValue::Constant(read_start) = read else { + return Err(format!( + "updated MLOAD at 0x{pc:x} has no proven address disjoint from copied runtime" + )); + }; + let read_end = read_start + .checked_add(32) + .ok_or("updated MLOAD range overflow")?; + for (exact_start, lower_bound) in &updated_destinations { + if let Some(runtime_memory_start) = exact_start { + let runtime_memory_end = runtime_memory_start + .checked_add(new_deployed_runtime_len) + .ok_or("updated runtime memory range overflow")?; + if *read_start < runtime_memory_end && read_end > *runtime_memory_start { + return Err(format!( + "updated MLOAD at 0x{pc:x} intersects copied runtime memory 0x{runtime_memory_start:x}..0x{runtime_memory_end:x}" + )); + } + } else if read_end > *lower_bound { + return Err(format!( + "updated MLOAD at 0x{pc:x} is not below the proven runtime-memory lower bound 0x{lower_bound:x}" + )); + } + } + } + let updated_secondary_copies = updated_copy_observations + .into_iter() + .filter(|observation| observation.instruction_index != updated_contract.copy_index) + .collect::>(); + + if let Some(old_secondary_copies) = old_secondary_copies { + let move_source = |source: usize| -> Result { + if new_creation_len >= original_creation_len { + source + .checked_add(new_creation_len - original_creation_len) + .ok_or_else(|| "constructor CODECOPY source overflow".into()) + } else { + source + .checked_sub(original_creation_len - new_creation_len) + .ok_or_else(|| "constructor CODECOPY source underflow".into()) + } + }; + let mut expected_secondary_copies = BTreeSet::new(); + for mut observation in old_secondary_copies { + let length = observation.length.ok_or_else(|| { + format!( + "secondary CODECOPY at instruction {} has an unproven original length", + observation.instruction_index + ) + })?; + if length == 0 { + expected_secondary_copies.insert(observation); + continue; + } + let old_source = observation.source.ok_or_else(|| { + format!( + "secondary CODECOPY at instruction {} has an unproven original source", + observation.instruction_index + ) + })?; + observation.source = Some(move_source(old_source)?); + expected_secondary_copies.insert(observation); + } + if updated_secondary_copies != expected_secondary_copies { + return Err(format!( + "secondary constructor CODECOPY provenance changed unexpectedly: expected {expected_secondary_copies:?}, found {updated_secondary_copies:?}" + )); } - return_patched = true; - tracing::debug!( - "Updated RETURN length PUSH at 0x{:x} to 0x{:x}", - info.pos, - new_deployed_runtime_len - ); - break; } - } - - if !return_patched { - tracing::debug!( - "RETURN reuses the CODECOPY length already patched on the constructor stack" - ); } tracing::debug!( @@ -443,111 +1771,194 @@ impl CleanReport { Ok(()) } - /// Patch immutable reference offsets in the init code. + /// Patch compiler-proven Solidity immutable reference offsets in the init code. /// - /// The Solidity compiler's init code writes immutable variable values into the - /// runtime bytecode at hardcoded byte offsets. When obfuscation transforms change - /// the runtime layout (e.g., PushSplit growing blocks), these offsets become stale. - /// This method detects the pattern `PUSH2 ; ... ADD` in the init code and - /// updates each offset using the supplied byte-offset remapping closure. + /// This intentionally supports one narrow, auditable lowering contract: /// - /// The `remap` closure takes an old byte offset within the runtime and returns the - /// new byte offset, or `None` if no mapping is available. + /// 1. An immutable use must be an exact `PUSH32` plus 32 zero-byte placeholder in the + /// original clean runtime template. + /// 2. The init code must contain exactly one Solidity runtime-copy sequence for the report's + /// exact runtime source offset and deployed-runtime length. + /// 3. Between that `CODECOPY` and the next control-transfer instruction (which must be + /// `RETURN`), every placeholder must have exactly one contiguous + /// `PUSH ; ADD; MSTORE` reference. + /// + /// Any missing or duplicate reference, missing remap, malformed PUSH, or width overflow is an + /// error. Edits are committed only after every reference validates, so failure is atomic. In + /// particular, constructor arithmetic that merely happens to use an in-range constant is never + /// rewritten. pub fn patch_init_immutable_refs( &mut self, + original_clean_runtime: &[u8], remap: &dyn Fn(usize) -> Option, ) -> Result<(), String> { + if original_clean_runtime.len() != self.clean_len { + return Err(format!( + "original clean runtime length mismatch: report={}, supplied={}", + self.clean_len, + original_clean_runtime.len() + )); + } + + let placeholders = immutable_placeholder_offsets(original_clean_runtime)?; + if placeholders.is_empty() { + return Ok(()); + } + if self.runtime_layout.len() != 1 || self.runtime_layout[0].len != self.clean_len { + return Err( + "Solidity immutable relocation requires one contiguous runtime template".into(), + ); + } + let runtime_start = self .runtime_layout .iter() .map(|span| span.offset) .min() .ok_or("No runtime layout found")?; - let runtime_end = runtime_start + self.clean_len; + let deployed_suffix_len = self + .removed + .iter() + .filter(|removed| { + removed.offset >= runtime_start + && !matches!(removed.kind, SectionKind::ConstructorArgs) + }) + .try_fold(0usize, |total, removed| { + total.checked_add(removed.data.len()) + }) + .ok_or("deployed runtime suffix length overflow")?; + let original_deployed_runtime_len = self + .clean_len + .checked_add(deployed_suffix_len) + .ok_or("deployed runtime length overflow")?; let init_section = self .removed - .iter_mut() + .iter() .find(|r| matches!(r.kind, SectionKind::Init)) .ok_or("No Init section found")?; - let mut init_bytes = init_section.data.to_vec(); - let mut patched = 0usize; - let mut idx = 0usize; - - while idx < init_bytes.len() { - let opcode = init_bytes[idx]; - if !(0x60..=0x7f).contains(&opcode) { - idx += 1; - continue; - } - - let width = (opcode - 0x60 + 1) as usize; - if idx + 1 + width > init_bytes.len() { - idx += 1; - continue; + let original_init = init_section.data.to_vec(); + let instructions = parse_instructions(&original_init, "init code")?; + + // Solidity's terminal runtime-copy sequence is: + // PUSH runtime_len; SWAP1; DUP2; PUSH runtime_start; DUP3; CODECOPY + // Exact values and exact opcode adjacency make this a compiler-shape check rather than a + // search for coincidental constants near an arbitrary CODECOPY. + let copy_candidates: Vec = (5..instructions.len()) + .filter(|index| { + let index = *index; + instructions[index].opcode == 0x39 + && instructions[index - 4].opcode == 0x90 + && instructions[index - 3].opcode == 0x81 + && instructions[index - 1].opcode == 0x82 + && parsed_push_value(&original_init, &instructions[index - 5]) + == Some(original_deployed_runtime_len) + && parsed_push_value(&original_init, &instructions[index - 2]) + == Some(runtime_start) + }) + .collect(); + let copy_index = match copy_candidates.as_slice() { + [index] => *index, + [] => { + return Err(format!( + "no exact Solidity runtime CODECOPY found for source 0x{runtime_start:x} and length 0x{original_deployed_runtime_len:x}" + )); } - - let mut value = 0usize; - for &byte in &init_bytes[idx + 1..idx + 1 + width] { - value = (value << 8) | byte as usize; + candidates => { + return Err(format!( + "ambiguous Solidity runtime CODECOPY: found {} exact candidates", + candidates.len() + )); } + }; - // Check if the next non-stack-manipulation opcode is ADD (0x01). - // The pattern is: PUSH2 ; (DUP/SWAP ops); ADD - let after = idx + 1 + width; - let is_add_target = if after < init_bytes.len() { - init_bytes[after] == 0x01 // ADD immediately follows - } else { - false + let control_transfer = instructions + .iter() + .enumerate() + .skip(copy_index + 1) + .find(|(_, instruction)| { + matches!( + instruction.opcode, + 0x00 | 0x56 | 0x57 | 0xf3 | 0xfd | 0xfe | 0xff + ) + }) + .ok_or("Solidity runtime CODECOPY has no following control transfer")?; + if control_transfer.1.opcode != 0xf3 { + return Err(format!( + "Solidity runtime-copy window ends with opcode 0x{:02x} at 0x{:x}, not RETURN", + control_transfer.1.opcode, control_transfer.1.pos + )); + } + let return_index = control_transfer.0; + + let mut edits = Vec::with_capacity(placeholders.len()); + for placeholder in placeholders { + let candidates: Vec<&ParsedInstruction> = (copy_index + 1 + ..return_index.saturating_sub(1)) + .filter_map(|index| { + let instruction = &instructions[index]; + (parsed_push_value(&original_init, instruction) == Some(placeholder) + && instructions[index + 1].opcode == 0x01 + && instructions[index + 2].opcode == 0x52) + .then_some(instruction) + }) + .collect(); + let reference = match candidates.as_slice() { + [reference] => *reference, + [] => { + return Err(format!( + "immutable placeholder at runtime offset 0x{placeholder:x} has no exact constructor reference" + )); + } + references => { + return Err(format!( + "immutable placeholder at runtime offset 0x{placeholder:x} has {} constructor references", + references.len() + )); + } }; + let new_value = remap(placeholder).ok_or_else(|| { + format!("immutable placeholder at runtime offset 0x{placeholder:x} has no remap") + })?; + let width = reference + .push_width + .expect("an instruction with a parsed push value is a PUSH"); + if width < std::mem::size_of::() && new_value >= (1usize << (width * 8)) { + return Err(format!( + "immutable remap 0x{new_value:x} does not fit in PUSH{width} at init offset 0x{:x}", + reference.pos + )); + } + edits.push((reference.pos, width, placeholder, new_value)); + } - // Only remap values that look like runtime offsets followed by ADD - if is_add_target - && value >= 1 - && value < runtime_end.saturating_sub(runtime_start) - && let Some(new_value) = remap(value) - && new_value != value - { - // Check that new value fits in the same width - let max = if width >= std::mem::size_of::() { - usize::MAX + // All validation above used an immutable snapshot. Apply to a fresh clone and publish it + // only once every edit is known to fit. + let mut patched_init = original_init; + for (pos, width, old_value, new_value) in &edits { + let bit_width = usize::BITS as usize; + for index in 0..*width { + let shift = (*width - 1 - index) * 8; + patched_init[*pos + 1 + index] = if shift >= bit_width { + 0 } else { - (1usize << (width * 8)) - 1 + ((new_value >> shift) & 0xff) as u8 }; - if new_value > max { - tracing::warn!( - "Immutable ref at init offset 0x{:x}: new value 0x{:x} exceeds \ - PUSH{} capacity", - idx, - new_value, - width - ); - } else { - for j in 0..width { - let shift = (width - 1 - j) * 8; - init_bytes[idx + 1 + j] = ((new_value >> shift) & 0xff) as u8; - } - tracing::debug!( - "Patched immutable ref at init offset 0x{:x}: 0x{:x} -> 0x{:x}", - idx, - value, - new_value - ); - patched += 1; - } } - - idx += 1 + width; - } - - if patched > 0 { tracing::debug!( - "Patched {} immutable reference offsets in init code", - patched + "Patched Solidity immutable reference at init offset 0x{:x}: 0x{:x} -> 0x{:x}", + pos, + old_value, + new_value ); - init_section.data = Bytes::from(init_bytes); } + self.removed + .iter_mut() + .find(|removed| matches!(removed.kind, SectionKind::Init)) + .expect("the init section was validated above") + .data = Bytes::from(patched_init); + tracing::debug!("Patched {} Solidity immutable references", edits.len()); Ok(()) } @@ -592,22 +2003,11 @@ impl CleanReport { Ok(self.assemble_sequential(clean, runtime_start_offset)) } - /// Reassemble bytecode, retaining the historical best-effort behavior for library callers. - /// Prefer [`Self::reassemble_checked`] when returning malformed deployment code is unsafe. - pub fn reassemble(&mut self, clean: &[u8]) -> Vec { - match self.reassemble_checked(clean) { - Ok(output) => output, - Err(error) => { - tracing::warn!("Targeted init code patching failed: {}", error); - let runtime_start_offset = self - .runtime_layout - .iter() - .map(|span| span.offset) - .min() - .unwrap_or(0); - self.assemble_sequential(clean, runtime_start_offset) - } - } + /// Reassemble bytecode with the same fail-closed guarantees as + /// [`Self::reassemble_checked`]. This compatibility name now returns an error rather than + /// emitting deployment bytecode after a required init-code patch failed. + pub fn reassemble(&mut self, clean: &[u8]) -> Result, String> { + self.reassemble_checked(clean) } fn assemble_sequential(&self, clean: &[u8], runtime_start_offset: usize) -> Vec { @@ -640,7 +2040,7 @@ impl CleanReport { #[cfg(test)] mod tests { - use super::strip_bytecode; + use super::{CleanReport, immutable_placeholder_offsets, strip_bytecode}; use crate::detection::{Section, SectionKind}; use crate::result::Error; use revm::primitives::B256; @@ -649,6 +2049,153 @@ mod tests { Section { kind, offset, len } } + fn runtime_with_immutable_placeholders(count: usize) -> (Vec, Vec) { + let mut runtime = Vec::new(); + let mut placeholders = Vec::new(); + for _ in 0..count { + runtime.push(0x7f); // PUSH32 + placeholders.push(runtime.len()); + runtime.extend_from_slice(&[0u8; 32]); + runtime.push(0x50); // POP + } + runtime.push(0x00); // STOP + (runtime, placeholders) + } + + fn immutable_report( + runtime: &[u8], + references: &[usize], + unrelated_add: Option, + ) -> (CleanReport, Vec, Option) { + assert!(runtime.len() < 0x100); + assert!(references.iter().all(|offset| *offset < 0x100)); + + // Exact Solidity terminal copy shape accepted by patch_init_immutable_refs. + let mut init = vec![ + 0x60, + 0x40, // PUSH1 0x40 + 0x51, // MLOAD + 0x60, + runtime.len() as u8, // PUSH1 runtime length + 0x90, // SWAP1 + 0x81, // DUP2 + 0x60, + 0x00, // PUSH1 runtime source; filled after init length is known + 0x82, // DUP3 + 0x39, // CODECOPY + ]; + let unrelated_immediate = unrelated_add.map(|value| { + assert!(value < 0x100); + let immediate = init.len() + 1; + init.extend_from_slice(&[0x60, value as u8, 0x01, 0x50]); // PUSH1; ADD; POP + immediate + }); + let mut reference_immediates = Vec::new(); + for reference in references { + reference_immediates.push(init.len() + 1); + init.extend_from_slice(&[0x60, *reference as u8, 0x01, 0x52]); // PUSH1; ADD; MSTORE + } + init.push(0xf3); // RETURN + assert!(init.len() < 0x100); + init[8] = init.len() as u8; + + let bytes = [init.as_slice(), runtime].concat(); + let sections = vec![ + section(SectionKind::Init, 0, init.len()), + section(SectionKind::Runtime, init.len(), runtime.len()), + ]; + let (_, report) = strip_bytecode(&bytes, §ions).unwrap(); + (report, reference_immediates, unrelated_immediate) + } + + fn init_bytes(report: &CleanReport) -> Vec { + report + .removed + .iter() + .find(|removed| removed.kind == SectionKind::Init) + .expect("fixture has init code") + .data + .to_vec() + } + + #[test] + fn init_validation_rejects_successful_return_that_bypasses_runtime_copy() { + // CALLVALUE chooses either the valid copy path or a direct entry into the proven RETURN + // window. A syntactic CODECOPY/RETURN match alone must not authorize the latter path. + let init = vec![ + 0x34, 0x60, 0x0a, 0x57, // CALLVALUE; PUSH1 return_window; JUMPI + 0x60, 0x01, 0x60, 0x0f, 0x5f, 0x39, // copy one runtime byte + 0x5b, 0x60, 0x01, 0x5f, 0xf3, // return_window: RETURN(0, 1) + ]; + let runtime = vec![0x00]; + let bytes = [init.as_slice(), runtime.as_slice()].concat(); + let sections = vec![ + section(SectionKind::Init, 0, init.len()), + section(SectionKind::Runtime, init.len(), runtime.len()), + ]; + let (clean, report) = strip_bytecode(&bytes, §ions).unwrap(); + + let error = report + .validate_init_runtime_contract(&clean) + .expect_err("a successful RETURN must be dominated by the runtime copy"); + + assert!( + error.contains("bypasses the proven runtime CODECOPY"), + "{error}" + ); + } + + #[test] + fn size_relocation_rechecks_post_copy_memory_reads_against_new_length() { + // MLOAD(1) is disjoint from the original one-byte [0,1) copy but intersects [0,2) + // after growth. Updated lowering must reject rather than merely patching length PUSHes. + let init = vec![ + 0x60, 0x01, 0x60, 0x0e, 0x5f, 0x39, // CODECOPY(0, 0x0e, 1) + 0x60, 0x01, 0x51, 0x50, // MLOAD(1); POP + 0x60, 0x01, 0x5f, 0xf3, // RETURN(0, 1) + ]; + let runtime = vec![0x00]; + let bytes = [init.as_slice(), runtime.as_slice()].concat(); + let sections = vec![ + section(SectionKind::Init, 0, init.len()), + section(SectionKind::Runtime, init.len(), runtime.len()), + ]; + let (clean, mut report) = strip_bytecode(&bytes, §ions).unwrap(); + report.validate_init_runtime_contract(&clean).unwrap(); + + let error = report + .reassemble_checked(&[0x00, 0x00]) + .expect_err("runtime growth must recheck post-copy reads"); + + assert!( + error.contains("updated MLOAD") && error.contains("intersects"), + "{error}" + ); + } + + #[test] + fn size_relocation_rejects_init_codesize_observation() { + let init = vec![ + 0x38, 0x50, // CODESIZE; POP -- still an observable size dependency + 0x60, 0x01, 0x60, 0x0c, 0x5f, 0x39, // CODECOPY(0, 0x0c, 1) + 0x60, 0x01, 0x5f, 0xf3, // RETURN(0, 1) + ]; + let runtime = vec![0x00]; + let bytes = [init.as_slice(), runtime.as_slice()].concat(); + let sections = vec![ + section(SectionKind::Init, 0, init.len()), + section(SectionKind::Runtime, init.len(), runtime.len()), + ]; + let (clean, mut report) = strip_bytecode(&bytes, §ions).unwrap(); + report.validate_init_runtime_contract(&clean).unwrap(); + + let error = report + .reassemble_checked(&[0x00, 0x00]) + .expect_err("CODESIZE-dependent init cannot be soundly resized"); + + assert!(error.contains("observes CODESIZE"), "{error}"); + } + // 0x00..0x1a : init (constructor) // 0x1a..0x23 : runtime // 0x23..end : auxdata (Solidity CBOR metadata) @@ -727,7 +2274,7 @@ mod tests { ]; let (clean, mut report) = strip_bytecode(&bytes, §ions).unwrap(); - let rebuilt = report.reassemble(&clean); + let rebuilt = report.reassemble(&clean).unwrap(); assert_eq!(rebuilt, bytes); } @@ -745,7 +2292,7 @@ mod tests { let mut new_runtime = bytes[0x1a..0x23].to_vec(); // appending two extra bytes new_runtime.extend_from_slice(&[0xde, 0xad]); - let rebuilt = report.reassemble(&new_runtime); + let rebuilt = report.reassemble(&new_runtime).unwrap(); let runtime_start = 0x1a; let mut expected_prefix = Vec::new(); @@ -788,7 +2335,7 @@ mod tests { } #[test] - fn reassembly_relocates_constructor_base_before_arguments_are_appended() { + fn reassembly_rejects_constructor_base_without_downstream_codesize_proof() { // The init code retains the runtime length across CODECOPY for RETURN, then contains // Solidity's constructor-data base sequence: PUSH creation_len; DUP1; CODESIZE; SUB. // No argument suffix is present yet, matching callers that append ABI data later. @@ -804,14 +2351,250 @@ mod tests { let (_, mut report) = strip_bytecode(&bytes, §ions).unwrap(); let grown_runtime = [0x5b, 0x5b, 0x5b, 0x00, 0x00]; - let rebuilt = report.reassemble_checked(&grown_runtime).unwrap(); + let before = init_bytes(&report); + let error = report + .reassemble_checked(&grown_runtime) + .expect_err("constructor-base syntax alone must not authorize CODESIZE relocation"); - assert_eq!(&rebuilt[1..2], &[grown_runtime.len() as u8]); assert_eq!( - &rebuilt[9..14], - &[0x60, 0x13, 0x80, 0x38, 0x03], - "constructor-data base must track the grown creation bytecode" + init_bytes(&report), + before, + "failed size relocation must leave init code unchanged" ); + assert!(error.contains("observes CODESIZE"), "{error}"); + } + + #[test] + fn reassembly_patches_explicit_return_length_across_unrelated_pushes() { + // The old byte-backscan inspected only four nearby PUSH opcodes and silently assumed the + // RETURN reused CODECOPY's length. Here CODECOPY consumes its length and RETURN uses an + // independent length PUSH separated by harmless PUSH0/POP pairs. + let mut init = vec![ + 0x60, 0x03, // PUSH1 runtime length + 0x60, 0x00, // PUSH1 runtime source; filled below + 0x5f, 0x39, // PUSH0; CODECOPY + 0x60, 0x03, 0x5f, // PUSH1 runtime length; PUSH0 + ]; + for _ in 0..5 { + init.extend_from_slice(&[0x5f, 0x50]); // PUSH0; POP + } + init.push(0xf3); + init[3] = init.len() as u8; + let runtime = [0x5b, 0x00, 0x00]; + let bytes = [init.as_slice(), runtime.as_slice()].concat(); + let sections = vec![ + section(SectionKind::Init, 0, init.len()), + section(SectionKind::Runtime, init.len(), runtime.len()), + ]; + let (_, mut report) = strip_bytecode(&bytes, §ions).unwrap(); + let grown_runtime = [0x5b, 0x5b, 0x5b, 0x00, 0x00]; + + let rebuilt = report.reassemble_checked(&grown_runtime).unwrap(); + + assert_eq!(rebuilt[1], grown_runtime.len() as u8); + assert_eq!(rebuilt[7], grown_runtime.len() as u8); assert_eq!(&rebuilt[init.len()..], grown_runtime.as_slice()); } + + #[test] + fn reassembly_rejects_return_length_push_with_observable_alias() { + // One PUSH supplies RETURN's length but is duplicated into SSTORE's value. Rewriting that + // immediate would change constructor state even though the RETURN stack shape is valid. + let init = vec![ + 0x60, 0x01, 0x60, 0x0d, 0x5f, 0x39, // CODECOPY(0, 0x0d, 1) + 0x60, 0x01, 0x80, 0x5f, 0x55, // DUP length; SSTORE(0, length) + 0x5f, 0xf3, // RETURN(0, retained length) + ]; + let runtime = [0x00]; + let bytes = [init.as_slice(), runtime.as_slice()].concat(); + let sections = vec![ + section(SectionKind::Init, 0, init.len()), + section(SectionKind::Runtime, init.len(), runtime.len()), + ]; + let (_, mut report) = strip_bytecode(&bytes, §ions).unwrap(); + let before = init_bytes(&report); + + let error = report.reassemble_checked(&[0x00, 0x00]).unwrap_err(); + + assert!(error.contains("also has a non-RETURN use"), "{error}"); + assert_eq!(init_bytes(&report), before); + } + + #[test] + fn reassembly_rejects_unproven_return_length_atomically() { + let mut init = vec![ + 0x60, 0x03, // PUSH1 runtime length + 0x60, 0x00, // PUSH1 runtime source; filled below + 0x5f, 0x39, // PUSH0; CODECOPY + 0x60, 0x03, 0x5f, 0x01, // compute rather than carry an exact RETURN length + 0x5f, 0xf3, // PUSH0; RETURN + ]; + init[3] = init.len() as u8; + let runtime = [0x5b, 0x00, 0x00]; + let bytes = [init.as_slice(), runtime.as_slice()].concat(); + let sections = vec![ + section(SectionKind::Init, 0, init.len()), + section(SectionKind::Runtime, init.len(), runtime.len()), + ]; + let (_, mut report) = strip_bytecode(&bytes, §ions).unwrap(); + let before = init_bytes(&report); + + let error = report + .reassemble_checked(&[0x5b, 0x5b, 0x5b, 0x00, 0x00]) + .unwrap_err(); + + assert!( + error.contains("runtime copy parameter is consumed") + || error.contains("RETURN length does not derive"), + "{error}" + ); + assert_eq!( + init_bytes(&report), + before, + "failed lowering must be atomic" + ); + } + + #[test] + fn immutable_patch_remaps_only_exact_placeholder_references() { + let (runtime, placeholders) = runtime_with_immutable_placeholders(2); + let (mut report, references, unrelated) = + immutable_report(&runtime, &placeholders, Some(2)); + + report + .patch_init_immutable_refs(&runtime, &|offset| Some(offset + 10)) + .unwrap(); + + let patched = init_bytes(&report); + assert_eq!(patched[references[0]], (placeholders[0] + 10) as u8); + assert_eq!(patched[references[1]], (placeholders[1] + 10) as u8); + assert_eq!( + patched[unrelated.expect("unrelated PUSH exists")], + 2, + "an unrelated PUSH/ADD must never be rewritten" + ); + } + + #[test] + fn immutable_patch_rejects_missing_reference_atomically() { + let (runtime, _) = runtime_with_immutable_placeholders(1); + let (mut report, _, _) = immutable_report(&runtime, &[], None); + let before = init_bytes(&report); + + let error = report + .patch_init_immutable_refs(&runtime, &|offset| Some(offset + 1)) + .unwrap_err(); + + assert!( + error.contains("has no exact constructor reference"), + "{error}" + ); + assert_eq!(init_bytes(&report), before); + } + + #[test] + fn immutable_patch_rejects_ambiguous_reference_atomically() { + let (runtime, placeholders) = runtime_with_immutable_placeholders(1); + let references = [placeholders[0], placeholders[0]]; + let (mut report, _, _) = immutable_report(&runtime, &references, None); + let before = init_bytes(&report); + + let error = report + .patch_init_immutable_refs(&runtime, &|offset| Some(offset + 1)) + .unwrap_err(); + + assert!(error.contains("has 2 constructor references"), "{error}"); + assert_eq!(init_bytes(&report), before); + } + + #[test] + fn immutable_patch_rejects_missing_remap_atomically() { + let (runtime, placeholders) = runtime_with_immutable_placeholders(1); + let (mut report, _, _) = immutable_report(&runtime, &placeholders, None); + let before = init_bytes(&report); + + let error = report + .patch_init_immutable_refs(&runtime, &|_| None) + .unwrap_err(); + + assert!(error.contains("has no remap"), "{error}"); + assert_eq!(init_bytes(&report), before); + } + + #[test] + fn immutable_patch_rejects_push_overflow_atomically() { + let (runtime, placeholders) = runtime_with_immutable_placeholders(2); + let (mut report, _, _) = immutable_report(&runtime, &placeholders, None); + let before = init_bytes(&report); + + let error = report + .patch_init_immutable_refs(&runtime, &|offset| { + (offset == placeholders[0]) + .then_some(offset + 1) + .or(Some(0x100)) + }) + .unwrap_err(); + + assert!(error.contains("does not fit in PUSH1"), "{error}"); + assert_eq!(init_bytes(&report), before); + } + + #[test] + fn immutable_patch_accepts_current_escrow_compiler_shapes() { + let fixtures = [ + ( + include_str!("../../../examples/escrow-bytecode/artifacts/erc20_deployment.hex"), + include_str!("../../../examples/escrow-bytecode/artifacts/erc20_runtime.hex"), + ), + ( + include_str!("../../../examples/escrow-bytecode/artifacts/native_deployment.hex"), + include_str!("../../../examples/escrow-bytecode/artifacts/native_runtime.hex"), + ), + ]; + + for (fixture_index, (deployment_hex, runtime_hex)) in fixtures.into_iter().enumerate() { + let deployment = hex::decode(deployment_hex.trim().trim_start_matches("0x")).unwrap(); + let runtime = hex::decode(runtime_hex.trim().trim_start_matches("0x")).unwrap(); + let metadata_payload_len = usize::from(u16::from_be_bytes([ + runtime[runtime.len() - 2], + runtime[runtime.len() - 1], + ])); + let auxdata_len = metadata_payload_len + 2; + let clean_len = runtime.len() - auxdata_len; + let matches: Vec<_> = deployment + .windows(runtime.len()) + .enumerate() + .filter_map(|(offset, window)| (window == runtime).then_some(offset)) + .collect(); + let [runtime_start] = matches.as_slice() else { + panic!("fixture runtime must occur exactly once in its deployment artifact"); + }; + let sections = vec![ + section(SectionKind::Init, 0, *runtime_start), + section(SectionKind::Runtime, *runtime_start, clean_len), + section( + SectionKind::Auxdata, + *runtime_start + clean_len, + auxdata_len, + ), + ]; + let (clean_runtime, mut report) = strip_bytecode(&deployment, §ions).unwrap(); + assert!( + !immutable_placeholder_offsets(&clean_runtime) + .unwrap() + .is_empty(), + "escrow fixture should exercise immutable references" + ); + + report + .validate_init_runtime_contract(&clean_runtime) + .unwrap_or_else(|error| { + panic!("escrow fixture {fixture_index} init/runtime provenance: {error}") + }); + + report + .patch_init_immutable_refs(&clean_runtime, &Some) + .unwrap(); + } + } } diff --git a/crates/core/src/validator.rs b/crates/core/src/validator.rs index a102a56f..edd552a5 100644 --- a/crates/core/src/validator.rs +++ b/crates/core/src/validator.rs @@ -20,9 +20,7 @@ use crate::{ /// If a target falls outside the bytecode range or does not land on a JUMPDEST the function /// returns an error listing all invalid jump targets found. pub async fn validate_jump_targets(bytecode: &[u8]) -> Result<()> { - let (instructions, _, _, _) = decoder::decode_bytecode(&hex::encode(bytecode), false) - .await - .map_err(|e| Error::Heimdall(format!("Failed to decode bytecode for validation: {}", e)))?; + let instructions = decoder::decode_executable_bytes(bytecode)?; let jumpdests: HashSet = instructions .iter() .filter_map(|instr| matches!(instr.op, Opcode::JUMPDEST).then_some(instr.pc)) diff --git a/crates/transforms/Cargo.toml b/crates/transforms/Cargo.toml index af1b4799..1598fc92 100644 --- a/crates/transforms/Cargo.toml +++ b/crates/transforms/Cargo.toml @@ -8,6 +8,7 @@ azoth-core = { path = "../core" } rand.workspace = true serde.workspace = true sha3.workspace = true +hmac.workspace = true tracing.workspace = true thiserror.workspace = true tracing-subscriber.workspace = true diff --git a/crates/transforms/README.md b/crates/transforms/README.md index d0bea4aa..3b78909a 100644 --- a/crates/transforms/README.md +++ b/crates/transforms/README.md @@ -1,224 +1,95 @@ -# Azoth Transforms - -The `azoth-transform` crate implements the obfuscation transformations that enhance bytecode complexity and resistance to analysis. This crate provides a pluggable architecture for applying various obfuscation techniques while maintaining semantic equivalence. - -## Architecture - -The transforms crate implements a pass-based architecture where each transformation operates on the CFG/IR representation: - -1. **Pass Interface** - Standardized transformation interface for modularity -2. **Transformation Passes** - Individual obfuscation techniques -3. **Metrics Integration** - Continuous evaluation during transformation -4. **Rollback Support** - Automatic rejection of ineffective passes - -## Current Transforms - -### Constructor arguments (`constructor_args.rs`) - -When the deployment payload contains bytes after the exact caller-supplied runtime, the pipeline masks every byte of that suffix and injects a seed-varied init-code decoder. Detection uses the complete runtime as an authoritative boundary and does not inspect the ABI, source, address shapes, or zero padding. Unsupported or ambiguous constructor copy layouts fail closed instead of returning plaintext arguments. This pass is automatic and is reported as `ConstructorArgs` in result metadata. - -The bytecode contains everything required to reverse the mask, so this is obfuscation against literal/static recovery rather than encryption. Trampoline form, chunk order, arithmetic mask synthesis, and constants vary with the seed; no marker or fixed decoder byte string is emitted. - -### Shuffle (`shuffle.rs`) - -Reorders basic blocks within the CFG while updating jump targets to maintain correctness. Simple block-level randomization that changes program layout without affecting execution. - -Example - -```assembly -Original -> 0x60015b6002 -Shuffled -> 0x5b60026001 -``` - -### Opaque Predicate (`opaque_predicate.rs`) - -Injects always-true (or always-false) predicates built from cheap arithmetic or constant-equality (e.g., XOR + ISZERO or EQ on identical constants). Adds dummy control-flow that never influences observable behavior but explodes CFG shape. - -Example - -Original bytecode: 0x6001600260016003 (8 bytes, 4 instructions, 1 block) - -```assembly -PUSH1 0x01 -PUSH1 0x02 -PUSH1 0x01 -PUSH1 0x03 -``` - -After OpaquePredicate: (~80–100 bytes, ~12 instructions, 3 blocks; seed-dependent) - -```assembly -// Original block (now with predicate appended) -PUSH1 0x01 -PUSH1 0x02 -PUSH1 0x01 -PUSH1 0x03 -PUSH32 C // Random 32-byte constant -PUSH32 C // Same constant -XOR // 0 -ISZERO // -> 1 (true) -PUSH2 true_pc -JUMPI -JUMPDEST // Join point (false path) -JUMP false_pc - -// New true_label block -JUMPDEST // True branch target (always taken) -// execution continues to original fallthrough - -// New false_label block -JUMPDEST // False branch target (never reached) -PUSH1 0x00 -JUMP // Dead code path -``` - -Changes: +80-100 bytes, splits 1 block into 3, adds always-true branching that never affects execution but complicates CFG analysis. - -### Jump Address Transformer (`jump_address_transformer.rs`) - -Splits jump targets into arithmetic operations. Replaces PUSH1 0x42 JUMP with PUSH1 0x20 PUSH1 0x22 ADD JUMP where the values sum to the original target. - -Example - -Original bytecode: 0x60085760015b00 (7 bytes, 5 instructions, 3 blocks) - -```assembly -PUSH1 0x08 // Direct jump target -JUMPI // Conditional jump to 0x08 -PUSH1 0x01 // Fallthrough path -JUMPDEST // Jump destination at 0x08 -STOP -``` - -After JumpAddressTransformer: 0x60046004015760015b00 (10 bytes, 7 instructions, 3 blocks) - -```assembly -PUSH1 0x04 // First part of split target -PUSH1 0x04 // Second part (0x04 + 0x04 = 0x08) -ADD // Compute original target at runtime -JUMPI // Conditional jump to computed value -PUSH1 0x01 // Fallthrough path unchanged -JUMPDEST // Same jump destination -STOP -``` - -Changes: +3 bytes, +2 instructions; replaces direct 0x08 with 0x04 + 0x04 via ADD. Net +6 gas (2 extra PUSH1s + ADD – original single PUSH1). - -### Function Dispatcher (`function_dispatcher.rs`) - -Replaces Solidity-style dispatchers with a cryptographically hardened version that is resistant to selector fingerprinting and pattern-based detection. - -#### Key Features - -* **Token Derivation**: Uses cryptographic `keccak256(secret || selector)[:4]` to generate fixed 4-byte tokens -* **Disguised Extraction**: Replaces obvious calldata patterns with obfuscated arithmetic (e.g., `XOR`, `SUB`, `MOD`, `MSTORE/MLOAD`) -* **SHR-Based Extraction**: Extracts tokens using right-shift operations that work correctly even when calldata includes function arguments -* **Comparison Replacement**: Replaces all `PUSH4 ` checks with `PUSH4 ` -* **Shuffled Order**: Randomizes comparison order to prevent pattern recognition -* **Internal Call Updates**: Updates all internal `PUSH4 selector; CALL` instructions to use `PUSH4 token; CALL` - -#### Token Extraction Method - -The dispatcher uses a right-shift (SHR) operation to extract the 4-byte token from calldata: - -1. **CALLDATALOAD(0)** loads the first 32 bytes with the selector left-aligned -2. **SHR 224** shifts right by 28 bytes (224 bits), moving the token to the low position -3. The shift automatically discards any function arguments, leaving only the 4-byte token - -This approach works correctly even when calldata includes arguments: - -``` -Calldata for bond(1000): - 0x500b6840 00000000000000000000000000000000000000000000000000000000000003e8 - │────────│ ──────────────────────────────────────────────────────────────│ - 4-byte 32-byte argument (uint256 = 1000) - token - -CALLDATALOAD(0): 0x500b684000000000000000000000000000000000000000000000000000000000 -SHR 224: 0x00000000000000000000000000000000000000000000000000000000500b6840 - └──────┘ - 4-byte token -``` - -Since we use **fixed 4-byte tokens**, the SHR operation alone is sufficient. The right-shift automatically removes all argument bytes, leaving only the token for comparison. No additional masking is needed. - -#### Example Transformation - -**Original dispatcher:** - -```assembly -PUSH1 0x00 ; offset 0 -CALLDATALOAD ; load calldata[0..31] -PUSH1 0xe0 ; 224 bits -SHR ; extract selector -DUP1 -PUSH4 0x7ff36ab5 ; selector for balanceOf -EQ -PUSH1 0x1a -JUMPI -DUP1 -PUSH4 0xa9059cbb ; selector for transfer -EQ -PUSH1 0x21 -JUMPI -``` - -**Obfuscated dispatcher:** - -```assembly -PUSH1 0x39 -PUSH1 0x39 -XOR ; disguised 0x00 (random arithmetic) -CALLDATALOAD ; load calldata[0..31] -PUSH1 0xe0 ; 224 bits -SHR ; extract 4-byte token from left -DUP1 -PUSH4 0x1278bea5 ; cryptographic token for 0xa9059cbb (transfer) -EQ -PUSH1 0x30 -JUMPI -DUP1 -PUSH4 0xa93a3604 ; cryptographic token for 0x7ff36ab5 (balanceOf) -EQ -PUSH1 0x29 -JUMPI -PUSH1 0x00 -DUP1 -REVERT ; default case -``` - -#### Changes - -* **Original selectors completely replaced** with cryptographically-derived tokens -* **Calldata offset computation disguised** using random arithmetic operations -* **Comparison order randomized** to prevent sequential pattern matching -* **Function bodies unchanged** - they still read arguments from offset 4+ as normal -* **Jump targets automatically updated** to maintain semantic equivalence - -This makes function selectors unrecognizable to static analysis tools and completely eliminates selector-based fingerprinting, while maintaining full compatibility with standard ABI encoding for function arguments. - -### Transform Interface - -```rust -#[async_trait] -pub trait Transform: Send + Sync { - fn name(&self) -> &'static str; - async fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result; -} -``` - -### Pass Execution - -Consumers typically orchestrate transforms themselves. A minimal driver looks like: - -```rust -let mut rng = seed.create_deterministic_rng(); -for transform in transforms { - if transform.apply(&mut cfg_ir, &mut rng)? { - // optionally recompute metrics, log deltas, etc. - } -} -``` - -The transforms operate on `CfgIrBundle` structures and can be combined with -metrics from `azoth-analysis` to evaluate effectiveness. +# Azoth transforms + +This crate contains Azoth's bytecode passes and the fail-closed orchestration pipeline. A pass +implements the synchronous `Transform` trait and edits `CfgIrBundle`, the relationship-aware EVM +control-flow representation from `azoth-core`. + +## Safe default profile + +The default and `ObfuscationConfig::with_seed` profiles admit `ClusterShuffle`. The CLI rejects +the legacy pass names listed below. A library caller can still instantiate them for research, but +that is not a production endorsement. “Safe” here means conservatively admitted by the current +checks; it does not mean formally proven or production-ready. + +`ClusterShuffle` permutes complete block clusters. A cluster is the smallest ordered set that must +stay together because of fallthrough, branch-false adjacency, section boundaries, or PC-relative +control. The pass keeps the runtime entry and end-of-code fallthrough anchors fixed and refuses to +run when control targets are unresolved or the code observes its own layout/bytes/hash. Static +jumps and stack-proven Solidity return addresses are represented as stable block relationships; +numeric PCs are written only during final lowering. + +An experimental opt-in can relabel selectors inside a detected native Solidity dispatcher without +changing its opcode shape. The returned private interaction manifest maps original selectors to +the replacement four-byte values. It is disabled in the safe profile: adapted calldata +changes `msg.sig`/`msg.data`, and the current literal guard cannot prove safety for forwarding, +hashing, proxies, self-calls, or dynamically synthesized selectors. + +Every byte classified as compiler auxdata or padding is preserved byte-for-byte. Auxdata must be +a complete compiler CBOR map whose claimed start is also an EVM instruction boundary; a two-byte +length alone cannot split code or a PUSH immediate. This detector is still a parsing aid, not a +proof that a suffix is unreachable code. The safe pipeline therefore never rewrites an IPFS +or Swarm digest in place. Exact metadata remains a cross-variant linkage signal until a future +pass can prove that changing it preserves all code-observation and execution behavior. + +When such a suffix exists, a layout change is allowed only if the retained runtime ends exactly +at a decoded terminal instruction and its control relationships are fully resolved. Otherwise the +suffix might be fallthrough code or a jump target that the CFG did not analyze, so the pipeline +fails closed. A no-op run may still return the byte-for-byte original artifact. + +## Transaction and replay rules + +Each pass receives a full 256-bit, input-bound, domain-separated RNG stream. Adding an unrelated +pass does not consume another pass's randomness. The same deployment bytes, runtime artifact, +seed, profile, and ordered pass list produce the same result. + +Pass execution is transactional: + +1. Clone the current IR. +2. Apply one pass to the clone. +3. Rebuild and validate relationships. +4. Commit the clone atomically only when the pass reports a change and validation succeeds. + +A pass error aborts the pipeline. It is never converted into success, and partial mutations are +never returned. Finalization validates jump targets, patches supported immutable offsets, enforces +EIP-170/EIP-3860 limits, and fails closed when it cannot preserve a required relationship. +The safe pipeline also rejects any executable-runtime opcode whose stack and control +semantics are not modelled. Copying an unknown byte verbatim would not make moving its containing +block safe; ordinary `0xfe` (`INVALID`) remains supported as a known terminal instruction. + +`ObfuscationResult` includes an input-bound seed commitment, Keccak hashes of input/output +artifacts, the ordered requested transform recipes and feature flags, and an HMAC-SHA3-256 +reproduction tag. A recipe records each transform's name and canonical `configuration_id`, even +when the pass became a no-op. `verify_integrity` checks those fields using the private seed. The +standalone `PrivateInteractionManifest::verify_integrity` additionally authenticates a serialized +selector map and its exact calldata rule against separately supplied input/output artifacts. These +checks authenticate replay artifacts; they are not a proof of EVM equivalence. The CLI emits +selector mappings only through explicitly requested private outputs: the interaction manifest or +a CFG debug trace. Both are published without overwrite from a fully synced private temporary file; +neither must be published or embedded on-chain. + +## Experimental and disabled passes + +The following implementations remain available for tests and research but are rejected by the +safe CLI until they satisfy semantic, detector, and corpus gates: + +- `Shuffle`: reorders individual blocks without the complete relationship policy. +- `OpaquePredicate`, `StorageGates`, and `Splice`: add recognizable synthetic control-flow motifs. +- `JumpAddressTransformer`, `ArithmeticChain`, and `PushSplit`: add normalizable arithmetic motifs + and need stronger whole-program data-flow proofs. +- `SlotShuffle`: cannot yet prove arbitrary/dynamic storage addressing is completely remapped. +- `StringObfuscate`: changes observable revert/return payloads. +- constructor-argument masking: opt-in through + `ObfuscationConfig::obfuscate_constructor_arguments`; it supports only some init-code shapes and + its decoder was perfectly detected in the local red-team corpus. + +The formal verifier is also fail-closed and currently reports verification unavailable. Release +decisions must therefore rely on explicit behavioral tests while the semantic model is completed; +checksums and successful deployment are not substitutes for equivalence proof. + +## Adding a pass + +A new pass should edit stable block identities and symbolic relationships, not concrete PCs. It +must have deterministic tests, transaction rollback/error tests, relationship validation, exact +replay coverage, REVM behavioral tests for success/revert/return/log/storage/calls, protocol-limit +tests, and a detector evaluation against compiler- and size-matched negative bytecode. It is added +to the CLI allowlist only after those gates pass. diff --git a/crates/transforms/src/arithmetic_chain/chain.rs b/crates/transforms/src/arithmetic_chain/chain.rs index fa6bda48..96f20959 100644 --- a/crates/transforms/src/arithmetic_chain/chain.rs +++ b/crates/transforms/src/arithmetic_chain/chain.rs @@ -4,7 +4,7 @@ //! compute a target constant value from scattered initial values. use super::types::{ArithmeticChainDef, ArithmeticOp, ChainConfig, ScatterStrategy}; -use rand::rngs::StdRng; +use azoth_core::seed::DeterministicRng; use rand::Rng; /// Generate an arithmetic chain for a target 32-byte constant. @@ -16,7 +16,7 @@ use rand::Rng; pub fn generate_chain( target: [u8; 32], config: &ChainConfig, - rng: &mut StdRng, + rng: &mut DeterministicRng, ) -> ArithmeticChainDef { let depth = rng.random_range(config.chain_depth.clone()); let operations: Vec = (0..depth).map(|_| ArithmeticOp::random(rng)).collect(); @@ -35,7 +35,7 @@ pub fn generate_chain( fn compute_initial_values( target: [u8; 32], operations: &[ArithmeticOp], - rng: &mut StdRng, + rng: &mut DeterministicRng, ) -> Vec<[u8; 32]> { let mut current = target; let mut initial_values = Vec::with_capacity(operations.len() + 1); @@ -73,7 +73,7 @@ pub fn evaluate_forward(initial_values: &[[u8; 32]], operations: &[ArithmeticOp] fn assign_scatter_locations( count: usize, config: &ChainConfig, - rng: &mut StdRng, + rng: &mut DeterministicRng, ) -> Vec { (0..count) .map(|_| { @@ -114,8 +114,8 @@ mod tests { use super::*; use rand::SeedableRng; - fn test_rng() -> StdRng { - StdRng::seed_from_u64(42) + fn test_rng() -> DeterministicRng { + DeterministicRng::seed_from_u64(42) } #[test] diff --git a/crates/transforms/src/arithmetic_chain/mod.rs b/crates/transforms/src/arithmetic_chain/mod.rs index abab6794..54e6d966 100644 --- a/crates/transforms/src/arithmetic_chain/mod.rs +++ b/crates/transforms/src/arithmetic_chain/mod.rs @@ -52,9 +52,9 @@ pub mod types; use crate::{Error, Result, Transform}; use azoth_core::cfg_ir::{Block, BlockBody, CfgIrBundle}; +use azoth_core::seed::DeterministicRng; use azoth_core::Opcode; use petgraph::graph::NodeIndex; -use rand::rngs::StdRng; use rand::Rng; use std::collections::HashSet; use tracing::debug; @@ -126,7 +126,7 @@ impl ArithmeticChain { &self, ir: &CfgIrBundle, protected_pcs: &HashSet, - rng: &mut StdRng, + rng: &mut DeterministicRng, ) -> Vec<(NodeIndex, usize, u8, [u8; 32])> { let mut targets = Vec::new(); @@ -253,6 +253,7 @@ impl ArithmeticChain { max_stack: body.max_stack, control: body.control.clone(), instructions: new_instructions, + section: body.section, }; // Use overwrite_block to record the trace @@ -269,7 +270,22 @@ impl Transform for ArithmeticChain { "ArithmeticChain" } - fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + fn configuration_id(&self) -> String { + let max_targets = self + .config + .max_targets + .map_or_else(|| "none".to_string(), |value| value.to_string()); + format!( + "ArithmeticChain@v1;depth={}..={};inline_ratio_bits={:08x};respect_protected_pcs={};max_targets={max_targets};transform_probability_bits={:08x}", + self.config.chain_depth.start(), + self.config.chain_depth.end(), + self.config.inline_ratio.to_bits(), + self.config.respect_protected_pcs, + self.config.transform_probability.to_bits(), + ) + } + + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut DeterministicRng) -> Result { debug!("=== ArithmeticChain Transform Start ==="); let protected_pcs = if self.config.respect_protected_pcs { @@ -438,6 +454,41 @@ fn instruction_size(instr: &azoth_core::decoder::Instruction) -> usize { mod tests { use super::*; + #[test] + fn configuration_id_binds_every_chain_option() { + let baseline = ArithmeticChain::new().configuration_id(); + assert_eq!(baseline, ArithmeticChain::new().configuration_id()); + + let variants = [ + ChainConfig { + chain_depth: 3..=8, + ..ChainConfig::default() + }, + ChainConfig { + inline_ratio: 0.5, + ..ChainConfig::default() + }, + ChainConfig { + respect_protected_pcs: false, + ..ChainConfig::default() + }, + ChainConfig { + max_targets: Some(7), + ..ChainConfig::default() + }, + ChainConfig { + transform_probability: 1.0, + ..ChainConfig::default() + }, + ]; + for variant in variants { + assert_ne!( + baseline, + ArithmeticChain::with_config(variant).configuration_id() + ); + } + } + #[test] fn parse_push_value_full() { let hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; diff --git a/crates/transforms/src/arithmetic_chain/reverse.rs b/crates/transforms/src/arithmetic_chain/reverse.rs index ab63b0fc..888faf7b 100644 --- a/crates/transforms/src/arithmetic_chain/reverse.rs +++ b/crates/transforms/src/arithmetic_chain/reverse.rs @@ -26,11 +26,11 @@ pub fn evaluate_forward(initial_values: &[[u8; 32]], operations: &[ArithmeticOp] #[cfg(test)] mod tests { use super::*; - use rand::rngs::StdRng; + use azoth_core::seed::DeterministicRng; use rand::{Rng, SeedableRng}; - fn test_rng() -> StdRng { - StdRng::seed_from_u64(12345) + fn test_rng() -> DeterministicRng { + DeterministicRng::seed_from_u64(12345) } #[test] diff --git a/crates/transforms/src/arithmetic_chain/scatter.rs b/crates/transforms/src/arithmetic_chain/scatter.rs index c84e0dc4..5865d435 100644 --- a/crates/transforms/src/arithmetic_chain/scatter.rs +++ b/crates/transforms/src/arithmetic_chain/scatter.rs @@ -15,8 +15,8 @@ use super::types::{ArithmeticChainDef, ScatterContext, ScatterStrategy}; use azoth_core::cfg_ir::CfgIrBundle; use azoth_core::decoder::Instruction; +use azoth_core::seed::DeterministicRng; use azoth_core::Opcode; -use rand::rngs::StdRng; /// Apply scattering to a chain, populating the scatter context with data /// section bytes and dead path blocks as needed. @@ -38,7 +38,7 @@ pub fn apply_scattering( _ir: &mut CfgIrBundle, chain: &mut ArithmeticChainDef, ctx: &mut ScatterContext, - _rng: &mut StdRng, + _rng: &mut DeterministicRng, ) -> crate::Result<()> { for (i, (value, strategy)) in chain .initial_values diff --git a/crates/transforms/src/arithmetic_chain/types.rs b/crates/transforms/src/arithmetic_chain/types.rs index 2e20d2f8..22571dac 100644 --- a/crates/transforms/src/arithmetic_chain/types.rs +++ b/crates/transforms/src/arithmetic_chain/types.rs @@ -4,8 +4,8 @@ //! transform, including operations, chain definitions, scatter strategies, and //! configuration options. +use azoth_core::seed::DeterministicRng; use petgraph::graph::NodeIndex; -use rand::rngs::StdRng; use rand::Rng; use serde::{Deserialize, Serialize}; use std::ops::RangeInclusive; @@ -39,7 +39,7 @@ impl ArithmeticOp { /// - ADD, SUB, XOR: 25% each (75% total) /// - AND, OR: 8% each (16% total) /// - MUL, DIV: 4.5% each (9% total) - pub fn random(rng: &mut StdRng) -> Self { + pub fn random(rng: &mut DeterministicRng) -> Self { let roll: f32 = rng.random(); if roll < 0.25 { Self::Add @@ -62,7 +62,11 @@ impl ArithmeticOp { /// /// Given `result = a OP b`, computes `(a, b)` such that applying the operation /// forward produces `result`. - pub fn compute_backward(&self, result: [u8; 32], rng: &mut StdRng) -> ([u8; 32], [u8; 32]) { + pub fn compute_backward( + &self, + result: [u8; 32], + rng: &mut DeterministicRng, + ) -> ([u8; 32], [u8; 32]) { match self { Self::Add => { let b = random_sized_value(rng); @@ -160,7 +164,7 @@ impl ArithmeticOp { /// /// Prefers even PUSH sizes (2, 4, 8, 16, 32) for better bytecode alignment, /// with weighted distribution favoring smaller values. -fn random_sized_value(rng: &mut StdRng) -> [u8; 32] { +fn random_sized_value(rng: &mut DeterministicRng) -> [u8; 32] { // Preferred even sizes with weights favoring smaller values // PUSH2, PUSH4, PUSH8, PUSH16, PUSH32 let sizes: [(u8, u32); 5] = [ @@ -290,7 +294,7 @@ fn wrapping_div(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] { } /// Pick a factor that divides the result evenly for MUL backward. -fn pick_exact_divisor(value: &[u8; 32], rng: &mut StdRng) -> [u8; 32] { +fn pick_exact_divisor(value: &[u8; 32], rng: &mut DeterministicRng) -> [u8; 32] { if value.iter().all(|&b| b == 0) { let mut result = [0u8; 32]; result[31] = 1; @@ -450,8 +454,8 @@ mod tests { use super::*; use rand::SeedableRng; - fn test_rng() -> StdRng { - StdRng::seed_from_u64(12345) + fn test_rng() -> DeterministicRng { + DeterministicRng::seed_from_u64(12345) } #[test] diff --git a/crates/transforms/src/cluster_shuffle.rs b/crates/transforms/src/cluster_shuffle.rs index 7e95611a..de96be3e 100644 --- a/crates/transforms/src/cluster_shuffle.rs +++ b/crates/transforms/src/cluster_shuffle.rs @@ -1,28 +1,22 @@ -//! Cluster-aware CFG shuffler. +//! Relationship-aware CFG layout diversification. //! -//! Instead of shuffling individual blocks, this transform will shuffle block -//! clusters (dispatcher tiers, stub+decoy pairs, storage gates) to preserve -//! logical adjacency while changing layout. -//! -//! Assembly example: -//! ```assembly -//! // Blocks before -//! [dispatcher_tier_0][controller_real][decoy_stub][storage_gate] -//! -//! // After cluster shuffle (one variant) -//! [storage_gate][dispatcher_tier_0][decoy_stub][controller_real] -//! ``` +//! This pass moves complete fallthrough/PC-relative clusters instead of individual blocks. The +//! instructions inside each cluster are left byte-for-byte intact, so local compiler idioms and +//! the opcode distribution remain those of the input contract. Stable graph identity is also +//! retained: concrete PCs are assigned once, during final lowering. -use crate::{Result, Transform}; +use crate::{Error, Result, Transform}; use azoth_core::cfg_ir::CfgIrBundle; -use rand::rngs::StdRng; +use azoth_core::seed::DeterministicRng; +use rand::seq::SliceRandom; use tracing::debug; -/// Cluster-level shuffle wrapper. -#[derive(Default)] +/// Deterministically permutes independently movable block clusters. +#[derive(Debug, Default)] pub struct ClusterShuffle; impl ClusterShuffle { + /// Creates the relationship-aware layout pass. pub fn new() -> Self { Self } @@ -33,8 +27,250 @@ impl Transform for ClusterShuffle { "ClusterShuffle" } - fn apply(&self, _ir: &mut CfgIrBundle, _rng: &mut StdRng) -> Result { - debug!("ClusterShuffle: placeholder apply (no-op)"); - Ok(false) + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut DeterministicRng) -> Result { + ir.refresh_relationships() + .map_err(|error| Error::CoreError(error.to_string()))?; + let relationships = ir.relationships().clone(); + + if !relationships.is_relocatable() { + debug!( + unresolved = relationships.unresolved_control.len(), + position_sensitive = relationships.position_sensitive.len(), + "ClusterShuffle: layout is not safely relocatable" + ); + return Ok(false); + } + let entry_position = relationships + .clusters + .iter() + .position(|cluster| cluster.anchors_entry) + .ok_or_else(|| Error::CoreError("relationship index has no entry cluster".into()))?; + if entry_position != 0 { + return Err(Error::CoreError( + "entry cluster is not first in current layout".into(), + )); + } + + let exit_position = relationships + .clusters + .iter() + .position(|cluster| cluster.anchors_exit); + if let Some(exit_position) = exit_position { + if exit_position + 1 != relationships.clusters.len() { + return Err(Error::CoreError( + "fallthrough-to-end cluster is not last in current layout".into(), + )); + } + } + + // Shuffle only consecutive, single-section-region runs. Entry, end-of-code, and + // mixed-region clusters remain fixed anchors. This keeps creation/runtime boundaries and + // the semantic end-of-code halt intact even when this generic pass is used outside the + // normal runtime-only pipeline. + let mut shuffled = relationships.clusters.clone(); + let mut changed = false; + let mut start = 0usize; + while start < shuffled.len() { + let cluster = &shuffled[start]; + let Some(region) = cluster.layout_region else { + start += 1; + continue; + }; + if cluster.anchors_entry || cluster.anchors_exit { + start += 1; + continue; + } + + let mut end = start + 1; + while end < shuffled.len() + && shuffled[end].layout_region == Some(region) + && !shuffled[end].anchors_entry + && !shuffled[end].anchors_exit + { + end += 1; + } + + if end - start >= 2 { + let original_ids: Vec<_> = shuffled[start..end] + .iter() + .map(|candidate| candidate.id) + .collect(); + shuffled[start..end].shuffle(rng); + if shuffled[start..end] + .iter() + .map(|candidate| candidate.id) + .eq(original_ids.iter().copied()) + { + shuffled[start..end].rotate_left(1); + } + changed = true; + } + start = end; + } + + if !changed { + debug!( + clusters = relationships.clusters.len(), + "ClusterShuffle: not enough independently movable clusters" + ); + return Ok(false); + } + + let mut order = Vec::with_capacity(ir.layout_order().len()); + for cluster in &shuffled { + order.extend(cluster.members.iter().copied()); + } + + if order == ir.layout_order() { + return Ok(false); + } + relationships + .validate_layout(&order) + .map_err(Error::CoreError)?; + ir.set_layout_order(order) + .map_err(|error| Error::CoreError(error.to_string()))?; + + debug!( + clusters = relationships.clusters.len(), + moved = relationships + .clusters + .iter() + .zip(&shuffled) + .filter(|(before, after)| before.id != after.id) + .count(), + "ClusterShuffle: applied relationship-safe layout" + ); + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use azoth_core::cfg_ir::{build_cfg_ir, Block}; + use azoth_core::decoder::Instruction; + use azoth_core::detection::{Section, SectionKind}; + use azoth_core::strip::{CleanReport, RuntimeSpan}; + use azoth_core::Opcode; + use rand::SeedableRng; + use revm::primitives::B256; + + fn instruction(pc: usize, op: Opcode, immediate: Option<&str>) -> Instruction { + Instruction { + pc, + op, + imm: immediate.map(str::to_string), + } + } + + fn build(instructions: &[Instruction], sections: &[Section], code_len: usize) -> CfgIrBundle { + let report = CleanReport { + runtime_layout: vec![RuntimeSpan { + offset: sections + .iter() + .find(|section| section.kind == SectionKind::Runtime) + .map_or(0, |section| section.offset), + len: sections + .iter() + .find(|section| section.kind == SectionKind::Runtime) + .map_or(code_len, |section| section.len), + }], + removed: Vec::new(), + swarm_hash: None, + bytes_saved: 0, + clean_len: code_len, + clean_keccak: B256::ZERO, + program_counter_mapping: Vec::new(), + }; + build_cfg_ir(instructions, sections, report, &vec![0; code_len]).expect("test CFG builds") + } + + #[test] + fn deterministic_shuffle_keeps_entry_and_end_of_code_anchors() { + let instructions = vec![ + instruction(0, Opcode::JUMPDEST, None), + instruction(1, Opcode::PUSH(1), Some("04")), + instruction(3, Opcode::JUMP, None), + instruction(4, Opcode::JUMPDEST, None), + instruction(5, Opcode::STOP, None), + instruction(6, Opcode::JUMPDEST, None), + instruction(7, Opcode::STOP, None), + instruction(8, Opcode::JUMPDEST, None), + ]; + let sections = [Section { + kind: SectionKind::Runtime, + offset: 0, + len: 9, + }]; + let original = build(&instructions, §ions, 9); + let original_order = original.layout_order().to_vec(); + let mut first = original.clone(); + let mut second = original; + let mut first_rng = DeterministicRng::seed_from_u64(7); + let mut second_rng = DeterministicRng::seed_from_u64(7); + + assert!(ClusterShuffle::new() + .apply(&mut first, &mut first_rng) + .unwrap()); + assert!(ClusterShuffle::new() + .apply(&mut second, &mut second_rng) + .unwrap()); + assert_eq!(first.layout_order(), second.layout_order()); + assert_eq!(first.layout_order().first(), original_order.first()); + assert_eq!(first.layout_order().last(), original_order.last()); + assert_ne!(first.layout_order(), original_order); + } + + #[test] + fn combined_cfg_keeps_each_executable_region_entry_fixed() { + let instructions = vec![ + instruction(0, Opcode::STOP, None), + instruction(1, Opcode::JUMPDEST, None), + instruction(2, Opcode::STOP, None), + instruction(3, Opcode::JUMPDEST, None), + instruction(4, Opcode::STOP, None), + instruction(5, Opcode::JUMPDEST, None), + instruction(6, Opcode::STOP, None), + ]; + let sections = [ + Section { + kind: SectionKind::Init, + offset: 0, + len: 1, + }, + Section { + kind: SectionKind::Runtime, + offset: 1, + len: 6, + }, + ]; + let mut bundle = build(&instructions, §ions, 7); + let original = bundle.layout_order().to_vec(); + let mut rng = DeterministicRng::seed_from_u64(11); + + assert!(ClusterShuffle::new().apply(&mut bundle, &mut rng).unwrap()); + assert_eq!(bundle.layout_order()[0], original[0]); + assert_eq!(bundle.layout_order()[1], original[1]); + assert_eq!(bundle.layout_order()[2..], [original[3], original[2]]); + for node in bundle.layout_order() { + assert!(matches!(bundle.cfg[*node], Block::Body(_))); + } + } + + #[test] + fn unresolved_dynamic_control_fails_closed_without_mutating_layout() { + let instructions = vec![instruction(0, Opcode::JUMP, None)]; + let sections = [Section { + kind: SectionKind::Runtime, + offset: 0, + len: 1, + }]; + let mut bundle = build(&instructions, §ions, 1); + let original = bundle.layout_order().to_vec(); + let mut rng = DeterministicRng::seed_from_u64(13); + + assert!(!ClusterShuffle::new().apply(&mut bundle, &mut rng).unwrap()); + assert_eq!(bundle.layout_order(), original); + assert!(!bundle.relationships().unresolved_control.is_empty()); } } diff --git a/crates/transforms/src/constructor_args.rs b/crates/transforms/src/constructor_args.rs index c5f91045..c3a67c04 100644 --- a/crates/transforms/src/constructor_args.rs +++ b/crates/transforms/src/constructor_args.rs @@ -9,12 +9,11 @@ use crate::arithmetic_chain::{compile_chain_inline, generate_chain, ChainConfig, ScatterStrategy}; use crate::{Error, Result}; +use azoth_core::seed::{DeterministicRng, Seed}; use azoth_core::strip::CleanReport; use azoth_core::{encoder, Opcode}; -use rand::rngs::StdRng; use rand::seq::SliceRandom; -use rand::{Rng, RngCore, SeedableRng}; -use sha3::{Digest, Sha3_256}; +use rand::{Rng, RngCore}; /// Measurements from constructor-argument obfuscation. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -92,11 +91,9 @@ pub fn obfuscate_constructor_args( let original_init = report.removed[init_index].data.to_vec(); let (copy_pc, destination_depth, block_end) = find_argument_copy(&original_init, creation_len)?; - let mut hasher = Sha3_256::new(); - hasher.update(b"AZOTH_CONSTRUCTOR_ARGUMENTS_V1"); - hasher.update(seed); - hasher.update((argument_bytes as u64).to_be_bytes()); - let mut rng = StdRng::from_seed(hasher.finalize().into()); + let mut rng_domain = b"AZOTH_CONSTRUCTOR_ARGUMENTS_V2".to_vec(); + rng_domain.extend_from_slice(&(argument_bytes as u64).to_be_bytes()); + let mut rng = Seed::from_bytes(*seed).derive_rng(&rng_domain); let original_args = report.removed[args_index].data.to_vec(); let chunks = argument_bytes.div_ceil(32); @@ -177,7 +174,7 @@ pub fn obfuscate_constructor_args( }) } -fn emit_stack_neutral_noise(out: &mut Vec, rng: &mut StdRng) { +fn emit_stack_neutral_noise(out: &mut Vec, rng: &mut DeterministicRng) { match rng.random_range(0..4) { 0 => {} 1 => { @@ -295,7 +292,7 @@ fn emit_chunk_decoder( destination_depth: usize, offset: usize, mask: [u8; 32], - rng: &mut StdRng, + rng: &mut DeterministicRng, ) -> Result<()> { let offset_first = offset > 0 && destination_depth < 16 && rng.random::(); if offset_first { @@ -323,7 +320,18 @@ fn emit_chunk_decoder( ); let mut chain = chain; chain.scatter_locations = vec![ScatterStrategy::Inline; chain.initial_values.len()]; - let instructions = compile_chain_inline(&chain); + let mut instructions = compile_chain_inline(&chain); + // The chain compiler returns a relocatable fragment. Canonicalize its local PCs before + // passing it across the encoder boundary, which deliberately rejects ambiguous IR. + let mut next_pc = 0usize; + for instruction in &mut instructions { + instruction.pc = next_pc; + next_pc = next_pc + .checked_add(instruction.byte_size()) + .ok_or_else(|| { + Error::EncodingError("constructor argument decoder is too large".into()) + })?; + } let encoded = encoder::encode(&instructions, &[]) .map_err(|error| Error::EncodingError(error.to_string()))?; out.extend_from_slice(&encoded); @@ -337,7 +345,7 @@ fn make_trampoline( pc: usize, target: usize, available: usize, - rng: &mut StdRng, + rng: &mut DeterministicRng, ) -> Result> { let mut variants = Vec::new(); if target <= u16::MAX as usize && available >= 4 { @@ -423,8 +431,8 @@ mod tests { use crate::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; use azoth_core::detection::locate_sections; use azoth_core::seed::Seed; - use azoth_core::strip::strip_bytecode; - use rand::RngCore; + use azoth_core::strip::{strip_bytecode, CleanReport}; + use rand::{RngCore, SeedableRng}; use revm::bytecode::Bytecode; use revm::context::result::{ExecutionResult, Output}; use revm::context::TxEnv; @@ -438,6 +446,13 @@ mod tests { include_str!("../../../examples/escrow-bytecode/artifacts/erc20_deployment.hex"); const ESCROW_RUNTIME: &str = include_str!("../../../examples/escrow-bytecode/artifacts/erc20_runtime.hex"); + // A compact, non-GAS constructor fixture. It copies the constructor suffix to memory 0x80, + // then deploys a fixed runtime. Both copies satisfy the production provenance contract; the + // constructor mask is nevertheless rejected because injecting its decoder would relocate the + // constructor's CODESIZE expression. + const SUPPORTED_RUNTIME: &str = "0x602a5f5260205ff3"; + const SUPPORTED_DEPLOYMENT: &str = + "0x608061002380380390823950600f565b600861001b5f3960085ff3602a5f5260205ff3"; const MOCK_TOKEN: Address = Address::new([0x11; 20]); fn argument_words(recipient: [u8; 20], amount: [u8; 32], payment: [u8; 32]) -> Vec { @@ -452,19 +467,39 @@ mod tests { args } - fn creation_with_args(args: &[u8]) -> Vec { + fn escrow_creation_with_args(args: &[u8]) -> Vec { let mut deployment = hex::decode(ESCROW_DEPLOYMENT.trim().trim_start_matches("0x")).unwrap(); deployment.extend_from_slice(args); deployment } - fn apply_mask(deployment: &[u8], seed: &Seed) -> (Vec, ConstructorArgsObfuscation) { - let runtime = hex::decode(ESCROW_RUNTIME.trim().trim_start_matches("0x")).unwrap(); + fn supported_creation_with_args(args: &[u8]) -> Vec { + let mut deployment = + hex::decode(SUPPORTED_DEPLOYMENT.trim().trim_start_matches("0x")).unwrap(); + deployment.extend_from_slice(args); + deployment + } + + fn mask_report( + deployment: &[u8], + seed: &Seed, + ) -> (Vec, CleanReport, ConstructorArgsObfuscation) { + let runtime = hex::decode(SUPPORTED_RUNTIME.trim().trim_start_matches("0x")).unwrap(); let sections = locate_sections(deployment, &[], &runtime).unwrap(); let (clean, mut report) = strip_bytecode(deployment, §ions).unwrap(); let metrics = obfuscate_constructor_args(&mut report, seed.as_bytes()).unwrap(); - (report.reassemble_checked(&clean).unwrap(), metrics) + (clean, report, metrics) + } + + fn masked_arguments(report: &CleanReport) -> Vec { + report + .removed + .iter() + .find(|removed| removed.kind == azoth_core::detection::SectionKind::ConstructorArgs) + .expect("fixture has constructor arguments") + .data + .to_vec() } fn deploy(bytecode: &[u8]) -> (Bytes, u64) { @@ -525,31 +560,35 @@ mod tests { } #[test] - fn masked_constructor_deploys_identical_runtime_without_plaintext_suffix() { + fn constructor_mask_generation_is_deterministic_but_reassembly_fails_closed() { let args = argument_words([0x22; 20], [0; 32], [0; 32]); - let original = creation_with_args(&args); + let original = supported_creation_with_args(&args); let seed = Seed::from_bytes([0x55; 32]); - let (masked, metrics) = apply_mask(&original, &seed); + let (clean, mut report, metrics) = mask_report(&original, &seed); + let masked = masked_arguments(&report); assert!(metrics.applied); assert_eq!(metrics.argument_bytes, args.len()); assert!(metrics.decoder_bytes > 0); - assert!( - !masked.windows(args.len()).any(|window| window == args), - "the ABI suffix must not survive verbatim" - ); + assert_ne!(masked, args, "the ABI suffix must not survive verbatim"); assert!(!masked.windows(5).any(|window| window == b"AZOTH")); - assert_eq!(deploy(&original).0, deploy(&masked).0); - - let (repeat, _) = apply_mask(&original, &seed); - assert_eq!(masked, repeat, "same seed must be deterministic"); - let (different, _) = apply_mask(&original, &Seed::from_bytes([0x56; 32])); + let error = report.reassemble_checked(&clean).unwrap_err(); + assert!(error.contains("observes CODESIZE"), "{error}"); + + let (_, repeat_report, _) = mask_report(&original, &seed); + assert_eq!( + masked, + masked_arguments(&repeat_report), + "same seed must be deterministic" + ); + let (_, different_report, _) = mask_report(&original, &Seed::from_bytes([0x56; 32])); + let different = masked_arguments(&different_report); assert_ne!(masked, different, "different seeds must vary the payload"); } #[test] - fn fuzzed_constructor_values_preserve_deployed_runtime() { - let mut rng = StdRng::seed_from_u64(0xA207_2026); + fn fuzzed_constructor_masks_fail_closed_during_reassembly() { + let mut rng = DeterministicRng::seed_from_u64(0xA207_2026); for case in 0..64u64 { let mut recipient = [0u8; 20]; let mut amount = [0u8; 32]; @@ -571,130 +610,110 @@ mod tests { let mut trailing = vec![0u8; trailing_len]; rng.fill_bytes(&mut trailing); args.extend_from_slice(&trailing); - let original = creation_with_args(&args); - let (masked, metrics) = apply_mask(&original, &Seed::from_bytes(seed)); + let original = supported_creation_with_args(&args); + let (clean, mut report, metrics) = mask_report(&original, &Seed::from_bytes(seed)); + let masked = masked_arguments(&report); assert!(metrics.applied, "case {case}"); assert_eq!(metrics.argument_bytes, args.len(), "case {case}"); - assert!( - !masked.windows(args.len()).any(|window| window == args), - "plaintext suffix survived fuzz case {case}" - ); - assert_eq!( - deploy(&original).0, - deploy(&masked).0, - "deployed runtime mismatch in fuzz case {case}" - ); + assert_ne!(masked, args, "plaintext suffix survived fuzz case {case}"); + let error = report.reassemble_checked(&clean).unwrap_err(); + assert!(error.contains("observes CODESIZE"), "case {case}: {error}"); } } #[tokio::test] - async fn full_pipeline_preserves_constructor_initialized_runtime() { - let recipient = [0x22; 20]; - let amount = [0x33; 32]; - let args = argument_words(recipient, amount, [0; 32]); - let full_creation = creation_with_args(&args); + async fn full_pipeline_rejects_constructor_mask_that_would_relocate_codesize() { + let mut args = [0x33; 32].to_vec(); + args.extend_from_slice(&[0x44; 32]); + let full_creation = supported_creation_with_args(&args); let full_hex = format!("0x{}", hex::encode(&full_creation)); let seed = Seed::from_bytes([0x77; 32]); - - let protected = obfuscate_bytecode( - &full_hex, - ESCROW_RUNTIME, - ObfuscationConfig::with_seed(seed), - ) - .await - .unwrap(); - let protected_creation = - hex::decode(protected.obfuscated_bytecode.trim_start_matches("0x")).unwrap(); - - assert!(protected.metadata.constructor_args_obfuscated); - assert_eq!(protected.metadata.constructor_argument_bytes, args.len()); - assert!(!protected_creation - .windows(args.len()) - .any(|window| window == args)); - assert!(!protected_creation - .windows(recipient.len()) - .any(|window| window == recipient)); - assert!(!protected_creation - .windows(amount.len()) - .any(|window| window == amount)); - let protected_runtime = deploy(&protected_creation).0; - assert!( - protected_runtime - .windows(recipient.len()) - .any(|window| window == recipient), - "decoded recipient must be written into transformed immutable references" - ); - assert!( - protected_runtime - .windows(amount.len()) - .any(|window| window == amount), - "decoded amount must be written into transformed immutable references" + let mut config = ObfuscationConfig::with_seed(seed); + config.obfuscate_constructor_arguments = true; + + let error = obfuscate_bytecode(&full_hex, SUPPORTED_RUNTIME, config) + .await + .expect_err("moving a CODESIZE-observing init program must fail closed"); + assert!(error.message.contains("observes CODESIZE"), "{error:?}"); + + // The synthetic fixture itself is executable and deploys the supplied runtime; + // rejection is caused by the relocation proof boundary, not malformed test bytecode. + let original_runtime = deploy(&full_creation).0; + assert_eq!( + original_runtime.as_ref(), + hex::decode(SUPPORTED_RUNTIME.trim_start_matches("0x")).unwrap() ); } #[tokio::test] - async fn full_pipeline_rejects_oversized_decoded_creation_payload() { - let mut args = argument_words([0x22; 20], [0x33; 32], [0; 32]); - args.resize(10_000, 0x5a); - let full_hex = format!("0x{}", hex::encode(creation_with_args(&args))); - - let error = obfuscate_bytecode( - &full_hex, - ESCROW_RUNTIME, - ObfuscationConfig::with_seed(Seed::from_bytes([0x88; 32])), - ) - .await - .unwrap_err(); + async fn full_pipeline_suppresses_constructor_mask_when_init_observes_gas() { + let args = argument_words([0x22; 20], [0x33; 32], [0; 32]); + let full_creation = escrow_creation_with_args(&args); + let full_hex = format!("0x{}", hex::encode(&full_creation)); + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes([0x78; 32])); + config.obfuscate_constructor_arguments = true; - assert!(error.message.contains("exceeds an EVM size limit")); + let protected = obfuscate_bytecode(&full_hex, ESCROW_RUNTIME, config) + .await + .expect("GAS-observing init must conservatively produce an identity artifact"); + + assert_eq!(protected.obfuscated_bytecode, full_hex); + assert!(!protected.metadata.constructor_args_obfuscated); + assert_eq!(protected.metadata.constructor_argument_bytes, 0); + } + + #[tokio::test] + async fn oversized_constructor_mask_fails_closed() { + let mut args = vec![0x5a; 32]; + args.resize(20_000, 0x5a); + let full_hex = format!("0x{}", hex::encode(supported_creation_with_args(&args))); + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes([0x88; 32])); + config.obfuscate_constructor_arguments = true; + + let error = obfuscate_bytecode(&full_hex, SUPPORTED_RUNTIME, config) + .await + .unwrap_err(); + + assert!( + error.message.contains("exceeds an EVM size limit") + || error.message.contains("exceeds PUSH2 capacity") + || error.message.contains("observes CODESIZE"), + "unexpected fail-closed error: {}", + error.message + ); } #[test] #[ignore = "release-mode benchmark; run explicitly with --ignored --nocapture"] - fn benchmark_constructor_argument_obfuscation() { + fn benchmark_constructor_argument_mask_generation() { let args = argument_words([0x22; 20], [0x33; 32], [0x44; 32]); - let original = creation_with_args(&args); - let (original_runtime, original_gas) = deploy(&original); + let original = supported_creation_with_args(&args); let iterations = 100u64; let started = Instant::now(); let mut decoder_bytes = 0usize; let mut min_decoder = usize::MAX; let mut max_decoder = 0usize; - let mut representative = None; + let mut representative_mask = None; for index in 0..iterations { let mut seed = [0u8; 32]; seed[..8].copy_from_slice(&index.to_be_bytes()); - let (masked, metrics) = apply_mask(&original, &Seed::from_bytes(seed)); + let (clean, mut report, metrics) = mask_report(&original, &Seed::from_bytes(seed)); decoder_bytes += metrics.decoder_bytes; min_decoder = min_decoder.min(metrics.decoder_bytes); max_decoder = max_decoder.max(metrics.decoder_bytes); - representative.get_or_insert(masked); + representative_mask.get_or_insert_with(|| masked_arguments(&report)); + let error = report.reassemble_checked(&clean).unwrap_err(); + assert!(error.contains("observes CODESIZE")); } let elapsed = started.elapsed(); - let masked = representative.unwrap(); - let (masked_runtime, masked_gas) = deploy(&masked); - assert_eq!(original_runtime, masked_runtime); - - let calldata_gas = |bytes: &[u8]| -> u64 { - bytes - .iter() - .map(|byte| if *byte == 0 { 4 } else { 16 }) - .sum() - }; + let masked = representative_mask.unwrap(); println!( - "BENCH original_bytes={} masked_bytes={} delta_bytes={} original_deploy_gas={} \ - masked_deploy_gas={} delta_deploy_gas={} original_calldata_gas={} \ - masked_calldata_gas={} avg_decoder_bytes={:.1} min_decoder_bytes={} \ + "MASK_GENERATION argument_bytes={} representative_mask_bytes={} \ + avg_decoder_bytes={:.1} min_decoder_bytes={} \ max_decoder_bytes={} avg_transform_us={:.1}", - original.len(), + args.len(), masked.len(), - masked.len() as i64 - original.len() as i64, - original_gas, - masked_gas, - masked_gas as i64 - original_gas as i64, - calldata_gas(&original), - calldata_gas(&masked), decoder_bytes as f64 / iterations as f64, min_decoder, max_decoder, diff --git a/crates/transforms/src/function_dispatcher/mod.rs b/crates/transforms/src/function_dispatcher/mod.rs index 27f8f30f..12e58d53 100644 --- a/crates/transforms/src/function_dispatcher/mod.rs +++ b/crates/transforms/src/function_dispatcher/mod.rs @@ -1,5 +1,8 @@ //! Function dispatcher transform. +// Retained as research-only source for future redesign. The production and explicit selector +// relabel paths deliberately do not synthesize these recognizable controller/decoy patterns. +#[allow(dead_code)] mod patterns; pub(crate) mod token; @@ -8,11 +11,10 @@ use crate::{Error, Result, Transform}; use azoth_core::cfg_ir::{Block, CfgIrBundle}; use azoth_core::decoder::Instruction; use azoth_core::detection::{detect_function_dispatcher, DispatcherInfo}; -use azoth_core::seed::Seed; +use azoth_core::seed::{DeterministicRng, Seed}; use azoth_core::Opcode; use petgraph::graph::NodeIndex; -use rand::rngs::StdRng; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tracing::debug; #[derive(Default)] @@ -36,6 +38,7 @@ impl FunctionDispatcher { } } + #[allow(dead_code)] pub(crate) fn seed(&self) -> Option<&Seed> { self.seed.as_ref() } @@ -253,6 +256,7 @@ impl FunctionDispatcher { } /// Syncs internal CALL sites with dispatcher tokens so remapped selectors still fire. + #[allow(dead_code)] fn update_internal_calls( &self, ir: &mut CfgIrBundle, @@ -342,7 +346,7 @@ impl FunctionDispatcher { return Err(Error::Generic(format!( "dispatcher: instruction at pc {} not found in CFG", instruction.pc - ))) + ))); } }; @@ -378,7 +382,7 @@ impl Transform for FunctionDispatcher { "FunctionDispatcher" } - fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + fn apply(&self, ir: &mut CfgIrBundle, _rng: &mut DeterministicRng) -> Result { let (runtime_instructions, index_by_pc) = self.collect_runtime_instructions(ir); if runtime_instructions.is_empty() { debug!("No runtime instructions available; skipping dispatcher transform"); @@ -398,86 +402,57 @@ impl Transform for FunctionDispatcher { return Ok(false); } - let runtime_len = if let Some((start, end)) = ir.runtime_bounds { - end.saturating_sub(start) - } else { - runtime_instructions - .last() - .map(|instr| instr.pc + instr.byte_size()) - .unwrap_or(0) - }; - let selector_count = dispatcher_info.selectors.len(); - let lightweight_dispatcher = selector_count <= 2 || runtime_len <= 96; - - if lightweight_dispatcher { + // Keep the compiler's native dispatcher shape. Synthesized decoy/controller tails are a + // cheap family signature and can depend on storage slots that the original contract owns. + // Selector relabeling changes the private interface without adding an opcode motif. + let selector_values: HashSet<_> = dispatcher_info + .selectors + .iter() + .map(|selector| selector.selector) + .collect(); + let dispatcher_pcs: HashSet<_> = dispatcher_info + .selectors + .iter() + .filter_map(|selector| runtime_instructions.get(selector.instruction_index)) + .map(|instruction| instruction.pc) + .collect(); + let duplicated_selector = runtime_instructions.iter().any(|instruction| { + if dispatcher_pcs.contains(&instruction.pc) { + return false; + } + let Opcode::PUSH(4) = instruction.op else { + return false; + }; + instruction + .imm + .as_deref() + .and_then(|immediate| u32::from_str_radix(immediate, 16).ok()) + .is_some_and(|value| selector_values.contains(&value)) + }); + if duplicated_selector { debug!( - runtime_len, - selectors = selector_count, - "Using lightweight dispatcher obfuscation path" + "Selector literal is used outside the dispatcher; refusing partial interface rewrite" ); - let preserve_bytes = HashMap::new(); - let seed = self.seed.as_ref().ok_or_else(|| { - Error::Generic("dispatcher: seed required for token mapping".into()) - })?; - let mapping = - generate_selector_token_mapping(&dispatcher_info.selectors, seed, &preserve_bytes)?; - if self.apply_dispatcher_patches( - ir, - &runtime_instructions, - &index_by_pc, - &dispatcher_info, - &mapping, - )? { - ir.selector_mapping = Some(mapping); - return Ok(true); - } else { - return Ok(false); - } + return Ok(false); } - let blueprint = self.build_blueprint(&dispatcher_info, rng); - let original_selector_count = blueprint.dispatcher.selectors.len(); - let selector_assignment_count = blueprint.selectors.len(); - let tier_count = blueprint - .selectors - .iter() - .map(|assignment| assignment.tier_index + 1) - .max() - .unwrap_or(0); - debug!( - tiers = tier_count, - selectors = original_selector_count, - assignments = selector_assignment_count, - "Prepared multi-tier dispatcher blueprint" - ); - - let Some(plan) = self.apply_layout_plan( + let preserve_bytes = HashMap::new(); + let seed = self + .seed + .as_ref() + .ok_or_else(|| Error::Generic("dispatcher: seed required for token mapping".into()))?; + let mapping = + generate_selector_token_mapping(&dispatcher_info.selectors, seed, &preserve_bytes)?; + let changed = self.apply_dispatcher_patches( ir, &runtime_instructions, &index_by_pc, &dispatcher_info, - &blueprint, - )? - else { - debug!("Multi-tier dispatcher layout not applied; skipping transform"); - return Ok(false); - }; - - let calls_modified = self.update_internal_calls(ir, &plan.mapping)?; - - if plan.dispatcher_modified || calls_modified { - ir.selector_mapping = Some(plan.mapping); - // Store dispatcher patch info for post-reindex patching - ir.dispatcher_controller_pcs = Some(plan.controller_pcs); - ir.dispatcher_patches = Some(plan.dispatcher_patches); - ir.stub_patches = Some(plan.stub_patches); - ir.decoy_patches = Some(plan.decoy_patches); - ir.controller_patches = Some(plan.controller_patches); - debug!("Function dispatcher obfuscated via multi-tier layout"); - Ok(true) - } else { - debug!("Dispatcher mapping produced no changes"); - Ok(false) + &mapping, + )?; + if changed { + ir.selector_mapping = Some(mapping); } + Ok(changed) } } diff --git a/crates/transforms/src/function_dispatcher/patterns/layout.rs b/crates/transforms/src/function_dispatcher/patterns/layout.rs index 28d3faf1..5a3165ad 100644 --- a/crates/transforms/src/function_dispatcher/patterns/layout.rs +++ b/crates/transforms/src/function_dispatcher/patterns/layout.rs @@ -310,11 +310,12 @@ fn create_tier_nodes( Instruction { pc: invalid_start + 1, op: Opcode::INVALID, - imm: Some("fe".to_string()), // Ensure it encodes as 0xfe, not a random byte + imm: None, }, ], max_stack: 0, control: BlockControl::Terminal, + section: azoth_core::detection::SectionKind::Runtime, }; next_pc += 2; let invalid_node = ir.add_block(Block::Body(invalid_block)); @@ -392,6 +393,7 @@ fn create_tier_nodes( instructions: decoy_instructions, max_stack: 2, control: BlockControl::Unknown, + section: azoth_core::detection::SectionKind::Runtime, }; let decoy_node = ir.add_block(Block::Body(decoy_block)); ir.pc_to_block.insert(decoy_start, decoy_node); @@ -434,6 +436,7 @@ fn create_tier_nodes( instructions: stub_instructions, max_stack: 1, control: BlockControl::Unknown, + section: azoth_core::detection::SectionKind::Runtime, }; let stub_node = ir.add_block(Block::Body(stub_block)); ir.pc_to_block.insert(stub_start, stub_node); @@ -637,6 +640,7 @@ fn create_selector_controller( instructions: instructions.clone(), max_stack: 2, control: BlockControl::Unknown, + section: azoth_core::detection::SectionKind::Runtime, }; debug!( @@ -678,15 +682,14 @@ fn locate_target_push(runtime: &[Instruction], selector: &FunctionSelector) -> O { match instr.op { Opcode::JUMPI => break, - Opcode::PUSH(_) | Opcode::PUSH0 => { + Opcode::PUSH(_) | Opcode::PUSH0 if instr .imm .as_ref() .and_then(|imm| usize::from_str_radix(imm, 16).ok()) - == Some(target) - { - return Some(idx); - } + == Some(target) => + { + return Some(idx); } _ => {} } diff --git a/crates/transforms/src/function_dispatcher/patterns/mod.rs b/crates/transforms/src/function_dispatcher/patterns/mod.rs index 1cc38ef7..a0eb6b76 100644 --- a/crates/transforms/src/function_dispatcher/patterns/mod.rs +++ b/crates/transforms/src/function_dispatcher/patterns/mod.rs @@ -15,15 +15,15 @@ use super::FunctionDispatcher; use azoth_core::cfg_ir::CfgIrBundle; use azoth_core::decoder::Instruction; use azoth_core::detection::DispatcherInfo; +use azoth_core::seed::DeterministicRng; use petgraph::graph::NodeIndex; -use rand::rngs::StdRng; use std::collections::HashMap; impl FunctionDispatcher { pub(crate) fn build_blueprint( &self, dispatcher: &DispatcherInfo, - rng: &mut StdRng, + rng: &mut DeterministicRng, ) -> DispatcherBlueprint { blueprint::build_blueprint(dispatcher, rng) } diff --git a/crates/transforms/src/function_dispatcher/token.rs b/crates/transforms/src/function_dispatcher/token.rs index 5d10cca6..e606e7aa 100644 --- a/crates/transforms/src/function_dispatcher/token.rs +++ b/crates/transforms/src/function_dispatcher/token.rs @@ -30,7 +30,12 @@ pub fn generate_selector_token_mapping( let secret: [u8; 32] = hasher.finalize().into(); let mut mapping = HashMap::with_capacity(selectors.len()); - let mut used_tokens = HashSet::with_capacity(selectors.len()); + // Reserve the complete original selector set before deriving any replacement. Merely + // checking `candidate != selector` is insufficient: a token for function A could otherwise + // equal function B's original selector. That would create duplicate dispatcher cases and + // make even the documented selector adapter ambiguous. + let mut used_tokens: HashSet = + selectors.iter().map(|selector| selector.selector).collect(); // First validate all byte indices for (selector_val, &byte_index) in preserve_bytes.iter() { @@ -249,6 +254,55 @@ mod tests { } } + #[test] + fn generated_tokens_are_disjoint_from_the_complete_original_set() { + let seed = Seed::from_bytes([0x5a; 32]); + let first_selector = FunctionSelector { + selector: 0x11223344, + instruction_index: 0, + target_address: 0x100, + }; + + // Construct a second original selector that is exactly the first selector's preferred + // token. This makes the cross-selector collision deterministic instead of relying on a + // probabilistic search in the test. + let singleton = generate_selector_token_mapping( + std::slice::from_ref(&first_selector), + &seed, + &HashMap::new(), + ) + .expect("singleton token"); + let candidate = singleton[&first_selector.selector].as_slice(); + let colliding_selector = + u32::from_be_bytes([candidate[0], candidate[1], candidate[2], candidate[3]]); + assert_ne!(colliding_selector, first_selector.selector); + + let selectors = vec![ + first_selector, + FunctionSelector { + selector: colliding_selector, + instruction_index: 10, + target_address: 0x200, + }, + ]; + let mapping = generate_selector_token_mapping(&selectors, &seed, &HashMap::new()) + .expect("collision-free mapping"); + let originals: HashSet = selectors.iter().map(|item| item.selector).collect(); + + for replacement in mapping.values() { + let replacement = u32::from_be_bytes([ + replacement[0], + replacement[1], + replacement[2], + replacement[3], + ]); + assert!( + !originals.contains(&replacement), + "replacement selector must not collide with any original selector" + ); + } + } + #[test] fn test_empty_selectors() { let selectors = vec![]; diff --git a/crates/transforms/src/jump_address_transformer.rs b/crates/transforms/src/jump_address_transformer.rs index f034a1b9..a2c40280 100644 --- a/crates/transforms/src/jump_address_transformer.rs +++ b/crates/transforms/src/jump_address_transformer.rs @@ -1,10 +1,11 @@ use crate::{Error, Result, Transform}; use azoth_core::cfg_ir::{Block, CfgIrBundle}; use azoth_core::decoder::Instruction; +use azoth_core::seed::DeterministicRng; use azoth_core::Opcode; use petgraph::graph::NodeIndex; use rand::seq::SliceRandom; -use rand::{rngs::StdRng, Rng}; +use rand::Rng; use tracing::debug; /// Jump Address Transformer obfuscates JUMP/JUMPI targets by splitting addresses @@ -42,7 +43,7 @@ impl JumpAddressTransformer { } /// Splits a jump target into two values that add up to the original - pub fn split_jump_target(&self, target: u64, rng: &mut StdRng) -> (u64, u64) { + pub fn split_jump_target(&self, target: u64, rng: &mut DeterministicRng) -> (u64, u64) { // Generate a random value less than the target let split_point = if target > 1 { rng.random_range(1..target) @@ -67,7 +68,7 @@ impl Transform for JumpAddressTransformer { "JumpAddressTransformer" } - fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut DeterministicRng) -> Result { debug!("=== JumpAddressTransformer Transform Start ==="); let mut changed = false; diff --git a/crates/transforms/src/lib.rs b/crates/transforms/src/lib.rs index bba9e70f..c887a164 100644 --- a/crates/transforms/src/lib.rs +++ b/crates/transforms/src/lib.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + pub mod arithmetic_chain; pub mod cluster_shuffle; pub mod constructor_args; @@ -13,9 +15,9 @@ pub mod storage_gates; pub mod string_obfuscate; use azoth_core::cfg_ir::CfgIrBundle; +use azoth_core::seed::DeterministicRng; use azoth_core::Opcode; use petgraph::graph::NodeIndex; -use rand::rngs::StdRng; use std::collections::HashSet; use thiserror::Error; @@ -50,8 +52,17 @@ pub type Result = std::result::Result; pub trait Transform: Send + Sync { /// Returns the transform's name for logging and identification. fn name(&self) -> &'static str; + /// Returns a canonical identifier for this transform instance's behavior-affecting options. + /// + /// Parameterless transforms may use this default. A transform with configurable behavior must + /// override it and encode every option deterministically. Implementation changes are protocol + /// changes and still require a pipeline-profile bump even when the instance options are + /// unchanged. + fn configuration_id(&self) -> String { + format!("{}@parameterless-v1", self.name()) + } /// Applies the transform to the CFG IR, returning whether changes were made. - fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result; + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut DeterministicRng) -> Result; } /// Parses a PUSH opcode string and returns the corresponding Opcode enum and immediate size. diff --git a/crates/transforms/src/obfuscator.rs b/crates/transforms/src/obfuscator.rs index e363c518..779dc48d 100644 --- a/crates/transforms/src/obfuscator.rs +++ b/crates/transforms/src/obfuscator.rs @@ -1,21 +1,22 @@ -use crate::arithmetic_chain::ArithmeticChain; -use crate::constructor_args::obfuscate_constructor_args; +use crate::cluster_shuffle::ClusterShuffle; +use crate::constructor_args::{obfuscate_constructor_args, ConstructorArgsObfuscation}; use crate::function_dispatcher::FunctionDispatcher; -use crate::push_split::PushSplit; -use crate::slot_shuffle::SlotShuffle; -use crate::string_obfuscate::StringObfuscate; use crate::Transform; -use azoth_core::seed::Seed; +use azoth_core::seed::{DeterministicRng, Seed}; use azoth_core::{ cfg_ir::{self, snapshot_bundle_with_runtime, Block, CfgIrDiff, OperationKind, TraceEvent}, - decoder, detection, encoder, process_bytecode_to_cfg, validator, Opcode, + decoder, detection, encoder, is_terminal_opcode, process_bytecode_to_cfg, validator, Opcode, }; -use serde::{Deserialize, Serialize}; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize, Serializer}; use serde_json::json; -use std::collections::{HashMap, HashSet}; +use sha3::{Digest, Keccak256, Sha3_256}; +use std::collections::{BTreeMap, HashMap, HashSet}; const MAX_INITCODE_SIZE: usize = 49_152; const MAX_RUNTIME_CODE_SIZE: usize = 24_576; +const PIPELINE_PROFILE: &[u8] = b"azoth-foundation-v4"; +type HmacSha3_256 = Hmac; /// Error from the obfuscation pipeline, including a partial trace for debugging. #[derive(Debug)] @@ -51,6 +52,17 @@ pub struct ObfuscationConfig { pub transforms: Vec>, /// Whether to preserve unknown opcodes pub preserve_unknown_opcodes: bool, + /// Whether to relabel selectors in a detected native dispatcher. + /// + /// Disabled in the production profile because adapted calldata changes observable `msg.sig` + /// and `msg.data` unless whole-program selector-taint analysis proves they are not consumed. + pub rewrite_function_selectors: bool, + /// Whether to rewrite an exactly located constructor-argument suffix. + /// + /// This is disabled in the production profile: the current decoder supports only a subset of + /// compiler init-code shapes and its normalization resistance has not passed the red-team + /// gate. Library callers may enable it explicitly for experimental evaluation. + pub obfuscate_constructor_arguments: bool, } impl ObfuscationConfig { @@ -58,23 +70,10 @@ impl ObfuscationConfig { pub fn with_seed(seed: Seed) -> Self { Self { seed, - transforms: Vec::new(), - preserve_unknown_opcodes: true, - } - } -} - -impl Default for ObfuscationConfig { - fn default() -> Self { - Self { - seed: Seed::generate(), - transforms: vec![ - Box::new(ArithmeticChain::new()), - Box::new(PushSplit::new()), - Box::new(SlotShuffle::new()), - Box::new(StringObfuscate::new()), - ], + transforms: vec![Box::new(ClusterShuffle::new())], preserve_unknown_opcodes: true, + rewrite_function_selectors: false, + obfuscate_constructor_arguments: false, } } } @@ -87,6 +86,14 @@ impl std::fmt::Debug for ObfuscationConfig { &format!("{} transforms", self.transforms.len()), ) .field("preserve_unknown_opcodes", &self.preserve_unknown_opcodes) + .field( + "rewrite_function_selectors", + &self.rewrite_function_selectors, + ) + .field( + "obfuscate_constructor_arguments", + &self.obfuscate_constructor_arguments, + ) .finish() } } @@ -117,7 +124,10 @@ pub struct ObfuscationResult { /// Metadata about the obfuscation process pub metadata: ObfuscationMetadata, /// Mapping from original selectors to tokens (if token dispatcher was applied) + #[serde(serialize_with = "serialize_optional_selector_mapping")] pub selector_mapping: Option>>, + /// Canonical, seed-bound integrity information for independent replay checks. + pub integrity: IntegrityManifest, /// Trace of CFG operations captured during obfuscation #[serde(default, skip_serializing_if = "Vec::is_empty")] pub trace: Vec, @@ -142,6 +152,174 @@ pub struct ObfuscationMetadata { pub constructor_decoder_bytes: usize, } +/// Canonical integrity information bound to one deterministic pipeline result. +/// +/// This manifest contains no private seed. A party that already possesses the source bytecode +/// and seed can rerun Azoth and compare every hash and the authenticated reproduction tag. The +/// tag is a seed-derived MAC, not a public signature or a proof of semantic equivalence. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct IntegrityManifest { + /// Manifest schema version. + pub schema_version: u32, + /// Versioned transformation profile used by the deterministic KDF. + pub pipeline_profile: String, + /// Canonical configuration needed to replay this profile exactly. + pub pipeline_configuration: PipelineConfigurationManifest, + /// Commitment to the private seed, without disclosing it. + pub seed_commitment: String, + /// Keccak-256 of the canonical input creation payload. + pub input_deployment_keccak256: String, + /// Keccak-256 of the caller-supplied runtime artifact. + pub input_runtime_keccak256: String, + /// Keccak-256 of the transformed creation payload. + pub output_deployment_keccak256: String, + /// Keccak-256 of the complete transformed runtime *template*, including compiler auxdata. + /// This does not claim to be the hash of materialized deployed code: constructor execution + /// can replace compiler immutable placeholders. Authenticate such code by deterministically + /// replaying the creation transaction in the same environment and comparing its code hash. + pub output_runtime_keccak256: String, + /// Ordered list of transforms that committed a change. + pub transforms_applied: Vec, + /// Commitment to the canonical selector map, when an interface rewrite was performed. + pub selector_mapping_commitment: Option, + /// Domain-separated HMAC-SHA3-256 tag binding all fields above to one replay result. + pub reproduction_commitment: String, +} + +/// Public, authenticated description of every pipeline choice that can affect replay. +/// +/// `transforms_applied` records only passes that committed a bytecode change. This structure also +/// records requested passes that deterministically became no-ops, plus feature switches, so two +/// different library configurations cannot silently claim the same replay recipe. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PipelineConfigurationManifest { + /// User-requested transforms, in invocation order. Repeated recipes remain repeated. + pub requested_transforms: Vec, + pub preserve_unknown_opcodes: bool, + pub rewrite_function_selectors: bool, + pub obfuscate_constructor_arguments: bool, +} + +/// Canonical identity of one requested transform instance. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TransformRecipeManifest { + pub name: String, + /// Versioned identifier returned by [`Transform::configuration_id`]. + pub configuration_id: String, +} + +/// Off-chain interaction guide intended for seed-authorized users and nodes. +/// +/// Publishing this object would disclose the original-selector to transformed-selector +/// association. It is therefore returned to the caller and emitted only on explicit CLI request; +/// it is never embedded in contract bytecode. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PrivateInteractionManifest { + pub integrity: IntegrityManifest, + /// Canonically ordered `0x` to `0x` mapping. + pub selector_mapping: BTreeMap, + /// Exact calldata adaptation rule for ordinary Solidity ABI calls. + pub calldata_rule: String, +} + +impl ObfuscationResult { + /// Builds the private, canonically serialized interaction guide for this result. + pub fn private_interaction_manifest(&self) -> PrivateInteractionManifest { + let selector_mapping = canonical_selector_mapping(self.selector_mapping.as_ref()); + let calldata_rule = canonical_calldata_rule(&selector_mapping); + PrivateInteractionManifest { + integrity: self.integrity.clone(), + selector_mapping, + calldata_rule: calldata_rule.to_string(), + } + } + + /// Verifies that this result and its manifest are bound to the supplied private inputs. + /// + /// This authenticates artifact integrity; it does not prove EVM semantic equivalence. The + /// caller must keep `seed` private and separately run the behavioral/formal release gates. + pub fn verify_integrity( + &self, + input_deployment: &[u8], + input_runtime: &[u8], + seed: &Seed, + ) -> Result<(), String> { + let output_deployment = decode_result_hex(&self.obfuscated_bytecode)?; + let output_runtime = decode_result_hex(&self.obfuscated_runtime)?; + if self.integrity.transforms_applied != self.metadata.transforms_applied { + return Err("transform list mismatch".to_string()); + } + if self + .integrity + .pipeline_configuration + .preserve_unknown_opcodes + != self.metadata.unknown_opcodes_preserved + { + return Err("unknown-opcode policy mismatch".to_string()); + } + if self.metadata.constructor_args_obfuscated + && !self + .integrity + .pipeline_configuration + .obfuscate_constructor_arguments + { + return Err("constructor-argument policy mismatch".to_string()); + } + + let canonical_mapping = canonical_selector_mapping(self.selector_mapping.as_ref()); + let expected_mapping_commitment = (!canonical_mapping.is_empty()).then(|| { + keccak_hex( + &serde_json::to_vec(&canonical_mapping) + .expect("BTreeMap serialization is infallible"), + ) + }); + verify_manifest_integrity( + &self.integrity, + input_deployment, + input_runtime, + &output_deployment, + &output_runtime, + seed, + expected_mapping_commitment.as_deref(), + ) + } +} + +impl PrivateInteractionManifest { + /// Authenticates an emitted private guide against the private inputs and output artifacts. + /// + /// This is intentionally independent of [`ObfuscationResult`], because the CLI emits the + /// guide as a standalone file. The calldata rule is required to be the canonical rule derived + /// from the authenticated mapping, so tampering with either field fails closed. + pub fn verify_integrity( + &self, + input_deployment: &[u8], + input_runtime: &[u8], + output_deployment: &[u8], + output_runtime: &[u8], + seed: &Seed, + ) -> Result<(), String> { + if self.calldata_rule != canonical_calldata_rule(&self.selector_mapping) { + return Err("non-canonical calldata adaptation rule".to_string()); + } + let mapping_commitment = (!self.selector_mapping.is_empty()).then(|| { + keccak_hex( + &serde_json::to_vec(&self.selector_mapping) + .expect("BTreeMap serialization is infallible"), + ) + }); + verify_manifest_integrity( + &self.integrity, + input_deployment, + input_runtime, + output_deployment, + output_runtime, + seed, + mapping_commitment.as_deref(), + ) + } +} + /// Main obfuscation pipeline pub async fn obfuscate_bytecode( deployment_bytecode: &str, @@ -160,13 +338,139 @@ pub async fn obfuscate_bytecode( trace: Vec::new(), })?; let original_size = bytes.len(); + let normalized_runtime = azoth_core::normalize_hex_string(runtime_bytecode) + .map_err(|error| ObfuscationError::from_err(error, &cfg_ir.trace))?; + let original_runtime_bytes = hex::decode(normalized_runtime) + .map_err(|error| ObfuscationError::from_err(error, &cfg_ir.trace))?; + // Preserve the exact, unlinked runtime code template used to build the CFG. Immutable + // relocation later authenticates references against its PUSH32-zero placeholders rather than + // guessing from constructor arithmetic. Runtime spans are normally singular, but concatenate + // them deterministically so the invariant remains explicit. + let mut original_runtime_spans = cfg_ir.clean_report.runtime_layout.clone(); + original_runtime_spans.sort_by_key(|span| span.offset); + let mut original_clean_runtime = Vec::with_capacity(cfg_ir.clean_report.clean_len); + for span in original_runtime_spans { + let end = span.offset.checked_add(span.len).ok_or_else(|| { + ObfuscationError::from_err("original runtime span overflow", &cfg_ir.trace) + })?; + let runtime_span = bytes.get(span.offset..end).ok_or_else(|| { + ObfuscationError::from_err("original runtime span is out of bounds", &cfg_ir.trace) + })?; + original_clean_runtime.extend_from_slice(runtime_span); + } + if original_clean_runtime.len() != cfg_ir.clean_report.clean_len { + return Err(ObfuscationError::from_err( + format!( + "original clean runtime length mismatch: report={}, reconstructed={}", + cfg_ir.clean_report.clean_len, + original_clean_runtime.len() + ), + &cfg_ir.trace, + )); + } + let has_init_section = cfg_ir + .clean_report + .removed + .iter() + .any(|removed| removed.kind == detection::SectionKind::Init); + if !has_init_section && bytes != original_runtime_bytes { + return Err(ObfuscationError::from_err( + "deployment differs from supplied runtime but has no proven init section", + &cfg_ir.trace, + )); + } + cfg_ir + .clean_report + .validate_init_runtime_contract(&original_clean_runtime) + .map_err(|error| ObfuscationError::from_err(error, &cfg_ir.trace))?; + // Changing even a single zero/nonzero init-code byte changes the creation transaction's + // intrinsic gas. A constructor that executes GAS can observe that difference or forward it + // to an external call. Until a layout solver preserves the complete creation gas schedule, + // every mutating pass is conservatively discarded for such inputs. + let init_observes_gas = instructions.iter().any(|instruction| { + matches!(instruction.op, Opcode::GAS) + && sections.iter().any(|section| { + section.kind == detection::SectionKind::Init + && instruction.pc >= section.offset + && instruction.pc < section.offset.saturating_add(section.len) + }) + }); + // Section detection is a parsing aid, not a proof that a trailing region is unreachable. + // Snapshot compiler-suffix candidates so finalization can enforce that no pass rewrites + // bytes which may still be executable or code-observable. + let original_deployed_suffixes: Vec<_> = cfg_ir + .clean_report + .removed + .iter() + .filter(|removed| { + matches!( + removed.kind, + detection::SectionKind::Auxdata | detection::SectionKind::Padding + ) + }) + .map(|removed| (removed.kind, removed.offset, removed.data.to_vec())) + .collect(); + let pipeline_seed = derive_pipeline_seed(&config.seed, &bytes, &original_runtime_bytes); + let pipeline_configuration = PipelineConfigurationManifest { + requested_transforms: config + .transforms + .iter() + .map(|transform| TransformRecipeManifest { + name: transform.name().to_string(), + configuration_id: transform.configuration_id(), + }) + .collect(), + preserve_unknown_opcodes: config.preserve_unknown_opcodes, + rewrite_function_selectors: config.rewrite_function_selectors, + obfuscate_constructor_arguments: config.obfuscate_constructor_arguments, + }; + // Bind the commitment to this input and profile. Reusing one private seed across contracts + // therefore does not create a stable off-chain linking identifier. + let seed_commitment = pipeline_seed + .derive_seed(b"integrity-seed-commitment") + .hash_hex(); + cfg_ir + .refresh_relationships() + .map_err(|error| ObfuscationError::from_err(error, &cfg_ir.trace))?; + let original_layout_order = cfg_ir.layout_order().to_vec(); + let original_control_unresolved = !cfg_ir.relationships().unresolved_control.is_empty(); + if !cfg_ir + .relationships() + .constructor_materialized_control + .is_empty() + { + return Err(ObfuscationError::from_err( + "constructor-materialized runtime word may determine a JUMP/JUMPI destination; layout transformation is unsupported", + &cfg_ir.trace, + )); + } + if !cfg_ir.relationships().position_sensitive.is_empty() { + return Err(ObfuscationError::from_err( + "runtime observes code position, size, bytes, or hash; exact observational equivalence under code variation is unsupported", + &cfg_ir.trace, + )); + } tracing::debug!(" Input size: {} bytes", original_size); - // Step 2: Analyze instructions for unknown opcodes - let (total_instructions, unknown_count, unknown_types) = analyze_instructions(&instructions); + // Step 2: Analyze only executable runtime instructions for opcodes the CFG cannot model. + // Init code, constructor arguments, and compiler suffixes are preserved outside this CFG and + // may contain arbitrary data bytes. A retained runtime opcode must have known stack/control + // semantics before any block can be relocated; merely copying its byte is not sufficient. + let retained_runtime_instructions = extract_instructions_from_cfg(&cfg_ir); + let (total_instructions, unknown_count, unknown_types) = + analyze_instructions(&retained_runtime_instructions, &bytes); tracing::debug!(" Total instructions: {}", total_instructions); tracing::debug!(" Unknown opcodes: {}", unknown_count); + if unknown_count > 0 { + return Err(ObfuscationError::from_err( + format!( + "runtime contains {unknown_count} unmodelled opcode(s): {}; refusing CFG transformation", + unknown_types.join(", ") + ), + &cfg_ir.trace, + )); + } // Log section info tracing::debug!( @@ -197,76 +501,69 @@ pub async fn obfuscate_bytecode( let runtime_section = sections .iter() .find(|s| s.kind == detection::SectionKind::Runtime); - - let dispatcher_info = if let Some(runtime_sec) = runtime_section { - // Filter instructions to only those in runtime section - let runtime_instructions: Vec<_> = instructions + let retained_runtime_ends_in_terminal = runtime_section.is_some_and(|runtime| { + let runtime_end = runtime.offset.saturating_add(runtime.len); + retained_runtime_instructions .iter() - .filter(|instruction| { - instruction.pc >= runtime_sec.offset - && instruction.pc < runtime_sec.offset + runtime_sec.len + .max_by_key(|instruction| instruction.pc) + .is_some_and(|instruction| { + instruction.pc.checked_add(instruction.byte_size()) == Some(runtime_end) + && is_terminal_opcode(instruction.op) }) - .cloned() - .collect(); + }); + let dispatcher_info = if runtime_section.is_some() { tracing::debug!( "Checking for dispatcher in {} runtime instructions", - runtime_instructions.len() + retained_runtime_instructions.len() ); - detection::detect_function_dispatcher(&runtime_instructions) + detection::detect_function_dispatcher(&retained_runtime_instructions) } else { // No runtime section = probably pure runtime bytecode detection::detect_function_dispatcher(&instructions) }; - let has_dispatcher = dispatcher_info.is_some(); - // Store dispatcher info in bundle for snapshot/TUI visualization cfg_ir.dispatcher_info = dispatcher_info.clone(); - if let Some(dispatcher) = dispatcher_info { - tracing::debug!( - "Function dispatcher detected with {} selectors - adding FunctionDispatcher transform", - dispatcher.selectors.len() - ); - all_transforms.push(Box::new(FunctionDispatcher::with_dispatcher_info_and_seed( - dispatcher, - config.seed.clone(), - ))); + if config.rewrite_function_selectors { + if let Some(dispatcher) = dispatcher_info { + tracing::debug!( + "Function dispatcher detected with {} selectors - adding experimental FunctionDispatcher transform", + dispatcher.selectors.len() + ); + all_transforms.push(Box::new(FunctionDispatcher::with_dispatcher_info_and_seed( + dispatcher, + pipeline_seed.derive_seed(b"function-dispatcher"), + ))); + } else { + tracing::debug!( + "No function dispatcher detected in runtime - skipping FunctionDispatcher transform" + ); + } } else { - tracing::debug!( - "No function dispatcher detected in runtime - skipping FunctionDispatcher transform" - ); + tracing::debug!("FunctionDispatcher is disabled by the safe profile"); } - let user_transform_names: Vec = config - .transforms - .iter() - .map(|t| t.name().to_string()) - .collect(); - // Add user-specified transforms (this moves config.transforms) all_transforms.extend(config.transforms); - // Track which transforms were applied (including the mandatory ones if dispatcher exists) + // Track only transforms that committed a validated change. let mut transforms_applied: Vec = Vec::new(); - if has_dispatcher { - transforms_applied.push("FunctionDispatcher".to_string()); - } - transforms_applied.extend(user_transform_names); // Track individual transform effects let mut transform_change_log = Vec::new(); let mut any_transform_changed = false; if !all_transforms.is_empty() { - // Create deterministic RNG from cryptographic seed - let mut shared_rng = config.seed.create_deterministic_rng(); - tracing::debug!("Applying {} transforms", all_transforms.len(),); + let mut transform_occurrences: HashMap<&'static str, u64> = HashMap::new(); for (i, transform) in all_transforms.iter().enumerate() { let transform_name = transform.name(); + let occurrence = transform_occurrences.entry(transform_name).or_default(); + let transform_occurrence = *occurrence; + *occurrence += 1; let pre_instruction_count = count_instructions_in_cfg(&cfg_ir); let pre_block_count = cfg_ir.cfg.node_count(); @@ -281,17 +578,73 @@ pub async fn obfuscate_bytecode( // Record transform start for trace grouping cfg_ir.record_transform_start(transform_name); - // Apply transform with deterministic RNG - let transform_changed = match transform.apply(&mut cfg_ir, &mut shared_rng) { - Ok(changed) => { - tracing::debug!(" Result: changed={}", changed); - changed - } - Err(e) => { - tracing::error!(" Transform {} failed: {}", transform_name, e); - false + // Each pass works on an isolated clone and receives its own input-bound RNG stream. + // Errors abort the pipeline; a pass returning false discards its clone. This prevents + // partial mutations from escaping and decouples later randomness from earlier passes. + let protected_reassembly_state = + serde_json::to_vec(&cfg_ir.clean_report).map_err(|error| { + ObfuscationError::from_err( + format!("failed to snapshot protected reconstruction state: {error}"), + &cfg_ir.trace, + ) + })?; + let mut candidate = cfg_ir.clone(); + let transform_configuration_id = transform.configuration_id(); + let mut pass_rng = derive_transform_rng( + &pipeline_seed, + transform_name, + &transform_configuration_id, + transform_occurrence, + ); + let mut transform_changed = + transform + .apply(&mut candidate, &mut pass_rng) + .map_err(|error| { + ObfuscationError::from_err( + format!("transform {transform_name} failed: {error}"), + &cfg_ir.trace, + ) + })?; + if transform_changed + && serde_json::to_vec(&candidate.clean_report).map_err(|error| { + ObfuscationError::from_err( + format!("failed to inspect protected reconstruction state: {error}"), + &candidate.trace, + ) + })? != protected_reassembly_state + { + return Err(ObfuscationError::from_err( + format!( + "transform {transform_name} attempted to mutate protected init, constructor, or compiler-suffix reconstruction state" + ), + &candidate.trace, + )); + } + if transform_changed && init_observes_gas { + tracing::debug!( + "discarding {transform_name}: reachable init GAS requires byte-for-byte creation stability" + ); + transform_changed = false; + } + if transform_changed { + candidate + .refresh_relationships() + .map_err(|error| ObfuscationError::from_err(error, &candidate.trace))?; + candidate + .validate_relationships() + .map_err(|error| ObfuscationError::from_err(error, &candidate.trace))?; + if !candidate.relationships().is_relocatable() { + return Err(ObfuscationError::from_err( + format!( + "transform {transform_name} introduced unresolved or position-sensitive relationships" + ), + &candidate.trace, + )); } - }; + cfg_ir = candidate; + transforms_applied.push(transform_name.to_string()); + } + tracing::debug!(" Result: changed={}", transform_changed); // Record transform end for trace grouping cfg_ir.record_transform_end(transform_name); @@ -317,6 +670,22 @@ pub async fn obfuscate_bytecode( } } + let layout_changed = cfg_ir.layout_order() != original_layout_order.as_slice(); + if layout_changed && !original_deployed_suffixes.is_empty() { + if original_control_unresolved { + return Err(ObfuscationError::from_err( + "layout change refused: runtime control may target detected auxdata or padding", + &cfg_ir.trace, + )); + } + if !retained_runtime_ends_in_terminal { + return Err(ObfuscationError::from_err( + "layout change refused: retained runtime falls through into detected auxdata or padding", + &cfg_ir.trace, + )); + } + } + // Start finalization phase for trace grouping cfg_ir.record_finalize_start(); @@ -342,12 +711,12 @@ pub async fn obfuscate_bytecode( tracing::debug!(" {}", log_entry); } - // Capture old instruction layout before reindexing (needed for immutable ref patching). - // For each runtime instruction, record (old_pc, byte_size) so we can build a byte-level - // displacement map after reindex_pcs remaps instruction PCs. + // Capture exact immutable-carrier identities before reindexing. A valid carrier must remain a + // unique PUSH32-zero instruction at its original PC. If a transform removes, replaces, or + // aliases it, the immutable remapper below deliberately has no answer and finalization fails. let old_runtime_start = cfg_ir.runtime_bounds.map(|(s, _)| s).unwrap_or(0); - let old_instr_layout: Vec<(usize, usize)> = { - let mut layout = Vec::new(); + let old_runtime_instruction_shapes: HashMap = { + let mut shapes = HashMap::new(); let rt_bounds = cfg_ir.runtime_bounds; for node_idx in cfg_ir.cfg.node_indices() { if let cfg_ir::Block::Body(body) = &cfg_ir.cfg[node_idx] { @@ -357,13 +726,18 @@ pub async fn obfuscate_bytecode( }; if in_runtime { for instr in &body.instructions { - layout.push((instr.pc, instr.byte_size())); + let is_placeholder_carrier = matches!(instr.op, Opcode::PUSH(32)) + && instr.imm.as_deref().is_some_and(|immediate| { + immediate.len() == 64 && immediate.bytes().all(|byte| byte == b'0') + }); + let entry = shapes.entry(instr.pc).or_insert((0, false)); + entry.0 += 1; + entry.1 |= is_placeholder_carrier; } } } } - layout.sort_by_key(|(pc, _)| *pc); - layout + shapes }; // Step 5: Reindex PCs @@ -379,12 +753,6 @@ pub async fn obfuscate_bytecode( .map_err(|e| ObfuscationError::from_err(e, &cfg_ir.trace))?; tracing::debug!(" Patched jump immediates after PC reindexing"); - // Remap orphan jump-address PUSHes (e.g. return addresses for internal function calls) - // that are not part of any recognized jump pattern. - cfg_ir - .remap_orphan_jump_pushes(&pc_mapping, old_runtime_bounds) - .map_err(|e| ObfuscationError::from_err(e, &cfg_ir.trace))?; - // Re-apply dispatcher jump target patches with OLD controller PCs (before updating) // NOTE: These patches update the PUSH2 instructions (jump targets), not the PUSH4 token instructions if let (Some(controller_pcs), Some(dispatcher_patches)) = ( @@ -633,58 +1001,53 @@ pub async fn obfuscate_bytecode( ); } - // Step 7b: Patch immutable reference offsets in init code. - // When transforms grow the runtime (e.g., PushSplit), the init code's hardcoded byte - // offsets for writing immutable variables become stale. Build a byte-level displacement - // map from the old instruction layout and pc_mapping, then patch the init code. + // Step 7b: Patch exact Solidity immutable references in the init code. Only an original + // PUSH32-zero placeholder whose carrier instruction survived uniquely can be relocated. + if cfg_ir + .clean_report + .removed + .iter() + .any(|removed| removed.kind == detection::SectionKind::Init) { let new_runtime_start = cfg_ir.runtime_bounds.map(|(s, _)| s).unwrap_or(0); - // Build byte-level remap: for each byte in the old runtime, compute where it lands - // in the new runtime. We build a sorted list of (old_rel_offset, new_rel_offset) for - // each instruction start, then for any query offset, find the containing instruction - // and compute the intra-instruction delta. - let mut byte_remap_entries: Vec<(usize, usize, usize)> = Vec::new(); // (old_rel, new_rel, size) - for &(old_pc, byte_size) in &old_instr_layout { - if let Some(&new_pc) = pc_mapping.get(&old_pc) { - let old_rel = old_pc.saturating_sub(old_runtime_start); - let new_rel = new_pc.saturating_sub(new_runtime_start); - byte_remap_entries.push((old_rel, new_rel, byte_size)); - } - } - byte_remap_entries.sort_by_key(|(old_rel, _, _)| *old_rel); - let remap = |old_offset: usize| -> Option { - // Binary search for the instruction containing this byte offset - match byte_remap_entries.binary_search_by_key(&old_offset, |(old_rel, _, _)| *old_rel) { - Ok(i) => { - // Exact match on instruction start - Some(byte_remap_entries[i].1) - } - Err(i) if i > 0 => { - // old_offset falls within the instruction at index i-1 - let (old_rel, new_rel, size) = byte_remap_entries[i - 1]; - let delta = old_offset - old_rel; - if delta < size { - Some(new_rel + delta) - } else { - None - } - } - _ => None, + // Placeholder offsets point to the first immediate byte, one byte after PUSH32. + let old_opcode_relative = old_offset.checked_sub(1)?; + let old_opcode_pc = old_runtime_start.checked_add(old_opcode_relative)?; + match old_runtime_instruction_shapes.get(&old_opcode_pc) { + Some(&(1, true)) => {} + _ => return None, } + let new_opcode_pc = *pc_mapping.get(&old_opcode_pc)?; + let new_opcode_relative = new_opcode_pc.checked_sub(new_runtime_start)?; + new_opcode_relative.checked_add(1) }; - if let Err(e) = cfg_ir.clean_report.patch_init_immutable_refs(&remap) { - tracing::warn!("Failed to patch init immutable refs: {}", e); - } + cfg_ir + .clean_report + .patch_init_immutable_refs(&original_clean_runtime, &remap) + .map_err(|error| ObfuscationError::from_err(error, &cfg_ir.trace))?; } // Step 7c: Mask an exact constructor-argument suffix and inject a seed-varied decoder. // This runs after init immutable patching so its insertion can remap all existing init jumps // once. It fails closed when arguments exist but their copy site is unsupported. - let constructor_args = - obfuscate_constructor_args(&mut cfg_ir.clean_report, config.seed.as_bytes()) - .map_err(|e| ObfuscationError::from_err(e, &cfg_ir.trace))?; + let constructor_args = if config.obfuscate_constructor_arguments && !init_observes_gas { + obfuscate_constructor_args( + &mut cfg_ir.clean_report, + pipeline_seed + .derive_seed(b"constructor-arguments") + .as_bytes(), + ) + .map_err(|e| ObfuscationError::from_err(e, &cfg_ir.trace))? + } else { + if config.obfuscate_constructor_arguments && init_observes_gas { + tracing::debug!( + "discarding ConstructorArgs: init GAS requires byte-for-byte creation stability" + ); + } + ConstructorArgsObfuscation::default() + }; if constructor_args.applied { transforms_applied.push("ConstructorArgs".to_string()); tracing::debug!( @@ -694,13 +1057,59 @@ pub async fn obfuscate_bytecode( ); } + let final_deployed_suffixes: Vec<_> = cfg_ir + .clean_report + .removed + .iter() + .filter(|removed| { + matches!( + removed.kind, + detection::SectionKind::Auxdata | detection::SectionKind::Padding + ) + }) + .map(|removed| (removed.kind, removed.offset, removed.data.to_vec())) + .collect(); + if final_deployed_suffixes != original_deployed_suffixes { + return Err(ObfuscationError::from_err( + "compiler auxdata or padding changed; the production pipeline preserves all detected suffix bytes", + &cfg_ir.trace, + )); + } + // Step 8: Reassemble final bytecode (init + runtime with data section + auxdata) let final_bytecode = cfg_ir .clean_report .reassemble_checked(&obfuscated_bytes) .map_err(|error| ObfuscationError::from_err(error, &cfg_ir.trace))?; + if init_observes_gas && final_bytecode != bytes { + return Err(ObfuscationError::from_err( + "init GAS identity fallback failed: final creation bytecode differs from the input", + &cfg_ir.trace, + )); + } let obfuscated_size = final_bytecode.len(); + // The public runtime result is the complete code that init code returns, including ordinary + // compiler padding and auxdata. Constructor arguments are creation-transaction data and are + // deliberately excluded. Keeping this byte sequence complete makes integrity hashes and + // deployed-runtime comparisons unambiguous. + let mut complete_runtime = obfuscated_bytes.clone(); + let mut deployed_suffixes: Vec<_> = cfg_ir + .clean_report + .removed + .iter() + .filter(|removed| { + matches!( + removed.kind, + detection::SectionKind::Auxdata | detection::SectionKind::Padding + ) + }) + .collect(); + deployed_suffixes.sort_by_key(|removed| removed.offset); + for removed in deployed_suffixes { + complete_runtime.extend_from_slice(&removed.data); + } + // CRITICAL DEBUGGING: Compare final bytecode to original let final_bytecode_snapshot = hex::encode(&final_bytecode); let bytecode_actually_changed = original_bytecode_snapshot != final_bytecode_snapshot; @@ -752,17 +1161,7 @@ pub async fn obfuscate_bytecode( } else { 0.0 }; - let deployed_suffix_size: usize = sections - .iter() - .filter(|section| { - matches!( - section.kind, - detection::SectionKind::Auxdata | detection::SectionKind::Padding - ) - }) - .map(|section| section.len) - .sum(); - let deployed_runtime_size = obfuscated_bytes.len() + deployed_suffix_size; + let deployed_runtime_size = complete_runtime.len(); let size_limit_exceeded = obfuscated_size > MAX_INITCODE_SIZE || deployed_runtime_size > MAX_RUNTIME_CODE_SIZE; if size_limit_exceeded { @@ -777,14 +1176,9 @@ pub async fn obfuscate_bytecode( // Step 11: Build result tracing::debug!("=== Building ObfuscationResult ==="); if let Some(ref mapping) = cfg_ir.selector_mapping { - tracing::debug!("Selector mapping has {} entries:", mapping.len()); - for (selector, token) in mapping { - tracing::debug!( - " Selector 0x{:08x} -> Token 0x{}", - selector, - hex::encode(token) - ); - } + // Selector maps are private interaction material. Never print their contents, including + // at debug level, because logs are frequently shipped to shared observability systems. + tracing::debug!("Selector mapping has {} entries", mapping.len()); } else { tracing::debug!("No selector mapping in result"); } @@ -797,9 +1191,44 @@ pub async fn obfuscate_bytecode( ); let trace = cfg_ir.trace.clone(); + let canonical_mapping = canonical_selector_mapping(cfg_ir.selector_mapping.as_ref()); + let selector_mapping_commitment = (!canonical_mapping.is_empty()).then(|| { + let encoded = serde_json::to_vec(&canonical_mapping) + .expect("BTreeMap serialization is infallible"); + keccak_hex(&encoded) + }); + let input_deployment_keccak256 = keccak_hex(&bytes); + let input_runtime_keccak256 = keccak_hex(&original_runtime_bytes); + let output_deployment_keccak256 = keccak_hex(&final_bytecode); + let output_runtime_keccak256 = keccak_hex(&complete_runtime); + let reproduction_commitment = reproduction_commitment( + &pipeline_seed, + &pipeline_configuration, + &seed_commitment, + &input_deployment_keccak256, + &input_runtime_keccak256, + &output_deployment_keccak256, + &output_runtime_keccak256, + &transforms_applied, + selector_mapping_commitment.as_deref(), + ); + let integrity = IntegrityManifest { + schema_version: 2, + pipeline_profile: String::from_utf8_lossy(PIPELINE_PROFILE).into_owned(), + pipeline_configuration, + seed_commitment, + input_deployment_keccak256, + input_runtime_keccak256, + output_deployment_keccak256, + output_runtime_keccak256, + transforms_applied: transforms_applied.clone(), + selector_mapping_commitment, + reproduction_commitment, + }; + Ok(ObfuscationResult { obfuscated_bytecode: format!("0x{}", hex::encode(&final_bytecode)), - obfuscated_runtime: format!("0x{}", hex::encode(&obfuscated_bytes)), + obfuscated_runtime: format!("0x{}", hex::encode(&complete_runtime)), original_size, obfuscated_size, size_increase_percentage, @@ -817,28 +1246,260 @@ pub async fn obfuscate_bytecode( constructor_decoder_bytes: constructor_args.decoder_bytes, }, selector_mapping: cfg_ir.selector_mapping, + integrity, trace, }) } +fn canonical_selector_mapping(mapping: Option<&HashMap>>) -> BTreeMap { + mapping + .into_iter() + .flatten() + .map(|(selector, replacement)| { + ( + format!("0x{selector:08x}"), + format!("0x{}", hex::encode(replacement)), + ) + }) + .collect() +} + +fn canonical_calldata_rule(mapping: &BTreeMap) -> &'static str { + if mapping.is_empty() { + "No selector rewrite was applied; submit ordinary ABI calldata unchanged" + } else { + "Replace calldata bytes 0..4 using selector_mapping; preserve bytes 4.. unchanged" + } +} + +fn serialize_optional_selector_mapping( + mapping: &Option>>, + serializer: S, +) -> Result +where + S: Serializer, +{ + mapping + .as_ref() + .map(|map| map.iter().collect::>()) + .serialize(serializer) +} + +fn keccak_hex(bytes: &[u8]) -> String { + let digest = Keccak256::digest(bytes); + format!("0x{}", hex::encode(digest)) +} + +fn decode_result_hex(value: &str) -> Result, String> { + hex::decode(value.trim_start_matches("0x")) + .map_err(|error| format!("invalid result hex: {error}")) +} + +fn derive_pipeline_seed(seed: &Seed, input_deployment: &[u8], input_runtime: &[u8]) -> Seed { + let mut input_hasher = Sha3_256::new(); + input_hasher.update(PIPELINE_PROFILE); + input_hasher.update(b"deployment"); + input_hasher.update((input_deployment.len() as u64).to_be_bytes()); + input_hasher.update(input_deployment); + input_hasher.update(b"runtime"); + input_hasher.update((input_runtime.len() as u64).to_be_bytes()); + input_hasher.update(input_runtime); + let input_digest: [u8; 32] = input_hasher.finalize().into(); + seed.derive_seed(&input_digest) +} + +fn derive_transform_rng( + pipeline_seed: &Seed, + transform_name: &str, + configuration_id: &str, + occurrence: u64, +) -> DeterministicRng { + let mut domain = b"AZOTH_TRANSFORM_STREAM_V1".to_vec(); + for field in [ + PIPELINE_PROFILE, + transform_name.as_bytes(), + configuration_id.as_bytes(), + ] { + domain.extend_from_slice(&(field.len() as u64).to_be_bytes()); + domain.extend_from_slice(field); + } + domain.extend_from_slice(&occurrence.to_be_bytes()); + pipeline_seed.derive_rng(&domain) +} + +#[allow(clippy::too_many_arguments)] +fn verify_manifest_integrity( + manifest: &IntegrityManifest, + input_deployment: &[u8], + input_runtime: &[u8], + output_deployment: &[u8], + output_runtime: &[u8], + seed: &Seed, + selector_mapping_commitment: Option<&str>, +) -> Result<(), String> { + if manifest.schema_version != 2 + || manifest.pipeline_profile != String::from_utf8_lossy(PIPELINE_PROFILE) + { + return Err("unsupported integrity manifest profile".to_string()); + } + if selector_mapping_commitment.is_some() + && !manifest.pipeline_configuration.rewrite_function_selectors + { + return Err( + "selector mapping is incompatible with the declared pipeline configuration".to_string(), + ); + } + if manifest + .transforms_applied + .iter() + .any(|name| name == "ConstructorArgs") + && !manifest + .pipeline_configuration + .obfuscate_constructor_arguments + { + return Err( + "constructor-argument transform is incompatible with the declared pipeline configuration" + .to_string(), + ); + } + + let pipeline_seed = derive_pipeline_seed(seed, input_deployment, input_runtime); + let expected_seed_commitment = pipeline_seed + .derive_seed(b"integrity-seed-commitment") + .hash_hex(); + if manifest.seed_commitment != expected_seed_commitment { + return Err("seed commitment mismatch".to_string()); + } + + for (label, actual, expected) in [ + ( + "input deployment", + manifest.input_deployment_keccak256.as_str(), + keccak_hex(input_deployment), + ), + ( + "input runtime", + manifest.input_runtime_keccak256.as_str(), + keccak_hex(input_runtime), + ), + ( + "output deployment", + manifest.output_deployment_keccak256.as_str(), + keccak_hex(output_deployment), + ), + ( + "output runtime", + manifest.output_runtime_keccak256.as_str(), + keccak_hex(output_runtime), + ), + ] { + if actual != expected { + return Err(format!("{label} hash mismatch")); + } + } + if manifest.selector_mapping_commitment.as_deref() != selector_mapping_commitment { + return Err("selector mapping commitment mismatch".to_string()); + } + + let expected_reproduction = reproduction_commitment( + &pipeline_seed, + &manifest.pipeline_configuration, + &manifest.seed_commitment, + &manifest.input_deployment_keccak256, + &manifest.input_runtime_keccak256, + &manifest.output_deployment_keccak256, + &manifest.output_runtime_keccak256, + &manifest.transforms_applied, + manifest.selector_mapping_commitment.as_deref(), + ); + if manifest.reproduction_commitment != expected_reproduction { + return Err("authenticated reproduction tag mismatch".to_string()); + } + Ok(()) +} + +fn update_commitment_field(mac: &mut HmacSha3_256, value: &[u8]) { + mac.update(&(value.len() as u64).to_be_bytes()); + mac.update(value); +} + +#[allow(clippy::too_many_arguments)] +fn reproduction_commitment( + pipeline_seed: &Seed, + pipeline_configuration: &PipelineConfigurationManifest, + seed_commitment: &str, + input_deployment: &str, + input_runtime: &str, + output_deployment: &str, + output_runtime: &str, + transforms: &[String], + selector_mapping: Option<&str>, +) -> String { + let authentication_key = pipeline_seed.derive_seed(b"integrity-reproduction-hmac"); + let mut mac = HmacSha3_256::new_from_slice(authentication_key.as_bytes()) + .expect("HMAC-SHA3-256 accepts a 32-byte key"); + mac.update(b"AZOTH_REPRODUCTION_COMMITMENT_V2"); + mac.update(&(pipeline_configuration.requested_transforms.len() as u64).to_be_bytes()); + for transform in &pipeline_configuration.requested_transforms { + update_commitment_field(&mut mac, transform.name.as_bytes()); + update_commitment_field(&mut mac, transform.configuration_id.as_bytes()); + } + mac.update(&[ + u8::from(pipeline_configuration.preserve_unknown_opcodes), + u8::from(pipeline_configuration.rewrite_function_selectors), + u8::from(pipeline_configuration.obfuscate_constructor_arguments), + ]); + for field in [ + PIPELINE_PROFILE, + seed_commitment.as_bytes(), + input_deployment.as_bytes(), + input_runtime.as_bytes(), + output_deployment.as_bytes(), + output_runtime.as_bytes(), + ] { + update_commitment_field(&mut mac, field); + } + mac.update(&(transforms.len() as u64).to_be_bytes()); + for transform in transforms { + update_commitment_field(&mut mac, transform.as_bytes()); + } + match selector_mapping { + Some(commitment) => { + mac.update(&[1]); + update_commitment_field(&mut mac, commitment.as_bytes()); + } + None => mac.update(&[0]), + } + format!("0x{}", hex::encode(mac.finalize().into_bytes())) +} + /// Analyzes instructions to count unknown opcodes and provide feedback. -fn analyze_instructions(instructions: &[decoder::Instruction]) -> (usize, usize, Vec) { +fn analyze_instructions( + instructions: &[decoder::Instruction], + original_bytecode: &[u8], +) -> (usize, usize, Vec) { let total_count = instructions.len(); let mut unknown_count = 0; let mut unknown_types = HashSet::new(); for instruction in instructions { - if matches!(instruction.op, Opcode::INVALID | Opcode::UNKNOWN(_)) { + // Native decoding distinguishes the real 0xfe INVALID opcode from every unassigned raw + // byte. A final truncated PUSH is executable via EVM zero-extension, but cannot safely be + // relocated or followed by inserted code, so it is unsupported by transformations too. + let is_unmodelled = instruction.op.is_unknown() || instruction.is_truncated_push(); + if is_unmodelled { unknown_count += 1; - unknown_types.insert(format!("{}", instruction.op)); + let raw = original_bytecode + .get(instruction.pc) + .map(|byte| format!("0x{byte:02x}")) + .unwrap_or_else(|| "out-of-bounds".to_string()); + unknown_types.insert(format!("{} ({raw})", instruction.op)); } } - ( - total_count, - unknown_count, - unknown_types.into_iter().collect(), - ) + let mut unknown_types: Vec<_> = unknown_types.into_iter().collect(); + unknown_types.sort(); + (total_count, unknown_count, unknown_types) } /// Count instructions in CFG @@ -975,3 +1636,274 @@ pub fn create_gas_report(result: &ObfuscationResult) -> serde_json::Value { } }) } + +#[cfg(test)] +mod safety_tests { + use super::{ + analyze_instructions, derive_pipeline_seed, derive_transform_rng, obfuscate_bytecode, + ObfuscationConfig, + }; + use azoth_core::{decoder::Instruction, seed::Seed, Opcode}; + use rand::RngCore; + + async fn assert_init_contract_rejected(deployment: &str, runtime: &str) -> String { + obfuscate_bytecode( + deployment, + runtime, + ObfuscationConfig::with_seed(Seed::from_bytes([1; 32])), + ) + .await + .expect_err("unsafe init/runtime provenance must fail closed") + .message + } + + #[test] + fn pipeline_seed_is_bound_to_both_bytecode_inputs() { + let root = Seed::from_bytes([0x42; 32]); + let baseline = derive_pipeline_seed(&root, b"deployment", b"runtime"); + assert_ne!( + baseline.as_bytes(), + derive_pipeline_seed(&root, b"other deployment", b"runtime").as_bytes() + ); + assert_ne!( + baseline.as_bytes(), + derive_pipeline_seed(&root, b"deployment", b"other runtime").as_bytes() + ); + } + + #[test] + fn transform_rng_is_bound_to_name_configuration_and_occurrence() { + let pipeline_seed = Seed::from_bytes([0x51; 32]); + let sample = |name, configuration, occurrence| { + derive_transform_rng(&pipeline_seed, name, configuration, occurrence).next_u64() + }; + + assert_eq!( + sample("Pass", "Pass@v1;a=1", 0), + sample("Pass", "Pass@v1;a=1", 0) + ); + assert_ne!( + sample("Pass", "Pass@v1;a=1", 0), + sample("Other", "Pass@v1;a=1", 0) + ); + assert_ne!( + sample("Pass", "Pass@v1;a=1", 0), + sample("Pass", "Pass@v1;a=2", 0) + ); + assert_ne!( + sample("Pass", "Pass@v1;a=1", 0), + sample("Pass", "Pass@v1;a=1", 1) + ); + } + + #[tokio::test] + async fn authenticated_recipe_records_requested_noop_passes() { + let seed = Seed::from_bytes([0x19; 32]); + let with_default_pass = + obfuscate_bytecode("0x00", "0x00", ObfuscationConfig::with_seed(seed.clone())) + .await + .expect("single terminal runtime is supported"); + let mut empty_config = ObfuscationConfig::with_seed(seed.clone()); + empty_config.transforms.clear(); + let without_pass = obfuscate_bytecode("0x00", "0x00", empty_config) + .await + .expect("empty pass list is supported"); + + assert_eq!( + with_default_pass.obfuscated_bytecode, without_pass.obfuscated_bytecode, + "the default layout pass is a no-op for one block" + ); + assert_ne!( + with_default_pass.integrity.pipeline_configuration, + without_pass.integrity.pipeline_configuration, + "the authenticated replay recipe must distinguish a requested no-op pass" + ); + assert_ne!( + with_default_pass.integrity.reproduction_commitment, + without_pass.integrity.reproduction_commitment, + "configuration changes must alter the authenticated reproduction tag" + ); + + with_default_pass + .verify_integrity(&[0x00], &[0x00], &seed) + .expect("default recipe authenticates"); + without_pass + .verify_integrity(&[0x00], &[0x00], &seed) + .expect("empty recipe authenticates"); + } + + #[test] + fn invalid_is_known_and_unknown_bytes_are_not() { + let invalid = Instruction { + pc: 0, + op: Opcode::INVALID, + imm: None, + }; + assert_eq!( + analyze_instructions(std::slice::from_ref(&invalid), &[0xfe]).1, + 0 + ); + assert_eq!(analyze_instructions(&[invalid], &[0xaa]).1, 0); + + let unknown = Instruction { + pc: 0, + op: Opcode::UNKNOWN(0xaa), + imm: None, + }; + assert_eq!(analyze_instructions(&[unknown], &[0xaa]).1, 1); + } + + #[tokio::test] + async fn constructor_materialized_jump_destination_is_rejected_before_layout() { + // The constructor copies the 48-byte runtime then writes 0x23 over the PUSH32-zero + // immediate at runtime offset 2. In the deployed code the first JUMP therefore enters the + // `return 42` block at 0x23. Reading the unlinked template as `PUSH32 0` would instead + // invent an edge to PC 0 and permit a shuffle to put the revert block at 0x23. + let runtime = format!("0x5b7f{}565b602a5f5260205ff35b5f5ffd", "00".repeat(32)); + let deployment = format!( + "0x60235f603090816016823950505f6002015260305ff3{}", + runtime.trim_start_matches("0x") + ); + + let error = obfuscate_bytecode( + &deployment, + &runtime, + ObfuscationConfig::with_seed(Seed::from_bytes([1; 32])), + ) + .await + .expect_err("a constructor-materialized jump destination must fail closed"); + + assert!( + error + .message + .contains("constructor-materialized runtime word"), + "unexpected error: {}", + error.message + ); + } + + #[tokio::test] + async fn init_returning_a_different_code_range_is_rejected() { + let runtime = "0x005b6001005b6002005b600300"; + let deployment = "0x600160175f3960015ff3005b6001005b6002005b60030000"; + + let message = assert_init_contract_rejected(deployment, runtime).await; + + assert!( + message.contains("no proven runtime CODECOPY") + || message.contains("deployment differs from supplied runtime"), + "unexpected error: {message}" + ); + } + + #[tokio::test] + async fn runtime_at_offset_zero_with_extra_deployment_bytes_is_rejected() { + let message = assert_init_contract_rejected("0x5b0001", "0x5b00").await; + + assert!( + message.contains("deployment differs from supplied runtime") + || message.contains("runtime"), + "unexpected error: {message}" + ); + } + + #[tokio::test] + async fn arbitrary_post_copy_runtime_patch_is_rejected() { + let runtime = "0x6003565b60005f5260205ff35b60995f5260205ff3"; + let deployment = format!("0x6015600f5f39602a60055360155ff3{}", &runtime[2..]); + + let message = assert_init_contract_rejected(&deployment, runtime).await; + + assert!( + message.contains("opcode 0x53"), + "unexpected error: {message}" + ); + } + + #[tokio::test] + async fn post_copy_runtime_observation_and_side_effect_are_rejected() { + let runtime = "0x005b6001005b6002005b600300"; + let deployment = format!("0x600d60105f39600d5ff205f55600d5ff3{}", &runtime[2..]); + + let message = assert_init_contract_rejected(&deployment, runtime).await; + + assert!( + message.contains("opcode 0xf2") + || message.contains("before proven RETURN") + || message.contains("no proven runtime CODECOPY"), + "unexpected error: {message}" + ); + } + + #[tokio::test] + async fn secondary_runtime_codecopy_observation_is_rejected() { + let runtime = "0x6003565b5f545f5260205ff35b60995f5260205ff3"; + let deployment = format!( + "0x602e3850506001601e5f395f515f55601560195f3960155ff3{}", + &runtime[2..] + ); + + let message = assert_init_contract_rejected(&deployment, runtime).await; + + assert!( + message.contains("secondary CODECOPY") || message.contains("CODESIZE"), + "unexpected error: {message}" + ); + } + + #[tokio::test] + async fn secondary_codecopy_cannot_observe_rewritten_init_immediate() { + let runtime = format!("0x6027565b7f{}50005b5f545f5260205ff3", "00".repeat(32)); + let deployment = format!( + "0x600160145f395f515f556030601b5f39602a5f6005015260305ff3{}", + &runtime[2..] + ); + + let message = assert_init_contract_rejected(&deployment, &runtime).await; + + assert!( + message.contains("secondary CODECOPY"), + "unexpected error: {message}" + ); + } + + #[tokio::test] + async fn immutable_write_with_unrelated_memory_base_is_rejected() { + let runtime = format!( + "0x6027565b7f{}50005b7f01{}5f5260205ff3", + "00".repeat(32), + "00".repeat(31) + ); + let deployment = format!("0x604f60125f39602a602460050152604f5ff3{}", &runtime[2..]); + + let message = assert_init_contract_rejected(&deployment, &runtime).await; + + assert!( + message.contains("proven copy destination"), + "unexpected error: {message}" + ); + } + + #[tokio::test] + async fn constructor_controlled_free_memory_pointer_cannot_hide_runtime_read() { + // The constructor argument sets memory[0x40] to 0x20. The compiler-shaped terminal copy + // therefore places the runtime at 0x20, and the following MLOAD feeds those bytes to + // storage before RETURN. A syntactic "initial free-memory value" fallback would mistake + // the destination for 0x80 and incorrectly call the MLOAD disjoint. + let runtime = "0x005b6001005b6002005b600300"; + let mut argument = "00".repeat(31); + argument.push_str("20"); + let deployment = format!( + "0x60808060405250602b35604052604051600d9081601e82396020515f55f3{}{}", + &runtime[2..], + argument + ); + + let message = assert_init_contract_rejected(&deployment, runtime).await; + + assert!( + message.contains("lower bound") || message.contains("MLOAD"), + "unexpected error: {message}" + ); + } +} diff --git a/crates/transforms/src/opaque_predicate.rs b/crates/transforms/src/opaque_predicate.rs index 1694104c..8b3c8af4 100644 --- a/crates/transforms/src/opaque_predicate.rs +++ b/crates/transforms/src/opaque_predicate.rs @@ -1,11 +1,12 @@ use crate::{Error, Result, Transform}; use azoth_core::cfg_ir::{Block, BlockBody, BlockControl, CfgIrBundle, EdgeType}; use azoth_core::decoder::Instruction; +use azoth_core::seed::DeterministicRng; use azoth_core::Opcode; use petgraph::graph::NodeIndex; use petgraph::visit::EdgeRef; use rand::prelude::SliceRandom; -use rand::{rngs::StdRng, Rng}; +use rand::Rng; use sha3::{Digest, Keccak256}; use tracing::debug; @@ -36,7 +37,7 @@ impl Transform for OpaquePredicate { "OpaquePredicate" } - fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut DeterministicRng) -> Result { debug!("=== OpaquePredicate Transform Start ==="); let mut changed = false; @@ -97,7 +98,7 @@ impl Transform for OpaquePredicate { true_start_pc, false_start_pc ); - let true_label = ir.cfg.add_node(Block::Body(BlockBody { + let true_label = ir.add_block(Block::Body(BlockBody { start_pc: true_start_pc, instructions: vec![Instruction { pc: true_start_pc, @@ -106,9 +107,10 @@ impl Transform for OpaquePredicate { }], max_stack: 0, control: BlockControl::Unknown, + section: azoth_core::detection::SectionKind::Runtime, })); - let false_label = ir.cfg.add_node(Block::Body(BlockBody { + let false_label = ir.add_block(Block::Body(BlockBody { start_pc: false_start_pc, instructions: vec![ Instruction { @@ -139,6 +141,7 @@ impl Transform for OpaquePredicate { ], max_stack: 1, control: BlockControl::Unknown, + section: azoth_core::detection::SectionKind::Runtime, })); if let Block::Body(body) = &mut ir.cfg[*block_id] { diff --git a/crates/transforms/src/push_split.rs b/crates/transforms/src/push_split.rs index 9c8e89a9..93dca454 100644 --- a/crates/transforms/src/push_split.rs +++ b/crates/transforms/src/push_split.rs @@ -22,8 +22,8 @@ use crate::{collect_protected_nodes, collect_protected_pcs, Error, Result, Transform}; use azoth_core::cfg_ir::{Block, BlockControl, CfgIrBundle, JumpTarget}; use azoth_core::decoder::Instruction; +use azoth_core::seed::DeterministicRng; use azoth_core::Opcode; -use rand::rngs::StdRng; use rand::Rng; use std::fmt::Write; use tracing::debug; @@ -43,7 +43,7 @@ impl Transform for PushSplit { "PushSplit" } - fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut DeterministicRng) -> Result { debug!("PushSplit: scanning for eligible PUSH4–PUSH16 literals"); let protected_pcs = collect_protected_pcs(ir); @@ -282,14 +282,18 @@ enum CombineOp { } /// Generate a randomized chain of (push, combine-op) pairs whose reduction yields `value`. -fn generate_chain(value: u128, width_bytes: u8, rng: &mut StdRng) -> Vec<(u128, CombineOp)> { +fn generate_chain( + value: u128, + width_bytes: u8, + rng: &mut DeterministicRng, +) -> Vec<(u128, CombineOp)> { let bits = (width_bytes as u32) * 8; let max_value = if bits == 128 { u128::MAX } else { (1u128 << bits) - 1 }; - let sample = |rng: &mut StdRng| -> u128 { rng.random_range(0..=max_value) }; + let sample = |rng: &mut DeterministicRng| -> u128 { rng.random_range(0..=max_value) }; let parts = rng.random_range(2..=4); let prefer_xor = rng.random_bool(0.4); @@ -464,7 +468,7 @@ mod tests { #[test] fn generated_chains_preserve_literal_value() { - let mut rng = StdRng::seed_from_u64(7); + let mut rng = DeterministicRng::seed_from_u64(7); for width in 4u8..=16 { let bits = (width as u32) * 8; let max_value = if bits == 128 { diff --git a/crates/transforms/src/shuffle.rs b/crates/transforms/src/shuffle.rs index 157e866e..6d6d9866 100644 --- a/crates/transforms/src/shuffle.rs +++ b/crates/transforms/src/shuffle.rs @@ -1,7 +1,8 @@ use crate::Result; use crate::Transform; use azoth_core::cfg_ir::{Block, CfgIrBundle}; -use rand::{rngs::StdRng, seq::SliceRandom}; +use azoth_core::seed::DeterministicRng; +use rand::seq::SliceRandom; use tracing::debug; pub struct Shuffle; @@ -11,7 +12,7 @@ impl Transform for Shuffle { "Shuffle" } - fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut DeterministicRng) -> Result { let mut block_indices: Vec<_> = ir .cfg .node_indices() diff --git a/crates/transforms/src/slot_shuffle.rs b/crates/transforms/src/slot_shuffle.rs index 5f0dfa1b..909ad83c 100644 --- a/crates/transforms/src/slot_shuffle.rs +++ b/crates/transforms/src/slot_shuffle.rs @@ -27,9 +27,9 @@ use crate::{collect_protected_pcs, Error, Result, Transform}; use azoth_core::cfg_ir::{Block, CfgIrBundle}; -use azoth_core::decoder::Instruction; +use azoth_core::decoder::{self, Instruction}; +use azoth_core::seed::DeterministicRng; use azoth_core::Opcode; -use rand::rngs::StdRng; use rand::seq::SliceRandom; use std::collections::{HashMap, HashSet}; use tracing::{debug, warn}; @@ -49,7 +49,7 @@ impl Transform for SlotShuffle { "SlotShuffle" } - fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut DeterministicRng) -> Result { debug!("SlotShuffle: scanning for storage slot literals"); let protected_pcs = collect_protected_pcs(ir); @@ -348,57 +348,6 @@ fn is_storage_slot_push(instructions: &[Instruction], idx: usize) -> bool { false } -/// Decode a raw byte slice of EVM bytecode into an [`Instruction`] stream -/// without involving the async Heimdall disassembler. This is a minimal -/// sync walker that only needs to be correct for the purpose -/// [`init_literal_slots`] uses it for: identifying `PUSH`/`PUSH0` / -/// `SLOAD` / `SSTORE` / `DUP(n)` / `SWAP(n)` / arithmetic opcodes so that -/// `trace_slot_source` can reason about stack flow. Unknown opcodes are -/// mapped via `Opcode::from(byte)`, which the upstream `eot` crate resolves -/// to the appropriate variant (including `INVALID` / `UNKNOWN(_)` for -/// unassigned bytes), and PUSH immediates are captured so the immediate's -/// value is available for slot normalisation. -fn decode_raw_instructions(bytes: &[u8]) -> Vec { - let mut instructions = Vec::with_capacity(bytes.len()); - let mut pc = 0usize; - while pc < bytes.len() { - let byte = bytes[pc]; - if byte == 0x5f { - // PUSH0 - instructions.push(Instruction { - pc, - op: Opcode::PUSH0, - imm: None, - }); - pc += 1; - } else if (0x60..=0x7f).contains(&byte) { - // PUSH1..=PUSH32 - let width = (byte - 0x5f) as usize; - let end = pc + 1 + width; - if end > bytes.len() { - // Truncated PUSH immediate — stop decoding rather than - // silently dropping the tail, since whatever follows - // isn't really code. - break; - } - instructions.push(Instruction { - pc, - op: Opcode::PUSH(width as u8), - imm: Some(hex::encode(&bytes[pc + 1..end])), - }); - pc = end; - } else { - instructions.push(Instruction { - pc, - op: Opcode::from(byte), - imm: None, - }); - pc += 1; - } - } - instructions -} - /// Decode the raw init-section byte slice, then run the same /// `slot_push_index` (adjacency + `trace_slot_source` backward walk) over /// every init SLOAD/SSTORE that the runtime collection phase uses. Returns @@ -427,7 +376,11 @@ pub fn init_literal_slots(bytes: &[u8]) -> (HashSet<(usize, Vec)>, Vec)> = HashSet::new(); let mut unresolved: Vec = Vec::new(); - let instructions = decode_raw_instructions(bytes); + let instructions = match decoder::decode_executable_bytes(bytes) { + Ok(instructions) => instructions, + Err(azoth_core::Error::TruncatedPush { pc, .. }) => return (touched, vec![pc]), + Err(_) => return (touched, vec![0]), + }; for (idx, instr) in instructions.iter().enumerate() { if !matches!(instr.op, Opcode::SLOAD | Opcode::SSTORE) { continue; @@ -574,7 +527,8 @@ fn trace_slot_source(instructions: &[Instruction], sstore_idx: usize) -> Option< } } - // Operations that pop 1, push 1 (net 0): position unchanged + // Operations that pop 1 and compute 1 output. Deeper stack entries retain their + // position, but the top output is not the original literal operand. Opcode::ISZERO | Opcode::NOT | Opcode::BALANCE @@ -583,7 +537,11 @@ fn trace_slot_source(instructions: &[Instruction], sstore_idx: usize) -> Option< | Opcode::BLOCKHASH | Opcode::MLOAD | Opcode::SLOAD - | Opcode::EXTCODEHASH => {} + | Opcode::EXTCODEHASH => { + if pos == 0 { + return None; + } + } // Binary operations: pop 2, push 1 (net -1) Opcode::ADD @@ -660,13 +618,13 @@ fn trace_slot_source(instructions: &[Instruction], sstore_idx: usize) -> Option< } // Copy operations: pop 3, push 0 (net -3) - Opcode::CODECOPY - | Opcode::CALLDATACOPY - | Opcode::EXTCODECOPY - | Opcode::RETURNDATACOPY => { + Opcode::CODECOPY | Opcode::CALLDATACOPY | Opcode::RETURNDATACOPY => { pos += 3; } + // EXTCODECOPY also consumes the external account address. + Opcode::EXTCODECOPY => pos += 4, + // LOG0-4: pop 2+n, push 0 Opcode::LOG0 => pos += 2, Opcode::LOG1 => pos += 3, @@ -735,7 +693,6 @@ fn format_slot_immediate(bytes: &[u8], width: usize) -> String { mod tests { use super::*; use azoth_core::seed::Seed; - use rand::rngs::StdRng; use std::collections::HashMap; fn instr(pc: usize, op: Opcode, imm: Option<&str>) -> Instruction { @@ -748,7 +705,7 @@ mod tests { fn build_mapping_for_order( ordered_width_slots: &[(usize, Vec>)], - rng: &mut StdRng, + rng: &mut DeterministicRng, ) -> HashMap, Vec> { let mut mapping = HashMap::new(); let mut stable_width_slots = ordered_width_slots.to_vec(); @@ -955,6 +912,36 @@ mod tests { assert_eq!(trace_slot_source(&instructions, 4), None); } + #[test] + fn trace_fails_for_unary_computed_slot() { + let instructions = vec![ + instr(0, Opcode::PUSH0, None), + instr(1, Opcode::ISZERO, None), + instr(2, Opcode::SLOAD, None), + ]; + + assert_eq!(trace_slot_source(&instructions, 2), None); + assert_eq!(slot_push_index(&instructions, 2), None); + } + + #[test] + fn trace_accounts_for_all_four_extcodecopy_inputs() { + // The slot stays below the address, destination, offset, and size consumed by + // EXTCODECOPY. A three-input model would incorrectly select the address PUSH as the slot. + let instructions = vec![ + instr(0, Opcode::PUSH(1), Some("07")), + instr(2, Opcode::PUSH0, None), + instr(3, Opcode::PUSH0, None), + instr(4, Opcode::PUSH0, None), + instr(5, Opcode::PUSH0, None), + instr(6, Opcode::EXTCODECOPY, None), + instr(7, Opcode::SLOAD, None), + ]; + + assert_eq!(trace_slot_source(&instructions, 6), Some(0)); + assert_eq!(slot_push_index(&instructions, 6), Some(0)); + } + #[test] fn trace_fails_for_runtime_value() { // If slot comes from CALLER or similar, we can't trace it diff --git a/crates/transforms/src/splice.rs b/crates/transforms/src/splice.rs index 3d35ce52..aeb43544 100644 --- a/crates/transforms/src/splice.rs +++ b/crates/transforms/src/splice.rs @@ -25,7 +25,7 @@ use crate::{Result, Transform}; use azoth_core::cfg_ir::CfgIrBundle; -use rand::rngs::StdRng; +use azoth_core::seed::DeterministicRng; /// Splice catalogued helper functions into the CFG. #[derive(Default)] @@ -42,7 +42,7 @@ impl Transform for Splice { "Splice" } - fn apply(&self, _ir: &mut CfgIrBundle, _rng: &mut StdRng) -> Result { + fn apply(&self, _ir: &mut CfgIrBundle, _rng: &mut DeterministicRng) -> Result { Ok(false) } } diff --git a/crates/transforms/src/storage_gates.rs b/crates/transforms/src/storage_gates.rs index 74fc3ebb..0f315df1 100644 --- a/crates/transforms/src/storage_gates.rs +++ b/crates/transforms/src/storage_gates.rs @@ -23,7 +23,7 @@ use crate::{Result, Transform}; use azoth_core::cfg_ir::CfgIrBundle; -use rand::rngs::StdRng; +use azoth_core::seed::DeterministicRng; use tracing::debug; /// Storage mutation + gate insertion. @@ -41,7 +41,7 @@ impl Transform for StorageGates { "StorageGates" } - fn apply(&self, _ir: &mut CfgIrBundle, _rng: &mut StdRng) -> Result { + fn apply(&self, _ir: &mut CfgIrBundle, _rng: &mut DeterministicRng) -> Result { debug!("StorageGates: placeholder apply (no-op)"); Ok(false) } diff --git a/crates/transforms/src/string_obfuscate.rs b/crates/transforms/src/string_obfuscate.rs index d0fa5cba..f13bb4a4 100644 --- a/crates/transforms/src/string_obfuscate.rs +++ b/crates/transforms/src/string_obfuscate.rs @@ -12,8 +12,8 @@ use crate::{collect_protected_pcs, Error, Result, Transform}; use azoth_core::cfg_ir::{Block, CfgIrBundle}; use azoth_core::decoder::Instruction; +use azoth_core::seed::DeterministicRng; use azoth_core::Opcode; -use rand::rngs::StdRng; use rand::RngCore; use std::collections::HashMap; use tracing::debug; @@ -33,7 +33,7 @@ impl Transform for StringObfuscate { "StringObfuscate" } - fn apply(&self, ir: &mut CfgIrBundle, rng: &mut StdRng) -> Result { + fn apply(&self, ir: &mut CfgIrBundle, rng: &mut DeterministicRng) -> Result { debug!("StringObfuscate: scanning for Error(string) literals"); let protected_pcs = collect_protected_pcs(ir); diff --git a/crates/tui/src/event.rs b/crates/tui/src/event.rs index cbe63cd7..33973f36 100644 --- a/crates/tui/src/event.rs +++ b/crates/tui/src/event.rs @@ -32,10 +32,8 @@ pub fn handle_key_event(app: &mut App, key: crossterm::event::KeyEvent) -> (bool ViewMode::Trace => app.select_prev(), ViewMode::DecompileDiff => app.diff_select_prev(), }, - KeyCode::Enter | KeyCode::Char(' ') => { - if app.view_mode == ViewMode::Trace { - app.toggle_expand(); - } + KeyCode::Enter | KeyCode::Char(' ') if app.view_mode == ViewMode::Trace => { + app.toggle_expand(); } KeyCode::PageDown | KeyCode::Char('d') => { for _ in 0..10 { diff --git a/crates/tui/src/format/operation.rs b/crates/tui/src/format/operation.rs index b26105cd..88c29e77 100644 --- a/crates/tui/src/format/operation.rs +++ b/crates/tui/src/format/operation.rs @@ -27,6 +27,10 @@ pub fn format_operation_kind_short(kind: &OperationKind) -> String { OperationKind::SetConditionalJump { source, .. } => format!("Branch({source})"), OperationKind::RebuildEdges { node } => format!("Edges({node})"), OperationKind::WriteSymbolicImmediates { node } => format!("Symbolic({node})"), + OperationKind::ReorderLayout { blocks_moved } => { + format!("ReorderLayout({blocks_moved})") + } + OperationKind::ResolveRelocations { count } => format!("Relocations({count})"), OperationKind::ReindexPcs => "ReindexPCs".to_string(), OperationKind::PatchJumpImmediates => "PatchJumps".to_string(), OperationKind::PatchDispatcher { blocks_modified } => { @@ -73,6 +77,12 @@ pub fn format_operation_kind_full(kind: &OperationKind) -> String { OperationKind::WriteSymbolicImmediates { node } => { format!("Write Symbolic Immediates for {node}") } + OperationKind::ReorderLayout { blocks_moved } => { + format!("Reorder Layout ({blocks_moved} blocks moved)") + } + OperationKind::ResolveRelocations { count } => { + format!("Resolve Code-Pointer Relocations ({count})") + } OperationKind::ReindexPcs => "Reindex PCs".to_string(), OperationKind::PatchJumpImmediates => "Patch Jump Immediates".to_string(), OperationKind::PatchDispatcher { blocks_modified } => { @@ -124,6 +134,8 @@ pub fn format_group_detail_lines( OperationKind::SetConditionalJump { .. } => "SetConditionalJump", OperationKind::RebuildEdges { .. } => "RebuildEdges", OperationKind::WriteSymbolicImmediates { .. } => "WriteSymbolicImmediates", + OperationKind::ReorderLayout { .. } => "ReorderLayout", + OperationKind::ResolveRelocations { .. } => "ResolveRelocations", OperationKind::ReindexPcs => "ReindexPcs", OperationKind::PatchJumpImmediates => "PatchJumpImmediates", OperationKind::PatchDispatcher { .. } => "PatchDispatcher", diff --git a/crates/verification/Cargo.toml b/crates/verification/Cargo.toml index e4b3bac0..bec6d812 100644 --- a/crates/verification/Cargo.toml +++ b/crates/verification/Cargo.toml @@ -17,5 +17,4 @@ z3.workspace = true num-bigint.workspace = true indexmap.workspace = true petgraph.workspace = true -eot.workspace = true revm.workspace = true diff --git a/crates/verification/README.md b/crates/verification/README.md index 7bcbc8e6..e94c63bc 100644 --- a/crates/verification/README.md +++ b/crates/verification/README.md @@ -1,41 +1,27 @@ ## azoth-verification -This crate provides mathematical guarantees that obfuscated smart contracts behave identically to their original versions. When we obfuscate bytecode, we fundamentally alter its structure while preserving functionality. Formal verification uses mathematical proofs to ensure this preservation is complete and correct. - -Traditional testing can only verify specific cases, but smart contracts must handle infinite input combinations. A single undetected difference between original and obfuscated contracts could compromise security or functionality. Formal verification provides mathematical certainty that the contracts are equivalent for **all possible inputs**, not just tested ones. - -We use SMT-LIB (Satisfiability Modulo Theories) as our mathematical language to express contract properties, and Z3 theorem prover to automatically verify these properties. This serves as link between low-level bytecode and high-level mathematical reasoning. - -Now our verification establishes four key equivalence properties: - -- Bisimulation -```smt -(assert (forall ((state State) (input Input)) - (= (execute-original state input) - (execute-obfuscated state input)))) -``` -For EVERY input and state, both contracts produce the same execution trace. - -- State Equivalence -```smt -(assert (forall ((initial-state State) (transaction Tx)) - (= (final-state (execute-original initial-state transaction)) - (final-state (execute-obfuscated initial-state transaction))))) -``` -After ANY transaction, the storage, balances, and contract state are identical between original and obfuscated versions. - -- Property Preservation -```smt -(assert (forall ((s State)) - (and (access-control-original s) - (access-control-obfuscated s)))) -``` -ALL security properties satisfied by original are satisfied by obfuscated. - -- Gas Bounds -```smt -(assert (forall ((tx Transaction)) - (<= (gas-used (execute-obfuscated tx)) - (* 1.15 (gas-used (execute-original tx)))))) -``` -For ANY transaction, obfuscated version uses at most 15% more gas. +This crate contains experimental semantic-analysis and SMT-encoding primitives. It does **not** +currently provide a formal equivalence guarantee for transformed EVM bytecode. + +The production-facing `FormalVerifier::prove_equivalence` method fails closed with +`Error::VerificationUnavailable`. The current semantic model does not encode every EVM behavior, +and the generated equivalence formulas do not yet express and discharge a complete negated +counterexample obligation. Treating either as a proof would be unsound. + +The lower-level `SmtSolver::check_satisfiability` API deliberately accepts only a small, exactly +parsed assertion language. It returns a result only when Z3 definitively reports `sat` or `unsat`. +Empty input, declarations, malformed or unsupported syntax, and Z3 `unknown` results are errors; +none are skipped or approximated. + +`FormalProof` and `ProofStatement` are inert proof-record types for future integration: + +- Public constructors create only invalid, unproven records. +- Caller-supplied `valid` and `proven` fields are ignored during deserialization. +- Empty or incomplete records cannot validate. +- Combining invalid records cannot make them valid. +- A checksum detects accidental changes to the record, but is not proof evidence or a signature. + +A future verifier may add an internal attestation path only after the complete EVM semantics, +proof obligations, solver result handling, and independently checkable evidence are implemented. +Until then, production callers must treat formal verification as unavailable and use differential +execution tests only as testing evidence, not as mathematical proof. diff --git a/crates/verification/src/lib.rs b/crates/verification/src/lib.rs index e0772301..b2e4b944 100644 --- a/crates/verification/src/lib.rs +++ b/crates/verification/src/lib.rs @@ -1,7 +1,7 @@ -//! Azoth's Formal Verification Engine +//! Azoth verification primitives and experimental semantic encodings. //! -//! This crate provides formal guarantees that obfuscated contracts are functionally -//! equivalent to their original versions through formal verification using SMT solvers. +//! The production equivalence entry point fails closed until every semantic +//! obligation has a sound implementation and independently checkable evidence. pub mod proofs; pub mod properties; @@ -13,12 +13,12 @@ pub use proofs::{FormalProof, ProofStatement, ProofType}; pub use properties::{ArithmeticOperation, SecurityProperty}; pub use result::{Error, Result}; -use std::time::Instant; - /// Result type for verification operations (alias for backward compatibility) pub type VerificationResult = Result; -/// Main formal verification engine +/// Formal-verification facade. +/// +/// Its equivalence operation currently returns a fail-closed unavailable error. #[derive(Debug)] pub struct FormalVerifier { #[allow(dead_code)] @@ -33,192 +33,22 @@ impl FormalVerifier { Ok(Self { smt_solver }) } - /// Main entry point: prove that two contracts are equivalent + /// Main entry point for contract equivalence verification. + /// + /// This currently returns [`Error::VerificationUnavailable`] rather than + /// manufacturing proof statements from incomplete semantic encodings. pub async fn prove_equivalence( &mut self, - original_bytecode: &[u8], - original_runtime: &[u8], - obfuscated_bytecode: &[u8], - obfuscated_runtime: &[u8], - security_properties: &[SecurityProperty], + _original_bytecode: &[u8], + _original_runtime: &[u8], + _obfuscated_bytecode: &[u8], + _obfuscated_runtime: &[u8], + _security_properties: &[SecurityProperty], ) -> VerificationResult { - let start_time = Instant::now(); - - tracing::info!("Starting formal verification of contract equivalence"); - - // Parse both contracts into semantic representations - let original_semantics = - semantics::extract_semantics_from_bytecode(original_bytecode, original_runtime).await?; - let obfuscated_semantics = - semantics::extract_semantics_from_bytecode(obfuscated_bytecode, obfuscated_runtime) - .await?; - - tracing::debug!("Extracted semantics for both contracts"); - - // Generate proof statements - let mut statements = Vec::new(); - - // 1. Prove bisimulation (step-by-step equivalence) - if let Ok(bisim_statement) = self - .prove_bisimulation(&original_semantics, &obfuscated_semantics) - .await - { - statements.push(bisim_statement); - } - - // 2. Prove state equivalence - if let Ok(state_statement) = self - .prove_state_equivalence(&original_semantics, &obfuscated_semantics) - .await - { - statements.push(state_statement); - } - - // 3. Prove property preservation - for property in security_properties { - if let Ok(prop_statement) = self - .prove_property_preservation(&original_semantics, &obfuscated_semantics, property) - .await - { - statements.push(prop_statement); - } - } - - // 4. Prove gas bounds - if let Ok(gas_statement) = self - .prove_gas_bounds(&original_semantics, &obfuscated_semantics) - .await - { - statements.push(gas_statement); - } - - let proof_time = start_time.elapsed(); - let _statements_clone = statements.clone(); // Clone for hash computation - - let proof = FormalProof::new( - ProofType::Combined(vec![ - ProofType::Bisimulation, - ProofType::StateEquivalence, - ProofType::PropertyPreservation, - ProofType::GasBounds, - ]), - statements, - proof_time, - ); - - tracing::info!( - "Formal verification completed in {:.2}s, valid: {}", - proof_time.as_secs_f64(), - proof.valid - ); - - Ok(proof) - } - - /// Prove bisimulation: every execution step is equivalent - async fn prove_bisimulation( - &mut self, - _original: &semantics::ContractSemantics, - _obfuscated: &semantics::ContractSemantics, - ) -> VerificationResult { - let start_time = Instant::now(); - - tracing::debug!("Proving bisimulation between contracts"); - - // Create bisimulation assertion - let bisim_formula = "(assert (forall ((state State) (input Input)) - (= (execute-original state input) - (execute-obfuscated state input))))" - .to_string(); - - // TODO: Implement actual SMT verification - let proven = true; // Placeholder - let proof_time = start_time.elapsed(); - - Ok(ProofStatement::new( - "Bisimulation: Every execution step produces identical results".to_string(), - bisim_formula, - proven, - proof_time, - )) - } - - /// Prove state equivalence: final states are identical - async fn prove_state_equivalence( - &mut self, - _original: &semantics::ContractSemantics, - _obfuscated: &semantics::ContractSemantics, - ) -> VerificationResult { - let start_time = Instant::now(); - - tracing::debug!("Proving state equivalence between contracts"); - - let state_equiv_formula = "(assert (forall ((initial-state State) (transaction Tx)) - (= (final-state (execute-original initial-state transaction)) - (final-state (execute-obfuscated initial-state transaction)))))" - .to_string(); - - // TODO: Implement actual SMT verification - let proven = true; - let proof_time = start_time.elapsed(); - - Ok(ProofStatement::new( - "State Equivalence: Final contract states are identical".to_string(), - state_equiv_formula, - proven, - proof_time, - )) - } - - /// Prove that security properties are preserved - async fn prove_property_preservation( - &mut self, - _original: &semantics::ContractSemantics, - _obfuscated: &semantics::ContractSemantics, - property: &SecurityProperty, - ) -> VerificationResult { - let start_time = Instant::now(); - - let description = property.description(); - let formal_statement = property.to_smt_formula(); - - // TODO: Implement actual property verification - let proven = true; - let proof_time = start_time.elapsed(); - - Ok(ProofStatement::new( - description, - formal_statement, - proven, - proof_time, - )) - } - - /// Prove gas consumption bounds - async fn prove_gas_bounds( - &mut self, - _original: &semantics::ContractSemantics, - _obfuscated: &semantics::ContractSemantics, - ) -> VerificationResult { - let start_time = Instant::now(); - - tracing::debug!("Proving gas consumption bounds"); - - let gas_bound_formula = "(assert (forall ((input Input)) - (<= (gas-consumed (execute-obfuscated input)) - (* 1.15 (gas-consumed (execute-original input))))))" - .to_string(); - - // TODO: Implement actual gas bounds verification - let proven = true; - let proof_time = start_time.elapsed(); - - Ok(ProofStatement::new( - "Gas Bounds: Obfuscated contract uses at most 15% more gas".to_string(), - gas_bound_formula, - proven, - proof_time, - )) + Err(Error::VerificationUnavailable { + reason: "equivalence, state, property, and gas obligations are not implemented soundly" + .to_string(), + }) } } @@ -230,14 +60,38 @@ pub struct TransformInfo { pub order: usize, } -/// Summary of verification results for quick inspection +/// Verifier-owned summary of verification results. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct VerificationSummary { - pub overall_passed: bool, - pub formal_verification_passed: bool, + #[serde(default, skip_deserializing)] + overall_passed: bool, + #[serde(default, skip_deserializing)] + formal_verification_passed: bool, pub verification_time_ms: u64, } +impl VerificationSummary { + /// Construct a summary from verifier-owned proof status. + pub fn from_proof(proof: &FormalProof, verification_time_ms: u64) -> Self { + let passed = proof.is_valid(); + Self { + overall_passed: passed, + formal_verification_passed: passed, + verification_time_ms, + } + } + + /// Whether every verification layer passed. + pub fn overall_passed(&self) -> bool { + self.overall_passed + } + + /// Whether a sound formal proof passed. + pub fn formal_verification_passed(&self) -> bool { + self.formal_verification_passed + } +} + #[cfg(test)] mod tests { use super::*; @@ -251,6 +105,18 @@ mod tests { assert!(verifier.is_ok() || matches!(verifier.unwrap_err(), Error::SmtSolver(_))); } + #[tokio::test] + async fn equivalence_verification_fails_closed_while_obligations_are_unimplemented() { + let mut verifier = FormalVerifier::new().unwrap(); + + let error = verifier + .prove_equivalence(&[], &[], &[], &[], &[]) + .await + .unwrap_err(); + + assert!(matches!(error, Error::VerificationUnavailable { .. })); + } + #[test] fn test_security_property_encoding() { let function_sel = [0x12, 0x34, 0x56, 0x78]; @@ -270,7 +136,6 @@ mod tests { let statements = vec![ProofStatement::new( "Test".to_string(), "(assert true)".to_string(), - true, Duration::from_millis(100), )]; @@ -283,4 +148,34 @@ mod tests { // Hash should be deterministic assert_eq!(proof.proof_hash.len(), 64); // SHA3-256 produces 32 bytes = 64 hex chars } + + #[test] + fn verification_summary_cannot_be_forged_by_deserialization() { + let summary: VerificationSummary = serde_json::from_str( + r#"{"overall_passed":true,"formal_verification_passed":true,"verification_time_ms":1}"#, + ) + .unwrap(); + + assert!(!summary.overall_passed()); + assert!(!summary.formal_verification_passed()); + } + + #[test] + fn hash_valid_record_does_not_make_verification_summary_pass() { + let statement = ProofStatement::new( + "unverified obligation".to_string(), + "(assert true)".to_string(), + Duration::from_millis(1), + ); + let proof = FormalProof::new( + ProofType::Bisimulation, + vec![statement], + Duration::from_millis(1), + ); + assert!(proof.verify_hash()); + + let summary = VerificationSummary::from_proof(&proof, 1); + assert!(!summary.overall_passed()); + assert!(!summary.formal_verification_passed()); + } } diff --git a/crates/verification/src/proofs.rs b/crates/verification/src/proofs.rs index 4d3297a6..1f9028c6 100644 --- a/crates/verification/src/proofs.rs +++ b/crates/verification/src/proofs.rs @@ -4,22 +4,29 @@ use serde::{Deserialize, Serialize}; use sha3::{Digest, Sha3_256}; use std::time::Duration; -/// A formal mathematical proof of contract equivalence +/// A record of formal proof obligations and their verifier-owned status. +/// +/// This record is not proof evidence unless [`FormalProof::is_valid`] returns +/// `true`. No current constructor can produce that status. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FormalProof { /// Type of proof generated pub proof_type: ProofType, - /// Mathematical statements proven + /// Mathematical proof obligations carried by this record pub statements: Vec, - /// Time taken to generate the proof + /// Time attributed to evaluating this proof record pub proof_time: Duration, - /// Whether the proof is valid - pub valid: bool, - /// Hash of the proof for integrity verification + /// Cached validity. Use [`FormalProof::is_valid`] rather than trusting serialized data. + /// + /// There is intentionally no constructor or setter that can make this `true` + /// while the equivalence verifier is unavailable. Serialized input is ignored. + #[serde(default, skip_deserializing)] + valid: bool, + /// Unkeyed checksum for detecting accidental record changes pub proof_hash: String, } -/// Types of formal proofs we can generate +/// Categories of formal proof obligations #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ProofType { /// Bisimulation proof showing step-by-step equivalence @@ -34,51 +41,103 @@ pub enum ProofType { Combined(Vec), } -/// A mathematical statement that has been proven +/// A mathematical proof obligation and its verifier-owned status. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProofStatement { - /// Human-readable description of what was proven + /// Human-readable description of the obligation pub description: String, /// Formal mathematical statement (in SMT-LIB format) pub formal_statement: String, - /// Whether this statement was successfully proven - pub proven: bool, - /// Time taken to prove this statement + /// Whether this statement was successfully proven by this process. + /// + /// Caller-controlled serialized data cannot set this field. + #[serde(default, skip_deserializing)] + proven: bool, + /// Time attributed to evaluating this obligation pub proof_time: Duration, } impl FormalProof { - /// Create a new formal proof + /// Create an unattested proof record. + /// + /// This constructor only packages statements. It cannot validate them and + /// therefore always creates an invalid proof. A sound verifier must eventually + /// provide a separate, internal attestation path before this crate can emit a + /// valid [`FormalProof`]. pub fn new( proof_type: ProofType, statements: Vec, proof_time: Duration, ) -> Self { - let valid = statements.iter().all(|s| s.proven); - let proof_hash = Self::compute_hash(&statements); + Self::build(proof_type, statements, proof_time) + } + + fn build(proof_type: ProofType, statements: Vec, proof_time: Duration) -> Self { + let proof_hash = Self::compute_hash(&proof_type, &statements, proof_time); Self { proof_type, statements, proof_time, - valid, + valid: false, proof_hash, } } - /// Compute hash of the proof for integrity verification - fn compute_hash(statements: &[ProofStatement]) -> String { + /// Compute the record checksum. + fn compute_hash( + proof_type: &ProofType, + statements: &[ProofStatement], + proof_time: Duration, + ) -> String { let mut hasher = Sha3_256::new(); + proof_type.update_hash(&mut hasher); + hasher.update((statements.len() as u64).to_le_bytes()); + Self::update_duration(&mut hasher, proof_time); for statement in statements { - hasher.update(statement.formal_statement.as_bytes()); - hasher.update(statement.proven.to_string().as_bytes()); + Self::update_length_prefixed(&mut hasher, statement.description.as_bytes()); + Self::update_length_prefixed(&mut hasher, statement.formal_statement.as_bytes()); + hasher.update([u8::from(statement.proven)]); + Self::update_duration(&mut hasher, statement.proof_time); } hex::encode(hasher.finalize()) } - /// Get the number of proven statements + fn update_length_prefixed(hasher: &mut Sha3_256, value: &[u8]) { + hasher.update((value.len() as u64).to_le_bytes()); + hasher.update(value); + } + + fn update_duration(hasher: &mut Sha3_256, duration: Duration) { + hasher.update(duration.as_secs().to_le_bytes()); + hasher.update(duration.subsec_nanos().to_le_bytes()); + } + + /// Recomputes whether this proof contains every declared obligation and all of them passed. + pub fn is_valid(&self) -> bool { + let required_statements = self.proof_type.obligation_count(); + self.valid + && required_statements > 0 + && self.statements.len() == required_statements + && self + .statements + .iter() + .all(ProofStatement::is_complete_and_proven) + && self.proof_hash + == Self::compute_hash(&self.proof_type, &self.statements, self.proof_time) + } + + /// Verifies the record checksum. + /// + /// This only detects changes relative to the stored checksum. It is not a + /// signature, solver evidence, or a substitute for [`FormalProof::is_valid`]. + pub fn verify_hash(&self) -> bool { + self.proof_hash == Self::compute_hash(&self.proof_type, &self.statements, self.proof_time) + } + + /// Get the number of verifier-proven statements. pub fn proven_statements_count(&self) -> usize { - self.statements.iter().filter(|s| s.proven).count() + self.statements.iter().filter(|s| s.is_proven()).count() } /// Get the total number of statements @@ -107,34 +166,60 @@ impl FormalProof { proof_types.push(proof.proof_type); } - Self::new(ProofType::Combined(proof_types), all_statements, total_time) + Self::build(ProofType::Combined(proof_types), all_statements, total_time) + } +} + +impl ProofType { + fn obligation_count(&self) -> usize { + match self { + Self::Combined(types) => types.iter().map(Self::obligation_count).sum(), + _ => 1, + } + } + + fn update_hash(&self, hasher: &mut Sha3_256) { + match self { + ProofType::Bisimulation => hasher.update(b"bisimulation"), + ProofType::StateEquivalence => hasher.update(b"state-equivalence"), + ProofType::PropertyPreservation => hasher.update(b"property-preservation"), + ProofType::GasBounds => hasher.update(b"gas-bounds"), + ProofType::Combined(types) => { + hasher.update(b"combined"); + hasher.update((types.len() as u64).to_le_bytes()); + for proof_type in types { + proof_type.update_hash(hasher); + } + } + } } } impl ProofStatement { - /// Create a new proof statement - pub fn new( - description: String, - formal_statement: String, - proven: bool, - proof_time: Duration, - ) -> Self { + fn is_complete_and_proven(&self) -> bool { + self.proven + && !self.description.trim().is_empty() + && !self.formal_statement.trim().is_empty() + } + + /// Create an unverified proof obligation. + pub fn new(description: String, formal_statement: String, proof_time: Duration) -> Self { Self { description, formal_statement, - proven, + proven: false, proof_time, } } - /// Create a successful proof statement - pub fn proven(description: String, formal_statement: String, proof_time: Duration) -> Self { - Self::new(description, formal_statement, true, proof_time) + /// Whether a sound verifier in this process proved the obligation. + pub fn is_proven(&self) -> bool { + self.proven } /// Create a failed proof statement pub fn failed(description: String, formal_statement: String, proof_time: Duration) -> Self { - Self::new(description, formal_statement, false, proof_time) + Self::new(description, formal_statement, proof_time) } } @@ -143,29 +228,31 @@ mod tests { use super::*; #[test] - fn test_proof_creation() { - let statements = vec![ProofStatement::proven( + fn public_constructors_cannot_claim_proven_or_valid() { + let statement = ProofStatement::new( "Test statement".to_string(), "(assert true)".to_string(), Duration::from_millis(100), - )]; + ); + assert!(!statement.is_proven()); let proof = FormalProof::new( ProofType::Bisimulation, - statements, + vec![statement], Duration::from_millis(100), ); - assert!(proof.valid); - assert_eq!(proof.proven_statements_count(), 1); - assert_eq!(proof.success_rate(), 1.0); + assert!(!proof.is_valid()); + assert!(proof.verify_hash()); + assert_eq!(proof.proven_statements_count(), 0); + assert_eq!(proof.success_rate(), 0.0); } #[test] - fn test_proof_combination() { + fn combining_unattested_proofs_remains_invalid() { let proof1 = FormalProof::new( ProofType::Bisimulation, - vec![ProofStatement::proven( + vec![ProofStatement::new( "Test 1".to_string(), "(assert true)".to_string(), Duration::from_millis(50), @@ -175,7 +262,7 @@ mod tests { let proof2 = FormalProof::new( ProofType::StateEquivalence, - vec![ProofStatement::proven( + vec![ProofStatement::new( "Test 2".to_string(), "(assert (= a b))".to_string(), Duration::from_millis(75), @@ -188,5 +275,127 @@ mod tests { assert_eq!(combined.total_statements_count(), 2); assert_eq!(combined.proof_time, Duration::from_millis(125)); assert!(matches!(combined.proof_type, ProofType::Combined(_))); + assert!(!combined.is_valid()); + assert_eq!(combined.proven_statements_count(), 0); + } + + #[test] + fn empty_proof_is_never_valid() { + let proof = FormalProof::new( + ProofType::Bisimulation, + Vec::new(), + Duration::from_millis(1), + ); + + assert!(!proof.is_valid()); + assert_eq!(proof.success_rate(), 0.0); + } + + #[test] + fn empty_or_failed_statement_cannot_report_proven() { + let empty = FormalProof::new( + ProofType::Bisimulation, + vec![ProofStatement::new( + String::new(), + String::new(), + Duration::from_millis(1), + )], + Duration::from_millis(1), + ); + let failed = FormalProof::new( + ProofType::Bisimulation, + vec![ProofStatement::failed( + "failed".to_string(), + "(assert false)".to_string(), + Duration::from_millis(1), + )], + Duration::from_millis(1), + ); + + assert!(!empty.is_valid()); + assert!(!failed.is_valid()); + assert_eq!(empty.proven_statements_count(), 0); + assert_eq!(failed.proven_statements_count(), 0); + } + + #[test] + fn proof_hash_binds_type_content_and_timing() { + let statement = ProofStatement::new( + "same".to_string(), + "(assert true)".to_string(), + Duration::from_millis(1), + ); + let bisimulation = FormalProof::new( + ProofType::Bisimulation, + vec![statement.clone()], + Duration::from_millis(1), + ); + let state = FormalProof::new( + ProofType::StateEquivalence, + vec![statement], + Duration::from_millis(1), + ); + + assert_ne!(bisimulation.proof_hash, state.proof_hash); + + let different_proof_time = FormalProof::new( + ProofType::Bisimulation, + vec![ProofStatement::new( + "same".to_string(), + "(assert true)".to_string(), + Duration::from_millis(1), + )], + Duration::from_millis(2), + ); + assert_ne!(bisimulation.proof_hash, different_proof_time.proof_hash); + + let different_statement_time = FormalProof::new( + ProofType::Bisimulation, + vec![ProofStatement::new( + "same".to_string(), + "(assert true)".to_string(), + Duration::from_millis(2), + )], + Duration::from_millis(1), + ); + assert_ne!(bisimulation.proof_hash, different_statement_time.proof_hash); + + let mut tampered = bisimulation; + tampered.statements[0].formal_statement = "(assert false)".to_string(); + assert!(!tampered.verify_hash()); + assert!(!tampered.is_valid()); + } + + #[test] + fn nested_combined_types_count_every_leaf_obligation() { + let proof_type = ProofType::Combined(vec![ + ProofType::Bisimulation, + ProofType::Combined(vec![ProofType::StateEquivalence, ProofType::GasBounds]), + ]); + + assert_eq!(proof_type.obligation_count(), 3); + assert_eq!(ProofType::Combined(Vec::new()).obligation_count(), 0); + } + + #[test] + fn caller_supplied_status_flags_are_ignored_during_deserialization() { + let proof = FormalProof::new( + ProofType::Bisimulation, + vec![ProofStatement::new( + "claim".to_string(), + "(assert (= x x))".to_string(), + Duration::from_millis(1), + )], + Duration::from_millis(1), + ); + let mut value = serde_json::to_value(&proof).unwrap(); + value["valid"] = serde_json::Value::Bool(true); + value["statements"][0]["proven"] = serde_json::Value::Bool(true); + + let deserialized: FormalProof = serde_json::from_value(value).unwrap(); + assert!(!deserialized.is_valid()); + assert!(!deserialized.statements[0].is_proven()); + assert_eq!(deserialized.proven_statements_count(), 0); + assert_eq!(deserialized.success_rate(), 0.0); } } diff --git a/crates/verification/src/result.rs b/crates/verification/src/result.rs index e175ea11..96018113 100644 --- a/crates/verification/src/result.rs +++ b/crates/verification/src/result.rs @@ -5,6 +5,18 @@ use thiserror::Error; /// Main error type for verification operations #[derive(Error, Debug)] pub enum Error { + /// The requested verification flow has no sound implementation yet. + #[error("verification is unavailable: {reason}")] + VerificationUnavailable { reason: String }, + /// A proof request contained no obligations or otherwise omitted required work. + #[error("verification is incomplete: {reason}")] + IncompleteProof { reason: String }, + /// The verifier cannot soundly interpret the requested formula or obligation. + #[error("unsupported verification obligation: {0}")] + Unsupported(String), + /// The SMT solver could not determine satisfiability. + #[error("SMT solver returned unknown: {0}")] + SolverUnknown(String), #[error("SMT solver error: {0}")] SmtSolver(String), #[error("Verification timeout after {seconds} seconds")] diff --git a/crates/verification/src/semantics.rs b/crates/verification/src/semantics.rs index c7d16af9..ac2cad48 100644 --- a/crates/verification/src/semantics.rs +++ b/crates/verification/src/semantics.rs @@ -284,18 +284,25 @@ pub async fn extract_semantics_from_bytecode( bytecode.len() ); - let (instructions, _, _, _) = - decoder::decode_bytecode(&format!("0x{}", hex::encode(bytecode)), false) - .await - .map_err(|e| Error::BytecodeAnalysis(format!("Failed to decode bytecode: {e}")))?; + let instructions = decoder::decode_bytes(bytecode) + .map_err(|e| Error::BytecodeAnalysis(format!("Failed to decode bytecode: {e}")))?; let sections = detection::locate_sections(bytecode, &instructions, runtime_bytes) .map_err(|e| Error::BytecodeAnalysis(format!("Failed to detect sections: {e}")))?; + let runtime = sections + .iter() + .find(|section| section.kind == detection::SectionKind::Runtime) + .ok_or_else(|| Error::BytecodeAnalysis("No runtime section found".to_string()))?; + let runtime_instructions = + decoder::decode_executable_range(bytecode, runtime.offset, runtime.len).map_err(|e| { + Error::BytecodeAnalysis(format!("Failed to decode runtime bytecode: {e}")) + })?; + let (_clean_runtime, clean_report) = strip::strip_bytecode(bytecode, §ions) .map_err(|e| Error::BytecodeAnalysis(format!("Failed to strip bytecode: {e}")))?; - let cfg_bundle = cfg_ir::build_cfg_ir(&instructions, §ions, clean_report, bytecode) + let cfg_bundle = cfg_ir::build_cfg_ir(&runtime_instructions, §ions, clean_report, bytecode) .map_err(|e| Error::BytecodeAnalysis(format!("Failed to build CFG: {e}")))?; extract_semantics(&cfg_bundle) @@ -400,12 +407,12 @@ impl SemanticAnalyzer { // Use CFG's existing edge information let incoming_edges: Vec = cfg .edges_directed(node_idx, petgraph::Direction::Incoming) - .map(|edge| edge.weight().clone()) + .map(|edge| *edge.weight()) .collect(); let outgoing_edges: Vec = cfg .edges_directed(node_idx, petgraph::Direction::Outgoing) - .map(|edge| edge.weight().clone()) + .map(|edge| *edge.weight()) .collect(); block_summaries.push(BlockSummary { @@ -567,18 +574,16 @@ impl SemanticAnalyzer { }); } } - Opcode::SSTORE => { - if stack.len() >= 2 { - let slot = stack[stack.len() - 1].clone(); - let value = stack[stack.len() - 2].clone(); - self.storage_accesses.push(StorageAccess { - pc: instruction.pc, - slot, - access_type: StorageAccessType::Store, - stored_value: Some(value), - conditions: path_conditions.clone(), - }); - } + Opcode::SSTORE if stack.len() >= 2 => { + let slot = stack[stack.len() - 1].clone(); + let value = stack[stack.len() - 2].clone(); + self.storage_accesses.push(StorageAccess { + pc: instruction.pc, + slot, + access_type: StorageAccessType::Store, + stored_value: Some(value), + conditions: path_conditions.clone(), + }); } _ => {} } @@ -726,24 +731,20 @@ impl SemanticAnalyzer { } } } - Opcode::CALLDATALOAD => { - if !stack.is_empty() { - let offset = stack.pop().unwrap(); - stack.push(StackValue::Symbolic(format!( - "CALLDATALOAD({})", - self.stack_value_to_string(&offset) - ))); - } + Opcode::CALLDATALOAD if !stack.is_empty() => { + let offset = stack.pop().unwrap(); + stack.push(StackValue::Symbolic(format!( + "CALLDATALOAD({})", + self.stack_value_to_string(&offset) + ))); } - Opcode::ADD => { - if stack.len() >= 2 { - let b = stack.pop().unwrap(); - let a = stack.pop().unwrap(); - stack.push(StackValue::Operation { - op: "ADD".to_string(), - operands: vec![Box::new(a), Box::new(b)], - }); - } + Opcode::ADD if stack.len() >= 2 => { + let b = stack.pop().unwrap(); + let a = stack.pop().unwrap(); + stack.push(StackValue::Operation { + op: "ADD".to_string(), + operands: vec![Box::new(a), Box::new(b)], + }); } Opcode::POP => { stack.pop(); @@ -974,23 +975,19 @@ impl SemanticAnalyzer { for pattern in &self.patterns { match pattern.pattern_type { - PatternType::ERC20Token => { - if self.is_transfer_function(selector) { - preconditions.push( - "(>= (balance (sender tx) (storage state)) (transfer-amount tx))" - .to_string(), - ); - preconditions.push( - "(not (= (recipient tx) #x0000000000000000000000000000000000000000))" - .to_string(), - ); - preconditions.push("(> (transfer-amount tx) 0)".to_string()); - } + PatternType::ERC20Token if self.is_transfer_function(selector) => { + preconditions.push( + "(>= (balance (sender tx) (storage state)) (transfer-amount tx))" + .to_string(), + ); + preconditions.push( + "(not (= (recipient tx) #x0000000000000000000000000000000000000000))" + .to_string(), + ); + preconditions.push("(> (transfer-amount tx) 0)".to_string()); } - PatternType::Ownable => { - if self.is_admin_function(selector) { - preconditions.push("(= (sender tx) (owner (storage state)))".to_string()); - } + PatternType::Ownable if self.is_admin_function(selector) => { + preconditions.push("(= (sender tx) (owner (storage state)))".to_string()); } PatternType::ReentrancyGuard => { preconditions.push("(not (guard-locked (storage state)))".to_string()); @@ -1216,6 +1213,8 @@ pub mod tests { dispatcher_blocks: std::collections::HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + layout_order: Vec::new(), + relationships: cfg_ir::RelationshipIndex::default(), }; let analyzer = SemanticAnalyzer::new(cfg_bundle); @@ -1248,6 +1247,7 @@ pub mod tests { instructions, max_stack: 1, control: cfg_ir::BlockControl::Unknown, + section: azoth_core::detection::SectionKind::Runtime, }); let node_idx = cfg.add_node(block); @@ -1271,6 +1271,8 @@ pub mod tests { dispatcher_blocks: std::collections::HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + layout_order: vec![node_idx], + relationships: cfg_ir::RelationshipIndex::default(), }; let analyzer = SemanticAnalyzer::new(cfg_bundle); @@ -1300,6 +1302,8 @@ pub mod tests { dispatcher_blocks: std::collections::HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + layout_order: Vec::new(), + relationships: cfg_ir::RelationshipIndex::default(), }; let analyzer = SemanticAnalyzer::new(cfg_bundle); diff --git a/crates/verification/src/smt.rs b/crates/verification/src/smt.rs index 85da2cd4..2ca4f335 100644 --- a/crates/verification/src/smt.rs +++ b/crates/verification/src/smt.rs @@ -1,18 +1,18 @@ -//! SMT solver integration for formal verification +//! Experimental SMT formula generation and a deliberately narrow Z3 adapter. //! -//! This module provides SMT-LIB formula generation and Z3 solver integration -//! for proving contract equivalence and property preservation. +//! Only the formula forms parsed exactly by this module are accepted. Unsupported +//! or incomplete obligations are errors; they are never approximated as `true`. use crate::semantics::{ContractSemantics, FunctionSemantics, ModificationType, StateModification}; use crate::{Error, VerificationResult}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use std::time::Duration; use z3::{ ast::{self, Ast}, Config, Context, Solver, }; -/// SMT solver for formal verification +/// SMT solver adapter for definitive satisfiability checks. #[derive(Debug)] pub struct SmtSolver { z3_context: Context, @@ -25,15 +25,40 @@ struct SmtFormula { assertions: Vec, } -/// Result from SMT solver -#[derive(Debug, Clone, Serialize, Deserialize)] +/// A definitive result from the SMT solver. +/// +/// Z3's `unknown` result is returned as [`Error::SolverUnknown`], so this type +/// can only represent `sat` or `unsat`. +#[derive(Debug, Clone, Serialize)] pub struct SmtResult { /// Whether the formula is satisfiable - pub satisfiable: bool, + satisfiable: bool, /// Model (if satisfiable) - pub model: Option, + model: Option, /// Time taken to solve - pub solve_time: Duration, + solve_time: Duration, +} + +impl SmtResult { + /// Whether Z3 definitively returned `sat`. + pub fn is_satisfiable(&self) -> bool { + self.satisfiable + } + + /// Whether Z3 definitively returned `unsat`. + pub fn is_unsatisfiable(&self) -> bool { + !self.satisfiable + } + + /// The model produced for a satisfiable formula, if Z3 supplied one. + pub fn model(&self) -> Option<&str> { + self.model.as_deref() + } + + /// Time spent in the definitive solver check. + pub fn solve_time(&self) -> Duration { + self.solve_time + } } /// Function parameter information extracted from semantic analysis @@ -83,6 +108,12 @@ impl SmtSolver { /// Check satisfiability of SMT formulas pub async fn check_satisfiability(&self, formulas: &[String]) -> VerificationResult { + if formulas.is_empty() { + return Err(Error::IncompleteProof { + reason: "no SMT assertions were supplied".to_string(), + }); + } + let start_time = std::time::Instant::now(); let solver = Solver::new(&self.z3_context); @@ -92,8 +123,7 @@ impl SmtSolver { } // Check satisfiability - let result = solver.check(); - let satisfiable = matches!(result, z3::SatResult::Sat); + let satisfiable = Self::definitive_satisfiability(solver.check())?; // Get model if satisfiable let model = if satisfiable { @@ -111,6 +141,16 @@ impl SmtSolver { }) } + fn definitive_satisfiability(result: z3::SatResult) -> VerificationResult { + match result { + z3::SatResult::Sat => Ok(true), + z3::SatResult::Unsat => Ok(false), + z3::SatResult::Unknown => Err(Error::SolverUnknown( + "Z3 did not return sat or unsat".to_string(), + )), + } + } + /// Parse and add SMT formula to solver fn parse_and_add_formula(&self, solver: &z3::Solver, formula: &str) -> VerificationResult<()> { if formula.trim().starts_with("(assert") { @@ -118,27 +158,83 @@ impl SmtSolver { let ast = self.parse_assertion_content(&content)?; solver.assert(&ast); Ok(()) + } else if formula.trim().starts_with("(declare-") { + Err(Error::Unsupported( + "standalone declarations are not supported by the typed SMT adapter; no assertion was checked" + .to_string(), + )) } else { - // Handle declarations - if formula.trim().starts_with("(declare-") { - // For now, skip declarations as they're handled by our type system - Ok(()) - } else { - Err(Error::SmtSolver(format!( - "Unsupported formula format: {formula}", - ))) - } + Err(Error::Unsupported(format!( + "unsupported formula format: {formula}", + ))) } } fn extract_assertion_content(&self, formula: &str) -> VerificationResult { - let trimmed = formula.trim(); - if trimmed.starts_with("(assert") && trimmed.ends_with(')') { - let content = &trimmed[8..trimmed.len() - 1].trim(); - Ok(content.to_string()) - } else { - Err(Error::SmtSolver("Invalid assertion format".to_string())) + let content = Self::exact_application_body(formula, "assert")?; + if content.is_empty() { + return Err(Error::IncompleteProof { + reason: "SMT assertion has an empty body".to_string(), + }); } + Ok(content.to_string()) + } + + /// Return the body of one exact S-expression application. + /// + /// This intentionally rejects trailing commands and unbalanced or surplus + /// parentheses. The adapter must never solve a lossy approximation of the + /// caller's input. + fn exact_application_body<'a>( + expression: &'a str, + operator: &str, + ) -> VerificationResult<&'a str> { + let expression = expression.trim(); + if !expression.starts_with('(') || !expression.ends_with(')') { + return Err(Error::Unsupported(format!( + "expected one complete `{operator}` expression: {expression}", + ))); + } + + let mut depth = 0usize; + for (offset, character) in expression.char_indices() { + match character { + '(' => depth += 1, + ')' => { + if depth == 0 { + return Err(Error::Unsupported(format!( + "unbalanced SMT expression: {expression}", + ))); + } + depth -= 1; + if depth == 0 && offset + character.len_utf8() != expression.len() { + return Err(Error::Unsupported(format!( + "trailing SMT input is not supported: {expression}", + ))); + } + } + _ => {} + } + } + if depth != 0 { + return Err(Error::Unsupported(format!( + "unbalanced SMT expression: {expression}", + ))); + } + + let inner = &expression[1..expression.len() - 1]; + let rest = inner.strip_prefix(operator).ok_or_else(|| { + Error::Unsupported(format!( + "expected `{operator}` application, got: {expression}", + )) + })?; + if !rest.is_empty() && !rest.chars().next().is_some_and(char::is_whitespace) { + return Err(Error::Unsupported(format!( + "invalid `{operator}` application: {expression}", + ))); + } + + Ok(rest.trim()) } fn parse_assertion_content(&self, content: &str) -> VerificationResult> { @@ -151,125 +247,63 @@ impl SmtSolver { Ok(ast::Bool::from_bool(&self.z3_context, false)) } else if content.starts_with("(=") { self.parse_equality(content) - } else if content.starts_with("(>") { - self.parse_comparison(content, ">") } else if content.starts_with("(>=") { self.parse_comparison(content, ">=") - } else if content.starts_with("(<") { - self.parse_comparison(content, "<") } else if content.starts_with("(<=") { self.parse_comparison(content, "<=") - } else if content.starts_with("(and") { - self.parse_and(content) - } else if content.starts_with("(or") { - self.parse_or(content) + } else if content.starts_with("(>") { + self.parse_comparison(content, ">") + } else if content.starts_with("(<") { + self.parse_comparison(content, "<") } else if content.starts_with("(not") { self.parse_not(content) - } else if content.starts_with("(forall") { - self.parse_forall(content) - } else if content.starts_with("(=>") { - self.parse_implies(content) } else { - // For now, treat unknown formulas as true to avoid failures - tracing::warn!("Unknown SMT formula pattern: {content}, treating as true"); - Ok(ast::Bool::from_bool(&self.z3_context, true)) + Err(Error::Unsupported(format!( + "unsupported SMT assertion: {content}", + ))) } } fn parse_equality(&self, content: &str) -> VerificationResult> { - // Simple equality parsing: (= a b) - if content.len() > 4 { - let inner = &content[2..content.len() - 1].trim(); - let parts: Vec<&str> = inner.split_whitespace().collect(); - if parts.len() == 2 { - let left = self.parse_term(parts[0])?; - let right = self.parse_term(parts[1])?; - Ok(left._eq(&right)) - } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } + let inner = Self::exact_application_body(content, "=")?; + let parts: Vec<&str> = inner.split_whitespace().collect(); + if parts.len() == 2 { + let left = self.parse_term(parts[0])?; + let right = self.parse_term(parts[1])?; + Ok(left._eq(&right)) } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) + Err(Error::Unsupported(format!( + "equality requires exactly two simple terms: {content}", + ))) } } fn parse_comparison(&self, content: &str, op: &str) -> VerificationResult> { - let op_len = op.len() + 1; // +1 for opening paren - if content.len() > op_len + 1 { - let inner = &content[op_len..content.len() - 1].trim(); - let parts: Vec<&str> = inner.split_whitespace().collect(); - if parts.len() == 2 { - let left = self.parse_int_term(parts[0])?; - let right = self.parse_int_term(parts[1])?; - match op { - ">" => Ok(left.gt(&right)), - ">=" => Ok(left.ge(&right)), - "<" => Ok(left.lt(&right)), - "<=" => Ok(left.le(&right)), - _ => Ok(ast::Bool::from_bool(&self.z3_context, true)), - } - } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) + let inner = Self::exact_application_body(content, op)?; + let parts: Vec<&str> = inner.split_whitespace().collect(); + if parts.len() == 2 { + let left = self.parse_int_term(parts[0])?; + let right = self.parse_int_term(parts[1])?; + match op { + ">" => Ok(left.gt(&right)), + ">=" => Ok(left.ge(&right)), + "<" => Ok(left.lt(&right)), + "<=" => Ok(left.le(&right)), + _ => Err(Error::Unsupported(format!( + "unsupported comparison operator: {op}", + ))), } } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) + Err(Error::Unsupported(format!( + "comparison requires exactly two simple terms: {content}", + ))) } } - fn parse_and(&self, _content: &str) -> VerificationResult> { - // For now, simplified and parsing - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } - - fn parse_or(&self, _content: &str) -> VerificationResult> { - // For now, simplified or parsing - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } - fn parse_not(&self, content: &str) -> VerificationResult> { - if content.len() > 5 { - let inner = &content[4..content.len() - 1].trim(); - let inner_ast = self.parse_assertion_content(inner)?; - Ok(inner_ast.not()) - } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } - } - - fn parse_forall(&self, content: &str) -> VerificationResult> { - let content = content.trim(); - if !content.starts_with("(forall") { - return Ok(ast::Bool::from_bool(&self.z3_context, true)); - } - - // Extract the body part after variable declarations - // For now, we'll parse basic forall patterns - if let Some(body_start) = content.find(")) ") { - let body = &content[body_start + 3..]; - let body = if let Some(stripped) = body.strip_suffix(')') { - stripped - } else { - body - }; - - // Parse the body formula - self.parse_assertion_content(body) - } else { - // Fallback for complex quantifiers - tracing::warn!("Complex quantifier pattern, approximating as true"); - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } - } - - fn parse_implies(&self, content: &str) -> VerificationResult> { - // Implication parsing: (=> a b) - if content.len() > 4 { - let _inner = &content[3..content.len() - 1].trim(); - // For now, simplified parsing - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } else { - Ok(ast::Bool::from_bool(&self.z3_context, true)) - } + let inner = Self::exact_application_body(content, "not")?; + let inner_ast = self.parse_assertion_content(inner)?; + Ok(inner_ast.not()) } fn parse_term(&self, term: &str) -> VerificationResult> { @@ -280,11 +314,12 @@ impl SmtSolver { if let Ok(value) = i64::from_str_radix(stripped, 16) { Ok(ast::Int::from_i64(&self.z3_context, value).into()) } else { - // Create integer variable (Z3 automatically infers sort) - Ok(ast::Int::new_const(&self.z3_context, term).into()) + Err(Error::Unsupported(format!( + "invalid or out-of-range hexadecimal integer literal: {term}", + ))) } } else { - // Variable name - create integer constant + self.validate_identifier(term)?; Ok(ast::Int::new_const(&self.z3_context, term).into()) } } @@ -296,13 +331,32 @@ impl SmtSolver { if let Ok(value) = i64::from_str_radix(stripped, 16) { Ok(ast::Int::from_i64(&self.z3_context, value)) } else { - Ok(ast::Int::new_const(&self.z3_context, term)) + Err(Error::Unsupported(format!( + "invalid or out-of-range hexadecimal integer literal: {term}", + ))) } } else { + self.validate_identifier(term)?; Ok(ast::Int::new_const(&self.z3_context, term)) } } + fn validate_identifier(&self, identifier: &str) -> VerificationResult<()> { + let mut chars = identifier.chars(); + let starts_validly = chars + .next() + .is_some_and(|character| character.is_ascii_alphabetic() || character == '_'); + let remainder_is_valid = chars + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')); + if starts_validly && remainder_is_valid { + Ok(()) + } else { + Err(Error::Unsupported(format!( + "unsupported SMT identifier: {identifier}", + ))) + } + } + /// Generate SMT formulas from contract semantics pub fn encode_contract_semantics( &self, @@ -848,16 +902,13 @@ impl SmtSolver { /// Prove that two contracts are equivalent pub async fn prove_equivalence( &self, - original: &ContractSemantics, - obfuscated: &ContractSemantics, + _original: &ContractSemantics, + _obfuscated: &ContractSemantics, ) -> VerificationResult { - let equivalence_formula = self.generate_equivalence_formula(original, obfuscated)?; - - // Check satisfiability (if unsatisfiable, then equivalence holds) - let result = self.check_satisfiability(&[equivalence_formula]).await?; - - // For equivalence proofs, we want UNSAT (meaning the negation is unsatisfiable) - Ok(!result.satisfiable) + Err(Error::VerificationUnavailable { + reason: "the current equivalence encoding is incomplete and does not encode a negated counterexample obligation" + .to_string(), + }) } } @@ -883,6 +934,81 @@ mod tests { let result = solver.check_satisfiability(&formulas).await; assert!(result.is_ok()); + let result = result.unwrap(); + assert!(!result.is_satisfiable()); + assert!(result.is_unsatisfiable()); + assert!(result.model().is_none()); + assert!(result.solve_time() <= Duration::from_secs(60)); + } + + #[tokio::test] + async fn empty_formula_set_is_rejected() { + let solver = SmtSolver::new().unwrap(); + + let error = solver.check_satisfiability(&[]).await.unwrap_err(); + assert!(matches!(error, Error::IncompleteProof { .. })); + + for empty_assertion in ["(assert)", "(assert )"] { + let error = solver + .check_satisfiability(&[empty_assertion.to_string()]) + .await + .unwrap_err(); + assert!( + matches!(error, Error::IncompleteProof { .. }), + "{empty_assertion}: {error}" + ); + } + } + + #[tokio::test] + async fn declaration_is_not_silently_dropped() { + let solver = SmtSolver::new().unwrap(); + let formulas = vec!["(declare-fun x () Int)".to_string()]; + + let error = solver.check_satisfiability(&formulas).await.unwrap_err(); + assert!(matches!(error, Error::Unsupported(_))); + } + + #[tokio::test] + async fn unsupported_or_malformed_assertions_are_rejected() { + let solver = SmtSolver::new().unwrap(); + + for formula in [ + "(assert (and true true))", + "(assert (= x))", + "(assert (forall ((x Int)) true))", + "(assert (= #xnot-hex 1))", + "(assert true))", + "(assert (= x 1)))", + "(assert (= x 1)", + "(assert true) (assert false)", + "(assertion false)", + "(assert (not))", + ] { + let error = solver + .check_satisfiability(&[formula.to_string()]) + .await + .unwrap_err(); + assert!(matches!(error, Error::Unsupported(_)), "{formula}: {error}"); + } + } + + #[tokio::test] + async fn unsupported_input_is_not_dropped_after_a_contradiction() { + let solver = SmtSolver::new().unwrap(); + let formulas = vec![ + "(assert false)".to_string(), + "(assert (and true true))".to_string(), + ]; + + let error = solver.check_satisfiability(&formulas).await.unwrap_err(); + assert!(matches!(error, Error::Unsupported(_))); + } + + #[test] + fn solver_unknown_is_not_classified_as_unsat() { + let error = SmtSolver::definitive_satisfiability(z3::SatResult::Unknown).unwrap_err(); + assert!(matches!(error, Error::SolverUnknown(_))); } #[test] diff --git a/docs/constructor-argument-obfuscation.md b/docs/constructor-argument-obfuscation.md index e61fb647..6d0dc810 100644 --- a/docs/constructor-argument-obfuscation.md +++ b/docs/constructor-argument-obfuscation.md @@ -1,14 +1,21 @@ # Constructor-argument obfuscation report +> **Historical experimental result — not a release recommendation.** This document records an +> earlier constructor-mask prototype and its local measurements. The current safe foundation +> profile disables constructor-argument masking. Subsequent local red-team evaluation recognized +> the decoder across the evaluated corpus, so the prototype failed Azoth's stealth gate. Its test +> counts and benchmark figures below describe that earlier experiment, not the current profile and +> not production evidence. + ## Executive report -Azoth now removes the report's literal constructor-tail disclosure without changing the input Solidity, source bytecode, ABI, or deployed contract behavior. The deployment runtime supplied to Azoth defines the boundary exactly; all bytes after that complete runtime are masked, and seed-varied init code restores them in memory before the original constructor continues. The returned creation payload therefore no longer contains the original ABI suffix verbatim. +The experimental pass removed literal constructor-tail disclosure in its supported test shapes without changing the input Solidity, source bytecode, or ABI. The deployment runtime supplied to Azoth defined the boundary exactly; bytes after that complete runtime were masked, and seed-varied init code restored them in memory before the original constructor continued. In those experiments, the returned creation payload no longer contained the original ABI suffix verbatim. This bounded behavior was not a proof for arbitrary constructors or environments. -This is the strongest honest Azoth-only response to `mirage-adversarial-privacy-report.md`. Constructor code and transaction input are public, so bytecode-only obfuscation cannot provide cryptographic confidentiality: a capable analyst can execute the init code or reverse its data flow, and any constructor value later written to public runtime code, storage, logs, calls, or proofs remains observable there. The change specifically raises the report's zero-effort static ABI-tail recovery into a program-analysis problem. It does not claim to solve the report's public-state or proof-disclosure findings, and it does not alter the report's CBOR metadata fingerprint finding. +This was intended as a bounded Azoth-only response to `mirage-adversarial-privacy-report.md`. Constructor code and transaction input are public, so bytecode-only obfuscation cannot provide cryptographic confidentiality: a capable analyst can execute the init code or reverse its data flow, and any constructor value later written to public runtime code, storage, logs, calls, or proofs remains observable there. The prototype raised the report's zero-effort static ABI-tail recovery into a program-analysis problem for its supported shapes. It did not solve the report's public-state, proof-disclosure, or CBOR metadata-fingerprint findings. -The implementation introduces no ABI-shaped heuristic and emits no Azoth marker, version header, fixed key, or fixed decoder byte string. The full runtime is already a required Azoth input and is used as an authoritative boundary. Decoder chunk order, arithmetic constants, instruction chains, and trampoline form are seed-derived. As with any public program transformation, a semantic classifier may still recognize self-decoding behavior; no non-ZK construction can honestly guarantee otherwise. +The prototype did not intentionally emit an Azoth marker, version header, fixed key, or one fixed decoder byte string. The full runtime was used as an authoritative boundary, while decoder chunk order, arithmetic constants, instruction chains, and trampoline form were seed-derived. Those variations did not make the construction unrecognizable: the later local detector identified the common self-decoding structure across the evaluated samples. Absence of a literal marker is therefore not evidence of indistinguishability. -Safety is fail-closed. If the supplied runtime is absent or ambiguous, if the constructor has no single supported argument-copy site, if the trampoline cannot preserve existing init-code program counters, or if the result exceeds EIP-170/EIP-3860 limits, obfuscation returns an error instead of exposing plaintext arguments or emitting known-undeployable output. +The prototype attempted to fail closed for the supported shapes: an absent or ambiguous runtime, an unsupported argument-copy site, an unsafe trampoline, or an EIP-170/EIP-3860 size violation returned an error. That parser and its differential tests were still narrower than complete EVM semantic equivalence. ## Technical report @@ -16,9 +23,9 @@ Safety is fail-closed. If the supplied runtime is absent or ambiguous, if the co The previous pipeline treated constructor data as untouched recovery material. Section detection also examined the end of the whole creation payload for Solidity CBOR metadata even though constructor arguments follow the compiler-generated creation bytecode. ABI words could therefore be mistaken for metadata, while the real argument suffix was reassembled unchanged. In the reported deployment this made all six recipient/token/amount rows recoverable by reading aligned words at the tail; no EVM analysis was necessary. -### Design and implementation +### Historical design and implementation -The fix has four cooperating parts: +The experimental prototype had four cooperating parts: 1. **Exact section boundaries.** The complete caller-supplied runtime must occur exactly once in the deployment payload. Its start separates init from runtime, its own CBOR trailer is split as auxdata, and every byte after its end is classified as `ConstructorArgs`. This is byte-exact and works for static, dynamic, packed-looking, all-zero, and adversarial argument values without ABI guessing. @@ -28,9 +35,17 @@ The fix has four cooperating parts: 4. **Correct recovery.** Reassembly now distinguishes deployed suffixes such as CBOR auxdata from transaction-only constructor arguments. Runtime `CODECOPY`/`RETURN` lengths exclude the arguments, creation offsets account for decoder growth, and the constructor's original creation-length constant is patched. Immutable-reference offsets continue to be remapped after runtime transforms. The final payload is checked against the 24,576-byte EIP-170 runtime limit and 49,152-byte EIP-3860 initcode limit. -The transform is automatically applied when an argument suffix exists. Callers may pass a full creation payload to `-D`, or pass compiler creation bytecode plus `--constructor-args `. Result metadata exposes `constructor_args_obfuscated`, `constructor_argument_bytes`, and `constructor_decoder_bytes` so release tooling can enforce that expected sensitive inputs were actually handled. +The library retains an explicit experimental opt-in through +`ObfuscationConfig::obfuscate_constructor_arguments`. It is **not** automatically applied by the +current safe profile, and the safe CLI does not expose it as an admitted pass. Result metadata still +records `constructor_args_obfuscated`, `constructor_argument_bytes`, and +`constructor_decoder_bytes` for research harnesses; those fields are not a release attestation. + +### Historical test snapshot -### Soundness and adversarial verification +The following table was captured during the earlier prototype work. It was useful regression +evidence for the tested fixtures, but it did not establish complete equivalence or stealth and must +not be combined with current-profile results. | Check | Scope | Result | |---|---:|---:| @@ -43,13 +58,13 @@ The transform is automatically applied when an argument suffix exists. Callers m | Static analysis | `cargo clippy ... -D warnings` | Passed | | Formatting | `cargo fmt --all -- --check` | Passed | -The built-in campaign randomly varied seeds, constructor recipients and amounts, and transform selections. Its existing REVM oracle required every transformed payload whose original deployed successfully to deploy successfully as well. The focused differential test supplied the stronger byte-for-byte deployed-runtime comparison. Unsupported and ambiguous copy layouts have explicit rejection tests. +The built-in campaign varied seeds, constructor recipients and amounts, and transform selections. Its REVM oracle required successful creation, while the focused test compared deployed runtime bytes for its selected cases. Neither check covered arbitrary calls, state transitions, logs, reverts, external effects, gas-sensitive behavior, forks, or all compiler shapes. -The full workspace build reaches the external Z3-backed verification crate but cannot compile it in the current environment because the system `z3.h` header is not installed. This is an environment prerequisite, not a failure in the changed core/transform/CLI crates; those crates compile and test cleanly. +At the time of this snapshot, the full workspace build could not compile the external Z3-backed crate because `z3.h` was absent. More importantly, the current production-facing equivalence verifier intentionally returns `VerificationUnavailable`; compiling Z3 does not turn the incomplete encoding into proof evidence. -### Benchmark +### Historical benchmark -Fixture: the repository's Solidity 0.8.30 ERC20 escrow with a 160-byte constructor suffix. Measurements use a release build and REVM. Size/gas deltas compare the original creation payload with the constructor-mask-only result for one representative deterministic seed; decoder distribution and transform time cover 100 deterministic seeds. +Fixture: the repository's Solidity 0.8.30 ERC20 escrow with a 160-byte constructor suffix. These earlier measurements used a release build and REVM. Size/gas deltas compare the original creation payload with the experimental constructor-mask-only result for one representative deterministic seed; decoder distribution and transform time covered 100 deterministic seeds. They are retained for historical engineering context only. | Metric | Before | After | Delta | |---|---:|---:|---:| @@ -61,8 +76,13 @@ Fixture: the repository's Solidity 0.8.30 ERC20 escrow with a 160-byte construct Across 100 seeds, decoder size averaged 569.7 bytes (433 minimum, 711 maximum), and masking averaged 124.1 microseconds per creation payload in the release benchmark. Cost grows approximately with the number of 32-byte chunks; these figures should not be extrapolated as measurements of the report's larger multi-row payload without benchmarking that exact fixture. -### Security boundary and rollout guidance +### Security boundary and current decision -This mitigation closes the report's direct plaintext-suffix extraction path. It does not encrypt transaction calldata, hide values after the EVM decodes them, suppress storage/log/call/proof disclosures, remove Solidity CBOR metadata, or prevent dynamic/symbolic recovery. Teams requiring confidentiality from validators, archive nodes, or skilled reverse engineers need a cryptographic protocol change, which was explicitly outside this work. +In its supported experiments, the pass removed the direct verbatim-suffix extraction path. It did not encrypt transaction calldata, hide values after EVM decoding, suppress storage/log/call/proof disclosures, remove Solidity CBOR metadata, or prevent dynamic/symbolic recovery. The recognizable decoder also created an Azoth-specific classification signal. -For rollout, require `constructor_args_obfuscated: true` whenever an expected deployment has constructor inputs, retain differential deployment testing for each production compiler/version, and treat a fail-closed unsupported-layout error as a release blocker. Re-run the benchmark on the exact production constructor payload because decoder overhead is argument-length dependent. +**Decision: do not roll this pass into the safe profile.** Current safe-profile evaluation must leave +`obfuscate_constructor_arguments` disabled. Redesign requires a representative Ethereum negative +corpus, an out-of-sample detector gate, complete constructor semantic obligations, and differential +behavior coverage before reconsideration. Teams that require constructor-value confidentiality +from validators, archive nodes, or skilled reverse engineers need a cryptographic protocol change; +public self-decoding init code cannot provide that confidentiality. diff --git a/docs/native-bytecode-decoder.md b/docs/native-bytecode-decoder.md new file mode 100644 index 00000000..a61e77d5 --- /dev/null +++ b/docs/native-bytecode-decoder.md @@ -0,0 +1,132 @@ +# Native EVM bytecode decoder + +## Purpose and boundary + +Azoth must understand instruction boundaries before it can build a control-flow graph or move +code. That operation is now performed entirely by `azoth-core`. The production path no longer +converts bytecode to third-party assembly text and parses that text back into instructions. This +removes an async boundary, a text-format dependency, duplicated opcode interpretation, and a class +of partial-result failures. + +Heimdall is deliberately retained only in `azoth-analysis` for optional decompiler and diff views. +It is not a production dependency of `azoth-core`, `azoth-transform`, or `azoth-verification`. +The integration-test crate still reaches it transitively through `azoth-analysis`, but native +decoder tests have no direct Heimdall dependency or use. EOT is not a workspace dependency. + +## Data flow + +The production data flow is: + +```text +hex/file input -> exact bytes -> native instruction stream -> section ownership -> CFG/IR + | + +-> assembly only on explicit diagnostic request +``` + +`crates/core/src/opcode.rs` is the single source of truth for byte-to-opcode mapping, canonical +names, immediate widths, stack inputs/outputs, and block-ending behavior. Fixed opcodes are +declared once in a macro table; `PUSH`, `DUP`, and `SWAP` families are derived from their byte +ranges. Checked encoding rejects impossible manually constructed values such as `PUSH(33)`. + +`crates/core/src/decoder.rs` performs a linear byte walk. Each `Instruction` records its exact +program counter, opcode, and physically present `PUSH` bytes. `crates/core/src/encoder.rs` encodes +from that owned representation and never consults a copy of the original bytecode to guess what an +instruction meant. Checked encoding also rejects immediate data attached to any opcode other than +`PUSH1` through `PUSH32`, validates contiguous program counters, and bounds an immediate before +decoding it; it never silently drops malformed IR fields or allocates from an untrusted claimed +operand size. + +## Two decoding modes + +### Lossless blob decoding + +`decode_bytes(&[u8])` accepts every byte sequence, including empty code. Unknown opcode bytes are +stored as `Opcode::UNKNOWN(byte)`, so decode followed by encode reproduces the exact input. + +This mode is necessary for a complete deployment artifact. Solidity compiler CBOR is data, not +executable code, but its bytes can resemble opcodes and may end syntactically inside a `PUSH` +operand when the whole artifact is viewed as one linear stream. Rejecting that before section +detection would reject ordinary compiler output. + +### Strict executable decoding + +`decode_executable_bytes(&[u8])` adds one safety check: a final `PUSHn` must contain all `n` +immediate bytes. A short final `PUSH` is legal EVM code because missing bytes read as zero. It is +not safe for a relocator, however: appending or moving another instruction after it changes the +value consumed by the `PUSH`. Strict mode returns `Error::TruncatedPush` with the byte offset, +declared width, and available width instead of returning a usable partial stream. + +The transformation pipeline first decodes the complete artifact losslessly, establishes exact +runtime and compiler-data boundaries, and then decodes the runtime again from its own first byte. +This second decode matters because creation code and deployed runtime are separate executions: a +`PUSH` in init data may consume bytes across the runtime boundary in the creation-code linear view, +while deployed execution still treats the first runtime byte as a fresh opcode. Runtime PCs are +rebased to their absolute offsets after this independent strict decode. Validators and transform +helpers operating on an already isolated executable slice call strict mode directly. + +## Unknown bytes and `INVALID` + +Byte `0xfe` has a named EVM meaning and is represented by `Opcode::INVALID`. Other unassigned bytes +remain `Opcode::UNKNOWN(original_byte)`. They are not collapsed to `INVALID`, because doing so +would destroy byte identity during reconstruction. + +In legacy EVM execution both cases halt exceptionally. Azoth therefore ends a basic block at an +unknown byte, but it assigns no stack metadata to that byte. Any analysis that would need to model +an unknown operation must reject the input rather than assuming a zero stack effect. + +## Protocol revision + +The table targets the Ethereum mainnet Fusaka execution-layer revision, named Osaka in execution +specifications. It includes EIP-7939 `CLZ` at byte `0x1e` and all earlier active legacy opcodes. +EOF was removed from Osaka; bytes reserved by the withdrawn EOF proposals remain unknown in +legacy code. The table does not attempt to decode an EOF container. + +Relevant specifications: + +- [EIP-7607: Hardfork Meta - Fusaka](https://eips.ethereum.org/EIPS/eip-7607) +- [EIP-7939: Count leading zeros (`CLZ`)](https://eips.ethereum.org/EIPS/eip-7939) +- [Execution-spec-tests changelog: EOF removed from Osaka](https://github.com/ethereum/execution-spec-tests/blob/main/docs/CHANGELOG.md#v450---2025-05-14) + +The fork target is intentionally explicit. A dependency upgrade can no longer change opcode +semantics implicitly, but a future network upgrade still requires a deliberate Azoth release. + +## Updating for a future hard fork + +For every fork that changes legacy bytecode interpretation: + +1. Read the final EIP and the executable execution specification; do not copy an opcode list from + a decompiler. +2. Add or modify the one table entry in `opcode.rs`, including exact stack inputs and outputs. +3. Update the known-byte and stack-effect oracle tests, mnemonic parsing, and terminal/control-flow + classification when applicable. +4. Add official execution-spec vectors or differential cases for the new byte. +5. Decide whether the byte is valid in legacy code, an EOF/container-only byte, or unassigned. +6. Review every exhaustive opcode match in CFG, relationship, verification, and transform code. + Unhandled known opcodes must fail closed where their semantics matter. +7. Rerun all round-trip, malformed-`PUSH`, REVM differential, fuzz, full workspace, Clippy, and + release benchmark gates. +8. Bump the pipeline profile and deterministic golden vectors if the interpretation can affect an + output or manifest. + +Do not activate a proposed opcode before its target fork is finalized and scheduled. Conversely, +do not leave a newly active byte as `UNKNOWN`: that is byte-lossless, but downstream analysis would +conservatively reject contracts that use it. + +## Verification and performance gates + +The decoder test suite covers all 256 byte values, every `PUSH1` through `PUSH32` width, every +possible final truncation length, opcode-looking immediate data, stable serialization, checked-in +deployment/runtime artifacts, a deterministic 10,000-case arbitrary-byte corpus, and instruction +boundaries differential-tested against REVM. + +Run the focused gates with: + +```sh +cargo test --locked -p azoth-core +cargo test --locked -p azoth-tests core::decoder +cargo run --locked --release -p azoth-examples --bin decoder_benchmark +``` + +The benchmark emits machine-readable JSON and measures decoding separately from assembly +rendering at 10, 100, and 1,000 iterations. Compiler CBOR is excluded from the strict executable +measurement and its byte count is reported explicitly. diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 88c929ac..955d7dc1 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -9,4 +9,6 @@ azoth-transform.workspace = true chrono.workspace = true hex.workspace = true serde_json.workspace = true +sha3.workspace = true tokio.workspace = true +revm.workspace = true diff --git a/examples/scripts/source_tree_fingerprint.pl b/examples/scripts/source_tree_fingerprint.pl new file mode 100755 index 00000000..1bfa8018 --- /dev/null +++ b/examples/scripts/source_tree_fingerprint.pl @@ -0,0 +1,360 @@ +#!/usr/bin/env perl + +# Compute Azoth's canonical dirty-worktree source fingerprint. +# +# This helper uses only modules shipped with Perl. It fingerprints tracked files +# and non-ignored untracked files in the root worktree and all initialized tracked +# submodules, including the escrow fixture submodule. +# +# Official-run example, from the Azoth repository root: +# +# azoth_tree_hash="$(perl examples/scripts/source_tree_fingerprint.pl --repo .)" +# azoth_base_revision="$(git rev-parse HEAD)" +# cargo run --locked --release -p azoth-examples --bin foundation_benchmark -- \ +# --source-revision "base:${azoth_base_revision};tree-sha256:${azoth_tree_hash}" ... +# +# Only the lowercase digest is written to stdout. Errors go to stderr and return +# a non-zero exit status. + +package Azoth::SourceTreeFingerprint; + +use strict; +use warnings; + +use Cwd qw(abs_path); +use Digest::SHA (); +use File::Spec (); +use Getopt::Long qw(GetOptionsFromArray); +use IO::Select (); +use IPC::Open3 qw(open3); +use Symbol qw(gensym); + +our $ALGORITHM_DESCRIPTION = 'azoth-source-tree-sha256-v1: in the root Git worktree and recursively in every initialized tracked submodule, enumerate `git ls-files --cached --others --exclude-standard -z`; replace each gitlink with the recursively enumerated leaf entries beneath its root-relative path and reject uninitialized gitlinks, missing leaves, or leaf types other than regular files and symlinks; represent a regular-file leaf as kind byte 0x00 plus its current file bytes and a symlink leaf as kind byte 0x01 plus its raw link-target bytes; sort leaves lexicographically by raw root-relative path bytes; SHA-256 the ASCII domain `AZOTH_SOURCE_TREE_SHA256_V1` followed by a NUL byte and, for each leaf, its kind byte, u64-be(path byte length), path bytes, u64-be(payload byte length), and payload bytes; encode the digest as lowercase hex'; +our $DOMAIN = "AZOTH_SOURCE_TREE_SHA256_V1\0"; +our $REGULAR_FILE = 0; +our $SYMLINK = 1; +our $GITLINK_MODE = '160000'; + +sub _display { + my ($raw) = @_; + $raw =~ s/([^\x20-\x7e]|\\)/sprintf('\\x%02x', ord($1))/ge; + return $raw; +} + +sub _run_git { + my ($repo, @arguments) = @_; + my $stderr = gensym(); + my ($stdin, $stdout); + my $pid = eval { open3($stdin, $stdout, $stderr, 'git', '-C', $repo, @arguments) }; + die "cannot start git in " . _display($repo) . ": $@" if !defined $pid; + close $stdin; + binmode $stdout; + binmode $stderr; + + my $stdout_fileno = fileno($stdout); + my $stderr_fileno = fileno($stderr); + my $selector = IO::Select->new($stdout, $stderr); + my ($output, $error) = ('', ''); + while (my @ready = $selector->can_read()) { + for my $handle (@ready) { + my $buffer = ''; + my $read = sysread($handle, $buffer, 65_536); + die "cannot read git output: $!\n" if !defined $read; + if ($read == 0) { + $selector->remove($handle); + close $handle; + next; + } + if (fileno($handle) == $stdout_fileno) { + $output .= $buffer; + } + elsif (fileno($handle) == $stderr_fileno) { + $error .= $buffer; + } + } + } + waitpid($pid, 0); + my $status = $?; + if ($status != 0) { + $error =~ s/\s+\z//; + my $exit_code = $status == -1 ? -1 : $status >> 8; + die 'git failed in ' + . _display($repo) + . " (exit $exit_code): $error\n"; + } + return $output; +} + +sub _nul_records { + my ($raw, $label) = @_; + return () if $raw eq ''; + die "$label did not produce a NUL-terminated record stream\n" + if substr($raw, -1) ne "\0"; + chop $raw; + return split /\0/, $raw, -1; +} + +sub _index_modes { + my ($repo) = @_; + my %modes; + for my $record (_nul_records(_run_git($repo, 'ls-files', '--stage', '-z'), + 'git ls-files --stage')) + { + my ($mode, $object_id, $stage, $path) = + $record =~ /\A([0-7]+) ([0-9a-f]+) ([0-3])\t(.*)\z/s; + die "malformed git index record\n" if !defined $path; + die 'unmerged index entry is unsupported: ' + . _display($path) + . " (stage $stage)\n" + if $stage ne '0'; + die 'duplicate git index entry: ' . _display($path) . "\n" + if exists $modes{$path}; + $modes{$path} = $mode; + } + return \%modes; +} + +sub _listed_paths { + my ($repo) = @_; + my @paths = _nul_records( + _run_git( + $repo, '-c', 'core.quotepath=false', 'ls-files', '--cached', + '--others', '--exclude-standard', '-z' + ), + 'git ls-files' + ); + my %seen; + for my $path (@paths) { + die "git produced duplicate worktree paths\n" if $seen{$path}++; + } + return @paths; +} + +sub _validate_relative_path { + my ($path) = @_; + die 'invalid Git worktree path: ' . _display($path) . "\n" + if $path eq '' || substr($path, 0, 1) eq '/'; + for my $component (split m{/}, $path, -1) { + die 'unsafe Git worktree path: ' . _display($path) . "\n" + if $component eq '' || $component eq '.' || $component eq '..'; + } +} + +sub _join_relative { + my ($prefix, $path) = @_; + return $prefix eq '' ? $path : "$prefix/$path"; +} + +sub _assert_worktree_root { + my ($repo, $is_submodule) = @_; + my $git_admin = File::Spec->catfile($repo, '.git'); + my @git_admin_stat = lstat $git_admin; + my $noun = $is_submodule ? 'submodule' : 'repository'; + die "$noun is not initialized at " . _display($repo) . "\n" + if !@git_admin_stat; + my $inside = _run_git($repo, 'rev-parse', '--is-inside-work-tree'); + my $prefix = _run_git($repo, 'rev-parse', '--show-prefix'); + $inside =~ s/\n\z//; + $prefix =~ s/\n\z//; + die 'path is not a Git worktree root: ' . _display($repo) . "\n" + if $inside ne 'true' || $prefix ne ''; +} + +sub _collect_repository { + my ($repo, $root_prefix, $active_worktrees) = @_; + _assert_worktree_root($repo, $root_prefix ne ''); + my $real_repo = abs_path($repo); + die 'cannot resolve worktree root: ' . _display($repo) . "\n" + if !defined $real_repo; + die 'recursive submodule worktree: ' . _display($repo) . "\n" + if $active_worktrees->{$real_repo}; + local $active_worktrees->{$real_repo} = 1; + + my $modes = _index_modes($repo); + my @leaves; + for my $path (_listed_paths($repo)) { + _validate_relative_path($path); + my $absolute_path = File::Spec->catfile($repo, split m{/}, $path, -1); + my $root_path = _join_relative($root_prefix, $path); + if (($modes->{$path} // '') eq $GITLINK_MODE) { + push @leaves, + _collect_repository($absolute_path, $root_path, $active_worktrees); + next; + } + + my @metadata = lstat $absolute_path; + die 'listed worktree leaf is missing: ' . _display($root_path) . "\n" + if !@metadata; + my $kind; + if (-f _) { + $kind = $REGULAR_FILE; + } + elsif (-l _) { + $kind = $SYMLINK; + } + else { + die 'unsupported worktree leaf type: ' . _display($root_path) . "\n"; + } + push @leaves, + { + kind => $kind, + path => $root_path, + absolute_path => $absolute_path, + }; + } + return @leaves; +} + +sub collect_leaves { + my ($repo) = @_; + $repo = File::Spec->rel2abs($repo); + my @leaves = _collect_repository($repo, '', {}); + @leaves = sort { $a->{path} cmp $b->{path} } @leaves; + my %seen; + for my $leaf (@leaves) { + die "submodule expansion produced duplicate root-relative paths\n" + if $seen{$leaf->{path}}++; + } + return @leaves; +} + +sub _u64 { + my ($value, $label) = @_; + die "$label is negative\n" if $value < 0; + return pack('Q>', $value); +} + +sub _stable_payload { + my ($leaf) = @_; + my @before = lstat $leaf->{absolute_path}; + die 'worktree leaf disappeared while hashing: ' . _display($leaf->{path}) . "\n" + if !@before; + + my $payload; + if ($leaf->{kind} == $REGULAR_FILE && -f _) { + open my $source, '<:raw', $leaf->{absolute_path} + or die 'cannot read worktree leaf ' + . _display($leaf->{path}) + . ": $!\n"; + local $/; + $payload = <$source>; + $payload = '' if !defined $payload; + close $source + or die 'cannot close worktree leaf ' + . _display($leaf->{path}) + . ": $!\n"; + } + elsif ($leaf->{kind} == $SYMLINK && -l _) { + $payload = readlink $leaf->{absolute_path}; + die 'cannot read symlink leaf ' . _display($leaf->{path}) . ": $!\n" + if !defined $payload; + } + else { + die 'worktree leaf changed type while hashing: ' + . _display($leaf->{path}) . "\n"; + } + + my @after = lstat $leaf->{absolute_path}; + die 'worktree leaf disappeared while hashing: ' . _display($leaf->{path}) . "\n" + if !@after; + # Compare mode, inode, size, mtime, and ctime. The operator must still quiesce edits + # before an official run; this catches ordinary concurrent replacements and writes. + for my $index (1, 2, 7, 9, 10) { + die 'worktree leaf changed while hashing: ' . _display($leaf->{path}) . "\n" + if $before[$index] != $after[$index]; + } + return $payload; +} + +sub fingerprint_records { + my (@records) = @_; + @records = sort { $a->[1] cmp $b->[1] } @records; + my %seen; + my $digest = Digest::SHA->new(256); + $digest->add($DOMAIN); + for my $record (@records) { + my ($kind, $path, $payload) = @{$record}; + die "duplicate root-relative fingerprint path\n" if $seen{$path}++; + die "unsupported fingerprint kind byte: $kind\n" + if $kind != $REGULAR_FILE && $kind != $SYMLINK; + _validate_relative_path($path); + $digest->add(pack('C', $kind)); + $digest->add(_u64(length($path), 'path length')); + $digest->add($path); + $digest->add(_u64(length($payload), 'payload length')); + $digest->add($payload); + } + return $digest->hexdigest; +} + +sub fingerprint_leaves { + my (@leaves) = @_; + my @records = map { + [$_->{kind}, $_->{path}, _stable_payload($_)] + } sort { $a->{path} cmp $b->{path} } @leaves; + return fingerprint_records(@records); +} + +sub repository_fingerprint { + my ($repo) = @_; + my @leaves = collect_leaves($repo); + return (fingerprint_leaves(@leaves), scalar @leaves); +} + +sub _usage { + my ($stream) = @_; + print {$stream} <<'USAGE'; +Usage: perl examples/scripts/source_tree_fingerprint.pl [OPTIONS] + + --repo PATH Git worktree root to fingerprint (default: .) + --describe Print the exact versioned algorithm description + --verbose Print the fingerprinted leaf count to stderr + --help Show this help +USAGE +} + +sub main { + my (@arguments) = @_; + my $repo = '.'; + my ($describe, $verbose, $help); + my $parsed = GetOptionsFromArray( + \@arguments, + 'repo=s' => \$repo, + 'describe' => \$describe, + 'verbose' => \$verbose, + 'help' => \$help, + ); + if (!$parsed || @arguments) { + _usage(*STDERR); + return 2; + } + if ($help) { + _usage(*STDOUT); + return 0; + } + if ($describe) { + print "$ALGORITHM_DESCRIPTION\n"; + return 0; + } + + my ($digest, $leaf_count); + my $ok = eval { + ($digest, $leaf_count) = repository_fingerprint($repo); + 1; + }; + if (!$ok) { + my $error = $@ || 'unknown fingerprint error'; + $error =~ s/\s+\z//; + print STDERR "error: $error\n"; + return 1; + } + print STDERR "fingerprinted_leaves=$leaf_count\n" if $verbose; + print "$digest\n"; + return 0; +} + +unless (caller) { + exit main(@ARGV); +} + +1; diff --git a/examples/scripts/source_tree_fingerprint.t b/examples/scripts/source_tree_fingerprint.t new file mode 100755 index 00000000..f89ea3c1 --- /dev/null +++ b/examples/scripts/source_tree_fingerprint.t @@ -0,0 +1,119 @@ +#!/usr/bin/env perl + +use strict; +use warnings; +no warnings 'once'; + +use File::Path qw(make_path); +use File::Temp qw(tempdir); +use FindBin qw($Bin); +use Test::More; + +my $loaded = do "$Bin/source_tree_fingerprint.pl"; +die $@ if !$loaded && $@; +die $! if !$loaded && $!; + +sub write_bytes { + my ($path, $bytes) = @_; + open my $output, '>:raw', $path or die "cannot write $path: $!"; + print {$output} $bytes; + close $output or die "cannot close $path: $!"; +} + +sub run_git { + my ($repo, @arguments) = @_; + return Azoth::SourceTreeFingerprint::_run_git($repo, @arguments); +} + +my @golden_records = ( + [ $Azoth::SourceTreeFingerprint::SYMLINK, 'z-link', '../target' ], + [ $Azoth::SourceTreeFingerprint::REGULAR_FILE, 'a.txt', "alpha\n" ], +); +my $golden = '2e1f99a3178df656d29ef2a7be9090f1e251d05962d900b4ca8eef68f8c6d1d2'; +is( + Azoth::SourceTreeFingerprint::fingerprint_records(@golden_records), + $golden, + 'record encoding matches its fixed SHA-256 vector' +); +is( + Azoth::SourceTreeFingerprint::fingerprint_records(reverse @golden_records), + $golden, + 'caller record order does not affect the digest' +); + +open my $benchmark, '<:raw', "$Bin/../src/bin/foundation_benchmark.rs" + or die "cannot read benchmark source: $!"; +local $/; +my $benchmark_source = <$benchmark>; +close $benchmark; +like( + $benchmark_source, + qr/\Q$Azoth::SourceTreeFingerprint::ALGORITHM_DESCRIPTION\E/, + 'helper and benchmark publish the same algorithm description' +); + +my $temporary = tempdir(CLEANUP => 1); +my $origin = "$temporary/fixture-origin"; +my $root = "$temporary/root"; +make_path($origin, $root); + +run_git($origin, 'init', '-q'); +write_bytes("$origin/.gitignore", "ignored-child\n"); +write_bytes("$origin/fixture.bin", 'fixture-v1'); +run_git($origin, 'add', '.gitignore', 'fixture.bin'); +run_git( + $origin, '-c', 'user.name=Azoth Test', '-c', + 'user.email=azoth@example.invalid', 'commit', '-q', '-m', 'fixture' +); + +run_git($root, 'init', '-q'); +write_bytes("$root/.gitignore", "ignored-root\n"); +write_bytes("$root/tracked.txt", 'tracked'); +write_bytes("$root/untracked.txt", 'untracked'); +write_bytes("$root/ignored-root", 'ignored'); +symlink 'tracked.txt', "$root/tracked-link" or die "cannot create symlink: $!"; +run_git($root, 'add', '.gitignore', 'tracked.txt'); +run_git( + $root, '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', + $origin, 'deps/fixture' +); +my $submodule = "$root/deps/fixture"; +write_bytes("$submodule/local.bin", 'local-v1'); +write_bytes("$submodule/ignored-child", 'ignored'); + +my @leaves = Azoth::SourceTreeFingerprint::collect_leaves($root); +my %paths = map { $_->{path} => 1 } @leaves; +ok($paths{'.gitmodules'}, 'root tracked files are included'); +ok($paths{'tracked-link'}, 'untracked symlinks are included'); +ok($paths{'untracked.txt'}, 'root untracked files are included'); +ok($paths{'deps/fixture/fixture.bin'}, 'submodule tracked files are included'); +ok($paths{'deps/fixture/local.bin'}, 'submodule untracked files are included'); +ok(!$paths{'ignored-root'}, 'root ignored files are excluded'); +ok(!$paths{'deps/fixture/ignored-child'}, 'submodule ignored files are excluded'); + +my ($first, $first_count) = + Azoth::SourceTreeFingerprint::repository_fingerprint($root); +my ($replay, $replay_count) = + Azoth::SourceTreeFingerprint::repository_fingerprint($root); +is($replay, $first, 'repository digest is deterministic'); +is($replay_count, $first_count, 'repository leaf count is deterministic'); + +write_bytes("$root/ignored-root", 'changed but ignored'); +my ($ignored_change) = + Azoth::SourceTreeFingerprint::repository_fingerprint($root); +is($ignored_change, $first, 'ignored content does not affect the digest'); + +write_bytes("$submodule/local.bin", 'local-v2'); +my ($submodule_change) = + Azoth::SourceTreeFingerprint::repository_fingerprint($root); +isnt($submodule_change, $first, 'dirty submodule content affects the digest'); + +run_git($root, 'submodule', 'deinit', '-f', '--', 'deps/fixture'); +my $uninitialized_ok = eval { + Azoth::SourceTreeFingerprint::repository_fingerprint($root); + 1; +}; +ok(!$uninitialized_ok, 'uninitialized tracked submodules are rejected'); +like($@, qr/not initialized/, 'uninitialized-submodule error is explicit'); + +done_testing(); diff --git a/examples/src/bin/decoder_benchmark.rs b/examples/src/bin/decoder_benchmark.rs new file mode 100644 index 00000000..082c9c1d --- /dev/null +++ b/examples/src/bin/decoder_benchmark.rs @@ -0,0 +1,205 @@ +//! Reproducible release-mode benchmark for Azoth's native bytecode decoder. +//! +//! The output is one JSON document. Decoding and assembly rendering are timed separately because +//! production CFG consumers do not need the human-readable assembly string. + +use azoth_core::decoder::{decode_executable_bytes, format_assembly, Instruction}; +use serde_json::{json, Value}; +use std::error::Error; +use std::hint::black_box; +use std::time::{Duration, Instant}; + +type BenchmarkResult = Result>; +type BytecodeParts<'a> = (&'a [u8], &'a [u8]); + +const STORAGE: &str = include_str!("../../../tests/bytecode/storage.hex"); +const COUNTER_RUNTIME: &str = include_str!("../../../tests/bytecode/counter/counter_runtime.hex"); +const NATIVE_ESCROW_RUNTIME: &str = + include_str!("../../escrow-bytecode/artifacts/native_runtime.hex"); +const ERC20_ESCROW_RUNTIME: &str = + include_str!("../../escrow-bytecode/artifacts/erc20_runtime.hex"); +const ITERATION_CHECKPOINTS: [usize; 3] = [10, 100, 1_000]; +const WARMUP_ITERATIONS: usize = 10; + +#[derive(Clone, Copy)] +struct Fixture { + name: &'static str, + source: &'static str, +} + +const FIXTURES: [Fixture; 4] = [ + Fixture { + name: "storage", + source: STORAGE, + }, + Fixture { + name: "counter_runtime", + source: COUNTER_RUNTIME, + }, + Fixture { + name: "escrow_native_runtime", + source: NATIVE_ESCROW_RUNTIME, + }, + Fixture { + name: "escrow_erc20_runtime", + source: ERC20_ESCROW_RUNTIME, + }, +]; + +fn main() -> BenchmarkResult<()> { + let mut measurements = Vec::new(); + + for fixture in FIXTURES { + let artifact = hex::decode(fixture.source.trim().trim_start_matches("0x"))?; + let (code, trailer) = split_compiler_trailer(&artifact)?; + let instructions = decode_executable_bytes(code)?; + + // Warm both paths before taking any sample. Keep the warmup outside every timed region. + for _ in 0..WARMUP_ITERATIONS { + drop(black_box(decode_executable_bytes(black_box(code))?)); + drop(black_box(format_assembly(black_box(&instructions)))); + } + + for iterations in ITERATION_CHECKPOINTS { + measurements.push(measure_decode( + fixture.name, + code, + trailer.len(), + instructions.len(), + iterations, + )?); + measurements.push(measure_render( + fixture.name, + code.len(), + trailer.len(), + &instructions, + iterations, + )); + } + } + + let report = json!({ + "schema": "azoth-native-decoder-benchmark-v1", + "profile": "release-mode wall clock; fixed checked-in fixtures; single thread", + "warmup_iterations": WARMUP_ITERATIONS, + "iteration_checkpoints": ITERATION_CHECKPOINTS, + "measurements": measurements, + }); + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn split_compiler_trailer(input: &[u8]) -> BenchmarkResult> { + let encoded_len = input + .get(input.len().saturating_sub(2)..) + .ok_or("fixture is too short for a compiler trailer length")?; + let payload_len = usize::from(u16::from_be_bytes([encoded_len[0], encoded_len[1]])); + let trailer_len = payload_len + .checked_add(2) + .ok_or("compiler trailer length overflow")?; + let split = input + .len() + .checked_sub(trailer_len) + .ok_or("compiler trailer exceeds fixture length")?; + Ok(input.split_at(split)) +} + +fn measure_decode( + fixture: &str, + code: &[u8], + trailer_bytes: usize, + instructions_per_iteration: usize, + iterations: usize, +) -> Result { + let started = Instant::now(); + let mut checksum = 0usize; + for _ in 0..iterations { + let instructions = decode_executable_bytes(black_box(code))?; + checksum = checksum.wrapping_add(instructions.len()); + black_box(instructions); + } + let elapsed = started.elapsed(); + black_box(checksum); + + Ok(measurement( + fixture, + "decode", + iterations, + code.len(), + trailer_bytes, + instructions_per_iteration, + None, + elapsed, + checksum, + )) +} + +fn measure_render( + fixture: &str, + code_bytes: usize, + trailer_bytes: usize, + instructions: &[Instruction], + iterations: usize, +) -> Value { + let representative = format_assembly(instructions); + let assembly_bytes = representative.len(); + black_box(representative); + + let started = Instant::now(); + let mut checksum = 0usize; + for _ in 0..iterations { + let assembly = format_assembly(black_box(instructions)); + checksum = checksum.wrapping_add(assembly.len()); + black_box(assembly); + } + let elapsed = started.elapsed(); + black_box(checksum); + + measurement( + fixture, + "format_assembly", + iterations, + code_bytes, + trailer_bytes, + instructions.len(), + Some(assembly_bytes), + elapsed, + checksum, + ) +} + +#[allow(clippy::too_many_arguments)] +fn measurement( + fixture: &str, + phase: &str, + iterations: usize, + code_bytes: usize, + trailer_bytes: usize, + instructions_per_iteration: usize, + assembly_bytes_per_iteration: Option, + elapsed: Duration, + checksum: usize, +) -> Value { + let elapsed_ns = elapsed.as_nanos(); + let bytes_processed = code_bytes.saturating_mul(iterations); + let seconds = elapsed.as_secs_f64(); + let mib_per_second = if seconds == 0.0 { + 0.0 + } else { + bytes_processed as f64 / (1024.0 * 1024.0) / seconds + }; + + json!({ + "fixture": fixture, + "phase": phase, + "iterations": iterations, + "code_bytes_per_iteration": code_bytes, + "compiler_trailer_bytes_excluded": trailer_bytes, + "instructions_per_iteration": instructions_per_iteration, + "assembly_bytes_per_iteration": assembly_bytes_per_iteration, + "elapsed_ns": elapsed_ns, + "mean_ns_per_iteration": elapsed_ns / iterations as u128, + "code_mib_per_second": mib_per_second, + "checksum": checksum, + }) +} diff --git a/examples/src/bin/foundation_benchmark.rs b/examples/src/bin/foundation_benchmark.rs new file mode 100644 index 00000000..d4f77c43 --- /dev/null +++ b/examples/src/bin/foundation_benchmark.rs @@ -0,0 +1,1399 @@ +#![recursion_limit = "256"] + +//! Reproducible variation benchmark for the Azoth safe foundation profile. +//! +//! The seed corpus is deliberately simple and auditable: seed `i` is 24 zero bytes followed by +//! the big-endian `u64` value `i`. A single run can report prefix checkpoints (normally 10, 100, +//! and 1,000), ensuring every comparison uses the same seed corpus. + +use azoth_core::seed::Seed; +use azoth_transform::cluster_shuffle::ClusterShuffle; +use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig, ObfuscationResult}; +use revm::context::result::{ExecutionResult, Output}; +use revm::context::TxEnv; +use revm::database::InMemoryDB; +use revm::primitives::{Address, Bytes, TxKind, U256}; +use revm::state::AccountInfo; +use revm::{Context, ExecuteEvm, MainBuilder, MainContext}; +use serde_json::{json, Value}; +use sha3::{Digest, Keccak256}; +use std::collections::{BTreeMap, HashSet}; +use std::error::Error; +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; +use std::time::Instant; +use tokio::task::JoinSet; + +const ERC20_DEPLOYMENT: &str = include_str!("../../escrow-bytecode/artifacts/erc20_deployment.hex"); +const ERC20_RUNTIME: &str = include_str!("../../escrow-bytecode/artifacts/erc20_runtime.hex"); +const NATIVE_DEPLOYMENT: &str = + include_str!("../../escrow-bytecode/artifacts/native_deployment.hex"); +const NATIVE_RUNTIME: &str = include_str!("../../escrow-bytecode/artifacts/native_runtime.hex"); +const COUNTER_DEPLOYMENT: &str = + include_str!("../../../tests/bytecode/counter/counter_deployment.hex"); +const COUNTER_RUNTIME: &str = include_str!("../../../tests/bytecode/counter/counter_runtime.hex"); +const EXPECTED_PIPELINE_PROFILE: &str = "azoth-foundation-v4"; +const SOURCE_FINGERPRINT_ALGORITHM: &str = "azoth-source-tree-sha256-v1: in the root Git worktree and recursively in every initialized tracked submodule, enumerate `git ls-files --cached --others --exclude-standard -z`; replace each gitlink with the recursively enumerated leaf entries beneath its root-relative path and reject uninitialized gitlinks, missing leaves, or leaf types other than regular files and symlinks; represent a regular-file leaf as kind byte 0x00 plus its current file bytes and a symlink leaf as kind byte 0x01 plus its raw link-target bytes; sort leaves lexicographically by raw root-relative path bytes; SHA-256 the ASCII domain `AZOTH_SOURCE_TREE_SHA256_V1` followed by a NUL byte and, for each leaf, its kind byte, u64-be(path byte length), path bytes, u64-be(payload byte length), and payload bytes; encode the digest as lowercase hex"; + +#[derive(Debug)] +struct Args { + iterations: usize, + checkpoints: Vec, + json_path: PathBuf, + csv_path: PathBuf, + determinism_checks: usize, + jobs: usize, + source_revision: String, +} + +#[derive(Clone, Copy)] +struct ContractFixture { + name: &'static str, + deployment: &'static str, + runtime: &'static str, + constructor_kind: ConstructorKind, +} + +#[derive(Clone, Copy)] +enum ConstructorKind { + None, + Erc20, + Native, +} + +struct SuccessRecord { + seed_index: usize, + creation: Vec, + runtime: Vec, + creation_change_percent: f64, + runtime_change_percent: f64, + size_delta_bytes: i64, + size_delta_percent: f64, + runtime_size_delta_bytes: i64, + runtime_size_delta_percent: f64, + deployment_gas: u64, + deployment_gas_delta_percent: f64, + transforms: Vec, + mapping_fingerprint: Option, + replay: ReplayCheck, +} + +struct FailureRecord { + seed_index: usize, + message: String, + replay: ReplayCheck, +} + +struct FailureRowData<'a> { + contract: &'a str, + seed_index: usize, + seed_hex: &'a str, + message: &'a str, + replay: ReplayCheck, + transform_elapsed_ms: f64, + source_revision: &'a str, +} + +struct ContractRun { + summary: Value, + rows: Vec, + csv: String, +} + +#[derive(Clone, Copy, Debug, Default)] +struct ReplayCheck { + checked: bool, + exact_output_match: Option, + exact_deployment_match: Option, +} + +struct SeedTransformRun { + seed_index: usize, + seed_hex: String, + transform_elapsed_ms: f64, + first: Result, + replay: Option>, +} + +#[derive(Debug, PartialEq)] +struct DeployedVariant { + creation: Vec, + runtime: Vec, + deployment_gas: u64, +} + +const FIXTURES: [ContractFixture; 3] = [ + ContractFixture { + name: "escrow_erc20", + deployment: ERC20_DEPLOYMENT, + runtime: ERC20_RUNTIME, + constructor_kind: ConstructorKind::Erc20, + }, + ContractFixture { + name: "escrow_native", + deployment: NATIVE_DEPLOYMENT, + runtime: NATIVE_RUNTIME, + constructor_kind: ConstructorKind::Native, + }, + ContractFixture { + name: "counter", + deployment: COUNTER_DEPLOYMENT, + runtime: COUNTER_RUNTIME, + constructor_kind: ConstructorKind::None, + }, +]; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = parse_args()?; + let started = Instant::now(); + let generated_at = chrono::Utc::now().to_rfc3339(); + + println!( + "Azoth foundation benchmark: {} seeds, checkpoints {:?}", + args.iterations, args.checkpoints + ); + + let mut summaries = Vec::new(); + let mut rows = Vec::new(); + let mut csv = String::from( + "contract,seed_index,seed_hex,status,error,exact_replay_checked,exact_output_replay_match,exact_deployment_replay_match,original_creation_bytes,obfuscated_creation_bytes,creation_size_delta_bytes,creation_size_delta_percent,creation_changed_positions,creation_change_percent,original_runtime_bytes,obfuscated_runtime_bytes,runtime_size_delta_bytes,runtime_size_delta_percent,runtime_changed_positions,runtime_change_percent,original_deployment_gas,obfuscated_deployment_gas,deployment_gas_delta_percent,transforms,constructor_args_obfuscated,blocks_created,instructions_added,transform_elapsed_ms,benchmark_schema,pipeline_profile,source_revision,original_creation_keccak256,obfuscated_creation_keccak256,original_runtime_keccak256,obfuscated_runtime_keccak256,obfuscation_result_keccak256\n", + ); + + for fixture in FIXTURES { + let run = run_contract(fixture, &args).await?; + summaries.push(run.summary); + rows.extend(run.rows); + csv.push_str(&run.csv); + } + + let report = json!({ + "schema": "azoth-foundation-benchmark-v4", + "generated_at": generated_at, + "iterations_per_contract": args.iterations, + "checkpoints": args.checkpoints, + "determinism_checks_per_contract": args.determinism_checks.min(args.iterations), + "parallel_jobs": args.jobs, + "source_identity": { + "declared_revision": args.source_revision, + "declared_revision_is_caller_asserted": true, + "declared_revision_verified_by_harness": false, + "source_fingerprint_algorithm": SOURCE_FINGERPRINT_ALGORITHM, + "workspace_package_version": env!("CARGO_PKG_VERSION"), + "cargo_lock_keccak256": keccak256_hex(include_bytes!("../../../Cargo.lock")), + "declaration_note": "declared_revision is supplied by the benchmark caller, is not verified by this process, and should contain both the base Git revision and a source fingerprint computed with source_fingerprint_algorithm" + }, + "seed_scheme": "32-byte big-endian uint256(seed_index); indices are consecutive from zero", + "pipeline": { + "profile": EXPECTED_PIPELINE_PROFILE, + "explicit_user_transforms": ["ClusterShuffle"], + "automatic_pipeline_stages": [], + "removed_suffixes": "compiler auxdata and padding are preserved byte-for-byte; layout changes require a terminal, fully resolved runtime boundary", + "function_selectors": "preserved unchanged in the safe profile", + "constructor_arguments": "preserved unchanged in the safe profile", + "decoder_preserves_raw_unknown_bytes": true, + "unknown_executable_opcode_policy": "fail closed: reject unmodelled UNKNOWN opcodes and raw INVALID values other than 0xfe" + }, + "metric_definition": { + "positional_change_percent": "(unequal aligned bytes + absolute length difference) / max(lengths) * 100", + "pairwise_diversity": "the same positional metric across every pair when there are at most 10,000 pairs, otherwise 10,000 distinct deterministic sampled unordered pairs", + "deployment_check": "Both fixture and transformed creation bytecode must deploy successfully in REVM; runtime metrics use the returned deployed bytecode rather than an internal intermediate buffer.", + "exact_replay": "Canonical complete ObfuscationResult equality, including the diagnostic trace, plus identical REVM deployed runtime and deployment gas for the same input and seed.", + "complete_result_hash": "obfuscation_result_keccak256 is Keccak-256 of the compact serde_json serialization of the complete ObfuscationResult; the result schema uses deterministic serializers for unordered maps", + "successful_sample_scope": "Variation, size, gas, uniqueness, and pairwise distributions include successful deployments only; requested-seed success/failure counts are reported beside them.", + "output_identity_classification": "A successful output is exact_identity only when both transformed creation bytecode and materialized deployed runtime are byte-for-byte equal to their baselines; every other successful output is changed. Changed-output rates use successful deployments as their denominator.", + "report_reproducibility": "Seed prefixes and pipeline outputs are reproducible; generated_at, wall_time_seconds, and transform_elapsed_ms are observational timing fields and are not expected to be byte-identical across runs.", + "note": "Positional change is Hamming distance over the shared prefix plus length difference. It is intentionally not labeled Levenshtein edit distance. Successful deployment is a smoke test, not a proof of behavioral equivalence." + }, + "wall_time_seconds": started.elapsed().as_secs_f64(), + "contracts": summaries, + "runs": rows, + }); + + if let Some(parent) = args.json_path.parent() { + fs::create_dir_all(parent)?; + } + if let Some(parent) = args.csv_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&args.json_path, serde_json::to_string_pretty(&report)?)?; + fs::write(&args.csv_path, csv)?; + + println!("JSON: {}", args.json_path.display()); + println!("CSV: {}", args.csv_path.display()); + println!("Wall time: {:.2}s", started.elapsed().as_secs_f64()); + Ok(()) +} + +async fn run_contract( + fixture: ContractFixture, + args: &Args, +) -> Result> { + let deployment_hex = full_deployment_hex(fixture)?; + let runtime_hex = normalized_hex(fixture.runtime); + let original_creation = hex::decode(&deployment_hex)?; + let fixture_runtime = hex::decode(&runtime_hex)?; + let (original_runtime, original_deployment_gas) = deploy_creation(&original_creation) + .map_err(|message| format!("{} original deployment failed: {message}", fixture.name))?; + if original_runtime.len() != fixture_runtime.len() { + return Err(format!( + "{} fixture runtime length ({}) differs from the runtime returned by its creation bytecode ({})", + fixture.name, + fixture_runtime.len(), + original_runtime.len(), + ) + .into()); + } + // The exact artifact runtime remains the authoritative init/runtime boundary passed to Azoth. + // The deployed baseline is used for measurements because constructors can patch immutables + // (the ERC20 escrow writes its token address into four runtime locations). + let (fixture_runtime_changed_positions, fixture_runtime_change_percent) = + positional_difference(&fixture_runtime, &original_runtime); + let mut successes = Vec::with_capacity(args.iterations); + let mut failures = Vec::new(); + let mut rows = Vec::with_capacity(args.iterations); + let mut csv = String::new(); + let mut exact_output_replay_mismatches = 0usize; + let mut exact_deployment_replay_mismatches = 0usize; + let contract_started = Instant::now(); + let original_creation_hash = keccak256_hex(&original_creation); + let original_runtime_hash = keccak256_hex(&original_runtime); + + println!( + "[{}] original creation={} B runtime={} B", + fixture.name, + original_creation.len(), + original_runtime.len() + ); + + for batch_start in (0..args.iterations).step_by(args.jobs) { + let batch_end = (batch_start + args.jobs).min(args.iterations); + let mut workers = JoinSet::new(); + for seed_index in batch_start..batch_end { + let deployment_hex = deployment_hex.clone(); + let runtime_hex = runtime_hex.clone(); + let check_replay = seed_index < args.determinism_checks; + workers.spawn(async move { + let seed = sequential_seed(seed_index); + let seed_hex = seed.to_hex(); + let case_started = Instant::now(); + let first = + obfuscate_with_safe_profile(&deployment_hex, &runtime_hex, seed.clone()).await; + let transform_elapsed_ms = case_started.elapsed().as_secs_f64() * 1_000.0; + let replay = if check_replay { + Some(obfuscate_with_safe_profile(&deployment_hex, &runtime_hex, seed).await) + } else { + None + }; + SeedTransformRun { + seed_index, + seed_hex, + transform_elapsed_ms, + first, + replay, + } + }); + } + + let mut batch_runs = Vec::with_capacity(batch_end - batch_start); + while let Some(joined) = workers.join_next().await { + batch_runs.push(joined.map_err(|error| format!("benchmark worker failed: {error}"))?); + } + batch_runs.sort_by_key(|run| run.seed_index); + + for SeedTransformRun { + seed_index, + seed_hex, + transform_elapsed_ms, + first, + replay, + } in batch_runs + { + let mut replay_check = ReplayCheck { + checked: replay.is_some(), + exact_output_match: replay + .as_ref() + .map(|second| same_complete_outcome(&first, second)), + exact_deployment_match: None, + }; + if replay_check.exact_output_match == Some(false) { + exact_output_replay_mismatches += 1; + } + + match first { + Ok(result) => { + if result.integrity.pipeline_profile != EXPECTED_PIPELINE_PROFILE { + let message = format!( + "benchmark profile mismatch: harness expects {}, pipeline emitted {}", + EXPECTED_PIPELINE_PROFILE, result.integrity.pipeline_profile + ); + record_failure( + &mut rows, + &mut csv, + FailureRowData { + contract: fixture.name, + seed_index, + seed_hex: &seed_hex, + message: &message, + replay: replay_check, + transform_elapsed_ms, + source_revision: &args.source_revision, + }, + )?; + failures.push(FailureRecord { + seed_index, + message, + replay: replay_check, + }); + continue; + } + let deployed = deployment_from_result(&result); + if let (Ok(deployed), Some(second)) = (&deployed, replay.as_ref()) { + replay_check.exact_deployment_match = Some(match second { + Ok(replay_result) => deployment_from_result(replay_result) + .as_ref() + .is_ok_and(|replay_deployed| replay_deployed == deployed), + Err(_) => false, + }); + if replay_check.exact_deployment_match == Some(false) { + exact_deployment_replay_mismatches += 1; + } + } + let DeployedVariant { + creation, + runtime, + deployment_gas, + } = match deployed { + Ok(deployed) => deployed, + Err(message) => { + record_failure( + &mut rows, + &mut csv, + FailureRowData { + contract: fixture.name, + seed_index, + seed_hex: &seed_hex, + message: &message, + replay: replay_check, + transform_elapsed_ms, + source_revision: &args.source_revision, + }, + )?; + failures.push(FailureRecord { + seed_index, + message, + replay: replay_check, + }); + continue; + } + }; + let (creation_changed, creation_change_percent) = + positional_difference(&original_creation, &creation); + let (runtime_changed, runtime_change_percent) = + positional_difference(&original_runtime, &runtime); + let size_delta_bytes = creation.len() as i64 - original_creation.len() as i64; + let size_delta_percent = percent_delta(original_creation.len(), creation.len()); + let runtime_size_delta_bytes = + runtime.len() as i64 - original_runtime.len() as i64; + let runtime_size_delta_percent = + percent_delta(original_runtime.len(), runtime.len()); + let deployment_gas_delta_percent = + percent_delta_u64(original_deployment_gas, deployment_gas); + let transforms = result.metadata.transforms_applied.clone(); + let mapping_fingerprint = selector_mapping_fingerprint(&result); + let obfuscated_creation_hash = keccak256_hex(&creation); + let obfuscated_runtime_hash = keccak256_hex(&runtime); + let obfuscation_result_hash = keccak256_hex(&serde_json::to_vec(&result)?); + + rows.push(json!({ + "benchmark_schema": "azoth-foundation-benchmark-v4", + "pipeline_profile": EXPECTED_PIPELINE_PROFILE, + "source_revision": args.source_revision, + "contract": fixture.name, + "seed_index": seed_index, + "seed_hex": seed_hex, + "status": "ok", + "exact_replay_checked": replay_check.checked, + "exact_output_replay_match": replay_check.exact_output_match, + "exact_deployment_replay_match": replay_check.exact_deployment_match, + "original_creation_bytes": original_creation.len(), + "obfuscated_creation_bytes": creation.len(), + "creation_size_delta_bytes": size_delta_bytes, + "creation_size_delta_percent": size_delta_percent, + "creation_changed_positions": creation_changed, + "creation_change_percent": creation_change_percent, + "original_runtime_bytes": original_runtime.len(), + "obfuscated_runtime_bytes": runtime.len(), + "original_creation_keccak256": original_creation_hash, + "obfuscated_creation_keccak256": obfuscated_creation_hash, + "original_runtime_keccak256": original_runtime_hash, + "obfuscated_runtime_keccak256": obfuscated_runtime_hash, + "obfuscation_result_keccak256": obfuscation_result_hash, + "runtime_size_delta_bytes": runtime_size_delta_bytes, + "runtime_size_delta_percent": runtime_size_delta_percent, + "runtime_changed_positions": runtime_changed, + "runtime_change_percent": runtime_change_percent, + "original_deployment_gas": original_deployment_gas, + "obfuscated_deployment_gas": deployment_gas, + "deployment_gas_delta_percent": deployment_gas_delta_percent, + "transforms": transforms, + "constructor_args_obfuscated": result.metadata.constructor_args_obfuscated, + "blocks_created": result.blocks_created, + "instructions_added": result.instructions_added, + "transform_elapsed_ms": transform_elapsed_ms, + })); + append_csv_row( + &mut csv, + &[ + fixture.name.to_string(), + seed_index.to_string(), + seed_hex, + "ok".to_string(), + String::new(), + replay_check.checked.to_string(), + optional_bool(replay_check.exact_output_match), + optional_bool(replay_check.exact_deployment_match), + original_creation.len().to_string(), + creation.len().to_string(), + size_delta_bytes.to_string(), + format!("{size_delta_percent:.6}"), + creation_changed.to_string(), + format!("{creation_change_percent:.6}"), + original_runtime.len().to_string(), + runtime.len().to_string(), + runtime_size_delta_bytes.to_string(), + format!("{runtime_size_delta_percent:.6}"), + runtime_changed.to_string(), + format!("{runtime_change_percent:.6}"), + original_deployment_gas.to_string(), + deployment_gas.to_string(), + format!("{deployment_gas_delta_percent:.6}"), + transforms.join("|"), + result.metadata.constructor_args_obfuscated.to_string(), + result.blocks_created.to_string(), + result.instructions_added.to_string(), + format!("{transform_elapsed_ms:.6}"), + "azoth-foundation-benchmark-v4".to_string(), + EXPECTED_PIPELINE_PROFILE.to_string(), + args.source_revision.clone(), + original_creation_hash.clone(), + obfuscated_creation_hash, + original_runtime_hash.clone(), + obfuscated_runtime_hash, + obfuscation_result_hash, + ], + )?; + + successes.push(SuccessRecord { + seed_index, + creation, + runtime, + creation_change_percent, + runtime_change_percent, + size_delta_bytes, + size_delta_percent, + runtime_size_delta_bytes, + runtime_size_delta_percent, + deployment_gas, + deployment_gas_delta_percent, + transforms, + mapping_fingerprint, + replay: replay_check, + }); + } + Err(error) => { + let message = error.to_string(); + record_failure( + &mut rows, + &mut csv, + FailureRowData { + contract: fixture.name, + seed_index, + seed_hex: &seed_hex, + message: &message, + replay: replay_check, + transform_elapsed_ms, + source_revision: &args.source_revision, + }, + )?; + failures.push(FailureRecord { + seed_index, + message, + replay: replay_check, + }); + } + } + } + println!( + "[{}] {}/{} seeds (ok={}, failed={})", + fixture.name, + batch_end, + args.iterations, + successes.len(), + failures.len() + ); + } + + let checkpoint_summaries: Vec = args + .checkpoints + .iter() + .copied() + .filter(|checkpoint| *checkpoint <= args.iterations) + .map(|checkpoint| { + summarize_checkpoint( + checkpoint, + &successes, + &failures, + &original_creation, + &original_runtime, + original_deployment_gas, + ) + }) + .collect(); + let exact_identity_outputs = successes + .iter() + .filter(|record| is_exact_identity(record, &original_creation, &original_runtime)) + .count(); + let changed_outputs = successes.len() - exact_identity_outputs; + + Ok(ContractRun { + summary: json!({ + "name": fixture.name, + "requested_seeds": args.iterations, + "successes": successes.len(), + "failures": failures.len(), + "exact_identity_outputs": exact_identity_outputs, + "changed_outputs": changed_outputs, + "changed_output_rate_percent_of_successes": ratio_percent(changed_outputs, successes.len()), + "original_creation_bytes": original_creation.len(), + "original_runtime_bytes": original_runtime.len(), + "original_deployment_gas": original_deployment_gas, + "runtime_artifact_template_changed_positions_after_constructor": fixture_runtime_changed_positions, + "runtime_artifact_template_change_percent_after_constructor": fixture_runtime_change_percent, + "constructor_argument_bytes": original_creation.len() + .saturating_sub(hex::decode(normalized_hex(fixture.deployment))?.len()), + "exact_replay_checks": args.determinism_checks.min(args.iterations), + "exact_output_replay_mismatches": exact_output_replay_mismatches, + "exact_deployment_replay_mismatches": exact_deployment_replay_mismatches, + "wall_time_seconds": contract_started.elapsed().as_secs_f64(), + "checkpoints": checkpoint_summaries, + }), + rows, + csv, + }) +} + +async fn obfuscate_with_safe_profile( + deployment_hex: &str, + runtime_hex: &str, + seed: Seed, +) -> Result { + obfuscate_bytecode(deployment_hex, runtime_hex, safe_profile(seed)).await +} + +fn safe_profile(seed: Seed) -> ObfuscationConfig { + // Pin the audited foundation profile. Auxdata and padding are preserved byte-for-byte. + // Selector rewriting and constructor masking stay disabled until their semantic and detector + // gates pass. + ObfuscationConfig { + seed, + transforms: vec![Box::new(ClusterShuffle::new())], + preserve_unknown_opcodes: true, + rewrite_function_selectors: false, + obfuscate_constructor_arguments: false, + } +} + +fn same_complete_outcome( + left: &Result, + right: &Result, +) -> bool { + match (left, right) { + (Ok(a), Ok(b)) => match (serde_json::to_vec(a), serde_json::to_vec(b)) { + (Ok(left), Ok(right)) => left == right, + _ => false, + }, + (Err(a), Err(b)) => { + a.message == b.message + && match (serde_json::to_vec(&a.trace), serde_json::to_vec(&b.trace)) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } + } + _ => false, + } +} + +fn deployment_from_result(result: &ObfuscationResult) -> Result { + let creation = decode_result_hex(&result.obfuscated_bytecode) + .map_err(|error| format!("transformed creation hex is invalid: {error}"))?; + let (runtime, deployment_gas) = deploy_creation(&creation) + .map_err(|error| format!("transformed deployment failed: {error}"))?; + let reported_runtime = decode_result_hex(&result.obfuscated_runtime) + .map_err(|error| format!("reported runtime hex is invalid: {error}"))?; + // The reported runtime is complete, but constructor execution can patch immutable words into + // it, so byte equality would incorrectly reject valid ERC20 deployments. Length must remain + // exact, and the deployed REVM result is authoritative for all runtime measurements. + if reported_runtime.len() != runtime.len() { + return Err(format!( + "pipeline reported runtime length differs from REVM deployed runtime (deployed={} B, reported={} B)", + runtime.len(), + reported_runtime.len(), + )); + } + Ok(DeployedVariant { + creation, + runtime, + deployment_gas, + }) +} + +fn summarize_checkpoint( + checkpoint: usize, + successes: &[SuccessRecord], + failures: &[FailureRecord], + original_creation: &[u8], + original_runtime: &[u8], + original_deployment_gas: u64, +) -> Value { + let subset: Vec<&SuccessRecord> = successes + .iter() + .filter(|record| record.seed_index < checkpoint) + .collect(); + let failure_subset: Vec<&FailureRecord> = failures + .iter() + .filter(|record| record.seed_index < checkpoint) + .collect(); + let observed_seed_indices: HashSet<_> = subset + .iter() + .map(|record| record.seed_index) + .chain(failure_subset.iter().map(|record| record.seed_index)) + .collect(); + assert_eq!( + subset.len() + failure_subset.len(), + checkpoint, + "checkpoint must contain exactly one outcome per requested seed" + ); + assert_eq!( + observed_seed_indices.len(), + checkpoint, + "checkpoint must contain exactly one outcome for every seed in its prefix" + ); + assert!( + (0..checkpoint).all(|seed_index| observed_seed_indices.contains(&seed_index)), + "checkpoint is missing an outcome from its requested seed prefix" + ); + let replay_checks: Vec = subset + .iter() + .map(|record| record.replay) + .chain(failure_subset.iter().map(|record| record.replay)) + .filter(|check| check.checked) + .collect(); + let exact_output_replay_mismatches = replay_checks + .iter() + .filter(|check| check.exact_output_match == Some(false)) + .count(); + let deployment_replay_checks = replay_checks + .iter() + .filter(|check| check.exact_deployment_match.is_some()) + .count(); + let exact_deployment_replay_mismatches = replay_checks + .iter() + .filter(|check| check.exact_deployment_match == Some(false)) + .count(); + let exact_identity_outputs = subset + .iter() + .filter(|record| is_exact_identity(record, original_creation, original_runtime)) + .count(); + let changed_outputs = subset.len() - exact_identity_outputs; + + let creation_change: Vec = subset + .iter() + .map(|record| record.creation_change_percent) + .collect(); + let runtime_change: Vec = subset + .iter() + .map(|record| record.runtime_change_percent) + .collect(); + let size_delta_bytes: Vec = subset + .iter() + .map(|record| record.size_delta_bytes as f64) + .collect(); + let size_delta_percent: Vec = subset + .iter() + .map(|record| record.size_delta_percent) + .collect(); + let runtime_size_delta_bytes: Vec = subset + .iter() + .map(|record| record.runtime_size_delta_bytes as f64) + .collect(); + let runtime_size_delta_percent: Vec = subset + .iter() + .map(|record| record.runtime_size_delta_percent) + .collect(); + let creation_sizes: Vec = subset + .iter() + .map(|record| record.creation.len() as f64) + .collect(); + let runtime_sizes: Vec = subset + .iter() + .map(|record| record.runtime.len() as f64) + .collect(); + let deployment_gas: Vec = subset + .iter() + .map(|record| record.deployment_gas as f64) + .collect(); + let deployment_gas_delta_percent: Vec = subset + .iter() + .map(|record| record.deployment_gas_delta_percent) + .collect(); + let unique_creation = subset + .iter() + .map(|record| &record.creation) + .collect::>() + .len(); + let unique_runtime = subset + .iter() + .map(|record| &record.runtime) + .collect::>() + .len(); + let mapping_subset: Vec<&String> = subset + .iter() + .filter_map(|record| record.mapping_fingerprint.as_ref()) + .collect(); + let unique_mappings = mapping_subset.iter().copied().collect::>().len(); + + let mut transform_counts = BTreeMap::::new(); + for record in &subset { + for transform in &record.transforms { + *transform_counts.entry(transform.clone()).or_default() += 1; + } + } + let mut failure_counts = BTreeMap::::new(); + for failure in &failure_subset { + *failure_counts.entry(failure.message.clone()).or_default() += 1; + } + + let creation_pairwise = pairwise_diversity(&subset, |record| &record.creation); + let runtime_pairwise = pairwise_diversity(&subset, |record| &record.runtime); + + json!({ + "requested_seeds": checkpoint, + "seed_prefix_start_inclusive": 0, + "seed_prefix_end_exclusive": checkpoint, + "seed_prefix_complete": true, + "successes": subset.len(), + "failures": failure_subset.len(), + "success_rate_percent": ratio_percent(subset.len(), checkpoint), + "exact_identity_outputs": exact_identity_outputs, + "changed_outputs": changed_outputs, + "changed_output_rate_percent_of_successes": ratio_percent(changed_outputs, subset.len()), + "exact_output_replay_checks": replay_checks.len(), + "exact_output_replay_mismatches": exact_output_replay_mismatches, + "exact_deployment_replay_checks": deployment_replay_checks, + "exact_deployment_replay_mismatches": exact_deployment_replay_mismatches, + "original_creation_bytes": original_creation.len(), + "original_runtime_bytes": original_runtime.len(), + "original_deployment_gas": original_deployment_gas, + "creation_change_percent": descriptive_stats(&creation_change), + "runtime_change_percent": descriptive_stats(&runtime_change), + "creation_size_bytes": descriptive_stats(&creation_sizes), + "runtime_size_bytes": descriptive_stats(&runtime_sizes), + "creation_size_delta_bytes": descriptive_stats(&size_delta_bytes), + "creation_size_delta_percent": descriptive_stats(&size_delta_percent), + "runtime_size_delta_bytes": descriptive_stats(&runtime_size_delta_bytes), + "runtime_size_delta_percent": descriptive_stats(&runtime_size_delta_percent), + "deployment_gas": descriptive_stats(&deployment_gas), + "deployment_gas_delta_percent": descriptive_stats(&deployment_gas_delta_percent), + "unique_creation_outputs": unique_creation, + "unique_creation_percent": ratio_percent(unique_creation, subset.len()), + "unique_runtime_outputs": unique_runtime, + "unique_runtime_percent": ratio_percent(unique_runtime, subset.len()), + "selector_mappings_observed": mapping_subset.len(), + "unique_selector_mappings": unique_mappings, + "creation_pairwise_change_percent": creation_pairwise, + "runtime_pairwise_change_percent": runtime_pairwise, + "transforms_applied_counts": transform_counts, + "failure_counts": failure_counts, + }) +} + +fn is_exact_identity( + record: &SuccessRecord, + original_creation: &[u8], + original_runtime: &[u8], +) -> bool { + record.creation == original_creation && record.runtime == original_runtime +} + +fn pairwise_diversity(records: &[&SuccessRecord], bytes: F) -> Value +where + F: Fn(&SuccessRecord) -> &[u8], +{ + let count = records.len(); + if count < 2 { + return json!({ + "pairs": 0, + "possible_pairs": 0, + "sampling": "all_pairs", + "stats": descriptive_stats(&[]), + }); + } + + let total_pairs = (count as u128) * ((count - 1) as u128) / 2; + let target_pairs = usize::try_from(total_pairs.min(10_000)).expect("sample cap fits usize"); + let mut values = Vec::with_capacity(target_pairs); + + if total_pairs <= 10_000 { + for left in 0..count { + for right in (left + 1)..count { + values.push(positional_difference(bytes(records[left]), bytes(records[right])).1); + } + } + } else { + // SplitMix64 sampling makes the pair set independent of OS entropy and iteration timing. + // Its mixed output avoids the short modulo cycles produced by sampling raw LCG state. The + // HashSet prevents duplicate or directionally repeated unordered pairs. + let mut state = 0x9e37_79b9_7f4a_7c15u64 ^ count as u64; + let mut sampled = HashSet::with_capacity(target_pairs); + while values.len() < target_pairs { + let mut left = splitmix64(&mut state) as usize % count; + let mut right = splitmix64(&mut state) as usize % count; + if left == right { + right = (right + 1) % count; + } + if left > right { + std::mem::swap(&mut left, &mut right); + } + if sampled.insert((left, right)) { + values.push(positional_difference(bytes(records[left]), bytes(records[right])).1); + } + } + } + + json!({ + "pairs": values.len(), + "possible_pairs": total_pairs, + "sampling": if total_pairs <= 10_000 { "all_pairs" } else { "distinct_deterministic_splitmix64_sample" }, + "stats": descriptive_stats(&values), + }) +} + +fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut value = *state; + value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + value ^ (value >> 31) +} + +fn descriptive_stats(values: &[f64]) -> Value { + if values.is_empty() { + return json!({ + "count": 0, + "mean": Value::Null, + "min": Value::Null, + "p50": Value::Null, + "p95": Value::Null, + "max": Value::Null, + }); + } + let mut sorted = values.to_vec(); + sorted.sort_by(f64::total_cmp); + let mean = sorted.iter().sum::() / sorted.len() as f64; + json!({ + "count": sorted.len(), + "mean": mean, + "min": sorted[0], + "p50": percentile(&sorted, 0.50), + "p95": percentile(&sorted, 0.95), + "max": sorted[sorted.len() - 1], + }) +} + +fn percentile(sorted: &[f64], quantile: f64) -> f64 { + if sorted.len() == 1 { + return sorted[0]; + } + let position = quantile * (sorted.len() - 1) as f64; + let lower = position.floor() as usize; + let upper = position.ceil() as usize; + if lower == upper { + sorted[lower] + } else { + sorted[lower] + (position - lower as f64) * (sorted[upper] - sorted[lower]) + } +} + +fn full_deployment_hex(fixture: ContractFixture) -> Result> { + let mut deployment = hex::decode(normalized_hex(fixture.deployment))?; + match fixture.constructor_kind { + ConstructorKind::None => {} + ConstructorKind::Erc20 => { + deployment.extend_from_slice(&abi_address([0x11; 20])); + deployment.extend_from_slice(&abi_address([0x22; 20])); + deployment.extend_from_slice(&abi_u256(1_000)); + deployment.extend_from_slice(&abi_u256(0)); + deployment.extend_from_slice(&abi_u256(0)); + } + ConstructorKind::Native => { + deployment.extend_from_slice(&abi_address([0x22; 20])); + deployment.extend_from_slice(&abi_u256(1_000)); + deployment.extend_from_slice(&abi_u256(0)); + deployment.extend_from_slice(&abi_u256(0)); + } + } + Ok(hex::encode(deployment)) +} + +fn abi_address(address: [u8; 20]) -> [u8; 32] { + let mut word = [0u8; 32]; + word[12..].copy_from_slice(&address); + word +} + +fn abi_u256(value: u128) -> [u8; 32] { + let mut word = [0u8; 32]; + word[16..].copy_from_slice(&value.to_be_bytes()); + word +} + +fn sequential_seed(index: usize) -> Seed { + let mut bytes = [0u8; 32]; + let index_bytes = index.to_be_bytes(); + let offset = bytes.len() - index_bytes.len(); + bytes[offset..].copy_from_slice(&index_bytes); + Seed::from_bytes(bytes) +} + +fn positional_difference(left: &[u8], right: &[u8]) -> (usize, f64) { + let aligned_mismatches = left.iter().zip(right).filter(|(a, b)| a != b).count(); + let changed = aligned_mismatches + left.len().abs_diff(right.len()); + let denominator = left.len().max(right.len()); + let percent = if denominator == 0 { + 0.0 + } else { + changed as f64 / denominator as f64 * 100.0 + }; + (changed, percent) +} + +fn percent_delta(original: usize, transformed: usize) -> f64 { + if original == 0 { + 0.0 + } else { + (transformed as f64 - original as f64) / original as f64 * 100.0 + } +} + +fn percent_delta_u64(original: u64, transformed: u64) -> f64 { + if original == 0 { + 0.0 + } else { + (transformed as f64 - original as f64) / original as f64 * 100.0 + } +} + +fn ratio_percent(numerator: usize, denominator: usize) -> f64 { + if denominator == 0 { + 0.0 + } else { + numerator as f64 / denominator as f64 * 100.0 + } +} + +fn selector_mapping_fingerprint(result: &ObfuscationResult) -> Option { + let mapping = result.selector_mapping.as_ref()?; + let mut entries: Vec<_> = mapping.iter().collect(); + entries.sort_by_key(|(selector, _)| **selector); + let mut fingerprint = String::new(); + for (selector, token) in entries { + let _ = write!(fingerprint, "{selector:08x}:{};", hex::encode(token)); + } + Some(fingerprint) +} + +fn decode_result_hex(input: &str) -> Result, hex::FromHexError> { + hex::decode(input.trim().trim_start_matches("0x")) +} + +fn deploy_creation(creation: &[u8]) -> Result<(Vec, u64), String> { + let deployer = Address::from([0x42u8; 20]); + let mut db = InMemoryDB::default(); + db.insert_account_info( + deployer, + AccountInfo { + balance: U256::from(1_000_000_000_000_000_000u128), + nonce: 0, + code_hash: revm::primitives::KECCAK_EMPTY, + code: None, + }, + ); + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + let outcome = evm + .transact(TxEnv { + caller: deployer, + gas_limit: 30_000_000, + kind: TxKind::Create, + data: Bytes::copy_from_slice(creation), + value: U256::ZERO, + nonce: 0, + ..Default::default() + }) + .map_err(|error| format!("EVM error: {error:?}"))?; + + match outcome.result { + ExecutionResult::Success { + output: Output::Create(runtime, Some(_)), + gas_used, + .. + } => Ok((runtime.to_vec(), gas_used)), + ExecutionResult::Success { + output, gas_used, .. + } => Err(format!( + "unexpected successful create output {output:?} (gas {gas_used})" + )), + ExecutionResult::Revert { output, gas_used } => Err(format!( + "reverted with 0x{} (gas {gas_used})", + hex::encode(output) + )), + ExecutionResult::Halt { reason, gas_used } => { + Err(format!("halted with {reason:?} (gas {gas_used})")) + } + } +} + +fn normalized_hex(input: &str) -> String { + input + .trim() + .trim_start_matches("0x") + .chars() + .filter(|character| !character.is_whitespace() && *character != '_') + .collect() +} + +fn record_failure( + rows: &mut Vec, + csv: &mut String, + failure: FailureRowData<'_>, +) -> std::fmt::Result { + let FailureRowData { + contract, + seed_index, + seed_hex, + message, + replay, + transform_elapsed_ms, + source_revision, + } = failure; + rows.push(json!({ + "benchmark_schema": "azoth-foundation-benchmark-v4", + "pipeline_profile": EXPECTED_PIPELINE_PROFILE, + "source_revision": source_revision, + "contract": contract, + "seed_index": seed_index, + "seed_hex": seed_hex, + "status": "error", + "error": message, + "exact_replay_checked": replay.checked, + "exact_output_replay_match": replay.exact_output_match, + "exact_deployment_replay_match": replay.exact_deployment_match, + "transform_elapsed_ms": transform_elapsed_ms, + })); + let mut columns = vec![String::new(); 36]; + columns[0] = contract.to_string(); + columns[1] = seed_index.to_string(); + columns[2] = seed_hex.to_string(); + columns[3] = "error".to_string(); + columns[4] = message.to_string(); + columns[5] = replay.checked.to_string(); + columns[6] = optional_bool(replay.exact_output_match); + columns[7] = optional_bool(replay.exact_deployment_match); + columns[27] = format!("{transform_elapsed_ms:.6}"); + columns[28] = "azoth-foundation-benchmark-v4".to_string(); + columns[29] = EXPECTED_PIPELINE_PROFILE.to_string(); + columns[30] = source_revision.to_string(); + append_csv_row(csv, &columns) +} + +fn append_csv_row(output: &mut String, columns: &[String]) -> std::fmt::Result { + debug_assert_eq!(columns.len(), 36); + for (index, cell) in columns.iter().enumerate() { + if index > 0 { + output.push(','); + } + if cell + .chars() + .any(|character| matches!(character, ',' | '"' | '\n' | '\r')) + { + write!(output, "{}", csv_quote(cell))?; + } else { + output.push_str(cell); + } + } + output.push('\n'); + Ok(()) +} + +fn optional_bool(value: Option) -> String { + value.map(|inner| inner.to_string()).unwrap_or_default() +} + +fn csv_quote(input: &str) -> String { + format!("\"{}\"", input.replace('"', "\"\"")) +} + +fn keccak256_hex(bytes: &[u8]) -> String { + hex::encode(Keccak256::digest(bytes)) +} + +fn parse_args() -> Result> { + let mut iterations = 1_000usize; + let mut checkpoints = vec![10usize, 100, 1_000]; + let mut json_path: Option = None; + let mut csv_path: Option = None; + let mut determinism_checks: Option = None; + let mut source_revision: Option = None; + let mut jobs = std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(1) + .min(8); + let mut arguments = std::env::args().skip(1); + + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--iterations" | "-i" => { + iterations = arguments + .next() + .ok_or("--iterations requires a value")? + .parse()?; + } + "--checkpoints" => { + let raw = arguments.next().ok_or("--checkpoints requires a value")?; + checkpoints = raw + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::parse) + .collect::, _>>()?; + } + "--json" => { + json_path = Some(PathBuf::from( + arguments.next().ok_or("--json requires a path")?, + )); + } + "--csv" => { + csv_path = Some(PathBuf::from( + arguments.next().ok_or("--csv requires a path")?, + )); + } + "--determinism-checks" => { + determinism_checks = Some( + arguments + .next() + .ok_or("--determinism-checks requires a value")? + .parse()?, + ); + } + "--jobs" | "-j" => { + jobs = arguments.next().ok_or("--jobs requires a value")?.parse()?; + } + "--source-revision" => { + source_revision = Some( + arguments + .next() + .ok_or("--source-revision requires a value")?, + ); + } + "--help" | "-h" => { + println!( + "Usage: cargo run --locked --release -p azoth-examples --bin foundation_benchmark -- \\\n --iterations 1000 --checkpoints 10,100,1000 \\\n --determinism-checks 1000 \\\n --source-revision 'base:;tree-sha256:' \\\n --json /tmp/azoth-foundation-benchmark.json \\\n --csv /tmp/azoth-foundation-benchmark.csv" + ); + std::process::exit(0); + } + other => return Err(format!("unknown argument: {other}").into()), + } + } + + if iterations == 0 { + return Err("--iterations must be greater than zero".into()); + } + if jobs == 0 { + return Err("--jobs must be greater than zero".into()); + } + let source_revision = source_revision.ok_or( + "--source-revision is required so benchmark artifacts identify the evaluated source", + )?; + if source_revision.trim().is_empty() { + return Err("--source-revision must not be empty".into()); + } + checkpoints.sort_unstable(); + checkpoints.dedup(); + if checkpoints.is_empty() || checkpoints.contains(&0) { + return Err("--checkpoints must contain positive values".into()); + } + if checkpoints.iter().any(|value| *value > iterations) { + return Err("every checkpoint must be <= --iterations".into()); + } + + Ok(Args { + iterations, + checkpoints, + json_path: json_path.unwrap_or_else(|| { + PathBuf::from(format!("/tmp/azoth-foundation-benchmark-{iterations}.json")) + }), + csv_path: csv_path.unwrap_or_else(|| { + PathBuf::from(format!("/tmp/azoth-foundation-benchmark-{iterations}.csv")) + }), + determinism_checks: determinism_checks.unwrap_or(iterations), + jobs: jobs.min(iterations), + source_revision, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positional_difference_counts_hamming_and_length() { + let (changed, percent) = positional_difference(b"abc", b"axc"); + assert_eq!(changed, 1); + assert!((percent - 100.0 / 3.0).abs() < 1e-12); + assert_eq!(positional_difference(b"abc", b"abcde"), (2, 40.0)); + } + + #[test] + fn sequential_seeds_are_reproducible_and_distinct() { + assert_eq!(sequential_seed(7).to_hex(), sequential_seed(7).to_hex()); + assert_ne!(sequential_seed(7).to_hex(), sequential_seed(8).to_hex()); + assert_eq!( + sequential_seed(0).to_hex(), + format!("0x{}", "00".repeat(32)) + ); + assert_eq!( + sequential_seed(1).to_hex(), + format!("0x{}01", "00".repeat(31)) + ); + } + + #[test] + fn fixture_constructor_lengths_are_exact() { + let erc20 = full_deployment_hex(FIXTURES[0]).unwrap(); + let native = full_deployment_hex(FIXTURES[1]).unwrap(); + assert_eq!(hex::decode(erc20).unwrap().len(), 8_969 + 160); + assert_eq!(hex::decode(native).unwrap().len(), 7_758 + 128); + } + + #[test] + fn safe_profile_is_explicitly_pinned() { + let profile = safe_profile(sequential_seed(0)); + assert!(profile.preserve_unknown_opcodes); + assert!(!profile.rewrite_function_selectors); + assert!(!profile.obfuscate_constructor_arguments); + assert_eq!(profile.transforms.len(), 1); + assert_eq!(profile.transforms[0].name(), "ClusterShuffle"); + } + + #[test] + fn csv_rows_have_the_documented_column_count() { + let mut output = String::new(); + append_csv_row(&mut output, &vec![String::new(); 36]).unwrap(); + assert_eq!(output.trim_end().split(',').count(), 36); + } + + #[test] + fn large_pairwise_sample_contains_ten_thousand_distinct_pairs() { + let records: Vec<_> = (0..1_000) + .map(|seed_index| SuccessRecord { + seed_index, + creation: seed_index.to_be_bytes().to_vec(), + runtime: seed_index.to_be_bytes().to_vec(), + creation_change_percent: 0.0, + runtime_change_percent: 0.0, + size_delta_bytes: 0, + size_delta_percent: 0.0, + runtime_size_delta_bytes: 0, + runtime_size_delta_percent: 0.0, + deployment_gas: 0, + deployment_gas_delta_percent: 0.0, + transforms: Vec::new(), + mapping_fingerprint: None, + replay: ReplayCheck::default(), + }) + .collect(); + let references: Vec<_> = records.iter().collect(); + let summary = pairwise_diversity(&references, |record| &record.runtime); + assert_eq!(summary["pairs"], 10_000); + assert_eq!( + summary["sampling"], + "distinct_deterministic_splitmix64_sample" + ); + } + + #[test] + fn checkpoint_separates_exact_identity_from_changed_successes() { + let original_creation = vec![0x60, 0x00]; + let original_runtime = vec![0x5b]; + let make_record = |seed_index, creation, runtime| SuccessRecord { + seed_index, + creation, + runtime, + creation_change_percent: 0.0, + runtime_change_percent: 0.0, + size_delta_bytes: 0, + size_delta_percent: 0.0, + runtime_size_delta_bytes: 0, + runtime_size_delta_percent: 0.0, + deployment_gas: 100, + deployment_gas_delta_percent: 0.0, + transforms: Vec::new(), + mapping_fingerprint: None, + replay: ReplayCheck::default(), + }; + let successes = vec![ + make_record(0, original_creation.clone(), original_runtime.clone()), + make_record(1, vec![0x60, 0x01], original_runtime.clone()), + make_record(2, original_creation.clone(), vec![0x00]), + ]; + + let failures = vec![FailureRecord { + seed_index: 3, + message: "expected test failure".to_string(), + replay: ReplayCheck::default(), + }]; + let summary = summarize_checkpoint( + 4, + &successes, + &failures, + &original_creation, + &original_runtime, + 100, + ); + + assert_eq!(summary["successes"], 3); + assert_eq!(summary["failures"], 1); + assert_eq!(summary["exact_identity_outputs"], 1); + assert_eq!(summary["changed_outputs"], 2); + let changed_rate = summary["changed_output_rate_percent_of_successes"] + .as_f64() + .unwrap(); + assert!((changed_rate - 200.0 / 3.0).abs() < 1e-12); + } + + #[test] + fn original_fixtures_deploy_and_keep_runtime_length() { + for fixture in FIXTURES { + let creation = hex::decode(full_deployment_hex(fixture).unwrap()).unwrap(); + let expected_runtime = hex::decode(normalized_hex(fixture.runtime)).unwrap(); + let (deployed_runtime, _) = deploy_creation(&creation).unwrap(); + assert_eq!( + deployed_runtime.len(), + expected_runtime.len(), + "{} runtime length", + fixture.name + ); + } + } +} diff --git a/examples/src/main.rs b/examples/src/main.rs index 4e788c35..6d86bfb9 100644 --- a/examples/src/main.rs +++ b/examples/src/main.rs @@ -1,4 +1,9 @@ -//! Mirage Privacy Protocol - Obfuscation Workflow +//! Mirage Privacy Protocol - safe-foundation replay workflow. +//! +//! This example demonstrates deterministic transformation and authenticated replay. It does not +//! claim semantic equivalence, indistinguishability, anonymity, or production readiness. + +#![recursion_limit = "256"] use azoth_core::seed::Seed; use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig, ObfuscationResult}; @@ -10,7 +15,7 @@ const MIRAGE_ESCROW_RUNTIME_PATH: &str = "escrow-bytecode/artifacts/erc20_runtim #[tokio::main] async fn main() -> Result<(), Box> { - println!("Mirage Privacy Protocol - Obfuscation Workflow"); + println!("Mirage Privacy Protocol - Safe Foundation Replay Workflow"); println!("================================================="); // Load contract bytecode (both deployment and runtime) @@ -24,8 +29,8 @@ async fn main() -> Result<(), Box> { runtime_bytecode.len() ); - // SENDER: Compile with obfuscation O(S, K2) - println!("\nSENDER: Compiling contract with obfuscation..."); + // SENDER: Run the currently admitted foundation profile O(S, K2). + println!("\nSENDER: Applying the safe foundation profile..."); let obfuscation_result = apply_mirage_obfuscation(&original_bytecode, &runtime_bytecode, &seed_k2).await?; let obfuscated_bytecode = hex::decode( @@ -34,13 +39,12 @@ async fn main() -> Result<(), Box> { .trim_start_matches("0x"), )?; - let size_increase = - calculate_percentage_increase(original_bytecode.len(), obfuscated_bytecode.len()); + let size_delta = calculate_percentage_delta(original_bytecode.len(), obfuscated_bytecode.len()); println!(" Original: {} bytes", original_bytecode.len()); println!( - " Obfuscated: {} bytes (+{:.1}%)", + " Output: {} bytes ({:+.1}%)", obfuscated_bytecode.len(), - size_increase + size_delta ); // Print transform information @@ -55,8 +59,8 @@ async fn main() -> Result<(), Box> { ); } - // VERIFIER: Verify bytecode integrity - println!("\nVERIFIER: Verifying deterministic compilation with K2..."); + // VERIFIER: Replay the exact inputs and authenticate the resulting artifact manifest. + println!("\nVERIFIER: Checking deterministic replay with K2..."); let recompilation_result = apply_mirage_obfuscation(&original_bytecode, &runtime_bytecode, &seed_k2).await?; let recompiled_bytecode = hex::decode( @@ -65,43 +69,47 @@ async fn main() -> Result<(), Box> { .trim_start_matches("0x"), )?; - // Check 1: Deterministic compilation (same seed = same result) - let deterministic_verified = obfuscated_bytecode == recompiled_bytecode; - if !deterministic_verified { - return Err("Deterministic compilation failed - seed produced different results".into()); + let deterministic_replay_verified = obfuscated_bytecode == recompiled_bytecode + && obfuscation_result.obfuscated_runtime == recompilation_result.obfuscated_runtime + && obfuscation_result.integrity == recompilation_result.integrity + && obfuscation_result.private_interaction_manifest() + == recompilation_result.private_interaction_manifest(); + if !deterministic_replay_verified { + return Err("Deterministic replay failed - identical private inputs diverged".into()); } - println!(" Deterministic compilation VERIFIED"); + println!(" Deterministic replay VERIFIED"); - // Check 2: Effective obfuscation (original ≠ obfuscated) - let obfuscation_applied = verify_obfuscation_applied(&original_bytecode, &obfuscated_bytecode); - if !obfuscation_applied { - return Err("No obfuscation detected - bytecode unchanged".into()); - } - println!(" Obfuscation transformation VERIFIED"); + obfuscation_result + .verify_integrity(&original_bytecode, &runtime_bytecode, &seed_k2) + .map_err(|error| format!("Integrity-manifest verification failed: {error}"))?; + let integrity_manifest_verified = true; + println!(" Authenticated artifact integrity VERIFIED"); - // Check 3: Functional equivalence - let functional_equivalence = - verify_functional_equivalence(&original_bytecode, &obfuscated_bytecode).await?; - if !functional_equivalence { - return Err("Functional equivalence failed - behavior changed".into()); - } + let bytecode_changed = original_bytecode != obfuscated_bytecode; + println!( + " Variation outcome: {}", + if bytecode_changed { + "changed artifact" + } else { + "exact identity (a conservative fallback, not a variation success)" + } + ); + println!(" Semantic equivalence: NOT VERIFIED (formal verifier unavailable)"); + println!(" Indistinguishability/anonymity: NOT EVALUATED"); // Gas analysis - println!("\nGAS ANALYSIS:"); + println!("\nCREATION INPUT INTRINSIC GAS ESTIMATE (not total deployment gas):"); let gas_analysis = analyze_gas_costs(&original_bytecode, &obfuscated_bytecode); + println!(" Original payload: {} gas", gas_analysis.original_gas); println!( - " Original deployment: {} gas", - gas_analysis.original_gas - ); - println!( - " Obfuscated deployment: {} gas", + " Transformed payload: {} gas", gas_analysis.obfuscated_gas ); - println!(" Gas overhead: {:.2}%", gas_analysis.overhead_percentage); + println!(" Gas delta: {:+.2}%", gas_analysis.delta_percentage); - // Deterministic compilation verification - println!("\nDETERMINISTIC COMPILATION TEST:"); - verify_deterministic_compilation_test(&original_bytecode, &runtime_bytecode, &seed_k2).await?; + println!("\nDETERMINISTIC REPLAY TEST:"); + let alternate_seed_changed = + verify_deterministic_replay_test(&original_bytecode, &runtime_bytecode, &seed_k2).await?; // Generate comprehensive report let report = generate_workflow_report( @@ -109,19 +117,29 @@ async fn main() -> Result<(), Box> { &obfuscated_bytecode, &gas_analysis, &obfuscation_result, - deterministic_verified, - obfuscation_applied, - functional_equivalence, + deterministic_replay_verified, + integrity_manifest_verified, + bytecode_changed, + alternate_seed_changed, ); save_report(&report, "mirage_report.json")?; - println!("\nMIRAGE WORKFLOW COMPLETED SUCCESSFULLY"); - println!(" Deterministic compilation: VERIFIED"); - println!(" Obfuscation applied: VERIFIED"); - println!(" Functional equivalence: VERIFIED"); - println!(" Gas overhead: {:.2}%", gas_analysis.overhead_percentage); - println!(" Size overhead: {size_increase:.1}%"); + println!("\nFOUNDATION REPLAY WORKFLOW COMPLETED"); + println!(" Deterministic replay: VERIFIED"); + println!(" Artifact integrity: VERIFIED"); + println!( + " Variation outcome: {}", + if bytecode_changed { + "CHANGED" + } else { + "IDENTITY" + } + ); + println!(" Semantic equivalence: NOT VERIFIED"); + println!(" Indistinguishability/anonymity: NOT EVALUATED"); + println!(" Input-gas delta: {:+.2}%", gas_analysis.delta_percentage); + println!(" Size delta: {size_delta:+.1}%"); println!(" Report saved: mirage_report.json"); Ok(()) @@ -160,7 +178,7 @@ fn load_mirage_contract() -> Result<(Vec, Vec), Box ObfuscationConfig { - // Build Mirage-specific transforms (function_dispatcher is added automatically) - let transforms = vec![ - Box::new(azoth_transform::shuffle::Shuffle) as Box, - Box::new(azoth_transform::jump_address_transformer::JumpAddressTransformer::new()), - Box::new(azoth_transform::opaque_predicate::OpaquePredicate::new()), - ]; - - ObfuscationConfig { - seed: seed_k2.clone(), - transforms, - preserve_unknown_opcodes: true, - } -} - -/// Verify that obfuscation was actually applied (original ≠ obfuscated) -fn verify_obfuscation_applied(original: &[u8], obfuscated: &[u8]) -> bool { - original != obfuscated -} - -/// Verify functional equivalence by testing contract behavior -async fn verify_functional_equivalence( - _original: &[u8], - _obfuscated: &[u8], -) -> Result> { - println!(" Functional equivalence testing not yet implemented"); - println!(" Using placeholder verification for development"); - - // TODO: Implement actual functional testing: - // 1. Deploy both contracts to test environment - // 2. Run identical transaction sequences - // 3. Compare contract states and outputs - // 4. Verify gas costs are reasonable - - Ok(true) +/// Build the conservative foundation configuration exposed by default. +fn create_safe_foundation_config(seed_k2: &Seed) -> ObfuscationConfig { + ObfuscationConfig::with_seed(seed_k2.clone()) } /// Gas analysis results @@ -221,66 +205,77 @@ async fn verify_functional_equivalence( struct GasAnalysis { original_gas: u64, obfuscated_gas: u64, - overhead_percentage: f64, + delta_percentage: f64, } -/// Analyze gas costs for deployment +/// Estimate only the intrinsic transaction-data component for the creation payload. +/// +/// This deliberately excludes init-code execution, memory expansion, EIP-3860 metering, and +/// code-deposit gas, so it must not be presented as total deployment gas. fn analyze_gas_costs(original: &[u8], obfuscated: &[u8]) -> GasAnalysis { - let original_gas = calculate_deployment_gas(original); - let obfuscated_gas = calculate_deployment_gas(obfuscated); - let overhead_percentage = calculate_gas_percentage_increase(original_gas, obfuscated_gas); + let original_gas = calculate_intrinsic_input_gas(original); + let obfuscated_gas = calculate_intrinsic_input_gas(obfuscated); + let delta_percentage = calculate_gas_percentage_delta(original_gas, obfuscated_gas); GasAnalysis { original_gas, obfuscated_gas, - overhead_percentage, + delta_percentage, } } -/// Calculate deployment gas using EVM formula: 21000 + 4*zeros + 16*nonzeros -fn calculate_deployment_gas(bytecode: &[u8]) -> u64 { +/// Calculate the base transaction plus zero/non-zero calldata byte cost. +fn calculate_intrinsic_input_gas(bytecode: &[u8]) -> u64 { let zero_bytes = bytecode.iter().filter(|&&b| b == 0).count() as u64; let non_zero_bytes = (bytecode.len() as u64) - zero_bytes; 21_000 + (zero_bytes * 4) + (non_zero_bytes * 16) } /// Calculate percentage increase between two values -fn calculate_percentage_increase(original: usize, new: usize) -> f64 { +fn calculate_percentage_delta(original: usize, new: usize) -> f64 { let orig = original as f64; let new_val = new as f64; ((new_val / orig) - 1.0) * 100.0 } /// Calculate percentage increase for gas values -fn calculate_gas_percentage_increase(original: u64, new: u64) -> f64 { +fn calculate_gas_percentage_delta(original: u64, new: u64) -> f64 { let orig = original as f64; let new_val = new as f64; ((new_val / orig) - 1.0) * 100.0 } -/// Verify deterministic compilation produces identical results -async fn verify_deterministic_compilation_test( +/// Verify exact same-seed replay and report, without requiring, cross-seed diversity. +/// +/// Different seeds may legitimately produce the same artifact when a pass has no movable units or +/// a safety gate returns the unchanged input. Cross-seed equality is therefore an outcome, not a +/// determinism failure. +async fn verify_deterministic_replay_test( bytecode: &[u8], runtime_bytecode: &[u8], seed: &Seed, -) -> Result<(), Box> { +) -> Result> { let result1 = apply_mirage_obfuscation(bytecode, runtime_bytecode, seed).await?; let result2 = apply_mirage_obfuscation(bytecode, runtime_bytecode, seed).await?; if result1.obfuscated_bytecode != result2.obfuscated_bytecode { - return Err("Same seed produced different bytecode - not deterministic!".into()); + return Err("Same seed produced different bytecode - replay is not deterministic".into()); } - println!(" Same seed produces identical bytecode"); + println!(" Same seed produces identical bytecode: VERIFIED"); - // Test different seeds produce different results - let different_seed = Seed::generate(); + let different_seed = Seed::from_bytes([0xa5; 32]); let diff_result = apply_mirage_obfuscation(bytecode, runtime_bytecode, &different_seed).await?; - if result1.obfuscated_bytecode == diff_result.obfuscated_bytecode { - return Err("Different seeds produced identical bytecode!".into()); - } - println!(" Different seeds produce different bytecode"); + let alternate_seed_changed = result1.obfuscated_bytecode != diff_result.obfuscated_bytecode; + println!( + " Fixed alternate seed outcome: {}", + if alternate_seed_changed { + "different artifact" + } else { + "same artifact (permitted for a no-op/identity fallback)" + } + ); - Ok(()) + Ok(alternate_seed_changed) } /// Generate comprehensive workflow report @@ -290,9 +285,10 @@ fn generate_workflow_report( obfuscated: &[u8], gas_analysis: &GasAnalysis, obfuscation_result: &ObfuscationResult, - deterministic_verified: bool, - obfuscation_applied: bool, - functional_equivalence: bool, + deterministic_replay_verified: bool, + integrity_manifest_verified: bool, + bytecode_changed: bool, + alternate_seed_changed: bool, ) -> serde_json::Value { json!({ "mirage_obfuscation_workflow": { @@ -300,37 +296,40 @@ fn generate_workflow_report( "bytecode_analysis": { "original_bytes": original.len(), "obfuscated_bytes": obfuscated.len(), - "size_increase_bytes": obfuscated.len() - original.len(), - "size_increase_percentage": calculate_percentage_increase(original.len(), obfuscated.len()), - "obfuscation_applied": obfuscation_applied, + "size_delta_bytes": obfuscated.len() as i64 - original.len() as i64, + "size_delta_percentage": calculate_percentage_delta(original.len(), obfuscated.len()), + "variation_outcome": if bytecode_changed { "changed" } else { "exact_identity" }, "unknown_opcodes_preserved": obfuscation_result.unknown_opcodes_count, "blocks_created": obfuscation_result.blocks_created, "instructions_added": obfuscation_result.instructions_added }, "gas_analysis": { - "original_deployment_gas": gas_analysis.original_gas, - "obfuscated_deployment_gas": gas_analysis.obfuscated_gas, - "gas_increase": (gas_analysis.obfuscated_gas as i64 - gas_analysis.original_gas as i64), - "gas_overhead_percentage": gas_analysis.overhead_percentage + "scope": "base transaction plus creation-payload calldata bytes only; not total deployment gas", + "original_intrinsic_input_gas_estimate": gas_analysis.original_gas, + "transformed_intrinsic_input_gas_estimate": gas_analysis.obfuscated_gas, + "gas_delta": (gas_analysis.obfuscated_gas as i64 - gas_analysis.original_gas as i64), + "gas_delta_percentage": gas_analysis.delta_percentage }, "verification_results": { - "deterministic_compilation": deterministic_verified, - "obfuscation_transformation_applied": obfuscation_applied, - "functional_equivalence_verified": functional_equivalence, - "overall_verification_passed": deterministic_verified && obfuscation_applied && functional_equivalence, - "verification_level": "preliminary_functional_testing", - "formal_verification_status": "pending_implementation" + "deterministic_replay_verified": deterministic_replay_verified, + "integrity_manifest_verified": integrity_manifest_verified, + "alternate_seed_changed_artifact": alternate_seed_changed, + "semantic_equivalence_status": "not_verified", + "formal_verification_status": "unavailable_fail_closed", + "release_gate_passed": false }, - "security_properties": { - "statistical_indistinguishability": obfuscation_applied, + "security_assessment": { + "statistical_indistinguishability": "not_evaluated", + "anonymity_set_membership": "not_demonstrated", + "clustering_resistance": "not_demonstrated", "transforms_applied": obfuscation_result.metadata.transforms_applied, - "verification_completeness": "basic_structural_validation" + "warning": "a changed artifact is not evidence of stealth or semantic equivalence" }, "mirage_protocol": { - "sender_workflow": if obfuscation_applied { "Contract successfully obfuscated with seed K2" } else { "ERROR: No obfuscation applied" }, - "executor_workflow": if deterministic_verified { "Bytecode determinism verified with K2" } else { "ERROR: Non-deterministic compilation" }, - "anonymity_set": if obfuscation_applied { "Blends with unverified contract deployments" } else { "WARNING: Unchanged bytecode may be recognizable" }, - "production_readiness": "requires_formal_verification" + "authorized_replay": if deterministic_replay_verified { "artifact reproduced from bytecode and K2" } else { "replay failed" }, + "integrity": if integrity_manifest_verified { "seed-bound artifact hashes authenticated" } else { "integrity check failed" }, + "interface": "safe profile preserves original selectors; no private selector mapping is needed", + "production_readiness": "no_go" }, "obfuscation_details": { "size_limit_exceeded": obfuscation_result.metadata.size_limit_exceeded, @@ -339,22 +338,15 @@ fn generate_workflow_report( }, "recommendations": { "immediate": [ - "Current verification provides basic confidence for development", - "Functional testing validates structural integrity", - "Deterministic compilation ensures Mirage protocol compatibility", - "Function dispatcher obfuscation automatically applied for baseline security" + "Treat deterministic replay and integrity authentication as narrower properties than equivalence", + "Count exact-identity results separately from changed outputs", + "Use the safe profile only for development and evaluation" ], "before_production": [ - "Implement formal verification (see GitHub issue)", - "Deploy test contracts with identical transaction sequences", - "Validate all ERC standard compliance", - "Security audit of obfuscated contracts", - "Gas optimization analysis" - ], - "monitoring": [ - "Track obfuscation effectiveness metrics", - "Monitor gas overhead in production", - "Verify deterministic compilation in CI/CD" + "Implement and independently validate complete semantic-equivalence obligations", + "Run differential behavior tests over calls, state, logs, reverts, external calls, and fork contexts", + "Evaluate genuinely changed outputs against a representative Ethereum negative corpus", + "Complete independent security review" ] } } diff --git a/tests/Cargo.toml b/tests/Cargo.toml index bac92339..e8513a13 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -12,7 +12,6 @@ tokio.workspace = true tracing-subscriber.workspace = true tracing.workspace = true hex.workspace = true -heimdall.workspace = true tempfile.workspace = true serde_json.workspace = true petgraph.workspace = true diff --git a/tests/src/analysis/metrics.rs b/tests/src/analysis/metrics.rs index 2be4c0a7..05211555 100644 --- a/tests/src/analysis/metrics.rs +++ b/tests/src/analysis/metrics.rs @@ -2,17 +2,17 @@ use azoth_analysis::{ collect_metrics, compare, metrics::{dom_overlap, dominator_pairs}, }; -use azoth_core::{cfg_ir, decoder, detection, result::Error, strip}; +use azoth_core::{cfg_ir, decoder, detection, strip}; use petgraph::graph::NodeIndex; /// Tests metrics computation for a simple bytecode with linear control flow. #[tokio::test] async fn test_collect_metrics_simple() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x600160015601"; // PUSH1 0x01, PUSH1 0x01, ADD let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode, false).await.unwrap(); @@ -37,11 +37,11 @@ async fn test_collect_metrics_simple() { /// Tests metrics computation for a single-block bytecode. #[tokio::test] async fn test_collect_metrics_single_block() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x600050"; // PUSH1 0x00, STOP let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode, false).await.unwrap(); @@ -63,11 +63,11 @@ async fn test_collect_metrics_single_block() { /// Tests metrics computation for a bytecode with conditional branching. #[tokio::test] async fn test_collect_metrics_branching() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x6000600157600256"; // PUSH1 0x00, JUMPI, JUMPDEST, STOP let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode, false).await.unwrap(); @@ -89,28 +89,31 @@ async fn test_collect_metrics_branching() { ); } -/// Tests that decoding an empty bytecode fails with a parse error. +/// Empty account code is a valid empty instruction stream. #[tokio::test] async fn test_collect_metrics_empty_input() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); - let err = decoder::decode_bytecode("0x", false) + .try_init(); + let (instructions, info, assembly, bytes) = decoder::decode_bytecode("0x", false) .await - .expect_err("empty blob must fail to decode"); - assert!(matches!(err, Error::ParseError { .. })); + .expect("empty account code must decode"); + assert!(instructions.is_empty()); + assert_eq!(info.byte_length, 0); + assert!(assembly.is_empty()); + assert!(bytes.is_empty()); } /// Tests metrics computation for a CFG with no body blocks. #[tokio::test] async fn test_collect_metrics_no_body_blocks() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x00"; // STOP let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode, false).await.unwrap(); @@ -126,11 +129,11 @@ async fn test_collect_metrics_no_body_blocks() { /// Tests the compare function for metrics. #[tokio::test] async fn test_compare_metrics() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode_before = "0x600050"; // PUSH1 0x00, STOP let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode_before, false) @@ -159,11 +162,11 @@ async fn test_compare_metrics() { /// Tests invariant: potency score increases with more edges. #[tokio::test] async fn test_potency_edge_increase() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode_simple = "0x600050"; // PUSH1 0x00, STOP let (instructions, _, _, bytes) = decoder::decode_bytecode(bytecode_simple, false) .await @@ -191,11 +194,11 @@ async fn test_potency_edge_increase() { /// Tests dominator and post-dominator computation for a branching CFG. #[tokio::test] async fn test_dominator_computation() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x6000600157600256"; // PUSH1 0x00, PUSH1 0x01, JUMPI, PUSH1 0x02, JUMP let (cfg_ir, _, _, _) = azoth_core::process_bytecode_to_cfg(bytecode, false, bytecode, false) .await diff --git a/tests/src/core/cfg_ir.rs b/tests/src/core/cfg_ir.rs index f033cf0f..597ce2da 100644 --- a/tests/src/core/cfg_ir.rs +++ b/tests/src/core/cfg_ir.rs @@ -292,6 +292,54 @@ fn push_reaches_jump_consumed_by_sload_as_slot_is_rejected() { assert!(!push_reaches_jump(&instrs, 0)); } +#[test] +fn push_reaches_jump_uses_native_stack_metadata_for_current_opcodes() { + for consumer in [Opcode::CLZ, Opcode::BLOBHASH, Opcode::TLOAD] { + let instrs = vec![ + prj_instr(0, Opcode::PUSH(2), Some("0100")), + prj_instr(3, consumer, None), + ]; + assert!( + !push_reaches_jump(&instrs, 0), + "{consumer} consumes the tracked literal" + ); + } + + let blob_base_fee = vec![ + prj_instr(0, Opcode::PUSH(2), Some("0100")), + prj_instr(3, Opcode::BLOBBASEFEE, None), + prj_instr(4, Opcode::POP, None), + prj_instr(5, Opcode::CLZ, None), + ]; + assert!(!push_reaches_jump(&blob_base_fee, 0)); + + let tstore = vec![ + prj_instr(0, Opcode::PUSH(2), Some("0100")), + prj_instr(3, Opcode::PUSH0, None), + prj_instr(4, Opcode::TSTORE, None), + ]; + assert!(!push_reaches_jump(&tstore, 0)); + + let mcopy = vec![ + prj_instr(0, Opcode::PUSH(2), Some("0100")), + prj_instr(3, Opcode::PUSH0, None), + prj_instr(4, Opcode::PUSH0, None), + prj_instr(5, Opcode::MCOPY, None), + ]; + assert!(!push_reaches_jump(&mcopy, 0)); + + // EXTCODECOPY consumes four operands. Tracking a value three positions below the top catches + // the previous hand-maintained model's incorrect three-input assumption. + let extcodecopy = vec![ + prj_instr(0, Opcode::PUSH(2), Some("0100")), + prj_instr(3, Opcode::PUSH0, None), + prj_instr(4, Opcode::PUSH0, None), + prj_instr(5, Opcode::PUSH0, None), + prj_instr(6, Opcode::EXTCODECOPY, None), + ]; + assert!(!push_reaches_jump(&extcodecopy, 0)); +} + #[test] fn push_reaches_jump_stack_carried_past_internal_call() { // Solidity internal-function-call convention: diff --git a/tests/src/core/decoder.rs b/tests/src/core/decoder.rs index 49beeee2..3c2fc15a 100644 --- a/tests/src/core/decoder.rs +++ b/tests/src/core/decoder.rs @@ -1,50 +1,376 @@ -use azoth_core::decoder::{decode_bytecode, parse_assembly, SourceType}; -use azoth_core::result::Error; -use heimdall::{disassemble, DisassemblerArgsBuilder}; - -#[allow(dead_code)] -// Fixture: PUSH1 0x01, PUSH1 0x02, ADD, STOP -const BYTECODE: &str = "0x6001600201600057"; - -#[tokio::test] -async fn test_hex_roundtrip() { - let (ins, info, asm, _) = decode_bytecode(BYTECODE, false).await.unwrap(); - tracing::debug!("\nRaw assembly:\n{}", asm); - tracing::debug!("Parsed instructions:"); - for instruction in &ins { - tracing::debug!("{}", instruction); - } - assert_eq!(ins.len(), 5); - - let expected_bytes = BYTECODE.trim_start_matches("0x").len() / 2; - assert_eq!(info.byte_length, expected_bytes); - - assert_eq!(info.source, SourceType::HexString); - assert!(!info.keccak_hash.is_empty()); -} - -#[tokio::test] -async fn test_bad_hex_fails() { - let result = decode_bytecode("0xZZ42", false).await; - assert!(matches!(result, Err(Error::HexDecode(_)))); -} - -#[tokio::test] -async fn test_invalid_assembly_fails() { - let args = DisassemblerArgsBuilder::new() - .target("0x".to_string()) // Empty bytecode - .output("print".into()) - .build() - .unwrap(); - let asm = disassemble(args) - .await - .map_err(|e| Error::Heimdall(e.to_string())); - match asm { - Ok(asm) => { - tracing::debug!("\nRaw assembly from invalid input:\n{}", asm); - let result = parse_assembly(&asm); - assert!(matches!(result, Err(Error::ParseError { .. }))); +use azoth_core::decoder::{ + decode_bytes, decode_executable_bytes, decode_input, format_assembly, SourceType, +}; +use azoth_core::encoder::encode; +use azoth_core::Opcode; +use revm::bytecode::{Bytecode, BytecodeIterator, LegacyRawBytecode, OpCode as RevmOpcode}; +use revm::primitives::Bytes; + +const STORAGE: &str = include_str!("../../bytecode/storage.hex"); +const COUNTER_DEPLOYMENT: &str = include_str!("../../bytecode/counter/counter_deployment.hex"); +const COUNTER_RUNTIME: &str = include_str!("../../bytecode/counter/counter_runtime.hex"); +const NATIVE_DEPLOYMENT: &str = + include_str!("../../../examples/escrow-bytecode/artifacts/native_deployment.hex"); +const NATIVE_RUNTIME: &str = + include_str!("../../../examples/escrow-bytecode/artifacts/native_runtime.hex"); +const ERC20_DEPLOYMENT: &str = + include_str!("../../../examples/escrow-bytecode/artifacts/erc20_deployment.hex"); +const ERC20_RUNTIME: &str = + include_str!("../../../examples/escrow-bytecode/artifacts/erc20_runtime.hex"); + +fn bytes(hex_source: &str) -> Vec { + hex::decode(hex_source.trim().trim_start_matches("0x")).expect("fixture is valid hex") +} + +/// Returns the EVM instruction prefix and the Solidity CBOR compiler trailer. +fn split_compiler_trailer(input: &[u8]) -> (&[u8], &[u8]) { + assert!(input.len() >= 2, "fixture must contain a compiler trailer"); + let payload_len = usize::from(u16::from_be_bytes([ + input[input.len() - 2], + input[input.len() - 1], + ])); + let trailer_len = payload_len + .checked_add(2) + .expect("two-byte compiler length cannot overflow"); + let split = input + .len() + .checked_sub(trailer_len) + .expect("compiler trailer length is in bounds"); + input.split_at(split) +} + +#[test] +fn exhaustive_opcode_stream_matches_revm_instruction_boundaries() { + // Put every possible opcode byte in code position. PUSH immediates are deliberately filled + // with opcode-looking bytes; an implementation that accidentally decodes an immediate will + // disagree with revm's independently maintained legacy-bytecode iterator. + let mut corpus = Vec::new(); + for opcode in 0u8..=u8::MAX { + corpus.push(opcode); + if (0x60..=0x7f).contains(&opcode) { + let width = usize::from(opcode - 0x5f); + corpus.extend((0..width).map(|index| { + const OPCODE_LIKE: [u8; 8] = [0x5b, 0x60, 0x7f, 0x56, 0xfe, 0xaa, 0x00, 0xff]; + OPCODE_LIKE[index % OPCODE_LIKE.len()] + })); + } + } + + let instructions = + decode_executable_bytes(&corpus).expect("complete exhaustive stream must decode"); + + let analyzed = LegacyRawBytecode(Bytes::copy_from_slice(&corpus)).into_analyzed(); + let bytecode = Bytecode::LegacyAnalyzed(analyzed); + let mut oracle = BytecodeIterator::new(&bytecode); + let mut expected = Vec::new(); + while oracle.position() < corpus.len() { + let pc = oracle.position(); + let opcode = oracle.next().expect("original bytes remain"); + expected.push((pc, opcode)); + } + + let actual: Vec<_> = instructions + .iter() + .map(|instruction| (instruction.pc, instruction.op.to_byte())) + .collect(); + assert_eq!(actual, expected); + assert_eq!( + encode(&instructions, &corpus).expect("exhaustive stream must re-encode"), + corpus + ); +} + +#[test] +fn opcode_metadata_matches_revm_for_every_byte() { + for byte in u8::MIN..=u8::MAX { + let native = Opcode::from_byte(byte); + match RevmOpcode::new(byte) { + Some(reference) => { + assert!(!native.is_unknown(), "known-byte mismatch at 0x{byte:02x}"); + assert_eq!( + native.to_string(), + reference.as_str(), + "mnemonic mismatch at 0x{byte:02x}" + ); + let info = native.info().expect("known native opcode has metadata"); + assert_eq!( + (info.inputs, info.outputs), + reference.input_output(), + "stack-effect mismatch at 0x{byte:02x}" + ); + assert_eq!( + native.immediate_size(), + usize::from(reference.info().immediate_size()), + "immediate-width mismatch at 0x{byte:02x}" + ); + assert_eq!( + native.is_terminal(), + reference.info().is_terminating(), + "terminal classification mismatch at 0x{byte:02x}" + ); + } + None => { + assert_eq!(native, Opcode::UNKNOWN(byte), "byte 0x{byte:02x}"); + assert_eq!(native.info(), None); + assert_eq!(native.immediate_size(), 0); + assert!(native.is_terminal()); + } + } + } +} + +#[test] +fn every_push_width_captures_exact_immediate_and_size() { + for width in 1u8..=32 { + let opcode = 0x5f + width; + let immediate: Vec<_> = (0..width).map(|index| index.wrapping_mul(17)).collect(); + let mut input = Vec::with_capacity(usize::from(width) + 2); + input.push(opcode); + input.extend_from_slice(&immediate); + input.push(0x00); + + let instructions = + decode_executable_bytes(&input).expect("complete PUSH must decode strictly"); + assert_eq!(instructions.len(), 2, "PUSH{width}"); + assert_eq!(instructions[0].pc, 0, "PUSH{width}"); + assert_eq!(instructions[0].op, Opcode::PUSH(width), "PUSH{width}"); + let encoded_immediate = hex::encode(&immediate); + assert_eq!( + instructions[0].imm.as_deref(), + Some(encoded_immediate.as_str()), + "PUSH{width}" + ); + assert_eq!(instructions[0].byte_size(), usize::from(width) + 1); + assert_eq!(instructions[1].pc, usize::from(width) + 1); + assert_eq!(instructions[1].op, Opcode::STOP); + assert_eq!( + encode(&instructions, &input).expect("PUSH stream must re-encode"), + input + ); + } +} + +#[test] +fn every_truncated_push_fails_strictly_but_round_trips_losslessly() { + for width in 1u8..=32 { + for available in 0..usize::from(width) { + // A leading STOP is important: Heimdall used to return partial success when a + // truncated PUSH followed one or more valid instructions. + let mut input = vec![0x00, 0x5f + width]; + input.extend((0..available).map(|index| index as u8)); + + let error = decode_executable_bytes(&input) + .expect_err("truncated executable PUSH must never yield partial success"); + let message = error.to_string(); + assert!( + message.contains(&format!("PUSH{width}")), + "error omits declared width for PUSH{width} with {available} bytes: {message}" + ); + assert!( + message.contains("0x1") || message.contains("pc 1") || message.contains("PC 1"), + "error omits nonzero failing PC for PUSH{width} with {available} bytes: {message}" + ); + + let instructions = decode_bytes(&input).expect("blob decoding must remain lossless"); + assert_eq!(instructions.len(), 2, "PUSH{width}, available={available}"); + let truncated = &instructions[1]; + assert_eq!(truncated.pc, 1); + assert_eq!(truncated.op, Opcode::PUSH(width)); + assert_eq!( + truncated.imm.as_deref(), + Some(hex::encode(&input[2..]).as_str()) + ); + assert_eq!(truncated.byte_size(), available + 1); + assert_eq!( + encode(&instructions, &input).expect("final short PUSH must re-encode exactly"), + input + ); + } + } +} + +#[test] +fn opcode_looking_push_data_is_not_decoded_as_code() { + let input = [0x65, 0x5b, 0x60, 0x7f, 0xfe, 0xaa, 0x00, 0x00]; + let instructions = decode_executable_bytes(&input).expect("complete PUSH6 must decode"); + + assert_eq!(instructions.len(), 2); + assert_eq!(instructions[0].op, Opcode::PUSH(6)); + assert_eq!(instructions[0].imm.as_deref(), Some("5b607ffeaa00")); + assert_eq!(instructions[1].pc, 7); + assert_eq!(instructions[1].op, Opcode::STOP); +} + +#[test] +fn unknown_bytes_remain_distinct_from_designated_invalid() { + let input = [0x0c, 0xaa, 0xd0, 0xe0, 0xf7, 0xfe]; + let instructions = + decode_executable_bytes(&input).expect("one-byte legacy opcodes must decode"); + let opcodes: Vec<_> = instructions + .iter() + .map(|instruction| instruction.op) + .collect(); + + assert_eq!( + opcodes, + vec![ + Opcode::UNKNOWN(0x0c), + Opcode::UNKNOWN(0xaa), + Opcode::UNKNOWN(0xd0), + Opcode::UNKNOWN(0xe0), + Opcode::UNKNOWN(0xf7), + Opcode::INVALID, + ] + ); + assert_eq!( + encode(&instructions, &input).expect("unknown bytes must round-trip losslessly"), + input + ); +} + +#[test] +fn checked_in_contract_artifacts_round_trip_losslessly() { + let fixtures = [ + ("storage", STORAGE), + ("counter deployment", COUNTER_DEPLOYMENT), + ("counter runtime", COUNTER_RUNTIME), + ("native escrow deployment", NATIVE_DEPLOYMENT), + ("native escrow runtime", NATIVE_RUNTIME), + ("ERC20 escrow deployment", ERC20_DEPLOYMENT), + ("ERC20 escrow runtime", ERC20_RUNTIME), + ]; + + for (name, source) in fixtures { + let artifact = bytes(source); + let instructions = decode_bytes(&artifact) + .unwrap_or_else(|error| panic!("{name} did not decode: {error}")); + let encoded = encode(&instructions, &artifact) + .unwrap_or_else(|error| panic!("{name} did not encode: {error}")); + assert_eq!(encoded, artifact, "{name} full artifact changed"); + + // The executable prefix must also satisfy the strict decoder. Compiler data stays opaque. + let (code, _trailer) = split_compiler_trailer(&artifact); + let strict = decode_executable_bytes(code) + .unwrap_or_else(|error| panic!("{name} code prefix did not decode: {error}")); + assert_eq!( + encode(&strict, code).expect("strict instruction prefix must encode"), + code, + "{name} code prefix changed" + ); + + let mut expected_pc = 0usize; + for instruction in &instructions { + assert_eq!(instruction.pc, expected_pc, "gap in {name}"); + expected_pc += instruction.byte_size(); + } + assert_eq!(expected_pc, artifact.len(), "incomplete coverage in {name}"); + } +} + +#[test] +fn input_wrapper_computes_metadata_without_changing_native_decode() { + let decoded = decode_input(STORAGE, false).expect("storage fixture must decode"); + assert_eq!(decoded.info.source, SourceType::HexString); + assert_eq!(decoded.info.byte_length, decoded.bytes.len()); + assert_eq!( + decoded.instructions, + decode_bytes(&decoded.bytes).expect("same bytes must decode identically") + ); + assert!(decoded.info.keccak_hash.iter().any(|byte| *byte != 0)); +} + +#[test] +fn assembly_rendering_is_explicit_and_deterministic() { + let input = [0x60, 0x01, 0x5f, 0xaa, 0xfe, 0x00]; + let instructions = decode_executable_bytes(&input).expect("sample must decode"); + let first = format_assembly(&instructions); + let second = format_assembly(&instructions); + + assert_eq!(first, second); + assert_eq!(first.lines().count(), instructions.len()); + assert!(first.contains("PUSH1")); + assert!(first.contains("PUSH0")); + assert!(first.contains("UNKNOWN")); + assert!(first.contains("INVALID")); + for instruction in &instructions { + assert!( + first.lines().any(|line| line == instruction.to_string()), + "render omitted {instruction}" + ); + } +} + +#[test] +fn malformed_hex_still_fails_before_instruction_decoding() { + assert!(decode_input("0xZZ42", false).is_err()); +} + +#[test] +fn opcode_serialization_is_stable_and_self_describing() { + assert_eq!(serde_json::to_string(&Opcode::ADD).unwrap(), r#""ADD""#); + assert_eq!( + serde_json::to_string(&Opcode::PUSH(4)).unwrap(), + r#"{"PUSH":4}"# + ); + assert_eq!( + serde_json::to_string(&Opcode::UNKNOWN(0xd0)).unwrap(), + r#"{"UNKNOWN":208}"# + ); + + assert!(serde_json::from_str::(r#"{"PUSH":0}"#).is_err()); + assert!(serde_json::from_str::(r#"{"DUP":17}"#).is_err()); + assert!(serde_json::from_str::(r#"{"SWAP":0}"#).is_err()); + assert!(serde_json::from_str::(r#"{"UNKNOWN":86}"#).is_err()); + assert_eq!( + serde_json::from_str::(r#"{"UNKNOWN":208}"#).unwrap(), + Opcode::UNKNOWN(0xd0) + ); +} + +#[test] +fn deterministic_arbitrary_byte_corpus_round_trips_exactly() { + // A local xorshift generator keeps this regression corpus dependency-free and reproducible. + // The test exercises empty inputs, every short length, and larger irregular blobs. + let mut state = 0x4d59_5df4_d0f3_3173_u64; + for case in 0..10_000usize { + let len = if case < 256 { + case + } else { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state as usize) % 2_048 + }; + + let mut input = Vec::with_capacity(len); + for _ in 0..len { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + input.push(state as u8); + } + + let instructions = decode_bytes(&input).expect("native raw decode is total"); + assert_eq!( + encode(&instructions, &input).expect("decoded bytes must re-encode"), + input, + "lossless round-trip failed for corpus case {case}" + ); + + let has_short_final_push = instructions + .last() + .is_some_and(|instruction| instruction.is_truncated_push()); + assert_eq!( + decode_executable_bytes(&input).is_err(), + has_short_final_push, + "strict-mode result disagrees with decoded boundary for corpus case {case}" + ); + + let mut expected_pc = 0usize; + for instruction in &instructions { + assert_eq!(instruction.pc, expected_pc, "PC gap in corpus case {case}"); + expected_pc += instruction.byte_size(); } - Err(e) => assert!(matches!(e, Error::Heimdall(_))), + assert_eq!(expected_pc, input.len(), "coverage mismatch in case {case}"); } } diff --git a/tests/src/core/detection/dispatcher.rs b/tests/src/core/detection/dispatcher.rs index 8d8730d1..e59428b5 100644 --- a/tests/src/core/detection/dispatcher.rs +++ b/tests/src/core/detection/dispatcher.rs @@ -5,11 +5,11 @@ use azoth_core::{ #[tokio::test] async fn test_dispatcher_detection() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x60c060405234801561000f575f5ffd5b5060405161162b38038061162b833981810160405281019061003191906100fd565b8073ffffffffffffffffffffffffffffffffffffffff1660a08173ffffffffffffffffffffffffffffffffffffffff16815250503373ffffffffffffffffffffffffffffffffffffffff1660808173ffffffffffffffffffffffffffffffffffffffff168152505050610128565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6100cc826100a3565b9050919050565b6100dc816100c2565b81146100e6575f5ffd5b50565b5f815190506100f7816100d3565b92915050565b5f602082840312156101125761011161009f565b5b5f61011f848285016100e9565b91505092915050565b60805160a0516114b86101735f395f818161052d015281816107c8015281816109b60152610c4801525f8181610290015281816103c5015281816105d701526108c401526114b85ff3fe608060405234801561000f575f5ffd5b50600436106100fe575f3560e01c80638bd03d0a11610095578063d415b3f911610064578063d415b3f91461022a578063e522538114610248578063f3a504f214610252578063fe03a46014610270576100fe565b80638bd03d0a146101b65780639940686e146101d4578063a65e2cfd146101f0578063cb766a561461020c576100fe565b80633ccfd60b116100d15780003ccfd60b146101665780635a4fd6451461017057806380f323a71461018e57806381972d00146101ac576100fe565b8063046f7da2146101025780631aa7c0ec1461010c578063308657d71461012a57806333ee5f3514610148575b5f5ffd5b"; diff --git a/tests/src/core/detection/sections.rs b/tests/src/core/detection/sections.rs index 45301bae..1fa3c30f 100644 --- a/tests/src/core/detection/sections.rs +++ b/tests/src/core/detection/sections.rs @@ -7,11 +7,11 @@ const STORAGE_BYTECODE: &str = include_str!("../../../bytecode/storage.hex"); #[tokio::test] async fn test_full_deploy_payload_properties() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let (instructions, info, _, bytes) = decode_bytecode(STORAGE_BYTECODE, false).await.unwrap(); diff --git a/tests/src/core/encoder.rs b/tests/src/core/encoder.rs index ff1fbd4b..6ac1cbcc 100644 --- a/tests/src/core/encoder.rs +++ b/tests/src/core/encoder.rs @@ -42,38 +42,37 @@ fn encode_return() { fn encode_unknown_hex_format() { let ins = Instruction { pc: 42, - op: Opcode::UNKNOWN(0xfe), + op: Opcode::UNKNOWN(0xaa), imm: None, }; - let original = vec![0xfe; 43]; // Ensure PC 42 exists + let original = vec![0xaa; 43]; // Ensure PC 42 exists let bytes = encode(&[ins], &original).unwrap(); - assert_eq!(bytes, vec![0xfe]); + assert_eq!(bytes, vec![0xaa]); } #[test] -fn encode_invalid_opcode_with_original() { +fn encode_invalid_opcode_is_always_fe() { let ins = Instruction { pc: 0, op: Opcode::INVALID, imm: None, }; - // With original bytecode, INVALID opcode is preserved from original - let original = vec![0x5c]; // Some unknown byte + // INVALID is the concrete 0xfe opcode; native decoding never uses it as an unknown marker. + let original = vec![0x5c]; let result = encode(&[ins], &original); assert!(result.is_ok()); - assert_eq!(result.unwrap(), vec![0x5c]); // Preserved from original! + assert_eq!(result.unwrap(), vec![0xfe]); } #[test] -fn encode_invalid_opcode_without_original_data() { +fn encode_invalid_opcode_never_silently_disappears() { let ins = Instruction { pc: 42, op: Opcode::INVALID, imm: None, }; - // PC 42 is beyond original bytecode, so it gets skipped - let original = vec![0x60, 0x01]; // Only 2 bytes, PC 42 doesn't exist + let original = vec![0x60, 0x01]; let result = encode(&[ins], &original); assert!(result.is_ok()); - assert_eq!(result.unwrap(), Vec::::new()); // Empty - skipped! + assert_eq!(result.unwrap(), vec![0xfe]); } diff --git a/tests/src/core/strip.rs b/tests/src/core/strip.rs index 1d99427a..a518451a 100644 --- a/tests/src/core/strip.rs +++ b/tests/src/core/strip.rs @@ -9,11 +9,11 @@ const COUNTER_RUNTIME_BYTECODE: &str = include_str!("../../bytecode/counter/coun #[tokio::test] async fn test_round_trip() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let (instructions, _, _, bytecode) = decode_bytecode(COUNTER_DEPLOYMENT_BYTECODE, false) .await @@ -21,7 +21,7 @@ async fn test_round_trip() { let sections = detection::locate_sections(&bytecode, &instructions, &[]).unwrap(); let (clean_runtime, mut report) = strip_bytecode(&bytecode, §ions).unwrap(); - let rebuilt = report.reassemble(&clean_runtime); + let rebuilt = report.reassemble(&clean_runtime).unwrap(); assert_eq!(bytecode, rebuilt, "Round-trip failed"); assert_eq!( @@ -33,11 +33,11 @@ async fn test_round_trip() { #[tokio::test] async fn test_runtime_only() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let (instructions, _, _, bytecode) = decode_bytecode(COUNTER_RUNTIME_BYTECODE, false) .await @@ -45,7 +45,7 @@ async fn test_runtime_only() { let sections = detection::locate_sections(&bytecode, &instructions, &[]).unwrap(); let (clean_runtime, mut report) = strip_bytecode(&bytecode, §ions).unwrap(); - let rebuilt = report.reassemble(&clean_runtime); + let rebuilt = report.reassemble(&clean_runtime).unwrap(); assert_eq!(bytecode, rebuilt, "Round-trip failed"); diff --git a/tests/src/e2e/arithmetic_chain_evm.rs b/tests/src/e2e/arithmetic_chain_evm.rs index 87fe3fd8..7423cfa7 100644 --- a/tests/src/e2e/arithmetic_chain_evm.rs +++ b/tests/src/e2e/arithmetic_chain_evm.rs @@ -82,6 +82,16 @@ fn chain_runtime_bytecode(chain_instrs: &[azoth_core::decoder::Instruction]) -> }, ]); + // `encode` deliberately requires canonical, contiguous IR. The chain compiler and this + // standalone EVM epilogue are independent fragments, so rebase their PCs after joining them. + let mut next_pc = 0usize; + for instruction in &mut instrs { + instruction.pc = next_pc; + next_pc = next_pc + .checked_add(instruction.byte_size()) + .ok_or_else(|| eyre!("standalone arithmetic-chain runtime is too large"))?; + } + encode(&instrs, &[]).map_err(|e| eyre!("encode failed: {:?}", e)) } diff --git a/tests/src/e2e/collect_proof.rs b/tests/src/e2e/collect_proof.rs index 58794a6f..6115c25f 100644 --- a/tests/src/e2e/collect_proof.rs +++ b/tests/src/e2e/collect_proof.rs @@ -5,17 +5,20 @@ //! (see `testCollectWithTransferProof_EIP1559`). The baseline flow verifies //! that the fixture and REVM harness are valid end-to-end: constructor funding, //! bonding, ABI-decoding the `ReceiptProof` struct, block-header parsing, MPT -//! receipt inclusion, and receipt-log validation. The obfuscated flow then runs -//! the same scenario against Azoth output to characterize where transforms -//! currently break that path. +//! receipt inclusion, and receipt-log validation. The Azoth flows request several +//! pass recipes and verify the hardened safety boundary: because this constructor +//! executes `GAS`, every mutating candidate is discarded, the exact input artifact +//! is authenticated, and the complete flow remains callable through standard ABI +//! selectors. use super::{ - build_standard_calldata, mock_token_bytecode, prepare_bytecode_with_args, EscrowMappings, - ObfuscatedCaller, ESCROW_BOND, ESCROW_COLLECT, ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + assert_erc20_identity_fallback, build_standard_calldata, mock_token_bytecode, + prepare_bytecode_with_args, ESCROW_BOND, ESCROW_COLLECT, ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, ESCROW_CONTRACT_RUNTIME_BYTECODE, }; use azoth_core::seed::Seed; use azoth_transform::arithmetic_chain::ArithmeticChain; +use azoth_transform::cluster_shuffle::ClusterShuffle; use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; use azoth_transform::push_split::PushSplit; use azoth_transform::slot_shuffle::SlotShuffle; @@ -431,11 +434,17 @@ async fn build_obfuscated_flow_inputs_with_seed( transforms: Vec>, seed: Seed, ) -> Result<(Bytes, Bytes, [u8; 4])> { - println!("\n=== Obfuscating escrow bytecode for {} ===", label); + println!("\n=== Processing escrow bytecode for {} ===", label); + let expected_requested_transforms: Vec<&str> = transforms + .iter() + .map(|transform| transform.name()) + .collect(); let config = ObfuscationConfig { - seed, + seed: seed.clone(), transforms, preserve_unknown_opcodes: true, + rewrite_function_selectors: false, + obfuscate_constructor_arguments: false, }; let obfuscation_result = obfuscate_bytecode( ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, @@ -445,20 +454,14 @@ async fn build_obfuscated_flow_inputs_with_seed( .await .map_err(|e| eyre!("Failed to obfuscate bytecode for {}: {:?}", label, e))?; - let selector_mapping = obfuscation_result - .selector_mapping - .as_ref() - .ok_or_else(|| eyre!("No selector mapping in obfuscation result"))?; - let mappings = EscrowMappings::from_obfuscator_output(selector_mapping) - .map_err(|e| eyre!("Failed to build EscrowMappings for {}: {}", label, e))?; - let caller = ObfuscatedCaller::new(mappings); + assert_erc20_identity_fallback(&obfuscation_result, &seed, &expected_requested_transforms)?; println!( - "✓ [{}] built EscrowMappings with {} selectors", + "✓ [{}] authenticated exact identity fallback for {} requested pass(es)", label, - selector_mapping.len() + expected_requested_transforms.len() ); - // Optional: dump obfuscated bytecode hex for offline disassembly/debug. + // Optional: dump the returned artifact for offline disassembly/debug. if let Ok(dir) = std::env::var("DUMP_OBFUSCATED") { let path = format!("{dir}/{label}.hex"); let body = obfuscation_result @@ -476,18 +479,9 @@ async fn build_obfuscated_flow_inputs_with_seed( U256::from(REWARD_AMOUNT), U256::from(PAYMENT_AMOUNT), )?; - let bond_calldata = caller.bond_call_data(U256::from(BOND_AMOUNT)); - let collect_selector: [u8; 4] = - caller - .collect_call_data() - .as_ref() - .try_into() - .map_err(|_| { - eyre!( - "collect_call_data() did not return exactly 4 bytes for {}", - label - ) - })?; + let bond_args = U256::from(BOND_AMOUNT).to_be_bytes::<32>(); + let bond_calldata = build_standard_calldata(ESCROW_BOND, &bond_args); + let collect_selector = ESCROW_COLLECT.0; Ok((deployment_bytecode, bond_calldata, collect_selector)) } @@ -567,8 +561,8 @@ async fn test_collect_with_erc20_proof_baseline_succeeds() -> Result<()> { } #[tokio::test] -async fn test_collect_with_erc20_proof_dispatcher_only_succeeds() -> Result<()> { - let label = "dispatcher_only"; +async fn test_collect_with_erc20_proof_no_transform_identity_succeeds() -> Result<()> { + let label = "no_transform_identity"; let (deployment_bytecode, bond_calldata, collect_selector) = build_obfuscated_flow_inputs(label, vec![]).await?; let outcome = @@ -576,43 +570,11 @@ async fn test_collect_with_erc20_proof_dispatcher_only_succeeds() -> Result<()> assert_collect_flow_success(label, outcome) } -// todo(g4titanx): create bugs.md and update bug findings, cause and fix, for posterity - -/// Regression probe for three distinct bugs in ArithmeticChain that all -/// manifested as `collect()` reverting with `WrongEventSignature()` -/// (selector `0x49b4a8ba`) on the ERC20 `Transfer` event topic check: -/// -/// 1. `scatter.rs::generate_load_instructions` was pushing the CODECOPY -/// arguments in the wrong stack order. EVM CODECOPY pops `destOffset` -/// from the top, then `offset`, then `size`, but the generator emitted -/// them as `PUSH destOffset; PUSH offset; PUSH size; CODECOPY`, leaving -/// `size` on top. The EVM then interpreted `size` (`0x20`) as -/// `destOffset` and `destOffset` (`0x00`) as `size`, performing a -/// zero-byte copy. The subsequent `MLOAD` always returned -/// zero-initialised memory, so the chain's first OR reduction -/// collapsed to `V0 | 0 = V0` and the final value diverged from the -/// backward-computed target. -/// -/// 2. `CfgIrBundle::patch_arithmetic_chain_codecopy_offsets` did not -/// exist. AC recorded its `runtime_length` estimate at Step 3 transform -/// time, then Step 5's dispatcher reapply passes -/// (`reapply_stub_patches`, `reapply_decoy_patches`, -/// `reapply_controller_patches`) widened some PUSHes post-`reindex_pcs` -/// and grew the runtime past the estimate. AC's CODECOPY offsets still -/// referenced the old estimate, so they pointed into live runtime code -/// instead of the appended data section — the chain loaded random -/// bytecode bytes as `V1`. -/// -/// 3. The post-reindex patch needed the right pattern after fix (1). -/// With the corrected `PUSH size; PUSH offset; PUSH destOffset; -/// CODECOPY` ordering, the offset PUSH moved one slot forward in the -/// instruction window; the scanner was updated to match the new -/// shape and capped with `old_value >= estimate` to avoid touching -/// coincidental `PUSH1 0x20; PUSH; PUSH1 0x00; CODECOPY` sequences -/// the Solidity compiler emits for unrelated code copies. +/// A legacy ArithmeticChain request must remain visible in the authenticated +/// recipe while its mutation is suppressed for this GAS-observing constructor. #[tokio::test] -async fn test_collect_with_erc20_proof_dispatcher_plus_arithmetic_chain_succeeds() -> Result<()> { - let label = "dispatcher_plus_arithmetic_chain"; +async fn test_collect_with_erc20_proof_arithmetic_chain_request_is_identity() -> Result<()> { + let label = "arithmetic_chain_identity_fallback"; let (deployment_bytecode, bond_calldata, collect_selector) = build_obfuscated_flow_inputs(label, vec![Box::new(ArithmeticChain::new())]).await?; let outcome = @@ -620,33 +582,11 @@ async fn test_collect_with_erc20_proof_dispatcher_plus_arithmetic_chain_succeeds assert_collect_flow_success(label, outcome) } -/// Regression probe for two PushSplit bugs that both made `collect()` -/// halt with `InvalidJump` at the 30M gas limit: -/// -/// 1. `push_split.rs` emitted the split chain as `PUSH p1; op; PUSH p2; -/// op; ...`, where the first `op` consumed whatever value the -/// preceding code had left on the stack. The generator expected the -/// chain to start from the identity element (0 for ADD/XOR), but the -/// replaced PUSH was a pure stack push — so the produced constant was -/// `(prev_top) ⊕ p1 ⊕ ... ⊕ p_n` instead of `p1 ⊕ ... ⊕ p_n`, silently -/// corrupting the literal (in this fixture, the error-selector -/// constant fed into the `revert CustomError()` emit sequence). Fix -/// was to prepend a `PUSH0` before the chain so the first op always -/// starts from zero. -/// -/// 2. `cfg_ir::remap_orphan_jump_pushes` only scanned blocks ending with -/// `JUMP`/`JUMPI`. Solidity's inherited-function-call convention -/// (EscrowERC20 → EscrowBase) pushes the return address in one block -/// and consumes it from a `JUMP` in a later block, with a `JUMPDEST` -/// separating them. After PushSplit grew some blocks, those return -/// addresses were stale but invisible to the extended scan. The fix -/// drops the JUMP-ending filter and walks every body block, scoped to -/// `PUSH2+` to avoid false positives on small literals that coincide -/// with early `JUMPDEST` PCs. This test caught it as a runtime JUMP -/// at PC `0x07be` consuming a stale PUSH2 from `0x07a6`. +/// A legacy PushSplit request must be discarded atomically rather than +/// exposing a partially transformed creation artifact. #[tokio::test] -async fn test_collect_with_erc20_proof_dispatcher_plus_push_split_succeeds() -> Result<()> { - let label = "dispatcher_plus_push_split"; +async fn test_collect_with_erc20_proof_push_split_request_is_identity() -> Result<()> { + let label = "push_split_identity_fallback"; let (deployment_bytecode, bond_calldata, collect_selector) = build_obfuscated_flow_inputs(label, vec![Box::new(PushSplit::new())]).await?; let outcome = @@ -654,35 +594,11 @@ async fn test_collect_with_erc20_proof_dispatcher_plus_push_split_succeeds() -> assert_collect_flow_success(label, outcome) } -/// Regression probe for two SlotShuffle bugs that made `bond()` revert -/// with `NotFunded()` (selector `0xd5ef09ba`) — i.e. the `funded = true` -/// SSTORE and the runtime `!funded` SLOAD disagreed on which slot -/// `funded` lived in: -/// -/// 1. SlotShuffle's collection and rewrite passes used `parse_slot_candidate`, -/// which only recognised adjacent `PUSH ; SLOAD/SSTORE`. Solidity's -/// read-modify-write of a packed bool field emits a `PUSH ; DUP1; -/// SLOAD; ...; SSTORE` pattern that shares the slot via the DUP — the -/// PUSH itself was never adjacent to the access, so it never made it -/// into the shuffle mapping and never got rewritten. The fix replaces -/// the adjacency check with a trace-based scan that runs backward from -/// every SLOAD/SSTORE via DUP/SWAP/arithmetic to find the source PUSH, -/// and records per-block `(push_idx, width, slot_bytes)` so the -/// rewrite phase patches exactly the PUSHes the collection phase saw. -/// -/// 2. The CFG only contains runtime-section blocks, but Solidity inlines -/// the constructor's invocation of `fund()` into the **init section**, -/// which is never seen by any transform. The init-code -/// `PUSH1 0x07; SSTORE` that sets `funded = true` would still write to -/// original slot `7`, while the runtime `PUSH1 0x07; SLOAD` got -/// remapped to some other slot — the two sections disagreed and -/// `bond()` read zero. Fix: `slot_shuffle.rs::init_literal_slots` -/// walks the raw init-section bytes, finds every `PUSH; SLOAD/SSTORE` -/// pair, and excludes those slot literals from the shuffle mapping so -/// init-touched slots stay at their original indices. +/// A legacy SlotShuffle request must not create disagreement between +/// constructor-written storage and runtime-read storage. #[tokio::test] -async fn test_collect_with_erc20_proof_dispatcher_plus_slot_shuffle_succeeds() -> Result<()> { - let label = "dispatcher_plus_slot_shuffle"; +async fn test_collect_with_erc20_proof_slot_shuffle_request_is_identity() -> Result<()> { + let label = "slot_shuffle_identity_fallback"; let (deployment_bytecode, bond_calldata, collect_selector) = build_obfuscated_flow_inputs(label, vec![Box::new(SlotShuffle::new())]).await?; let outcome = @@ -691,8 +607,8 @@ async fn test_collect_with_erc20_proof_dispatcher_plus_slot_shuffle_succeeds() - } #[tokio::test] -async fn test_collect_with_erc20_proof_dispatcher_plus_string_obfuscate_succeeds() -> Result<()> { - let label = "dispatcher_plus_string_obfuscate"; +async fn test_collect_with_erc20_proof_string_obfuscate_request_is_identity() -> Result<()> { + let label = "string_obfuscate_identity_fallback"; let (deployment_bytecode, bond_calldata, collect_selector) = build_obfuscated_flow_inputs(label, vec![Box::new(StringObfuscate::new())]).await?; let outcome = @@ -702,31 +618,23 @@ async fn test_collect_with_erc20_proof_dispatcher_plus_string_obfuscate_succeeds #[tokio::test] async fn test_collect_with_erc20_proof_default_pipeline_succeeds() -> Result<()> { - let label = "default_pipeline"; - let (deployment_bytecode, bond_calldata, collect_selector) = build_obfuscated_flow_inputs( - label, - vec![ - Box::new(ArithmeticChain::new()), - Box::new(PushSplit::new()), - Box::new(SlotShuffle::new()), - Box::new(StringObfuscate::new()), - ], - ) - .await?; + let label = "safe_default_pipeline_identity_fallback"; + let (deployment_bytecode, bond_calldata, collect_selector) = + build_obfuscated_flow_inputs(label, vec![Box::new(ClusterShuffle::new())]).await?; let outcome = execute_collect_proof_flow(deployment_bytecode, bond_calldata, collect_selector, label)?; assert_collect_flow_success(label, outcome) } -/// Seed that produced a failing mainnet `collect()` (reverted with -/// `WrongEventSignature()` on the ERC20 Transfer-topic check). Reproduces -/// the report end-to-end against the full default transform pipeline. +/// Historical seed that exposed a legacy transform interaction. It is retained +/// to prove that the safety gate now returns an authenticated identity artifact +/// before that interaction can reach deployment. const MAINNET_FAILING_SEED: &str = "0xb1314f5c5063267ec70a9b9bb6f3d6b0cfb96b0f54773b3e534f54cd92caa5b4"; #[tokio::test] -async fn test_collect_with_erc20_proof_failing_seed_default_pipeline() -> Result<()> { - let label = "failing_seed_default_pipeline"; +async fn test_collect_with_erc20_proof_historical_seed_legacy_recipe_is_identity() -> Result<()> { + let label = "historical_seed_legacy_recipe_identity_fallback"; let seed = Seed::from_hex(MAINNET_FAILING_SEED).expect("valid seed"); let (deployment_bytecode, bond_calldata, collect_selector) = build_obfuscated_flow_inputs_with_seed( @@ -745,12 +653,11 @@ async fn test_collect_with_erc20_proof_failing_seed_default_pipeline() -> Result assert_collect_flow_success(label, outcome) } -/// Bisect the failing seed across transform subsets to localise which -/// pass (or interaction) corrupts the Transfer topic. Prints the outcome -/// for each subset; ignored by default (run explicitly with `--run-ignored`). +/// Optional exhaustive audit that applies the historical seed to legacy pass +/// subsets and checks that each subset reaches the same safe identity boundary. #[tokio::test] #[ignore] -async fn bisect_failing_seed_transform_subsets() -> Result<()> { +async fn audit_historical_seed_identity_fallback_across_transform_subsets() -> Result<()> { type Builder = fn() -> Box; let all: &[(&str, Builder)] = &[ ("AC", || Box::new(ArithmeticChain::new())), @@ -775,42 +682,32 @@ async fn bisect_failing_seed_transform_subsets() -> Result<()> { &[0, 1, 2, 3], ]; - println!("\n=== bisecting failing seed across subsets ==="); + println!("\n=== auditing identity fallback across legacy subsets ==="); for subset in subsets { let names: Vec<&str> = subset.iter().map(|&i| all[i].0).collect(); - let label = format!("bisect[{}]", names.join("+")); + let label = format!("subset[{}]", names.join("+")); let seed = Seed::from_hex(MAINNET_FAILING_SEED).expect("valid seed"); let transforms: Vec> = subset.iter().map(|&i| (all[i].1)()).collect(); - let built = build_obfuscated_flow_inputs_with_seed(&label, transforms, seed).await; - let (deployment_bytecode, bond_calldata, collect_selector) = match built { - Ok(v) => v, - Err(e) => { - println!(" {label}: OBFUSCATION FAILED: {e}"); - continue; - } - }; - match execute_collect_proof_flow( + let (deployment_bytecode, bond_calldata, collect_selector) = + build_obfuscated_flow_inputs_with_seed(&label, transforms, seed).await?; + let outcome = execute_collect_proof_flow( deployment_bytecode, bond_calldata, collect_selector, &label, - ) { - Ok(FlowOutcome::Success { gas_used }) => { - println!(" {label}: SUCCESS (gas {gas_used})") - } - Ok(other) => println!(" {label}: FAIL {other:?}"), - Err(e) => println!(" {label}: ERROR {e}"), - } + )?; + assert_collect_flow_success(&label, outcome)?; } Ok(()) } -/// Same failing seed, ArithmeticChain only, to localise the corruption to a -/// single transform if the full-pipeline test fails. +/// The historical seed must also remain safe when the old ArithmeticChain +/// recipe is requested by itself. #[tokio::test] -async fn test_collect_with_erc20_proof_failing_seed_arithmetic_chain_only() -> Result<()> { - let label = "failing_seed_arithmetic_chain_only"; +async fn test_collect_with_erc20_proof_historical_seed_arithmetic_chain_is_identity() -> Result<()> +{ + let label = "historical_seed_arithmetic_chain_identity_fallback"; let seed = Seed::from_hex(MAINNET_FAILING_SEED).expect("valid seed"); let (deployment_bytecode, bond_calldata, collect_selector) = build_obfuscated_flow_inputs_with_seed(label, vec![Box::new(ArithmeticChain::new())], seed) diff --git a/tests/src/e2e/deploy.rs b/tests/src/e2e/deploy.rs index 433aa57f..7de60f22 100644 --- a/tests/src/e2e/deploy.rs +++ b/tests/src/e2e/deploy.rs @@ -1,5 +1,6 @@ use super::{ - deploy_contract, ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, ESCROW_CONTRACT_RUNTIME_BYTECODE, + assert_erc20_identity_fallback, deploy_contract, ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + ESCROW_CONTRACT_RUNTIME_BYTECODE, }; use azoth_core::seed::Seed; use azoth_transform::jump_address_transformer::JumpAddressTransformer; @@ -35,34 +36,35 @@ fn create_config_with_transforms( seed, transforms, preserve_unknown_opcodes: true, + rewrite_function_selectors: false, + obfuscate_constructor_arguments: false, } } #[tokio::test] -async fn test_function_dispatch_only() -> Result<()> { - let seed = Seed::generate(); +async fn test_safe_profile_preserves_interface_and_falls_back_on_gas() -> Result<()> { + let seed = Seed::from_bytes([0x51; 32]); - println!("Testing FunctionDispatcher only (no additional transforms)"); + println!("Testing the safe profile's GAS-observer identity fallback"); - let config = create_config_with_transforms(vec![], seed); + let config = ObfuscationConfig::with_seed(seed.clone()); let result = obfuscate_bytecode( ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, ESCROW_CONTRACT_RUNTIME_BYTECODE, config, ) .await - .map_err(|e| eyre!("Failed to obfuscate with function dispatcher: {}", e))?; + .map_err(|e| eyre!("Safe-profile processing failed: {}", e))?; - assert!(result - .metadata - .transforms_applied - .contains(&"FunctionDispatcher".to_string())); + assert_erc20_identity_fallback(&result, &seed, &["ClusterShuffle"])?; - let (address, _) = - deploy_and_verify_contract_revm(&result.obfuscated_bytecode, "FunctionDispatcher")?; + let (address, _) = deploy_and_verify_contract_revm( + &result.obfuscated_bytecode, + "safe-profile identity fallback", + )?; println!( - "✓ FunctionDispatcher test passed - Deployed at: {}", + "✓ identity fallback deployed with the standard interface at: {}", address ); diff --git a/tests/src/e2e/determinism.rs b/tests/src/e2e/determinism.rs index 492da820..d978ea6e 100644 --- a/tests/src/e2e/determinism.rs +++ b/tests/src/e2e/determinism.rs @@ -25,7 +25,7 @@ async fn test_same_seed_produces_same_deployed_runtime() -> Result<()> { let result_b = obfuscate_bytecode( ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, ESCROW_CONTRACT_RUNTIME_BYTECODE, - ObfuscationConfig::with_seed(seed), + ObfuscationConfig::with_seed(seed.clone()), ) .await?; @@ -51,10 +51,94 @@ async fn test_same_seed_produces_same_deployed_runtime() -> Result<()> { result_a.obfuscated_runtime, result_b.obfuscated_runtime, "same seed should produce identical obfuscated runtime template" ); + assert_eq!( + result_a.integrity, result_b.integrity, + "same inputs must produce an identical integrity manifest" + ); + assert_eq!( + result_a.private_interaction_manifest(), + result_b.private_interaction_manifest(), + "same inputs must produce an identical private interaction guide" + ); + assert_eq!( + serde_json::to_string(&result_a)?, + serde_json::to_string(&result_b)?, + "the complete serialized result, including its diagnostic trace, must replay exactly" + ); assert_eq!( deployed_a.runtime, deployed_b.runtime, "same seed and same constructor args should produce identical deployed runtime" ); + let input_deployment = hex::decode(azoth_core::normalize_hex_string( + ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + )?)?; + let input_runtime = hex::decode(azoth_core::normalize_hex_string( + ESCROW_CONTRACT_RUNTIME_BYTECODE, + )?)?; + result_a + .verify_integrity(&input_deployment, &input_runtime, &seed) + .map_err(color_eyre::eyre::Error::msg)?; + let output_deployment = hex::decode(result_a.obfuscated_bytecode.trim_start_matches("0x"))?; + let output_runtime = hex::decode(result_a.obfuscated_runtime.trim_start_matches("0x"))?; + let guide = result_a.private_interaction_manifest(); + let serialized_guide = serde_json::to_vec(&guide)?; + let decoded_guide: azoth_transform::obfuscator::PrivateInteractionManifest = + serde_json::from_slice(&serialized_guide)?; + decoded_guide + .verify_integrity( + &input_deployment, + &input_runtime, + &output_deployment, + &output_runtime, + &seed, + ) + .map_err(color_eyre::eyre::Error::msg)?; + + let wrong_seed = Seed::from_bytes([0xa5; 32]); + assert!( + result_a + .verify_integrity(&input_deployment, &input_runtime, &wrong_seed) + .is_err(), + "a party without the correct seed must not authenticate the manifest" + ); + let mut tampered = result_a.clone(); + tampered.obfuscated_bytecode.push_str("00"); + assert!( + tampered + .verify_integrity(&input_deployment, &input_runtime, &seed) + .is_err(), + "artifact tampering must invalidate the manifest" + ); + let mut tampered_configuration = result_a.clone(); + tampered_configuration + .integrity + .pipeline_configuration + .requested_transforms + .push(azoth_transform::obfuscator::TransformRecipeManifest { + name: "unrequested-pass".to_string(), + configuration_id: "unrequested-pass@parameterless-v1".to_string(), + }); + assert!( + tampered_configuration + .verify_integrity(&input_deployment, &input_runtime, &seed) + .is_err(), + "pipeline-configuration tampering must invalidate the authenticated manifest" + ); + let mut tampered_guide = decoded_guide; + tampered_guide.calldata_rule = "send unrelated calldata".to_string(); + assert!( + tampered_guide + .verify_integrity( + &input_deployment, + &input_runtime, + &output_deployment, + &output_runtime, + &seed, + ) + .is_err(), + "a standalone guide with a modified interaction rule must fail authentication" + ); + Ok(()) } diff --git a/tests/src/e2e/escrow.rs b/tests/src/e2e/escrow.rs index d1bdbcd9..0dc213aa 100644 --- a/tests/src/e2e/escrow.rs +++ b/tests/src/e2e/escrow.rs @@ -1,13 +1,16 @@ -//! End-to-end tests for calling obfuscated contract functions. +//! End-to-end behavioral coverage for the safe-profile ERC20 escrow result. //! -//! These tests verify that obfuscated contracts not only deploy successfully, -//! but also execute correctly when functions are called using obfuscated tokens -//! instead of standard 4-byte selectors. +//! This fixture's constructor executes `GAS`, so the hardened pipeline must +//! return the exact input artifact and preserve its standard four-byte Solidity +//! selectors. The test checks the identity fallback before exercising stateful +//! calls against the deployed result. use super::{ - mock_token_bytecode, prepare_bytecode, EscrowMappings, ObfuscatedCaller, - ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, ESCROW_CONTRACT_RUNTIME_BYTECODE, MOCK_TOKEN_ADDR, + assert_erc20_identity_fallback, mock_token_bytecode, prepare_bytecode, EscrowMappings, + ObfuscatedCaller, ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, ESCROW_CONTRACT_RUNTIME_BYTECODE, + MOCK_TOKEN_ADDR, }; +use azoth_core::seed::Seed; use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; use color_eyre::eyre::eyre; use color_eyre::Result; @@ -68,7 +71,7 @@ impl Inspector, } #[tokio::test] -async fn test_obfuscated_function_calls() -> Result<()> { +async fn test_safe_profile_identity_contract_function_calls() -> Result<()> { let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) @@ -133,9 +136,12 @@ async fn test_obfuscated_function_calls() -> Result<()> { } } - // obfuscate contract - println!("\n=== Proceeding with Obfuscated Deployment ==="); - let config = ObfuscationConfig::default(); + // Process through the production-safe profile. ClusterShuffle may produce a + // runtime candidate, but the constructor's GAS observation forces that + // candidate to be discarded atomically. + println!("\n=== Processing Safe-Profile Identity Fallback ==="); + let seed = Seed::from_bytes([0x32; 32]); + let config = ObfuscationConfig::with_seed(seed.clone()); let obfuscation_result = obfuscate_bytecode( ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, @@ -143,31 +149,18 @@ async fn test_obfuscated_function_calls() -> Result<()> { config, ) .await - .map_err(|e| eyre!("Failed to obfuscate bytecode: {:?}", e))?; + .map_err(|e| eyre!("Failed to process bytecode through safe profile: {:?}", e))?; println!( - "✓ Contract obfuscated ({} -> {} bytes, {:+.1}%)", + "✓ Safe-profile result ({} -> {} bytes, {:+.1}%)", obfuscation_result.original_size, obfuscation_result.obfuscated_size, obfuscation_result.size_increase_percentage ); - // extracting selector mappings - let selector_mapping = obfuscation_result - .selector_mapping - .as_ref() - .ok_or_else(|| eyre!("No selector mapping found in obfuscation result"))?; - println!("✓ Extracted {} selector mappings", selector_mapping.len()); - - println!("Selectors found:"); - for (selector, token) in selector_mapping.iter() { - println!(" 0x{:08x} -> 0x{}", selector, hex::encode(token)); - } - - let escrow_mappings = EscrowMappings::from_obfuscator_output(selector_mapping) - .map_err(|e| eyre!("Failed to create escrow mappings: {}", e))?; - - println!("✓ Created EscrowMappings with obfuscated tokens"); + assert_erc20_identity_fallback(&obfuscation_result, &seed, &["ClusterShuffle"])?; + let escrow_mappings = EscrowMappings::identity(); + println!("✓ Verified identity artifact and standard Solidity selectors"); // setup EVM with mock token contract let mut db = InMemoryDB::default(); @@ -299,7 +292,7 @@ async fn test_obfuscated_function_calls() -> Result<()> { } }; - println!("✓ Obfuscated contract deployed at: {}", contract_address); + println!("✓ Safe-profile result deployed at: {}", contract_address); // Validate all PUSH+JUMP pairs in deployed bytecode println!("\n=== Validating Deployed Bytecode ==="); @@ -329,10 +322,7 @@ async fn test_obfuscated_function_calls() -> Result<()> { .map(|i| i.pc) .collect(); - println!( - "Obfuscated deployed bytecode has {} JUMPDESTs", - jumpdests.len() - ); + println!("Deployed bytecode has {} JUMPDESTs", jumpdests.len()); // Check all PUSH+JUMP/JUMPI pairs let mut valid_jumps = 0; @@ -360,7 +350,7 @@ async fn test_obfuscated_function_calls() -> Result<()> { } } - println!("Obfuscated deployed bytecode jump statistics:"); + println!("Deployed bytecode jump statistics:"); println!(" Total jumps: {}", valid_jumps + invalid_jumps.len()); println!(" Valid jumps: {}", valid_jumps); println!(" Invalid jumps: {}", invalid_jumps.len()); @@ -402,7 +392,7 @@ async fn test_obfuscated_function_calls() -> Result<()> { let is_bonded_calldata = caller.is_bonded_call_data(); println!( - " Calldata (obfuscated): 0x{}", + " Calldata (standard ABI): 0x{}", hex::encode(&is_bonded_calldata) ); @@ -450,7 +440,7 @@ async fn test_obfuscated_function_calls() -> Result<()> { let payment_amount = U256::from(5000); let fund_calldata = caller.fund_call_data(reward_amount, payment_amount); println!( - " Calldata (obfuscated): 0x{} (reward: {}, payment: {})", + " Calldata (standard ABI): 0x{} (reward: {}, payment: {})", hex::encode(&fund_calldata), reward_amount, payment_amount @@ -529,7 +519,10 @@ async fn test_obfuscated_function_calls() -> Result<()> { let bond_amount = U256::from(2500); let bond_calldata = caller.bond_call_data(bond_amount); - println!(" Calldata (obfuscated): 0x{}", hex::encode(&bond_calldata)); + println!( + " Calldata (standard ABI): 0x{}", + hex::encode(&bond_calldata) + ); let bond_tx = TxEnv { caller: deployer, @@ -612,12 +605,12 @@ async fn test_obfuscated_function_calls() -> Result<()> { println!(" Result after bonding: is_bonded = {}", is_bonded); - println!("✓ bond() executed successfully on obfuscated contract"); + println!("✓ bond() executed successfully on the safe identity artifact"); - println!("\n✓ Obfuscated contract executes correctly"); - println!("✓ Valid tokens route to correct functions"); - println!("✓ Token extraction works with function arguments (bond with uint256)"); - println!("✓ State changes are preserved through obfuscation"); + println!("\n✓ Safe-profile identity artifact executes correctly"); + println!("✓ Standard selectors route to the expected functions"); + println!("✓ ABI arguments work for bond(uint256)"); + println!("✓ Stateful escrow behavior is preserved"); Ok(()) } diff --git a/tests/src/e2e/mod.rs b/tests/src/e2e/mod.rs index f9a980af..3521d93a 100644 --- a/tests/src/e2e/mod.rs +++ b/tests/src/e2e/mod.rs @@ -1,13 +1,13 @@ -//! End to end ethereum tests. +//! End-to-end Ethereum tests. //! -//! Test variations of obfuscation options: -//! - Function dispatch only (all options off) -//! - Each transformation type enabled -//! - Each combination of 2 transformations -//! - All options enabled -//! -//! Each test case should assert that the contract is deployable - +//! The ERC20 escrow constructor executes `GAS`. Changing any creation-bytecode byte can therefore +//! change an observable value through transaction intrinsic gas. The hardened pipeline responds by +//! authenticating and returning the exact input artifact whenever a requested pass would mutate +//! this fixture. These tests cover both sides of that contract: the identity fallback itself and +//! the escrow's behavior through its unchanged Solidity ABI. + +use azoth_core::seed::Seed; +use azoth_transform::obfuscator::ObfuscationResult; use color_eyre::eyre::eyre; use color_eyre::Result; use revm::bytecode::Bytecode; @@ -206,6 +206,20 @@ macro_rules! define_contract_selectors { } impl [<$contract Mappings>] { + /// Build a mapping that preserves the contract's standard Solidity ABI. + /// + /// The production profile does not currently rewrite selectors. This constructor + /// lets the behavioral harness reuse its calldata builders without pretending an + /// interface adaptation occurred. + #[allow(dead_code)] + pub fn identity() -> Self { + Self { + $( + $fn_name: [<$contract:upper _ $fn_name:upper>], + )* + } + } + /// Create mappings from obfuscator output (HashMap>) #[allow(dead_code)] pub fn from_obfuscator_output( @@ -392,6 +406,66 @@ pub fn build_standard_calldata(selector: Selector, args: &[u8]) -> Bytes { Bytes::from(data) } +/// Assert the conservative result required for the GAS-observing ERC20 escrow constructor. +/// +/// Requested passes remain present in the authenticated replay recipe, but no pass may be reported +/// as applied and neither the creation payload nor runtime template may change. This distinction +/// keeps a safe no-op auditable instead of silently presenting it as successful variation. +#[allow(dead_code)] +pub fn assert_erc20_identity_fallback( + result: &ObfuscationResult, + seed: &Seed, + expected_requested_transforms: &[&str], +) -> Result<()> { + let input_deployment = hex::decode(azoth_core::normalize_hex_string( + ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + )?)?; + let input_runtime = hex::decode(azoth_core::normalize_hex_string( + ESCROW_CONTRACT_RUNTIME_BYTECODE, + )?)?; + let output_deployment = hex::decode(result.obfuscated_bytecode.trim_start_matches("0x"))?; + let output_runtime = hex::decode(result.obfuscated_runtime.trim_start_matches("0x"))?; + + assert_eq!( + output_deployment, input_deployment, + "a GAS-observing constructor requires byte-for-byte stable creation code" + ); + assert_eq!( + output_runtime, input_runtime, + "identity fallback must preserve the complete runtime template" + ); + assert_eq!(result.original_size, result.obfuscated_size); + assert_eq!(result.size_increase_percentage, 0.0); + assert!( + result.metadata.transforms_applied.is_empty(), + "discarded candidates must not be reported as applied" + ); + assert!(result.integrity.transforms_applied.is_empty()); + assert!( + result.selector_mapping.is_none(), + "the safe profile must preserve standard Solidity selectors" + ); + + let configuration = &result.integrity.pipeline_configuration; + assert!(configuration.preserve_unknown_opcodes); + assert!(!configuration.rewrite_function_selectors); + assert!(!configuration.obfuscate_constructor_arguments); + let requested_names: Vec<_> = configuration + .requested_transforms + .iter() + .map(|recipe| recipe.name.as_str()) + .collect(); + assert_eq!(requested_names, expected_requested_transforms); + assert!(configuration + .requested_transforms + .iter() + .all(|recipe| !recipe.configuration_id.is_empty())); + + result + .verify_integrity(&input_deployment, &input_runtime, seed) + .map_err(color_eyre::eyre::Error::msg) +} + #[cfg(test)] mod deploy; diff --git a/tests/src/e2e/test_counter.rs b/tests/src/e2e/test_counter.rs index a0eb5bd6..1491e5e0 100644 --- a/tests/src/e2e/test_counter.rs +++ b/tests/src/e2e/test_counter.rs @@ -1,3 +1,4 @@ +use azoth_core::seed::Seed; use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; use color_eyre::eyre::eyre; use color_eyre::Result; @@ -56,10 +57,13 @@ async fn test_obfuscated_counter_deploys_and_counts() -> Result<()> { .without_time() .try_init(); + // Selector rewriting is an experimental interface transform, never a production default. + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes([0x31; 32])); + config.rewrite_function_selectors = true; let obfuscation_result = obfuscate_bytecode( COUNTER_DEPLOYMENT_BYTECODE, COUNTER_RUNTIME_BYTECODE, - ObfuscationConfig::default(), + config, ) .await .map_err(|e| eyre!("Bytecode transformation failed: {}", e))?; diff --git a/tests/src/transforms/cluster_shuffle.rs b/tests/src/transforms/cluster_shuffle.rs new file mode 100644 index 00000000..e0490e52 --- /dev/null +++ b/tests/src/transforms/cluster_shuffle.rs @@ -0,0 +1,409 @@ +use azoth_core::{ + cfg_ir::CfgIrBundle, + detection::SectionKind, + process_bytecode_to_cfg, + seed::{DeterministicRng, Seed}, +}; +use azoth_transform::obfuscator::{obfuscate_bytecode, ObfuscationConfig}; +use azoth_transform::Transform; + +const STACK_CARRIED_JUMPI: &str = "0x6009600090575f5ff35b5f5ffd"; + +// This is structurally valid compiler CBOR and begins on an instruction boundary, but execution +// still falls through into it. LOG2 consumes four zeroes, the `solc` pair becomes a PUSH5 plus a +// PC/MSTORE/RETURN program, and a valid IPFS pair remains later in the same map. Detection is not +// proof that an auxdata-classified suffix is unreachable or safe to rewrite. +const EXECUTABLE_AUXDATA_LOOKALIKE: &str = concat!( + "0x5f5f5f5f", // stack inputs for the suffix's LOG2 + "a264736f6c634a585f5260205ff3000000", // solc pair with executable value bytes + "646970667358221220", // IPFS key, 34-byte value, and multihash prefix + "1111111111111111111111111111111111111111111111111111111111111111", + "003a", // length marker: 58-byte CBOR payload +); +const MOVABLE_PREFIX: &str = "0x6007565b005b005b"; +const MOVABLE_PREFIX_WITH_LIVE_PC_SUFFIX: &str = concat!( + "0x6007565b005b005b5f5f5f", // movable blocks, then JUMPDEST and LOG1 inputs + "a164736f6c634a585f5260205ff3000000", // valid CBOR; PC executes at runtime + "0011", // length marker: 17-byte CBOR payload +); +const STATIC_JUMP_INTO_DETECTED_SUFFIX: &str = "0x600d565b0000a164736f6c63435b0000000a"; +const DUAL_USE_JUMPI_POINTER: &str = "0x600c805f90575f5260205ff35b005b00"; + +struct CorruptDetectedSuffix; +struct CorruptInitSection; + +impl Transform for CorruptDetectedSuffix { + fn name(&self) -> &'static str { + "CorruptDetectedSuffix" + } + + fn apply( + &self, + ir: &mut CfgIrBundle, + _rng: &mut DeterministicRng, + ) -> azoth_transform::Result { + let suffix = ir + .clean_report + .removed + .iter_mut() + .find(|removed| removed.kind == SectionKind::Auxdata) + .expect("fixture must contain detected auxdata"); + let mut bytes = suffix.data.to_vec(); + bytes[10] ^= 0xff; + suffix.data = bytes.into(); + Ok(true) + } +} + +impl Transform for CorruptInitSection { + fn name(&self) -> &'static str { + "CorruptInitSection" + } + + fn apply( + &self, + ir: &mut CfgIrBundle, + _rng: &mut DeterministicRng, + ) -> azoth_transform::Result { + let init = ir + .clean_report + .removed + .iter_mut() + .find(|removed| removed.kind == SectionKind::Init) + .expect("fixture must contain init code"); + let mut bytes = init.data.to_vec(); + bytes[0] ^= 0x01; + init.data = bytes.into(); + Ok(true) + } +} + +fn push32_split_reproducer() -> String { + let mut bytes = vec![0x7f]; + bytes.extend_from_slice(&[0u8; 29]); + bytes.extend_from_slice(&[0x55, 0x00, 0x01]); + assert_eq!(bytes.len(), 33); + format!("0x{}", hex::encode(bytes)) +} + +fn execution_succeeds(runtime_hex: &str) -> bool { + use revm::bytecode::Bytecode; + use revm::context::result::ExecutionResult; + use revm::context::TxEnv; + use revm::database::InMemoryDB; + use revm::primitives::{Address, Bytes, TxKind, U256}; + use revm::state::AccountInfo; + use revm::{Context, ExecuteEvm, MainBuilder, MainContext}; + + let runtime = hex::decode(runtime_hex.trim_start_matches("0x")).expect("runtime hex"); + let contract = Address::from([0x11; 20]); + let caller = Address::from([0x22; 20]); + let mut db = InMemoryDB::default(); + db.insert_account_info( + contract, + AccountInfo { + nonce: 1, + code_hash: revm::primitives::KECCAK_EMPTY, + code: Some(Bytecode::new_raw(Bytes::from(runtime))), + balance: U256::ZERO, + }, + ); + db.insert_account_info( + caller, + AccountInfo { + nonce: 0, + code_hash: revm::primitives::KECCAK_EMPTY, + code: None, + balance: U256::from(1_000_000u64), + }, + ); + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + let result = evm + .transact(TxEnv { + caller, + gas_limit: 1_000_000, + kind: TxKind::Call(contract), + value: U256::ZERO, + nonce: 0, + ..Default::default() + }) + .expect("execute runtime"); + + matches!(result.result, ExecutionResult::Success { .. }) +} + +fn execution_output(runtime_hex: &str) -> Vec { + use revm::bytecode::Bytecode; + use revm::context::result::{ExecutionResult, Output}; + use revm::context::TxEnv; + use revm::database::InMemoryDB; + use revm::primitives::{Address, Bytes, TxKind, U256}; + use revm::state::AccountInfo; + use revm::{Context, ExecuteEvm, MainBuilder, MainContext}; + + let runtime = hex::decode(runtime_hex.trim_start_matches("0x")).expect("runtime hex"); + let contract = Address::from([0x11; 20]); + let caller = Address::from([0x22; 20]); + let mut db = InMemoryDB::default(); + db.insert_account_info( + contract, + AccountInfo { + nonce: 1, + code_hash: revm::primitives::KECCAK_EMPTY, + code: Some(Bytecode::new_raw(Bytes::from(runtime))), + balance: U256::ZERO, + }, + ); + db.insert_account_info( + caller, + AccountInfo { + nonce: 0, + code_hash: revm::primitives::KECCAK_EMPTY, + code: None, + balance: U256::from(1_000_000u64), + }, + ); + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + let result = evm + .transact(TxEnv { + caller, + gas_limit: 1_000_000, + kind: TxKind::Call(contract), + value: U256::ZERO, + nonce: 0, + ..Default::default() + }) + .expect("execute runtime"); + + match result.result { + ExecutionResult::Success { + output: Output::Call(bytes), + .. + } => bytes.to_vec(), + other => panic!("runtime did not return successfully: {other:?}"), + } +} + +#[tokio::test] +async fn safe_pipeline_preserves_stack_carried_jumpi_false_path() { + assert!(execution_succeeds(STACK_CARRIED_JUMPI)); + + let result = obfuscate_bytecode( + STACK_CARRIED_JUMPI, + STACK_CARRIED_JUMPI, + ObfuscationConfig::with_seed(Seed::from_bytes([0; 32])), + ) + .await + .expect("safe profile must accept the resolved dynamic JUMPI"); + + assert_eq!(result.obfuscated_runtime, STACK_CARRIED_JUMPI); + assert!(execution_succeeds(&result.obfuscated_runtime)); + assert!(!result + .metadata + .transforms_applied + .iter() + .any(|name| name == "ClusterShuffle")); +} + +#[tokio::test] +async fn safe_pipeline_preserves_executable_auxdata_lookalike_byte_for_byte() { + let (_, _, sections, _) = process_bytecode_to_cfg( + EXECUTABLE_AUXDATA_LOOKALIKE, + false, + EXECUTABLE_AUXDATA_LOOKALIKE, + false, + ) + .await + .expect("the fixture must pass the production section detector"); + let detected_suffix = sections + .iter() + .find(|section| section.kind == SectionKind::Auxdata) + .expect("length marker must classify the executable tail as auxdata"); + assert_eq!((detected_suffix.offset, detected_suffix.len), (4, 60)); + + let original_output = execution_output(EXECUTABLE_AUXDATA_LOOKALIKE); + assert_eq!(original_output.len(), 32); + assert_eq!(original_output[31], 11); + + let result = obfuscate_bytecode( + EXECUTABLE_AUXDATA_LOOKALIKE, + EXECUTABLE_AUXDATA_LOOKALIKE, + ObfuscationConfig::with_seed(Seed::from_bytes([0x42; 32])), + ) + .await + .expect("the safe profile may preserve a metadata-like executable suffix"); + + assert_eq!(result.obfuscated_runtime, EXECUTABLE_AUXDATA_LOOKALIKE); + assert_eq!(result.obfuscated_bytecode, EXECUTABLE_AUXDATA_LOOKALIKE); + assert_eq!( + execution_output(&result.obfuscated_runtime), + original_output + ); + assert!(!result + .metadata + .transforms_applied + .iter() + .any(|name| name == "SolidityMetadata")); +} + +#[tokio::test] +async fn finalizer_rejects_any_pass_that_mutates_a_detected_suffix() { + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes([0x43; 32])); + config.transforms.push(Box::new(CorruptDetectedSuffix)); + + let error = obfuscate_bytecode( + EXECUTABLE_AUXDATA_LOOKALIKE, + EXECUTABLE_AUXDATA_LOOKALIKE, + config, + ) + .await + .expect_err("a pass must never be able to commit a detected-suffix mutation"); + assert!( + error + .message + .contains("attempted to mutate protected init, constructor, or compiler-suffix"), + "{}", + error.message + ); +} + +#[tokio::test] +async fn commit_gate_rejects_transform_mutation_of_init_reconstruction_state() { + let runtime = MOVABLE_PREFIX; + let deployment = format!("0x6008600a5f3960085ff3{}", &runtime[2..]); + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes([0x35; 32])); + config.transforms.clear(); + config.transforms.push(Box::new(CorruptInitSection)); + + let error = obfuscate_bytecode(&deployment, runtime, config) + .await + .expect_err("a transform must not mutate init bytes through the reconstruction report"); + assert!( + error + .message + .contains("attempted to mutate protected init, constructor, or compiler-suffix"), + "{}", + error.message + ); +} + +#[tokio::test] +async fn init_gas_fallback_is_exact_identity_even_when_constructor_masking_is_requested() { + let runtime = MOVABLE_PREFIX; + let mut deployment = format!("0x5a506008600c5f3960085ff3{}", &runtime[2..]); + deployment.push_str(&"11".repeat(32)); + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes([0x36; 32])); + config.obfuscate_constructor_arguments = true; + + let result = obfuscate_bytecode(&deployment, runtime, config) + .await + .expect("init GAS must conservatively fall back to an exact identity artifact"); + assert_eq!(result.obfuscated_bytecode, deployment); + assert_eq!(result.obfuscated_runtime, runtime); + assert!(result.metadata.transforms_applied.is_empty()); + assert!(!result.metadata.constructor_args_obfuscated); +} + +#[tokio::test] +async fn no_transform_never_splits_or_duplicates_a_push_immediate() { + let bytecode = push32_split_reproducer(); + let (_, _, sections, _) = process_bytecode_to_cfg(&bytecode, false, &bytecode, false) + .await + .expect("a length-like PUSH32 immediate must remain complete runtime code"); + assert_eq!(sections.len(), 1); + assert_eq!(sections[0].kind, SectionKind::Runtime); + assert_eq!((sections[0].offset, sections[0].len), (0, 33)); + + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes([0x44; 32])); + config.transforms.clear(); + let result = obfuscate_bytecode(&bytecode, &bytecode, config) + .await + .expect("a no-transform run must preserve the exact instruction span"); + + assert_eq!(result.obfuscated_runtime, bytecode); + assert_eq!(result.obfuscated_bytecode, bytecode); + assert_eq!(result.obfuscated_size, 33); + assert!(result.metadata.transforms_applied.is_empty()); +} + +#[tokio::test] +async fn safe_pipeline_refuses_layout_change_when_detected_suffix_is_live_code() { + let seed = Seed::from_bytes([0x24; 32]); + let movable = obfuscate_bytecode( + MOVABLE_PREFIX, + MOVABLE_PREFIX, + ObfuscationConfig::with_seed(seed.clone()), + ) + .await + .expect("the control-flow prefix must be independently movable"); + assert!(movable + .metadata + .transforms_applied + .iter() + .any(|name| name == "ClusterShuffle")); + + let original_output = execution_output(MOVABLE_PREFIX_WITH_LIVE_PC_SUFFIX); + assert_eq!(original_output, [vec![0; 31], vec![18]].concat()); + + let error = obfuscate_bytecode( + MOVABLE_PREFIX_WITH_LIVE_PC_SUFFIX, + MOVABLE_PREFIX_WITH_LIVE_PC_SUFFIX, + ObfuscationConfig::with_seed(seed), + ) + .await + .expect_err("layout changes must not cross an executable metadata-like boundary"); + assert!( + error + .message + .contains("retained runtime falls through into detected auxdata or padding"), + "{}", + error.message + ); +} + +#[tokio::test] +async fn jump_into_detected_suffix_is_recorded_as_unresolved_and_refused() { + let (bundle, _, sections, _) = process_bytecode_to_cfg( + STATIC_JUMP_INTO_DETECTED_SUFFIX, + false, + STATIC_JUMP_INTO_DETECTED_SUFFIX, + false, + ) + .await + .expect("direct-target fixture must build"); + let detected_suffix = sections + .iter() + .find(|section| section.kind == SectionKind::Auxdata) + .expect("fixture tail must be detected as auxdata"); + assert_eq!((detected_suffix.offset, detected_suffix.len), (6, 12)); + assert!(!bundle.relationships().unresolved_control.is_empty()); + assert!(execution_succeeds(STATIC_JUMP_INTO_DETECTED_SUFFIX)); + + let error = obfuscate_bytecode( + STATIC_JUMP_INTO_DETECTED_SUFFIX, + STATIC_JUMP_INTO_DETECTED_SUFFIX, + ObfuscationConfig::with_seed(Seed::from_bytes([0x55; 32])), + ) + .await + .expect_err("a jump target outside the retained runtime must produce no artifact"); + assert!( + error.message.contains("invalid jump target"), + "{}", + error.message + ); +} + +#[tokio::test] +async fn safe_pipeline_refuses_dual_use_pointer_on_dynamic_jumpi_false_path() { + assert!(execution_succeeds(DUAL_USE_JUMPI_POINTER)); + + let error = obfuscate_bytecode( + DUAL_USE_JUMPI_POINTER, + DUAL_USE_JUMPI_POINTER, + ObfuscationConfig::with_seed(Seed::from_bytes([0; 32])), + ) + .await + .expect_err("the production profile must reject a code pointer also observed as data"); + + assert!(error.message.contains("runtime observes code position")); +} diff --git a/tests/src/transforms/function_dispatcher.rs b/tests/src/transforms/function_dispatcher.rs index 773fa7cc..d7327e67 100644 --- a/tests/src/transforms/function_dispatcher.rs +++ b/tests/src/transforms/function_dispatcher.rs @@ -10,9 +10,69 @@ use azoth_transform::obfuscator::ObfuscationConfig; const SIMPLE_BYTECODE: &str = "0x60003560e01c80637ff36ab514601e578063a9059cbb14602357600080fd5b600080fd5b600080fd"; +// One-function dispatcher whose implementation returns `msg.sig`. Replacing the dispatcher +// selector changes this function's observable return value even when the caller follows the +// selector map, so the production profile must never relabel it automatically. +const MSG_SIG_RUNTIME: &str = "0x60003560e01c806311223344146013575f5ffd5b5f3560e01c5f5260205ff3"; +const MSG_SIG_DEPLOYMENT: &str = + "0x601f600a5f39601f5ff360003560e01c806311223344146013575f5ffd5b5f3560e01c5f5260205ff3"; + const COUNTER_BYTECODE: &str = "0x6080604052348015600e575f5ffd5b506101d98061001c5f395ff3fe608060405234801561000f575f5ffd5b506004361061004a575f3560e01c806306661abd1461004e578063371303c01461006c5780636d4ce63c14610076578063b3bcfa8214610094575b5f5ffd5b61005661009e565b60405161006391906100f7565b60405180910390f35b6100746100a3565b005b61007e6100bd565b60405161008b91906100f7565b60405180910390f35b61009c6100c5565b005b5f5481565b60015f5f8282546100b4919061013d565b92505081905550565b5f5f54905090565b60015f5f8282546100d69190610170565b92505081905550565b5f819050919050565b6100f1816100df565b82525050565b5f60208201905061010a5f8301846100e8565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f610147826100df565b9150610152836100df565b925082820190508082111561016a57610169610110565b5b92915050565b5f61017a826100df565b9150610185836100df565b925082820390508181111561019d5761019c610110565b5b9291505056fea264697066735822122078c44612ebfc52f8c09e96e351b62f1c6feebaa2694fa7d29431ccb4ae9ed15064736f6c634300081c0033"; +fn execute_runtime(runtime_hex: &str, calldata: Vec) -> Vec { + use revm::bytecode::Bytecode; + use revm::context::result::{ExecutionResult, Output}; + use revm::context::TxEnv; + use revm::database::InMemoryDB; + use revm::primitives::{Address, Bytes, TxKind, U256}; + use revm::state::AccountInfo; + use revm::{Context, ExecuteEvm, MainBuilder, MainContext}; + + let runtime = hex::decode(runtime_hex.trim_start_matches("0x")).expect("runtime hex"); + let contract = Address::from([0x11; 20]); + let caller = Address::from([0x22; 20]); + let mut db = InMemoryDB::default(); + db.insert_account_info( + contract, + AccountInfo { + nonce: 1, + code_hash: revm::primitives::KECCAK_EMPTY, + code: Some(Bytecode::new_raw(Bytes::from(runtime))), + balance: U256::ZERO, + }, + ); + db.insert_account_info( + caller, + AccountInfo { + nonce: 0, + code_hash: revm::primitives::KECCAK_EMPTY, + code: None, + balance: U256::from(1_000_000u64), + }, + ); + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + let result = evm + .transact(TxEnv { + caller, + gas_limit: 1_000_000, + kind: TxKind::Call(contract), + data: Bytes::from(calldata), + value: U256::ZERO, + nonce: 0, + ..Default::default() + }) + .expect("execute runtime"); + + match result.result { + ExecutionResult::Success { + output: Output::Call(output), + .. + } => output.to_vec(), + other => panic!("runtime call did not succeed: {other:?}"), + } +} + #[tokio::test] async fn test_dispatcher_transformation_and_determinism() { let _ = tracing_subscriber::fmt() @@ -59,8 +119,10 @@ async fn test_dispatcher_transformation_and_determinism() { // Obfuscation with deterministic seed let seed = Seed::generate(); - let config1 = ObfuscationConfig::with_seed(seed.clone()); - let config2 = ObfuscationConfig::with_seed(seed.clone()); + let mut config1 = ObfuscationConfig::with_seed(seed.clone()); + config1.rewrite_function_selectors = true; + let mut config2 = ObfuscationConfig::with_seed(seed.clone()); + config2.rewrite_function_selectors = true; let result1 = obfuscate_bytecode(SIMPLE_BYTECODE, SIMPLE_BYTECODE, config1) .await @@ -198,13 +260,11 @@ async fn test_counter_dispatcher_detection() { "Expected four selectors in Counter dispatcher" ); - let result = obfuscate_bytecode( - COUNTER_BYTECODE, - counter_runtime, - ObfuscationConfig::default(), - ) - .await - .expect("obfuscator should succeed"); + let mut config = ObfuscationConfig::with_seed(Seed::from_bytes([0x33; 32])); + config.rewrite_function_selectors = true; + let result = obfuscate_bytecode(COUNTER_BYTECODE, counter_runtime, config) + .await + .expect("obfuscator should succeed"); assert!( result @@ -244,3 +304,65 @@ async fn test_counter_dispatcher_detection() { "Selector mapping should cover all dispatcher selectors" ); } + +#[tokio::test] +async fn production_profile_never_relabels_msg_sig_observer() { + const ORIGINAL_SELECTOR: u32 = 0x11223344; + let seed = Seed::from_bytes([0x42; 32]); + + let safe_result = obfuscate_bytecode( + MSG_SIG_DEPLOYMENT, + MSG_SIG_RUNTIME, + ObfuscationConfig::with_seed(seed.clone()), + ) + .await + .expect("safe profile accepts the msg.sig fixture without selector rewriting"); + + assert!( + safe_result.selector_mapping.is_none(), + "safe profile must not publish a mapping it did not prove semantically sound" + ); + assert!( + !safe_result + .metadata + .transforms_applied + .iter() + .any(|name| name == "FunctionDispatcher"), + "FunctionDispatcher must remain disabled in the production profile" + ); + let (safe_runtime, _, _, _) = decoder::decode_bytecode(&safe_result.obfuscated_runtime, false) + .await + .expect("decode safe output"); + assert!(safe_runtime.iter().any(|instruction| { + instruction.op == azoth_core::Opcode::PUSH(4) + && instruction.imm.as_deref() == Some("11223344") + })); + let original_output = execute_runtime( + &safe_result.obfuscated_runtime, + ORIGINAL_SELECTOR.to_be_bytes().to_vec(), + ); + assert_eq!(&original_output[28..], &ORIGINAL_SELECTOR.to_be_bytes()); + + // The legacy operation remains behind an explicit experimental API flag. This assertion + // prevents a future refactor from silently turning it back into a production default. + let mut experimental = ObfuscationConfig::with_seed(seed); + experimental.transforms.clear(); + experimental.rewrite_function_selectors = true; + let experimental_result = obfuscate_bytecode(MSG_SIG_DEPLOYMENT, MSG_SIG_RUNTIME, experimental) + .await + .expect("explicit experimental selector rewrite"); + let mapping = experimental_result + .selector_mapping + .expect("explicit opt-in produces a selector map"); + let replacement = mapping + .get(&ORIGINAL_SELECTOR) + .expect("original selector mapping"); + assert_ne!(replacement.as_slice(), ORIGINAL_SELECTOR.to_be_bytes()); + let experimental_output = + execute_runtime(&experimental_result.obfuscated_runtime, replacement.clone()); + assert_eq!(&experimental_output[28..], replacement); + assert_ne!( + experimental_output, original_output, + "adapted call exposes the replacement through msg.sig" + ); +} diff --git a/tests/src/transforms/jump_address.rs b/tests/src/transforms/jump_address.rs index 47fe48cf..b1a90669 100644 --- a/tests/src/transforms/jump_address.rs +++ b/tests/src/transforms/jump_address.rs @@ -4,12 +4,12 @@ use azoth_transform::jump_address_transformer::JumpAddressTransformer; use azoth_transform::Transform; #[tokio::test] -async fn test_jump_address_transformer() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) +async fn legacy_jump_address_transformer_fails_closed_on_invalid_layout() { + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); // Simple bytecode with a conditional jump let bytecode = "0x60085760015b00"; // PUSH1 0x08, JUMPI, PUSH1 0x01, JUMPDEST, STOP @@ -17,44 +17,19 @@ async fn test_jump_address_transformer() { .await .unwrap(); - // Count instructions before transformation - let mut instruction_count_before = 0; - for node_idx in cfg_ir.cfg.node_indices() { - if let azoth_core::cfg_ir::Block::Body(body) = &cfg_ir.cfg[node_idx] { - instruction_count_before += body.instructions.len(); - } - } - let seed = Seed::from_hex("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") .unwrap(); let mut rng = seed.create_deterministic_rng(); let transform = JumpAddressTransformer::new(); - let changed = transform.apply(&mut cfg_ir, &mut rng).unwrap(); - assert!(changed, "JumpAddressTransformer should modify bytecode"); - - // Count instructions after transformation - let mut instruction_count_after = 0; - for node_idx in cfg_ir.cfg.node_indices() { - if let azoth_core::cfg_ir::Block::Body(body) = &cfg_ir.cfg[node_idx] { - instruction_count_after += body.instructions.len(); - } - } - - // Should have more instructions after transformation + let error = transform + .apply(&mut cfg_ir, &mut rng) + .expect_err("the legacy pass must not emit overlapping instruction spans"); assert!( - instruction_count_after > instruction_count_before, - "Instruction count should increase: before={}, after={}", - instruction_count_before, - instruction_count_after - ); - - // Verify we added exactly 2 more instructions (1 PUSH was replaced with 2 PUSH + 1 ADD = net +2) - assert_eq!( - instruction_count_after, - instruction_count_before + 2, - "Should add exactly 2 instructions" + error.to_string().contains("invalid block structure") + && error.to_string().contains("gap or overlap"), + "unexpected fail-closed error: {error}" ); } diff --git a/tests/src/transforms/mod.rs b/tests/src/transforms/mod.rs index 2c7157d6..38c4de92 100644 --- a/tests/src/transforms/mod.rs +++ b/tests/src/transforms/mod.rs @@ -1,4 +1,6 @@ #[cfg(test)] +mod cluster_shuffle; +#[cfg(test)] mod determinism; #[cfg(test)] mod function_dispatcher; diff --git a/tests/src/transforms/opaque_predicate.rs b/tests/src/transforms/opaque_predicate.rs index 17b0ce28..0c154d93 100644 --- a/tests/src/transforms/opaque_predicate.rs +++ b/tests/src/transforms/opaque_predicate.rs @@ -5,12 +5,12 @@ use azoth_transform::opaque_predicate::OpaquePredicate; use azoth_transform::Transform; #[tokio::test] -async fn test_opaque_predicate_adds_blocks() { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) +async fn legacy_opaque_predicate_fails_closed_on_invalid_layout() { + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) .with_ansi(false) .without_time() - .init(); + .try_init(); let bytecode = "0x6001600260016003"; // PUSH1 0x01, PUSH1 0x02, PUSH1 0x01, PUSH1 0x03 let (mut cfg_ir, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) .await @@ -21,11 +21,13 @@ async fn test_opaque_predicate_adds_blocks() { .unwrap(); let mut rng = seed.create_deterministic_rng(); let transform = OpaquePredicate::new(); - let changed = transform.apply(&mut cfg_ir, &mut rng).unwrap(); - assert!(changed, "OpaquePredicate should insert predicates"); - let after = collect_metrics(&cfg_ir, &cfg_ir.clean_report).unwrap(); + let error = transform + .apply(&mut cfg_ir, &mut rng) + .expect_err("the relationship validator must reject an invalid legacy layout"); assert!( - after.block_cnt > before.block_cnt, - "Block count should increase" + error.to_string().contains("invalid block structure") + && error.to_string().contains("gap or overlap"), + "unexpected fail-closed error: {error}" ); + assert_eq!(before.block_cnt, 1); } diff --git a/tests/src/transforms/shuffle.rs b/tests/src/transforms/shuffle.rs index 9d2fe4ed..141912fd 100644 --- a/tests/src/transforms/shuffle.rs +++ b/tests/src/transforms/shuffle.rs @@ -1,11 +1,10 @@ -use azoth_analysis::collect_metrics; use azoth_core::process_bytecode_to_cfg; use azoth_core::seed::Seed; use azoth_transform::shuffle::Shuffle; use azoth_transform::Transform; #[tokio::test] -async fn test_shuffle_reorders_blocks() { +async fn legacy_shuffle_fails_closed_instead_of_using_temporary_overlapping_pcs() { let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) @@ -16,21 +15,21 @@ async fn test_shuffle_reorders_blocks() { .await .unwrap(); - let before = collect_metrics(&cfg_ir, &cfg_ir.clean_report).unwrap(); - let seed = Seed::generate(); + let seed = Seed::from_bytes([0x51; 32]); let mut rng = seed.create_deterministic_rng(); let transform = Shuffle; - let changed = transform.apply(&mut cfg_ir, &mut rng).unwrap(); - let after = collect_metrics(&cfg_ir, &cfg_ir.clean_report).unwrap(); - assert!(changed, "Shuffle should reorder blocks"); - assert_eq!( - before.byte_len, after.byte_len, - "Byte length should not change" + let error = transform + .apply(&mut cfg_ir, &mut rng) + .expect_err("temporary block PCs must fail structural validation"); + assert!( + error.to_string().contains("invalid block structure") + && error.to_string().contains("gap or overlap"), + "unexpected fail-closed error: {error}" ); } #[tokio::test] -async fn test_shuffle_storage_bytecode() { +async fn legacy_shuffle_storage_fixture_fails_closed() { let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .with_ansi(false) @@ -57,31 +56,15 @@ async fn test_shuffle_storage_bytecode() { println!("Block PCs before shuffle: {:?}", before_pcs); - let seed = Seed::generate(); + let seed = Seed::from_bytes([0x52; 32]); let mut rng = seed.create_deterministic_rng(); let transform = Shuffle; - let changed = transform.apply(&mut cfg_ir, &mut rng).unwrap(); - - // Collect block start PCs after shuffle - let after_pcs: Vec = cfg_ir - .cfg - .node_indices() - .filter_map(|n| { - if let azoth_core::cfg_ir::Block::Body(body) = &cfg_ir.cfg[n] { - Some(body.start_pc) - } else { - None - } - }) - .collect(); - - println!("Block PCs after shuffle: {:?}", after_pcs); - println!("Shuffle changed: {}", changed); - - // Verify block count didn't change - assert_eq!( - before_pcs.len(), - after_pcs.len(), - "Block count should remain the same" + let error = transform + .apply(&mut cfg_ir, &mut rng) + .expect_err("temporary block PCs must fail structural validation"); + assert!( + error.to_string().contains("invalid block structure") + && error.to_string().contains("gap or overlap"), + "unexpected fail-closed error: {error}" ); } diff --git a/tests/src/transforms/slot_shuffle.rs b/tests/src/transforms/slot_shuffle.rs index 5de493b4..cf27d6c7 100644 --- a/tests/src/transforms/slot_shuffle.rs +++ b/tests/src/transforms/slot_shuffle.rs @@ -137,16 +137,13 @@ fn init_literal_slots_empty_bytes_yields_empty_result() { } #[test] -fn init_literal_slots_truncated_push_terminates_cleanly() { - // PUSH2 with only 1 immediate byte — decoder stops at the - // truncated instruction rather than reading out of bounds or - // producing garbage. The preceding PUSH1 0x05; SSTORE pair is - // still captured. +fn init_literal_slots_truncated_push_fails_closed() { + // A partial view of init code is unsafe for storage remapping. Report the malformed tail and + // discard candidates collected before it so SlotShuffle is disabled atomically. let bytes = vec![PUSH1, 0x09, PUSH1, 0x05, SSTORE, PUSH2, 0x12]; let (touched, unresolved) = init_literal_slots(&bytes); - let expected: HashSet<_> = std::iter::once((1usize, vec![0x05u8])).collect(); - assert_eq!(touched, expected); - assert!(unresolved.is_empty()); + assert!(touched.is_empty()); + assert_eq!(unresolved, vec![5]); } #[test]