diff --git a/crates/cli/README.md b/crates/cli/README.md index 1758b47..47b9ab1 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -67,7 +67,7 @@ Options: - `-D, --deployment ` - Input deployment bytecode (required) - `-R, --runtime ` - Input runtime bytecode (required) - `--seed ` - Cryptographic seed for deterministic obfuscation -- `--passes ` - Comma-separated list of transforms (default: shuffle) +- `--passes ` - Comma-separated list of transforms (default: `arithmetic_chain,push_split,slot_shuffle,string_obfuscate,cluster_shuffle`) - `--emit ` - Path to write gas/size report as JSON - `--emit-debug ` - Path to emit detailed CFG trace debug report as JSON - `--tui` - Launch TUI to view debug trace after obfuscation diff --git a/crates/cli/src/commands/decompile_diff.rs b/crates/cli/src/commands/decompile_diff.rs index 7630e5a..1af8c26 100644 --- a/crates/cli/src/commands/decompile_diff.rs +++ b/crates/cli/src/commands/decompile_diff.rs @@ -40,7 +40,7 @@ pub struct DecompileDiffArgs { #[arg(short = 'R', long = "runtime")] pub runtime_bytecode: String, - /// Comma-separated list of transforms (default: shuffle). + /// Comma-separated list of transforms (default: `DEFAULT_PASSES`). #[arg(long, default_value = DEFAULT_PASSES)] pub passes: String, diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index b7d5f5a..fad6070 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -13,7 +13,8 @@ pub mod tui; use thiserror::Error; -pub const DEFAULT_PASSES: &str = "arithmetic_chain, push_split, slot_shuffle, string_obfuscate"; +pub const DEFAULT_PASSES: &str = + "arithmetic_chain, push_split, slot_shuffle, string_obfuscate, cluster_shuffle"; /// Errors that can occur during obfuscation. #[derive(Debug, Error)] diff --git a/crates/transforms/src/cluster_shuffle.rs b/crates/transforms/src/cluster_shuffle.rs index 7e95611..11175a5 100644 --- a/crates/transforms/src/cluster_shuffle.rs +++ b/crates/transforms/src/cluster_shuffle.rs @@ -1,21 +1,77 @@ //! Cluster-aware CFG shuffler. //! -//! 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. +//! Rather than shuffling individual body blocks (which breaks any block +//! whose control-flow exit is `Fallthrough` or `Branch`, because a +//! conditional JUMPI's false branch and an implicit fall-through both +//! require the next block to sit at the next PC), this transform groups +//! blocks into "clusters" that must stay adjacent and shuffles *those*. //! -//! Assembly example: +//! A cluster is a maximal run of blocks, in current `start_pc` order, +//! where every non-final block in the run ends in `Fallthrough` or +//! `Branch`. "Maximal" means the run is grown until it cannot be +//! extended: we keep absorbing the next block for as long as the +//! current cluster's tail ends in `Fallthrough` or `Branch`, and we +//! stop only when the tail ends in `Jump`, `Terminal`, or `Unknown` — +//! i.e. the first block whose successor-in-memory is irrelevant to +//! execution. Clusters naturally capture: +//! +//! * Dispatcher tiers emitted by `FunctionDispatcher` — each tier is a +//! `Branch` block whose false branch falls through to the next tier, +//! so fallthrough-clustering chains all tiers plus the fallback into +//! one cluster. +//! * Compiler-emitted basic blocks where Solidity splits a function +//! across a JUMPDEST without an intervening JUMP — the first block +//! ends in `Fallthrough` into the JUMPDEST block. +//! +//! Dispatcher-synthesised stubs, decoys, controllers and invalid sinks +//! all end in `Jump`, `Terminal`, or `Unknown` control, so each is its +//! own singleton cluster. Pinning is handled by marking every cluster +//! that contains a dispatcher block as frozen, not by bundling them +//! into a single cluster. +//! +//! Blocks ending in `Jump`, `Terminal`, or `Unknown` are cluster +//! boundaries: execution either transfers via a PUSH target (resolved by +//! `patch_jump_immediates` + `remap_orphan_jump_pushes` after reindex) +//! or ends, so the next PC can be anywhere. +//! +//! The shuffle pins the cluster containing the smallest original +//! `start_pc` at position 0 — the deployed runtime starts executing at +//! runtime-relative PC 0, so that cluster must remain the entry point — +//! and randomises the order of the rest. Temporary `start_pc` values are +//! assigned with a large gap between clusters so `reindex_pcs` observes +//! the new ordering, then reindex compacts everything back to a +//! contiguous PC layout. Jump immediates are remapped by the existing +//! post-reindex pipeline (`patch_jump_immediates`, +//! `remap_orphan_jump_pushes`, and the dispatcher reapply passes), so +//! every `JUMP`/`JUMPI` target stays correct across the rearrangement. +//! +//! # Dispatcher coexistence +//! +//! When a `FunctionDispatcher` is present, the clusters containing +//! stub / decoy / invalid-sink / controller blocks are pinned in place +//! (frozen). Every other runtime cluster is still free to move, and +//! the dispatcher's stored `push_width`s absorb the resulting target-PC +//! shifts because `function_dispatcher::patterns::layout` now pre-sizes +//! those PUSHes via `safe_runtime_push_width` — typically PUSH2 — which +//! comfortably holds any EIP-170-compliant runtime PC. This removes the +//! earlier `OddLength` encoder failures from post-reindex width +//! overflow. +//! +//! For example: //! ```assembly //! // Blocks before -//! [dispatcher_tier_0][controller_real][decoy_stub][storage_gate] +//! [runtime_entry][helper_a][helper_b][tail_revert] //! //! // After cluster shuffle (one variant) -//! [storage_gate][dispatcher_tier_0][decoy_stub][controller_real] +//! [runtime_entry][tail_revert][helper_a][helper_b] //! ``` use crate::{Result, Transform}; -use azoth_core::cfg_ir::CfgIrBundle; +use azoth_core::cfg_ir::{Block, BlockControl, CfgIrBundle}; +use petgraph::graph::NodeIndex; use rand::rngs::StdRng; +use rand::seq::SliceRandom; +use std::collections::HashSet; use tracing::debug; /// Cluster-level shuffle wrapper. @@ -33,8 +89,230 @@ 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 StdRng) -> Result { + // Collect runtime body blocks in current PC order. Non-body nodes + // (Entry / Exit) are not part of any cluster — they don't carry + // bytecode and `reindex_pcs` leaves them alone. Non-runtime + // bodies are explicitly excluded so this transform stays safe + // even if a future change to `process_bytecode_to_cfg` starts + // including init-code blocks in the graph; shuffling init into + // the runtime range would silently corrupt the constructor. + let runtime_bounds = ir.runtime_bounds; + let in_runtime = |start_pc: usize| -> bool { + match runtime_bounds { + Some((start, end)) => start_pc >= start && start_pc < end, + // When runtime_bounds is unknown, fall back to treating + // every body as shuffle-eligible — matches the prior + // behaviour of this transform before the filter was + // added. Contracts without runtime_bounds don't go + // through the dispatcher coexistence path anyway. + None => true, + } + }; + let mut sorted: Vec<(usize, NodeIndex)> = ir + .cfg + .node_indices() + .filter_map(|n| match ir.cfg.node_weight(n) { + Some(Block::Body(body)) if in_runtime(body.start_pc) => Some((body.start_pc, n)), + _ => None, + }) + .collect(); + sorted.sort_by_key(|(pc, _)| *pc); + + if sorted.len() < 2 { + debug!("ClusterShuffle: fewer than two body blocks, nothing to shuffle"); + return Ok(false); + } + + // Build clusters as maximal fallthrough chains. Start each block + // as its own singleton, then merge adjacent pairs where the + // earlier cluster's tail exits via `Fallthrough` or `Branch`. + // After this pass every cluster's tail has `Jump`, `Terminal`, + // or `Unknown` control — meaning the next PC is irrelevant for + // that cluster's execution, so it's safe to follow it with any + // other cluster. + let mut clusters: Vec> = sorted.iter().map(|(_, n)| vec![*n]).collect(); + + let mut i = 0; + while i + 1 < clusters.len() { + let tail = *clusters[i] + .last() + .expect("cluster is never empty by construction"); + let needs_next = matches!( + ir.cfg.node_weight(tail), + Some(Block::Body(body)) + if matches!(body.control, BlockControl::Fallthrough | BlockControl::Branch { .. }) + ); + if needs_next { + let next = clusters.remove(i + 1); + clusters[i].extend(next); + // Don't advance: the new tail of clusters[i] might also + // need merging with the (new) clusters[i + 1]. + } else { + i += 1; + } + } + + if clusters.len() < 2 { + debug!( + "ClusterShuffle: fallthrough clustering collapsed into {} cluster(s); nothing to shuffle", + clusters.len() + ); + return Ok(false); + } + + // Pin the runtime entry block and the dispatcher infrastructure. + // + // * Entry block: the deployed EVM executes from runtime-relative + // PC 0, so whatever block lands at slot 0 after reindex must + // be the original entry. For dispatcher-free contracts that's + // usually a free-standing constructor prologue or the first + // JUMPDEST; for dispatcher-present contracts it's the first + // tier of the selector cascade. Either way, we pin the cluster + // containing the block with the smallest pre-shuffle start_pc + // by adding that block's node to `pinned_nodes`. + // + // * Dispatcher blocks (stubs, decoys, controllers, invalid + // sinks) reference each other via relative PCs that Step 5's + // reapply_{stub,decoy,controller}_patches rewrites using the + // dispatcher's stored push_width. Since `safe_runtime_push_width` + // in function_dispatcher/patterns/layout.rs now pre-sizes every + // dispatcher PUSH to fit any runtime PC, the *targets* those + // PUSHes point at (function-body entries) are free to move — + // only the stubs/decoys themselves stay put so the dispatcher's + // internal layout invariants hold. + let mut pinned_nodes: HashSet = ir + .dispatcher_blocks + .iter() + .map(|&idx| NodeIndex::new(idx)) + .collect(); + if let Some(&(_, entry_node)) = sorted.first() { + pinned_nodes.insert(entry_node); + } + + // Mark each cluster as frozen if it contains any pinned node. + // Frozen clusters stay in their original position slot in the + // final layout; free clusters are shuffled among themselves, + // filling the non-frozen slots in their new order. + let frozen_mask: Vec = clusters + .iter() + .map(|c| c.iter().any(|n| pinned_nodes.contains(n))) + .collect(); + + let free_indices: Vec = (0..clusters.len()).filter(|i| !frozen_mask[*i]).collect(); + + if free_indices.len() < 2 { + debug!( + "ClusterShuffle: only {} free cluster(s) after freezing dispatcher dependencies; nothing to shuffle", + free_indices.len() + ); + return Ok(false); + } + + debug!( + "ClusterShuffle: {} total cluster(s), {} frozen, {} free", + clusters.len(), + frozen_mask.iter().filter(|b| **b).count(), + free_indices.len() + ); + + // Pull the free clusters out (preserving their original + // ordering for the shuffle comparison), shuffle, and splice + // back into the non-frozen slots. + let free_original: Vec> = + free_indices.iter().map(|&i| clusters[i].clone()).collect(); + let mut free_shuffled = free_original.clone(); + free_shuffled.shuffle(rng); + if free_shuffled == free_original && free_shuffled.len() > 1 { + free_shuffled.rotate_left(1); + debug!("ClusterShuffle: shuffle was identity; rotated left by 1 to force change"); + } + + let mut final_order: Vec> = Vec::with_capacity(clusters.len()); + let mut free_iter = free_shuffled.into_iter(); + for (i, cluster) in clusters.into_iter().enumerate() { + if frozen_mask[i] { + final_order.push(cluster); + } else { + final_order.push( + free_iter + .next() + .expect("free_shuffled length matches free_indices length"), + ); + } + } + + // Detect whether the final order is a no-op (identical to the + // starting arrangement). If so the whole transform has no + // effect — skip reindex to avoid producing an empty trace event. + let original_layout: Vec = sorted.iter().map(|(_, n)| *n).collect(); + let new_layout: Vec = final_order.iter().flatten().copied().collect(); + if new_layout == original_layout { + debug!("ClusterShuffle: final layout matches original; nothing to do"); + return Ok(false); + } + + // Assign temporary `start_pc` values with a large gap between + // clusters so the obfuscator's Step 5 `reindex_pcs` observes + // the new ordering. The gap is not a real byte offset — it + // just has to be large enough that no two clusters' temp PCs + // overlap after adding an intra-cluster delta. The subsequent + // `reindex_pcs` will re-sort by `start_pc` and assign + // contiguous new PCs, producing a single pc_mapping that + // flows through `patch_jump_immediates`, + // `remap_orphan_jump_pushes`, and the dispatcher reapply + // passes — all the jump fix-up happens once in one pipeline + // stage. + // + // Critically, we do NOT call `reindex_pcs()` here. Doing so + // would consume the pc_mapping for our shuffle inside + // `write_symbolic_immediates` (which only covers terminal + // jumps), and the obfuscator's later Step 5 `reindex_pcs` + // would then see already-contiguous PCs and produce an + // identity mapping, leaving every non-terminal + // return-address PUSH untouched. Letting Step 5 do the + // single reindex ensures the full downstream patch chain + // runs against our layout change. + // + // Temp PCs are anchored at the current runtime start so every + // shuffled block still reports `is_runtime() == true` when + // `reindex_pcs` recomputes `runtime_bounds` block-by-block. + // Without this anchoring, cluster 0 would land at PC 0, which + // is below any contract's runtime_start, and `reindex_pcs` + // would misclassify shuffled bodies as init — wiping + // `runtime_bounds` post-reindex and forcing `patch_jump_immediates` + // / `remap_orphan_jump_pushes` to reinterpret every + // runtime-relative PUSH immediate as an absolute PC. That was + // the real cause of the "~500 stale jumps" regression + // documented earlier: every direct `PUSH ; JUMP` pattern + // consulted the wrong base when looking up its old_pc. + const CLUSTER_GAP: usize = 1_000_000; + let runtime_start = ir.runtime_bounds.map(|(s, _)| s).unwrap_or(0); + let mut max_temp_pc = runtime_start; + for (cluster_idx, cluster) in final_order.iter().enumerate() { + let base = runtime_start.saturating_add(cluster_idx.saturating_mul(CLUSTER_GAP)); + for (intra_idx, node) in cluster.iter().enumerate() { + if let Some(Block::Body(body)) = ir.cfg.node_weight_mut(*node) { + let temp_pc = base.saturating_add(intra_idx); + body.start_pc = temp_pc; + max_temp_pc = max_temp_pc.max(temp_pc.saturating_add(1)); + } + } + } + + // Widen runtime_bounds so the shuffled blocks still fall inside + // it during `reindex_pcs`'s per-block `is_runtime` check. + // `reindex_pcs` overwrites `runtime_bounds` as soon as it finishes, + // so this expansion is visible only within that pass. + if let Some((start, end)) = ir.runtime_bounds { + ir.runtime_bounds = Some((start, end.max(max_temp_pc))); + } + + debug!( + "ClusterShuffle: assigned temporary start_pcs for {} cluster(s); Step 5 reindex will compact", + final_order.len() + ); + + Ok(true) } } diff --git a/crates/transforms/src/function_dispatcher/mod.rs b/crates/transforms/src/function_dispatcher/mod.rs index 27f8f30..11085e9 100644 --- a/crates/transforms/src/function_dispatcher/mod.rs +++ b/crates/transforms/src/function_dispatcher/mod.rs @@ -15,6 +15,29 @@ use rand::rngs::StdRng; use std::collections::HashMap; use tracing::debug; +/// True when `value` fits in a `PUSH` immediate. +/// +/// `push_width` counts bytes (so `PUSH2` passes 2) and may exceed the +/// host's word size — on 64-bit `PUSH8` would shift by 64, and anything +/// wider would overflow a `usize`. `checked_shl` returns `None` in that +/// range; we treat it as "capacity ≥ usize::MAX", i.e. anything fits, +/// which is the only semantically-meaningful answer when the width +/// covers every representable value. +pub(crate) fn push_width_holds(value: usize, push_width: u8) -> bool { + let bits = (push_width as u32).saturating_mul(8); + match 1usize.checked_shl(bits) { + Some(capacity) => value < capacity, + None => true, + } +} + +/// Capacity of a `PUSH` as a usize; saturates at `usize::MAX` +/// for widths that cover the whole word. Used only for error messages. +pub(crate) fn push_width_capacity(push_width: u8) -> usize { + let bits = (push_width as u32).saturating_mul(8); + 1usize.checked_shl(bits).unwrap_or(usize::MAX) +} + #[derive(Default)] pub struct FunctionDispatcher { cached_dispatcher: Option, @@ -174,6 +197,28 @@ impl FunctionDispatcher { new_controller_pc }; + // Tier PUSHes reuse the push_width from the original runtime + // bytecode (they're the source-level `PUSH; + // JUMPI` pattern). If a layout-shifting transform like + // `ClusterShuffle` moved the controller past this width's + // capacity, `format!("{:0w$x}", ...)` would silently emit a + // hex string whose length doesn't match push_width * 2, + // producing an OddLength encoder failure downstream or — + // worse — a plausible-looking but wrong immediate when the + // width/value happen to align. Fail loud instead. + if !push_width_holds(controller_rel, push_width) { + return Err(Error::Generic(format!( + "reapply_dispatcher_patches: selector 0x{:08x} controller_rel 0x{:x} \ + overflows stored push_width {} (capacity 0x{:x}). Either the source \ + tier PUSH was narrower than the runtime allows or a transform moved \ + the controller beyond it — tier PUSH widening is not yet implemented.", + selector, + controller_rel, + push_width, + push_width_capacity(push_width), + ))); + } + let formatted = format!( "{:0width$x}", controller_rel, @@ -231,6 +276,22 @@ impl FunctionDispatcher { new_target_pc }; + // Controller PUSHes are pre-widened via `safe_runtime_push_width` + // at layout time, so this overflow shouldn't trigger for any + // EIP-170-compliant contract. Guard anyway so that a future + // change to that helper (e.g. narrowing it too aggressively) + // fails loud here instead of producing a malformed immediate. + if !push_width_holds(target_rel, push_width) { + return Err(Error::Generic(format!( + "reapply_controller_patches: target_rel 0x{:x} overflows stored \ + push_width {} (capacity 0x{:x}) — `safe_runtime_push_width` is \ + not wide enough for this runtime.", + target_rel, + push_width, + push_width_capacity(push_width), + ))); + } + let formatted = format!("{:0width$x}", target_rel, width = push_width as usize * 2); debug!( @@ -481,3 +542,41 @@ impl Transform for FunctionDispatcher { } } } + +#[cfg(test)] +mod tests { + use super::{push_width_capacity, push_width_holds}; + + #[test] + fn push_width_holds_narrow() { + // PUSH1 accepts 0..=0xff + assert!(push_width_holds(0, 1)); + assert!(push_width_holds(0xff, 1)); + assert!(!push_width_holds(0x100, 1)); + // PUSH2 accepts 0..=0xffff + assert!(push_width_holds(0xffff, 2)); + assert!(!push_width_holds(0x1_0000, 2)); + } + + #[test] + fn push_width_holds_wide_does_not_panic() { + // PUSH8 shifts by 64 on a 64-bit target; the naive + // `1usize << 64` panics in debug mode. push_width_holds + // must gracefully treat this as "fits anything". + assert!(push_width_holds(usize::MAX, 8)); + assert!(push_width_holds(usize::MAX, 32)); + } + + #[test] + fn push_width_capacity_narrow() { + assert_eq!(push_width_capacity(1), 0x100); + assert_eq!(push_width_capacity(2), 0x1_0000); + assert_eq!(push_width_capacity(4), 0x1_0000_0000); + } + + #[test] + fn push_width_capacity_saturates_on_wide() { + assert_eq!(push_width_capacity(8), usize::MAX); + assert_eq!(push_width_capacity(32), usize::MAX); + } +} diff --git a/crates/transforms/src/function_dispatcher/patterns/controller.rs b/crates/transforms/src/function_dispatcher/patterns/controller.rs index 497c8b5..47a79b9 100644 --- a/crates/transforms/src/function_dispatcher/patterns/controller.rs +++ b/crates/transforms/src/function_dispatcher/patterns/controller.rs @@ -76,6 +76,9 @@ pub enum PredicateType { /// * `slot` - Storage slot to check /// * `match_target` - Where to jump if the slot value is zero /// * `fallback_target` - Where to jump if the slot value is non-zero +/// * `jump_target_width` - PUSH width used for the two jump-target PUSHes. +/// Callers must pass a width large enough to hold any post-reindex target, +/// since `reapply_controller_patches` reuses this width verbatim. /// /// # Returns /// @@ -85,6 +88,7 @@ pub fn generate_storage_check_instructions( slot: u64, match_target: usize, fallback_target: usize, + jump_target_width: u8, ) -> Vec { let mut instructions = Vec::new(); let mut pc = start_pc; @@ -118,8 +122,7 @@ pub fn generate_storage_check_instructions( }); pc += 1; - // Determine push width for match target - let match_width = minimal_push_width(match_target); + let match_width = jump_target_width; instructions.push(Instruction { pc, op: Opcode::PUSH(match_width), @@ -139,8 +142,7 @@ pub fn generate_storage_check_instructions( }); pc += 1; - // Determine push width for fallback target - let fallback_width = minimal_push_width(fallback_target); + let fallback_width = jump_target_width; instructions.push(Instruction { pc, op: Opcode::PUSH(fallback_width), @@ -175,6 +177,9 @@ pub fn generate_storage_check_instructions( /// * `expected_value` - The byte value to compare against /// * `match_target` - Where to jump if the comparison succeeds /// * `fallback_target` - Where to jump if the comparison fails +/// * `jump_target_width` - PUSH width used for the two jump-target PUSHes. +/// Callers must pass a width large enough to hold any post-reindex target, +/// since `reapply_controller_patches` reuses this width verbatim. /// /// # Returns /// @@ -185,6 +190,7 @@ pub fn generate_byte_extraction_instructions( expected_value: u8, match_target: usize, fallback_target: usize, + jump_target_width: u8, ) -> Vec { let mut instructions = Vec::new(); let mut pc = start_pc; @@ -237,8 +243,7 @@ pub fn generate_byte_extraction_instructions( }); pc += 1; - // Determine push width for match target - let match_width = minimal_push_width(match_target); + let match_width = jump_target_width; instructions.push(Instruction { pc, op: Opcode::PUSH(match_width), @@ -258,8 +263,7 @@ pub fn generate_byte_extraction_instructions( }); pc += 1; - // Determine push width for fallback target - let fallback_width = minimal_push_width(fallback_target); + let fallback_width = jump_target_width; instructions.push(Instruction { pc, op: Opcode::PUSH(fallback_width), @@ -493,6 +497,7 @@ mod tests { 0x7f, // expected value 0x2000, // match target 0x3000, // fallback target + 2, // jump target width (PUSH2) ); let next_pc = 0x1000 + instructions.size(); @@ -544,6 +549,7 @@ mod tests { 0x1000, 0x7a3c, // Random storage slot 0x2000, // Match target (if zero) 0x3000, // Fallback target (if non-zero) + 2, // jump target width (PUSH2) ); let next_pc = 0x1000 + instructions.size(); @@ -587,7 +593,7 @@ mod tests { // Test with a small slot value (1-byte PUSH) let instructions = generate_storage_check_instructions( 0x1000, 0x05, // Small slot - 0x2000, 0x3000, + 0x2000, 0x3000, 2, ); // First instruction should be PUSH1 for small slot @@ -600,7 +606,7 @@ mod tests { // Test with a larger slot value (2-byte PUSH) let instructions = generate_storage_check_instructions( 0x1000, 0xabcd, // Larger slot requiring 2 bytes - 0x2000, 0x3000, + 0x2000, 0x3000, 2, ); // First instruction should be PUSH2 for larger slot @@ -610,7 +616,7 @@ mod tests { #[test] fn test_storage_check_instruction_sequence() { - let instructions = generate_storage_check_instructions(0x1000, 0x100, 0x2000, 0x3000); + let instructions = generate_storage_check_instructions(0x1000, 0x100, 0x2000, 0x3000, 2); // Verify the sequence order let opcodes: Vec<_> = instructions.iter().map(|i| &i.op).collect(); diff --git a/crates/transforms/src/function_dispatcher/patterns/layout.rs b/crates/transforms/src/function_dispatcher/patterns/layout.rs index 28d3faf..715eb3c 100644 --- a/crates/transforms/src/function_dispatcher/patterns/layout.rs +++ b/crates/transforms/src/function_dispatcher/patterns/layout.rs @@ -267,6 +267,57 @@ fn minimal_push_width(value: usize) -> u8 { 32 } +/// Width large enough to hold any PC that could land in the runtime section +/// after subsequent transforms rearrange it. +/// +/// Stub, decoy and controller PUSHes are patched post-reindex by +/// `reapply_{stub,decoy,controller}_patches`, which reuses the width chosen +/// here verbatim via `format!("{:0width$x}", new_target_rel, width = push_width * 2)`. +/// If a later layout-shifting transform (e.g. `ClusterShuffle`) moves a +/// target past the stored width's capacity, the reapply produces a +/// malformed hex immediate and the encoder fails with `OddLength`. +/// +/// # Why the `0xffff` floor +/// +/// Azoth does **not** enforce EIP-170 (the mainnet-level 24 576-byte runtime +/// size cap). Any bytecode can be passed in; this helper derives its width +/// from the runtime size of the *actual* input and widens automatically if +/// that's larger than `0xffff`. +/// +/// The `0xffff` value is a heuristic floor motivated by EIP-170 as the +/// common case: every contract deployed on an EIP-170-compliant chain fits +/// in PUSH2 (24 576 < 65 535), so the floor means we effectively always +/// pick PUSH2 in practice. This is preferable to hard-coding `2` because: +/// +/// * For tiny synthetic fixtures (e.g. the ~80-byte `storage.hex` used in +/// tests) the minimal-width-of-runtime-size would be PUSH1, and a PUSH1 +/// stub can't hold the post-shuffle targets of a runtime large enough +/// to produce multiple clusters. The floor guarantees PUSH2 regardless +/// of input size. +/// * For non-EIP-170 environments (private chains, experimental L2s with +/// raised size caps, off-chain interpretation of large bytecode), +/// `minimal_push_width(runtime_size)` takes over once `runtime_size` +/// exceeds `0xffff`, promoting to PUSH3 for contracts up to 16 MB and +/// PUSH4 beyond that, without any code change. +/// +/// In short: the floor encodes "every realistic mainnet contract fits +/// PUSH2" as a lower bound, and the `minimal_push_width(runtime_size.max(...))` +/// pattern widens automatically for anything bigger. +/// +/// # Size cost +/// +/// ~1 byte per patched dispatcher PUSH for contracts whose targets would +/// otherwise fit PUSH1 (≤ 0xff rel). With ~4-6 patched PUSHes per selector +/// tier, the overhead for a 4-selector dispatcher is under ~30 bytes of +/// bytecode and zero runtime gas (PUSH1 and PUSH2 both cost 3 gas). +fn safe_runtime_push_width(ir: &CfgIrBundle) -> u8 { + let runtime_size = ir + .runtime_bounds + .map(|(start, end)| end.saturating_sub(start)) + .unwrap_or(0); + minimal_push_width(runtime_size.max(0xffff)) +} + fn format_immediate(value: u128, width: u8) -> String { format!("{:0width$x}", value, width = width as usize * 2) } @@ -325,9 +376,9 @@ fn create_tier_nodes( let mut pc = next_pc; let decoy_start = pc; - let invalid_width = minimal_push_width(invalid_rel); + let invalid_width = safe_runtime_push_width(ir); let target_rel = runtime_relative(ir, target_pc); - let target_width = minimal_push_width(target_rel); + let target_width = safe_runtime_push_width(ir); let mut decoy_instructions = Vec::new(); decoy_instructions.push(Instruction { pc, @@ -403,7 +454,7 @@ fn create_tier_nodes( let stub_start = next_pc; let stub_target_rel = runtime_relative(ir, decoy_start); - let stub_width = minimal_push_width(stub_target_rel); + let stub_width = safe_runtime_push_width(ir); debug!( stub_start = format_args!("0x{:04x}", stub_start), decoy_start = format_args!("0x{:04x}", decoy_start), @@ -501,32 +552,17 @@ fn create_selector_controller( let selector_bytes = selector.to_be_bytes(); let expected_byte = selector_bytes[config.byte_index as usize]; - // Calculate where the JUMPDEST will be after the byte extraction block - // The block consists of: + // The byte extraction block is: // - PUSH1 0x00 + CALLDATALOAD + PUSH1 index + BYTE + PUSH1 expected + EQ (9 bytes fixed) - // - PUSH(match_width) + imm + JUMPI (variable: 1 + match_width + 1) - // - PUSH(fallback_width) + imm + JUMP (variable: 1 + fallback_width + 1) + // - PUSH(jump_target_width) + imm + JUMPI (1 + w + 1) + // - PUSH(jump_target_width) + imm + JUMP (1 + w + 1) // - // We need to calculate match_width, but it depends on byte_match_pc which we're calculating. - // Use iterative approach: start with PUSH1, check if sufficient, adjust if needed. + // With a uniform jump-target width chosen up front, the block + // size is deterministic and no iterative sizing is required. let byte_fail_rel = invalid_rel; - let fallback_width = minimal_push_width(byte_fail_rel); - let mut match_width = 1u8; - let byte_match_pc; - - loop { - let byte_block_size = - 9 + (1 + match_width as usize + 1) + (1 + fallback_width as usize + 1); - let candidate_pc = next_pc + byte_block_size; - let candidate_rel = runtime_relative(ir, candidate_pc); - let required_width = minimal_push_width(candidate_rel); - - if required_width <= match_width { - byte_match_pc = candidate_pc; - break; - } - match_width = required_width; - } + let jump_target_width = safe_runtime_push_width(ir); + let byte_block_size = 9 + 2 * (1 + jump_target_width as usize + 1); + let byte_match_pc = next_pc + byte_block_size; let byte_instrs = generate_byte_extraction_instructions( next_pc, @@ -534,6 +570,7 @@ fn create_selector_controller( expected_byte, runtime_relative(ir, byte_match_pc), byte_fail_rel, + jump_target_width, ); let byte_block_size = byte_instrs.size(); @@ -585,6 +622,7 @@ fn create_selector_controller( config.storage_slot, stub_rel, // If storage is zero (default), go to stub (real path) invalid_rel, // If storage is non-zero, trap at invalid + safe_runtime_push_width(ir), ); let storage_block_size = storage_instrs.size(); @@ -613,7 +651,7 @@ fn create_selector_controller( } } - let stub_width = minimal_push_width(stub_rel); + let stub_width = safe_runtime_push_width(ir); let final_push_pc = next_pc; instructions.push(Instruction { pc: next_pc, diff --git a/crates/transforms/src/obfuscator.rs b/crates/transforms/src/obfuscator.rs index f69228d..bd3dce1 100644 --- a/crates/transforms/src/obfuscator.rs +++ b/crates/transforms/src/obfuscator.rs @@ -1,4 +1,5 @@ use crate::arithmetic_chain::ArithmeticChain; +use crate::cluster_shuffle::ClusterShuffle; use crate::function_dispatcher::FunctionDispatcher; use crate::push_split::PushSplit; use crate::slot_shuffle::SlotShuffle; @@ -69,6 +70,7 @@ impl Default for ObfuscationConfig { Box::new(PushSplit::new()), Box::new(SlotShuffle::new()), Box::new(StringObfuscate::new()), + Box::new(ClusterShuffle::new()), ], preserve_unknown_opcodes: true, } @@ -453,6 +455,23 @@ pub async fn obfuscate_bytecode( new_decoy_pc }; + // Stub PUSHes are pre-widened via `safe_runtime_push_width`. + // Guard against a regression in that helper so any future + // narrowing fails loud rather than emitting a malformed hex + // immediate the encoder rejects with `OddLength`. + if !crate::function_dispatcher::push_width_holds(decoy_rel, push_width) { + return Err(ObfuscationError::from_err( + format!( + "reapply_stub_patches: decoy_rel 0x{:x} overflows stored push_width {} \ + (capacity 0x{:x})", + decoy_rel, + push_width, + crate::function_dispatcher::push_width_capacity(push_width), + ), + &cfg_ir.trace, + )); + } + let formatted = format!("{:0width$x}", decoy_rel, width = push_width as usize * 2); tracing::debug!( @@ -530,6 +549,21 @@ pub async fn obfuscate_bytecode( new_target_pc }; + // Decoy PUSHes are pre-widened via `safe_runtime_push_width`. + // Mirror the stub-patch guard for the same reason. + if !crate::function_dispatcher::push_width_holds(target_rel, push_width) { + return Err(ObfuscationError::from_err( + format!( + "reapply_decoy_patches: target_rel 0x{:x} overflows stored push_width {} \ + (capacity 0x{:x})", + target_rel, + push_width, + crate::function_dispatcher::push_width_capacity(push_width), + ), + &cfg_ir.trace, + )); + } + let formatted = format!("{:0width$x}", target_rel, width = push_width as usize * 2); tracing::debug!( diff --git a/tests/src/e2e/collect_proof.rs b/tests/src/e2e/collect_proof.rs index ae8c9ec..231b813 100644 --- a/tests/src/e2e/collect_proof.rs +++ b/tests/src/e2e/collect_proof.rs @@ -16,6 +16,7 @@ use super::{ }; 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; @@ -692,6 +693,31 @@ async fn test_collect_with_erc20_proof_dispatcher_plus_string_obfuscate_succeeds assert_collect_flow_success(label, outcome) } +#[tokio::test] +async fn test_collect_with_erc20_proof_dispatcher_plus_cluster_shuffle_succeeds() -> Result<()> { + let shuffle_label = "dispatcher_plus_cluster_shuffle"; + let (shuffle_deployment, shuffle_bond, shuffle_collect) = + build_obfuscated_flow_inputs(shuffle_label, vec![Box::new(ClusterShuffle::new())]).await?; + + let baseline_label = "dispatcher_only_baseline"; + let (baseline_deployment, _, _) = build_obfuscated_flow_inputs(baseline_label, vec![]).await?; + if shuffle_deployment == baseline_deployment { + return Err(eyre!( + "ClusterShuffle produced the same deployment bytecode as \ + the dispatcher-only baseline for the same seed — the \ + transform was a silent no-op" + )); + } + + let outcome = execute_collect_proof_flow( + shuffle_deployment, + shuffle_bond, + shuffle_collect, + shuffle_label, + )?; + assert_collect_flow_success(shuffle_label, outcome) +} + #[tokio::test] async fn test_collect_with_erc20_proof_default_pipeline_succeeds() -> Result<()> { let label = "default_pipeline"; diff --git a/tests/src/e2e/determinism.rs b/tests/src/e2e/determinism.rs index 492da82..bb60a67 100644 --- a/tests/src/e2e/determinism.rs +++ b/tests/src/e2e/determinism.rs @@ -1,8 +1,15 @@ use super::{ deploy_contract, ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, ESCROW_CONTRACT_RUNTIME_BYTECODE, }; +use azoth_core::process_bytecode_to_cfg; 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; +use azoth_transform::string_obfuscate::StringObfuscate; +use azoth_transform::Transform; use color_eyre::Result; const FIXED_SEED: &str = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -12,6 +19,56 @@ fn runtime_preview_hex(bytes: &[u8]) -> String { format!("0x{}", hex::encode(&bytes[..preview_len])) } +fn cfg_instruction_snapshot( + cfg_ir: &azoth_core::cfg_ir::CfgIrBundle, +) -> Vec<(usize, String, Option)> { + let mut snapshot = Vec::new(); + + for node in cfg_ir.cfg.node_indices() { + if let azoth_core::cfg_ir::Block::Body(body) = &cfg_ir.cfg[node] { + for instr in &body.instructions { + snapshot.push((instr.pc, format!("{:?}", instr.op), instr.imm.clone())); + } + } + } + + snapshot +} + +async fn assert_transform_deterministic_for_bytecode( + bytecode_name: &str, + bytecode: &str, + transform_name: &str, + make_transform: fn() -> Box, +) { + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let (mut cfg_a, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) + .await + .unwrap(); + let (mut cfg_b, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) + .await + .unwrap(); + + let mut rng_a = seed.create_deterministic_rng(); + let mut rng_b = seed.create_deterministic_rng(); + let transform_a = make_transform(); + let transform_b = make_transform(); + + let changed_a = transform_a.apply(&mut cfg_a, &mut rng_a).unwrap(); + let changed_b = transform_b.apply(&mut cfg_b, &mut rng_b).unwrap(); + let snapshot_a = cfg_instruction_snapshot(&cfg_a); + let snapshot_b = cfg_instruction_snapshot(&cfg_b); + + assert_eq!( + changed_a, changed_b, + "{transform_name} should report the same changed flag for the same seed on {bytecode_name}" + ); + assert_eq!( + snapshot_a, snapshot_b, + "{transform_name} should produce an identical instruction stream for the same seed on {bytecode_name}" + ); +} + #[tokio::test] async fn test_same_seed_produces_same_deployed_runtime() -> Result<()> { let seed = Seed::from_hex(FIXED_SEED).unwrap(); @@ -58,3 +115,93 @@ async fn test_same_seed_produces_same_deployed_runtime() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn arithmetic_chain_is_deterministic_for_same_seed() { + assert_transform_deterministic_for_bytecode( + "escrow runtime", + ESCROW_CONTRACT_RUNTIME_BYTECODE, + "ArithmeticChain", + || Box::new(ArithmeticChain::new()), + ) + .await; + assert_transform_deterministic_for_bytecode( + "escrow deployment", + ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + "ArithmeticChain", + || Box::new(ArithmeticChain::new()), + ) + .await; +} + +#[tokio::test] +async fn push_split_is_deterministic_for_same_seed() { + assert_transform_deterministic_for_bytecode( + "escrow runtime", + ESCROW_CONTRACT_RUNTIME_BYTECODE, + "PushSplit", + || Box::new(PushSplit::new()), + ) + .await; + assert_transform_deterministic_for_bytecode( + "escrow deployment", + ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + "PushSplit", + || Box::new(PushSplit::new()), + ) + .await; +} + +#[tokio::test] +async fn slot_shuffle_is_deterministic_for_same_seed() { + assert_transform_deterministic_for_bytecode( + "escrow runtime", + ESCROW_CONTRACT_RUNTIME_BYTECODE, + "SlotShuffle", + || Box::new(SlotShuffle::new()), + ) + .await; + assert_transform_deterministic_for_bytecode( + "escrow deployment", + ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + "SlotShuffle", + || Box::new(SlotShuffle::new()), + ) + .await; +} + +#[tokio::test] +async fn string_obfuscate_is_deterministic_for_same_seed() { + assert_transform_deterministic_for_bytecode( + "escrow runtime", + ESCROW_CONTRACT_RUNTIME_BYTECODE, + "StringObfuscate", + || Box::new(StringObfuscate::new()), + ) + .await; + assert_transform_deterministic_for_bytecode( + "escrow deployment", + ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + "StringObfuscate", + || Box::new(StringObfuscate::new()), + ) + .await; +} + +#[tokio::test] +async fn cluster_shuffle_is_deterministic_for_same_seed() { + assert_transform_deterministic_for_bytecode( + "escrow runtime", + ESCROW_CONTRACT_RUNTIME_BYTECODE, + "ClusterShuffle", + || Box::new(ClusterShuffle::new()), + ) + .await; + assert_transform_deterministic_for_bytecode( + "escrow deployment", + ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, + "ClusterShuffle", + || Box::new(ClusterShuffle::new()), + ) + .await; +} diff --git a/tests/src/transforms/cluster_shuffle.rs b/tests/src/transforms/cluster_shuffle.rs new file mode 100644 index 0000000..382443d --- /dev/null +++ b/tests/src/transforms/cluster_shuffle.rs @@ -0,0 +1,239 @@ +//! Behavioural tests for `ClusterShuffle`. +//! +//! This module focuses on what the transform actually *does*: +//! +//! * `cluster_shuffle_preserves_runtime_entry` — pins down the +//! entry-cluster invariant (the block with the smallest pre-shuffle +//! `start_pc` must stay at the smallest post-shuffle `start_pc`). +//! * `cluster_shuffle_relocates_clusters` — demonstrates and asserts +//! that the transform actually moves clusters. Prints a before/after +//! layout to stdout (`cargo nextest run cluster_shuffle_relocates_clusters +//! --nocapture`) so the effect is visible without reading bytecode. + +use crate::e2e::ESCROW_CONTRACT_RUNTIME_BYTECODE; +use azoth_core::cfg_ir::{Block, BlockControl, CfgIrBundle}; +use azoth_core::process_bytecode_to_cfg; +use azoth_core::seed::Seed; +use azoth_transform::cluster_shuffle::ClusterShuffle; +use azoth_transform::Transform; +use petgraph::graph::NodeIndex; + +const FIXED_SEED: &str = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn compute_clusters(cfg: &CfgIrBundle) -> Vec> { + let runtime_bounds = cfg.runtime_bounds; + let in_runtime = |start_pc: usize| match runtime_bounds { + Some((s, e)) => start_pc >= s && start_pc < e, + None => true, + }; + let mut sorted: Vec<(usize, NodeIndex)> = cfg + .cfg + .node_indices() + .filter_map(|n| match &cfg.cfg[n] { + Block::Body(body) if in_runtime(body.start_pc) => Some((body.start_pc, n)), + _ => None, + }) + .collect(); + sorted.sort_by_key(|(pc, _)| *pc); + let mut clusters: Vec> = sorted.iter().map(|(_, n)| vec![*n]).collect(); + let mut i = 0; + while i + 1 < clusters.len() { + let tail = *clusters[i].last().unwrap(); + let needs_next = matches!( + &cfg.cfg[tail], + Block::Body(body) + if matches!( + body.control, + BlockControl::Fallthrough | BlockControl::Branch { .. } + ) + ); + if needs_next { + let next = clusters.remove(i + 1); + clusters[i].extend(next); + } else { + i += 1; + } + } + clusters +} + +fn describe_cluster(cfg: &CfgIrBundle, cluster: &[NodeIndex]) -> String { + let first_pc = match &cfg.cfg[cluster[0]] { + Block::Body(body) => body.start_pc, + _ => 0, + }; + let last = cluster.last().unwrap(); + let tail_control = match &cfg.cfg[*last] { + Block::Body(body) => format!("{:?}", body.control), + _ => "?".into(), + }; + format!( + "pc=0x{:04x} len={:<2} tail={}", + first_pc, + cluster.len(), + tail_control.chars().take(24).collect::(), + ) +} + +async fn load_escrow_runtime_cfg_without_dispatcher() -> CfgIrBundle { + let (mut cfg, _, _, _) = process_bytecode_to_cfg( + ESCROW_CONTRACT_RUNTIME_BYTECODE, + false, + ESCROW_CONTRACT_RUNTIME_BYTECODE, + false, + ) + .await + .unwrap(); + // Simulate a dispatcher-free contract: drop any pinning signals so + // the entry-pinning in ClusterShuffle is exercised in isolation. + cfg.dispatcher_blocks.clear(); + cfg.dispatcher_info = None; + cfg +} + +/// Runs `ClusterShuffle` on a CFG with no `dispatcher_blocks` (simulating +/// a dispatcher-free contract) and verifies the block with the smallest +/// pre-shuffle `start_pc` also carries the smallest post-shuffle +/// `start_pc`. Before the entry-pinning fix, the only pinning came from +/// dispatcher_blocks, so a dispatcher-free contract could have its entry +/// cluster shuffled off slot 0, silently redirecting the deployed +/// runtime's entrypoint. +#[tokio::test] +async fn cluster_shuffle_preserves_runtime_entry() { + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let mut cfg = load_escrow_runtime_cfg_without_dispatcher().await; + + let original_entry_node = cfg + .cfg + .node_indices() + .filter_map(|n| match &cfg.cfg[n] { + Block::Body(body) => Some((body.start_pc, n)), + _ => None, + }) + .min_by_key(|(pc, _)| *pc) + .map(|(_, node)| node) + .expect("runtime CFG has at least one body block"); + + let mut rng = seed.create_deterministic_rng(); + let changed = ClusterShuffle::new().apply(&mut cfg, &mut rng).unwrap(); + assert!( + changed, + "escrow runtime has enough free clusters that ClusterShuffle should always change something" + ); + + let post_shuffle_entry = cfg + .cfg + .node_indices() + .filter_map(|n| match &cfg.cfg[n] { + Block::Body(body) => Some((body.start_pc, n)), + _ => None, + }) + .min_by_key(|(pc, _)| *pc) + .map(|(_, node)| node) + .unwrap(); + + assert_eq!( + post_shuffle_entry, original_entry_node, + "ClusterShuffle must pin the runtime entry block at the smallest \ + post-shuffle start_pc even when no dispatcher_blocks are present" + ); +} + +/// Demonstrable cluster relocation. Prints the cluster layout before and +/// after `ClusterShuffle` on the escrow runtime (dispatcher-free) and +/// asserts that at least one cluster changed slots. Run with +/// `cargo nextest run cluster_shuffle_relocates_clusters --nocapture` +/// to see the pre/post layout. +/// +/// This test bridges the gap that the e2e `collect_proof` probe leaves: +/// that test proves the obfuscated bytecode differs from a +/// dispatcher-only baseline, but doesn't pin down *what* changed at the +/// CFG level. Here we look at the cluster ordering directly. +#[tokio::test] +async fn cluster_shuffle_relocates_clusters() { + let seed = Seed::from_hex(FIXED_SEED).unwrap(); + let mut cfg = load_escrow_runtime_cfg_without_dispatcher().await; + + let clusters_before = compute_clusters(&cfg); + // Each cluster is identified by its lead NodeIndex; slot index + // before the shuffle is cluster_id's position in start_pc order. + let id_by_lead: std::collections::HashMap = clusters_before + .iter() + .enumerate() + .map(|(slot, cluster)| (cluster[0], slot)) + .collect(); + + println!("=== ClusterShuffle demo on escrow runtime (dispatcher-free) ==="); + println!( + "{} runtime body blocks organised into {} clusters before shuffle:", + clusters_before.iter().map(Vec::len).sum::(), + clusters_before.len() + ); + for (slot, cluster) in clusters_before.iter().enumerate().take(10) { + println!(" [{:>2}] {}", slot, describe_cluster(&cfg, cluster)); + } + if clusters_before.len() > 10 { + println!(" ... {} more clusters", clusters_before.len() - 10); + } + + let mut rng = seed.create_deterministic_rng(); + let changed = ClusterShuffle::new().apply(&mut cfg, &mut rng).unwrap(); + assert!(changed); + + // Re-cluster in post-shuffle PC order. Cluster identities (lead + // NodeIndex) are preserved; slot positions may have moved. + let clusters_after = compute_clusters(&cfg); + + println!(); + println!("After shuffle (same cluster leads, new slots):"); + let mut moved_slots = 0usize; + for (new_slot, cluster) in clusters_after.iter().enumerate().take(10) { + let old_slot = id_by_lead + .get(&cluster[0]) + .copied() + .expect("cluster lead preserved across shuffle"); + let delta = if old_slot == new_slot { + " ".to_string() + } else { + moved_slots += 1; + format!("({:+})", new_slot as isize - old_slot as isize) + }; + println!( + " [{:>2} was {:>2}] {} {}", + new_slot, + old_slot, + describe_cluster(&cfg, cluster), + delta + ); + } + if clusters_after.len() > 10 { + // Count remaining moves without printing. + for (new_slot, cluster) in clusters_after.iter().enumerate().skip(10) { + let old_slot = id_by_lead.get(&cluster[0]).copied().unwrap(); + if old_slot != new_slot { + moved_slots += 1; + } + } + println!(" ... {} more clusters", clusters_after.len() - 10); + } + println!(); + println!( + "{} of {} clusters changed slots; cluster 0 (runtime entry) pinned at slot 0.", + moved_slots, + clusters_after.len() + ); + + // Entry cluster must stay at slot 0. + assert_eq!( + clusters_before[0][0], clusters_after[0][0], + "entry cluster moved off slot 0" + ); + + // At least one non-entry cluster must have relocated. Under the + // fixed seed the escrow runtime has dozens of free clusters, so + // this is overwhelmingly the common case. + assert!( + moved_slots > 0, + "ClusterShuffle didn't move any cluster — shuffle was a silent no-op" + ); +} diff --git a/tests/src/transforms/determinism.rs b/tests/src/transforms/determinism.rs deleted file mode 100644 index dd14a53..0000000 --- a/tests/src/transforms/determinism.rs +++ /dev/null @@ -1,132 +0,0 @@ -use crate::e2e::{ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, ESCROW_CONTRACT_RUNTIME_BYTECODE}; -use azoth_core::process_bytecode_to_cfg; -use azoth_core::seed::Seed; -use azoth_transform::arithmetic_chain::ArithmeticChain; -use azoth_transform::push_split::PushSplit; -use azoth_transform::slot_shuffle::SlotShuffle; -use azoth_transform::string_obfuscate::StringObfuscate; -use azoth_transform::Transform; - -const FIXED_SEED: &str = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - -fn cfg_instruction_snapshot( - cfg_ir: &azoth_core::cfg_ir::CfgIrBundle, -) -> Vec<(usize, String, Option)> { - let mut snapshot = Vec::new(); - - for node in cfg_ir.cfg.node_indices() { - if let azoth_core::cfg_ir::Block::Body(body) = &cfg_ir.cfg[node] { - for instr in &body.instructions { - snapshot.push((instr.pc, format!("{:?}", instr.op), instr.imm.clone())); - } - } - } - - snapshot -} - -async fn assert_transform_deterministic_for_bytecode( - bytecode_name: &str, - bytecode: &str, - transform_name: &str, - make_transform: fn() -> Box, -) { - let seed = Seed::from_hex(FIXED_SEED).unwrap(); - let (mut cfg_a, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) - .await - .unwrap(); - let (mut cfg_b, _, _, _) = process_bytecode_to_cfg(bytecode, false, bytecode, false) - .await - .unwrap(); - - let mut rng_a = seed.create_deterministic_rng(); - let mut rng_b = seed.create_deterministic_rng(); - let transform_a = make_transform(); - let transform_b = make_transform(); - - let changed_a = transform_a.apply(&mut cfg_a, &mut rng_a).unwrap(); - let changed_b = transform_b.apply(&mut cfg_b, &mut rng_b).unwrap(); - let snapshot_a = cfg_instruction_snapshot(&cfg_a); - let snapshot_b = cfg_instruction_snapshot(&cfg_b); - - assert_eq!( - changed_a, changed_b, - "{transform_name} should report the same changed flag for the same seed on {bytecode_name}" - ); - assert_eq!( - snapshot_a, snapshot_b, - "{transform_name} should produce an identical instruction stream for the same seed on {bytecode_name}" - ); -} - -#[tokio::test] -async fn arithmetic_chain_is_deterministic_for_same_seed() { - assert_transform_deterministic_for_bytecode( - "escrow runtime", - ESCROW_CONTRACT_RUNTIME_BYTECODE, - "ArithmeticChain", - || Box::new(ArithmeticChain::new()), - ) - .await; - assert_transform_deterministic_for_bytecode( - "escrow deployment", - ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, - "ArithmeticChain", - || Box::new(ArithmeticChain::new()), - ) - .await; -} - -#[tokio::test] -async fn push_split_is_deterministic_for_same_seed() { - assert_transform_deterministic_for_bytecode( - "escrow runtime", - ESCROW_CONTRACT_RUNTIME_BYTECODE, - "PushSplit", - || Box::new(PushSplit::new()), - ) - .await; - assert_transform_deterministic_for_bytecode( - "escrow deployment", - ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, - "PushSplit", - || Box::new(PushSplit::new()), - ) - .await; -} - -#[tokio::test] -async fn slot_shuffle_is_deterministic_for_same_seed() { - assert_transform_deterministic_for_bytecode( - "escrow runtime", - ESCROW_CONTRACT_RUNTIME_BYTECODE, - "SlotShuffle", - || Box::new(SlotShuffle::new()), - ) - .await; - assert_transform_deterministic_for_bytecode( - "escrow deployment", - ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, - "SlotShuffle", - || Box::new(SlotShuffle::new()), - ) - .await; -} - -#[tokio::test] -async fn string_obfuscate_is_deterministic_for_same_seed() { - assert_transform_deterministic_for_bytecode( - "escrow runtime", - ESCROW_CONTRACT_RUNTIME_BYTECODE, - "StringObfuscate", - || Box::new(StringObfuscate::new()), - ) - .await; - assert_transform_deterministic_for_bytecode( - "escrow deployment", - ESCROW_CONTRACT_DEPLOYMENT_BYTECODE, - "StringObfuscate", - || Box::new(StringObfuscate::new()), - ) - .await; -} diff --git a/tests/src/transforms/mod.rs b/tests/src/transforms/mod.rs index 2c7157d..e5f31e1 100644 --- a/tests/src/transforms/mod.rs +++ b/tests/src/transforms/mod.rs @@ -1,5 +1,5 @@ #[cfg(test)] -mod determinism; +mod cluster_shuffle; #[cfg(test)] mod function_dispatcher; #[cfg(test)]