From c6d3daad312ca7aa32477c3bf12948ba002287cc Mon Sep 17 00:00:00 2001 From: g4titanx Date: Thu, 28 May 2026 12:04:41 +0100 Subject: [PATCH 1/2] feat(core): add asm-item IR mirroring solc legacyAssembly --- Cargo.lock | 3 + crates/core/Cargo.toml | 5 + crates/core/src/asm_ir/cfg.rs | 390 +++++++++++++++++++++++++++++++++ crates/core/src/asm_ir/item.rs | 245 +++++++++++++++++++++ crates/core/src/asm_ir/mod.rs | 112 ++++++++++ crates/core/src/lib.rs | 2 + 6 files changed, 757 insertions(+) create mode 100644 crates/core/src/asm_ir/cfg.rs create mode 100644 crates/core/src/asm_ir/item.rs create mode 100644 crates/core/src/asm_ir/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 9990be00..713b6d26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1335,11 +1335,14 @@ dependencies = [ "eot", "heimdall-disassembler", "hex", + "indexmap 2.11.4", "petgraph", "rand 0.9.2", "revm", "serde", + "serde_json", "sha3", + "tempfile", "thiserror 2.0.16", "tiny-keccak", "tokio", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 356d7c2c..980972a6 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -6,13 +6,18 @@ edition = "2024" [dependencies] eot.workspace = true hex.workspace = true +indexmap.workspace = true heimdall.workspace = true petgraph.workspace = true rand.workspace = true serde.workspace = true +serde_json.workspace = true sha3.workspace = true thiserror.workspace = true tiny-keccak.workspace = true tracing.workspace = true tokio.workspace = true revm.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/core/src/asm_ir/cfg.rs b/crates/core/src/asm_ir/cfg.rs new file mode 100644 index 00000000..746b93c2 --- /dev/null +++ b/crates/core/src/asm_ir/cfg.rs @@ -0,0 +1,390 @@ +//! Control-flow graph over a list of `AsmItem`s. +//! +//! Unlike the existing `cfg_ir` module (which builds blocks from decoded +//! instructions and tracks them by program counter), this CFG keys blocks by +//! their **tag id** — the symbolic label that solc emits in `legacyAssembly`. +//! Items are the source of truth; the CFG is a view over them. +//! +//! Round-trip property: `flatten(build_asm_cfg(items)) == items` for any +//! valid solc-emitted item list. Mutating the CFG's blocks and flattening +//! produces a new item list that solc can re-assemble. + +use super::item::AsmItem; +use std::collections::HashMap; + +/// A basic-block identifier — the `value` of a solc `tag` item. +pub type AsmBlockId = u64; + +/// A basic block in the asm-item CFG. +/// +/// Items are stored in source order, including the leading `tag` and +/// `JUMPDEST` items for non-entry blocks. The entry block (items before the +/// first `tag` in the list) has `id = None`. +#[derive(Debug, Clone)] +pub struct AsmBlock { + /// Tag id, or `None` for the entry block (items before the first `tag`). + pub id: Option, + /// Items in this block, in source order. + pub items: Vec, +} + +impl AsmBlock { + /// Returns the block's terminator item (the first control-transfer or + /// halt opcode), if any. Blocks without a terminator implicitly + /// fall through to the next block. + pub fn terminator(&self) -> Option<&AsmItem> { + self.items.iter().find(|i| i.is_terminator()) + } + + /// Returns the index within `items` of the terminator, if any. + pub fn terminator_index(&self) -> Option { + self.items.iter().position(|i| i.is_terminator()) + } + + /// Returns true if this block ends in an unconditional halt (no outgoing + /// edges, not even fall-through). + pub fn is_halt(&self) -> bool { + matches!( + self.terminator().map(|i| i.name.as_str()), + Some("STOP" | "RETURN" | "REVERT" | "INVALID" | "SELFDESTRUCT" | "RETF" | "RETURNCONTRACT"), + ) + } +} + +/// Edge kind in the asm-item CFG. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AsmEdgeKind { + /// Unconditional jump (`JUMP`). + Jump, + /// Conditional branch (`JUMPI`) — the taken edge. + BranchTaken, + /// Fall-through into the lexically next block (after `JUMPI` or after + /// a block without a terminator). + Fallthrough, +} + +/// Where an edge points to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AsmEdgeTarget { + /// Resolved to a block via `PUSH [tag]` immediately before the jump, or + /// by fall-through to the lexically next block. + Block(AsmBlockId), + /// Unresolved: the jump's target was computed (no static `PUSH [tag]`), + /// or fall-through reached the end of the item list with no next block. + Unresolved, +} + +/// A directed edge from one block to another. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AsmEdge { + /// Edge classification. + pub kind: AsmEdgeKind, + /// Target block (resolved or unresolved). + pub target: AsmEdgeTarget, +} + +/// CFG over a list of `AsmItem`s. +#[derive(Debug, Clone)] +pub struct AsmCfg { + /// Blocks in source order. The entry block (if any) is `blocks[0]`. + pub blocks: Vec, + /// Index into `blocks` keyed by tag id. The entry block is not keyed. + pub block_by_id: HashMap, +} + +impl AsmCfg { + /// Total number of basic blocks (including the entry block). + pub fn block_count(&self) -> usize { + self.blocks.len() + } + + /// Lookup a block by tag id. + pub fn block(&self, id: AsmBlockId) -> Option<&AsmBlock> { + self.block_by_id.get(&id).map(|&i| &self.blocks[i]) + } + + /// Mutable lookup by tag id. + pub fn block_mut(&mut self, id: AsmBlockId) -> Option<&mut AsmBlock> { + let idx = *self.block_by_id.get(&id)?; + Some(&mut self.blocks[idx]) + } + + /// Outgoing edges from a block, computed from its items. + /// + /// `next_block_id` is the tag id of the lexically next block (used to + /// resolve fall-through targets). Pass `None` if this block is last. + pub fn outgoing_edges( + &self, + block_idx: usize, + ) -> Vec { + let block = &self.blocks[block_idx]; + let next_block_id: Option = self + .blocks + .get(block_idx + 1) + .and_then(|b| b.id); + + let Some(term_idx) = block.terminator_index() else { + // No terminator → implicit fall-through. + return match next_block_id { + Some(id) => vec![AsmEdge { + kind: AsmEdgeKind::Fallthrough, + target: AsmEdgeTarget::Block(id), + }], + None => vec![], + }; + }; + + let term = &block.items[term_idx]; + let resolved_target = preceding_push_tag(&block.items, term_idx); + + match term.name.as_str() { + "JUMP" => vec![AsmEdge { + kind: AsmEdgeKind::Jump, + target: resolved_target + .map(AsmEdgeTarget::Block) + .unwrap_or(AsmEdgeTarget::Unresolved), + }], + "JUMPI" => { + let mut edges = vec![AsmEdge { + kind: AsmEdgeKind::BranchTaken, + target: resolved_target + .map(AsmEdgeTarget::Block) + .unwrap_or(AsmEdgeTarget::Unresolved), + }]; + // Fall-through edge. + if let Some(id) = next_block_id { + edges.push(AsmEdge { + kind: AsmEdgeKind::Fallthrough, + target: AsmEdgeTarget::Block(id), + }); + } else { + edges.push(AsmEdge { + kind: AsmEdgeKind::Fallthrough, + target: AsmEdgeTarget::Unresolved, + }); + } + edges + } + // Halts have no outgoing edges. + _ => vec![], + } + } + + /// Flatten the CFG back into a single item list, preserving source order. + /// Round-trip property: `flatten(build_asm_cfg(items)) == items` for any + /// valid item list. + pub fn flatten(&self) -> Vec { + self.blocks + .iter() + .flat_map(|b| b.items.iter().cloned()) + .collect() + } +} + +/// Find the most recent `PUSH [tag]` immediately preceding the terminator at +/// `term_idx`, ignoring intervening source-map noise. Returns the resolved +/// tag id or `None` if the previous item isn't a `PUSH [tag]`. +fn preceding_push_tag(items: &[AsmItem], term_idx: usize) -> Option { + if term_idx == 0 { + return None; + } + let prev = &items[term_idx - 1]; + if prev.is_push_tag() { + prev.tag_id() + } else { + None + } +} + +/// Build a CFG over a list of asm items. +/// +/// Splits the list into basic blocks: items before the first `tag` form the +/// (id-less) entry block; each subsequent block runs from a `tag` to just +/// before the next `tag`. +/// +/// Edges are not stored on blocks — they're derived on demand from items via +/// [`AsmCfg::outgoing_edges`], because items are the source of truth and +/// transforms manipulate items directly. +pub fn build_asm_cfg(items: &[AsmItem]) -> AsmCfg { + let mut blocks: Vec = Vec::new(); + let mut block_by_id: HashMap = HashMap::new(); + + let mut current_id: Option = None; + let mut current_items: Vec = Vec::new(); + + for item in items { + if item.is_tag() { + // Flush the current block (if non-empty) before starting a new one. + if !current_items.is_empty() || current_id.is_some() { + let idx = blocks.len(); + if let Some(id) = current_id { + block_by_id.insert(id, idx); + } + blocks.push(AsmBlock { + id: current_id, + items: std::mem::take(&mut current_items), + }); + } + current_id = item.tag_id(); + current_items.push(item.clone()); + } else { + current_items.push(item.clone()); + } + } + + // Flush the final block. + if !current_items.is_empty() || current_id.is_some() { + let idx = blocks.len(); + if let Some(id) = current_id { + block_by_id.insert(id, idx); + } + blocks.push(AsmBlock { + id: current_id, + items: current_items, + }); + } + + AsmCfg { + blocks, + block_by_id, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asm_ir::item::AsmItem; + + fn it(name: &str) -> AsmItem { + AsmItem::op(name) + } + fn it_v(name: &str, value: &str) -> AsmItem { + AsmItem::op_with_value(name, value) + } + + #[test] + fn empty_input_produces_empty_cfg() { + let cfg = build_asm_cfg(&[]); + assert_eq!(cfg.block_count(), 0); + assert_eq!(cfg.flatten().len(), 0); + } + + #[test] + fn entry_block_only() { + // No tags → entire item list is the entry block. + let items = vec![it("PUSH"), it("MSTORE"), it("STOP")]; + let cfg = build_asm_cfg(&items); + assert_eq!(cfg.block_count(), 1); + assert_eq!(cfg.blocks[0].id, None); + assert_eq!(cfg.blocks[0].items.len(), 3); + assert!(cfg.block_by_id.is_empty()); + // Halt → no outgoing edges. + assert!(cfg.outgoing_edges(0).is_empty()); + } + + #[test] + fn splits_on_tags() { + let items = vec![ + it("PUSH"), // entry block: prologue + it_v("tag", "1"), // start of block 1 + it("JUMPDEST"), + it("ADD"), + it("STOP"), + it_v("tag", "2"), // start of block 2 + it("JUMPDEST"), + it("RETURN"), + ]; + let cfg = build_asm_cfg(&items); + assert_eq!(cfg.block_count(), 3); + assert_eq!(cfg.blocks[0].id, None); // entry + assert_eq!(cfg.blocks[0].items.len(), 1); + assert_eq!(cfg.blocks[1].id, Some(1)); + assert_eq!(cfg.blocks[1].items.len(), 4); + assert_eq!(cfg.blocks[2].id, Some(2)); + assert_eq!(cfg.blocks[2].items.len(), 3); + assert_eq!(cfg.block_by_id.len(), 2); + assert_eq!(cfg.block(1).unwrap().items.len(), 4); + assert_eq!(cfg.block(2).unwrap().items.len(), 3); + } + + #[test] + fn jump_edge_resolves_via_preceding_push_tag() { + // tag 1: PUSH [tag] 2 ; JUMP → edge to block 2 + let items = vec![ + it_v("tag", "1"), + it("JUMPDEST"), + it_v("PUSH [tag]", "2"), + it("JUMP"), + it_v("tag", "2"), + it("JUMPDEST"), + it("STOP"), + ]; + let cfg = build_asm_cfg(&items); + let edges = cfg.outgoing_edges(0); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].kind, AsmEdgeKind::Jump); + assert_eq!(edges[0].target, AsmEdgeTarget::Block(2)); + } + + #[test] + fn jumpi_produces_taken_and_fallthrough_edges() { + // tag 1: ... PUSH [tag] 3 ; JUMPI then tag 2 (fall-through), then tag 3 + let items = vec![ + it_v("tag", "1"), + it("JUMPDEST"), + it_v("PUSH [tag]", "3"), + it("JUMPI"), + it_v("tag", "2"), + it("JUMPDEST"), + it("STOP"), + it_v("tag", "3"), + it("JUMPDEST"), + it("RETURN"), + ]; + let cfg = build_asm_cfg(&items); + let edges = cfg.outgoing_edges(0); + assert_eq!(edges.len(), 2); + assert_eq!(edges[0].kind, AsmEdgeKind::BranchTaken); + assert_eq!(edges[0].target, AsmEdgeTarget::Block(3)); + assert_eq!(edges[1].kind, AsmEdgeKind::Fallthrough); + assert_eq!(edges[1].target, AsmEdgeTarget::Block(2)); + } + + #[test] + fn jump_without_preceding_push_tag_is_unresolved() { + // Computed jump: target on the stack but not from a static PUSH [tag]. + let items = vec![ + it_v("tag", "1"), + it("JUMPDEST"), + it("CALLDATALOAD"), + it("JUMP"), + ]; + let cfg = build_asm_cfg(&items); + let edges = cfg.outgoing_edges(0); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].kind, AsmEdgeKind::Jump); + assert_eq!(edges[0].target, AsmEdgeTarget::Unresolved); + } + + #[test] + fn flatten_round_trips() { + let items = vec![ + it("PUSH"), + it_v("tag", "1"), + it("JUMPDEST"), + it("ADD"), + it_v("PUSH [tag]", "2"), + it("JUMP"), + it_v("tag", "2"), + it("JUMPDEST"), + it("STOP"), + ]; + let cfg = build_asm_cfg(&items); + let flat = cfg.flatten(); + assert_eq!(flat.len(), items.len()); + for (a, b) in flat.iter().zip(items.iter()) { + assert_eq!(a.name, b.name); + assert_eq!(a.value, b.value); + } + } +} diff --git a/crates/core/src/asm_ir/item.rs b/crates/core/src/asm_ir/item.rs new file mode 100644 index 00000000..d7ba42f0 --- /dev/null +++ b/crates/core/src/asm_ir/item.rs @@ -0,0 +1,245 @@ +//! Typed Rust mirror of solc's `legacyAssembly` JSON schema. +//! +//! See `libevmasm/Assembly.cpp::validateSingleInstruction` in the solc source +//! for the canonical schema. The shape: +//! +//! ```jsonc +//! { +//! ".code": [ AsmItem, ... ], // outer code = init bytecode +//! ".data": { "0": SubAssembly, ... }, // sub-assemblies (runtime is "0") +//! "sourceList": [ "Foo.sol", ... ] +//! } +//! ``` +//! +//! Each item has a `name` (opcode mnemonic or special form like `PUSH [tag]`, +//! `tag`, `PUSHIMMUTABLE`, `VERBATIM`) and an optional `value` (literal hex, +//! tag id, immutable id). Source-map fields (`begin`, `end`, `source`, +//! `jumpType`, `modifierDepth`) are preserved for round-trip fidelity. + +use crate::Opcode; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +/// A single solc legacy-assembly item. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AsmItem { + /// Opcode mnemonic or special form (`PUSH`, `PUSH [tag]`, `tag`, + /// `JUMPDEST`, `PUSHIMMUTABLE`, `ASSIGNIMMUTABLE`, `PUSH [$]`, + /// `PUSH #[$]`, `PUSHSIZE`, `PUSHLIB`, `VERBATIM`, `RJUMP`, etc.). + pub name: String, + /// Optional value: hex literal for `PUSH`, decimal id for `tag` / `PUSH + /// [tag]`, ast id for `PUSHIMMUTABLE` / `ASSIGNIMMUTABLE`, sub-assembly + /// key for `PUSH [$]` / `PUSH #[$]`, hash for `PUSHLIB`, or hex bytes for + /// `VERBATIM`. + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + /// Source range start byte (source-map field). + #[serde(skip_serializing_if = "Option::is_none")] + pub begin: Option, + /// Source range end byte (source-map field). + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, + /// Source file index (into `LegacyAssembly::source_list`). + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + /// Jump kind: `[in]`, `[out]`, or absent. + #[serde(rename = "jumpType", skip_serializing_if = "Option::is_none")] + pub jump_type: Option, + /// Modifier nesting depth. + #[serde(rename = "modifierDepth", skip_serializing_if = "Option::is_none")] + pub modifier_depth: Option, +} + +impl AsmItem { + /// Construct a name-only item (no value, no source-map fields). Useful for + /// transforms that emit synthetic items. + pub fn op(name: impl Into) -> Self { + Self { + name: name.into(), + value: None, + begin: None, + end: None, + source: None, + jump_type: None, + modifier_depth: None, + } + } + + /// Construct an item with a name and a value (e.g. `PUSH` with a hex + /// literal, or `tag` with a decimal id). + pub fn op_with_value(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + value: Some(value.into()), + begin: None, + end: None, + source: None, + jump_type: None, + modifier_depth: None, + } + } + + /// Returns true if this item is a `tag` declaration. The accompanying + /// `JUMPDEST` byte is emitted by a separate adjacent item. + pub fn is_tag(&self) -> bool { + self.name == "tag" + } + + /// Returns true if this item names a jump target via `PUSH [tag]`. + pub fn is_push_tag(&self) -> bool { + self.name == "PUSH [tag]" + } + + /// Parse `self.name` into a typed `eot::UnifiedOpcode`, when the item + /// actually represents a real EVM opcode. Returns `None` for the + /// asm-item-only special kinds (`tag`, `PUSH [tag]`, `PUSH [$]`, + /// `PUSH #[$]`, `PUSHIMMUTABLE`, `ASSIGNIMMUTABLE`, `PUSHLIB`, + /// `PUSHSIZE`, `VERBATIM`, etc.) since those aren't EVM opcodes at all + /// — they only have a name because that's how solc encodes them in + /// legacy assembly json. + pub fn opcode(&self) -> Option { + Opcode::from_str(&self.name).ok() + } + + /// Returns true if this item terminates a basic block — either a halt + /// (`STOP`/`RETURN`/`REVERT`/`INVALID`/`SELFDESTRUCT`) or a control + /// transfer (`JUMP`/`JUMPI`/EOF jumps). Implemented via `eot::Opcode`'s + /// own classification rather than a hand-maintained string list. + pub fn is_terminator(&self) -> bool { + let Some(op) = self.opcode() else { + return false; + }; + // `as_opcode().terminates()` covers halts (STOP/RETURN/REVERT/INVALID + // /SELFDESTRUCT + EOF RETF/RETURNCONTRACT). `is_control_flow()` also + // covers JUMP, JUMPI, JUMPDEST, plus EOF jumps + // RJUMP/RJUMPI/RJUMPV/CALLF/JUMPF. We don't want JUMPDEST to count + // as a terminator (it's a label), so subtract it explicitly. + op.as_opcode().terminates() || (op.is_control_flow() && op != Opcode::JUMPDEST) + } + + /// For `tag` and `PUSH [tag]` items, parse the decimal tag id from + /// `value`. Returns `None` for items without a tag-id value. + pub fn tag_id(&self) -> Option { + if !self.is_tag() && !self.is_push_tag() { + return None; + } + self.value.as_deref().and_then(|s| s.parse().ok()) + } +} + +/// A value in a `.data` map: either a nested sub-assembly (the runtime is +/// conventionally `data["0"]`) or a raw hex blob. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum DataValue { + /// A nested sub-assembly with its own code, data, and auxdata. + SubAssembly(SubAssembly), + /// A raw hex data blob (no executable code). + Raw(String), +} + +/// A sub-assembly nested under `.data`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubAssembly { + /// Code items for this sub-assembly. + #[serde(rename = ".code")] + pub code: Vec, + /// Recursive nested data map. + #[serde(rename = ".data", default, skip_serializing_if = "IndexMap::is_empty")] + pub data: IndexMap, + /// Auxdata trailer (typically the CBOR metadata blob for the runtime). + #[serde(rename = ".auxdata", skip_serializing_if = "Option::is_none")] + pub auxdata: Option, +} + +/// The top-level legacy assembly emitted by solc. +/// +/// `code` is the init bytecode (the constructor). `data["0"]` is conventionally +/// the runtime sub-assembly (what gets returned and lives on chain). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LegacyAssembly { + /// Init code items. + #[serde(rename = ".code")] + pub code: Vec, + /// Sub-assemblies and data blobs. + #[serde(rename = ".data", default, skip_serializing_if = "IndexMap::is_empty")] + pub data: IndexMap, + /// Source file paths referenced by item `source` indices. + #[serde(rename = "sourceList", default, skip_serializing_if = "Option::is_none")] + pub source_list: Option>, +} + +impl LegacyAssembly { + /// Mutable handle to the runtime sub-assembly (conventionally `data["0"]`). + pub fn runtime_mut(&mut self) -> Option<&mut SubAssembly> { + match self.data.get_mut("0")? { + DataValue::SubAssembly(sub) => Some(sub), + DataValue::Raw(_) => None, + } + } + + /// Immutable handle to the runtime sub-assembly. + pub fn runtime(&self) -> Option<&SubAssembly> { + match self.data.get("0")? { + DataValue::SubAssembly(sub) => Some(sub), + DataValue::Raw(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_classification() { + assert!(AsmItem::op("STOP").is_terminator()); + assert!(AsmItem::op("JUMP").is_terminator()); + assert!(!AsmItem::op("ADD").is_terminator()); + + assert!(AsmItem::op_with_value("tag", "5").is_tag()); + assert!(!AsmItem::op_with_value("PUSH", "deadbeef").is_tag()); + + assert!(AsmItem::op_with_value("PUSH [tag]", "5").is_push_tag()); + assert!(!AsmItem::op_with_value("PUSH", "deadbeef").is_push_tag()); + + assert_eq!( + AsmItem::op_with_value("tag", "42").tag_id(), + Some(42) + ); + assert_eq!( + AsmItem::op_with_value("PUSH [tag]", "7").tag_id(), + Some(7) + ); + assert_eq!(AsmItem::op_with_value("PUSH", "deadbeef").tag_id(), None); + } + + #[test] + fn legacy_assembly_round_trips() { + let json = r#"{ + ".code": [ + {"name":"PUSH","value":"80"}, + {"name":"tag","value":"1"}, + {"name":"JUMPDEST"} + ], + ".data": { + "0": { + ".code": [{"name":"STOP"}], + ".auxdata": "deadbeef" + } + }, + "sourceList": ["foo.sol"] + }"#; + let asm: LegacyAssembly = serde_json::from_str(json).expect("parse"); + assert_eq!(asm.code.len(), 3); + let runtime = asm.runtime().expect("runtime"); + assert_eq!(runtime.code.len(), 1); + assert_eq!(runtime.auxdata.as_deref(), Some("deadbeef")); + + let back = serde_json::to_string(&asm).expect("serialize"); + let asm2: LegacyAssembly = serde_json::from_str(&back).expect("reparse"); + assert_eq!(asm2.code.len(), 3); + assert_eq!(asm2.runtime().unwrap().code.len(), 1); + } +} diff --git a/crates/core/src/asm_ir/mod.rs b/crates/core/src/asm_ir/mod.rs new file mode 100644 index 00000000..3efbe24b --- /dev/null +++ b/crates/core/src/asm_ir/mod.rs @@ -0,0 +1,112 @@ +//! The labelled-assembly IR: typed mirror of solc's `legacyAssembly` schema, +//! plus a CFG built over it where blocks are keyed by tag id rather than pc. +//! +//! Today, azoth's `cfg_ir` operates on `Vec` — flat opcodes with +//! resolved program counters. Every transform that resizes code invalidates +//! those pcs, so the obfuscator pipeline runs `reindex_pcs()` and a long +//! finalisation dance (`patch_jump_immediates`, dispatcher reapply, decoy / +//! controller / orphan-push remap) after every change. +//! +//! This module is the alternative: items reference jumps by symbolic `tag` +//! ids (`PUSH [tag]` items name their target block), pushes don't carry an +//! explicit width (solc picks the smallest one that fits), and the CFG is a +//! view over the item list with blocks identified by the tag id at their +//! head. Transforms that operate on items never touch a pc and never need +//! reindexing — solc resolves everything at the bottom when it assembles. + +pub mod cfg; +pub mod item; + +pub use cfg::{AsmBlock, AsmBlockId, AsmCfg, AsmEdge, AsmEdgeKind, AsmEdgeTarget, build_asm_cfg}; +pub use item::{AsmItem, DataValue, LegacyAssembly, SubAssembly}; + +#[cfg(test)] +mod roundtrip_tests { + //! Proof that the IR faithfully mirrors solc's `legacyAssembly`: load the + //! artifact's assembly, serialize it back, hand it to + //! `solc --import-asm-json`, and confirm the bytecode is byte-identical to + //! the artifact's own `bytecode.object`. + + use super::LegacyAssembly; + use std::process::Command; + + const ESCROW_ERC20: &str = + "../../examples/escrow-bytecode/out/EscrowERC20.sol/EscrowERC20.json"; + const ESCROW_NATIVE: &str = + "../../examples/escrow-bytecode/out/EscrowNative.sol/EscrowNative.json"; + + /// Returns true if `solc` on PATH supports `--import-asm-json`. + fn solc_supports_import_asm_json() -> bool { + let Ok(out) = Command::new("solc").arg("--help").output() else { + return false; + }; + String::from_utf8_lossy(&out.stdout).contains("import-asm-json") + || String::from_utf8_lossy(&out.stderr).contains("import-asm-json") + } + + /// Load `legacyAssembly` and `bytecode.object` from a foundry artifact. + fn load(path: &str) -> Option<(LegacyAssembly, String)> { + let raw = std::fs::read_to_string(path).ok()?; + let v: serde_json::Value = serde_json::from_str(&raw).ok()?; + let asm: LegacyAssembly = serde_json::from_value(v.get("legacyAssembly")?.clone()).ok()?; + let bytecode = v + .get("bytecode")? + .get("object")? + .as_str()? + .trim_start_matches("0x") + .to_string(); + Some((asm, bytecode)) + } + + /// Serialize `asm` and assemble it via `solc --import-asm-json`, returning + /// the produced bytecode hex (no `0x` prefix). + fn assemble(asm: &LegacyAssembly) -> Option { + let json = serde_json::to_string(asm).ok()?; + let mut tmp = tempfile::NamedTempFile::new().ok()?; + use std::io::Write as _; + tmp.write_all(json.as_bytes()).ok()?; + tmp.flush().ok()?; + let out = Command::new("solc") + .arg("--import-asm-json") + .arg("--bin") + .arg(tmp.path()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&out.stdout); + stdout + .lines() + .skip_while(|l| !l.starts_with("Binary:")) + .nth(1) + .map(|l| l.trim().to_string()) + } + + fn assert_roundtrip(path: &str) { + if !solc_supports_import_asm_json() { + eprintln!("skipping {path}: solc with --import-asm-json not on PATH"); + return; + } + let Some((asm, expected)) = load(path) else { + eprintln!("skipping {path}: missing legacyAssembly (run forge build with extra_output)"); + return; + }; + let produced = assemble(&asm).expect("solc --import-asm-json should succeed"); + assert_eq!( + produced.to_lowercase(), + expected.to_lowercase(), + "round-trip bytecode mismatch for {path}", + ); + } + + #[test] + fn roundtrip_escrow_erc20_byte_identical() { + assert_roundtrip(ESCROW_ERC20); + } + + #[test] + fn roundtrip_escrow_native_byte_identical() { + assert_roundtrip(ESCROW_NATIVE); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9c9a72b8..e2edc626 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,4 +1,6 @@ +pub mod asm_ir; pub mod cfg_ir; +pub mod compiler; pub mod decoder; pub mod detection; pub mod encoder; From 32a8d42c8cecb7d70e5937cd1126bcf0264779b2 Mon Sep 17 00:00:00 2001 From: g4titanx Date: Thu, 28 May 2026 12:05:49 +0100 Subject: [PATCH 2/2] feat(core): load compiler facts from foundry artifact (CompilerContext) --- crates/core/src/cfg_ir/mod.rs | 6 + crates/core/src/compiler.rs | 333 ++++++++++++++++++++++++ crates/core/src/detection/dispatcher.rs | 136 ++++++++++ crates/core/src/detection/mod.rs | 2 +- crates/verification/src/semantics.rs | 3 + 5 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 crates/core/src/compiler.rs diff --git a/crates/core/src/cfg_ir/mod.rs b/crates/core/src/cfg_ir/mod.rs index 4348761d..8219717f 100644 --- a/crates/core/src/cfg_ir/mod.rs +++ b/crates/core/src/cfg_ir/mod.rs @@ -165,6 +165,11 @@ pub struct CfgIrBundle { /// rewrite every AC-emitted offset PUSH so CODECOPY still points into /// the appended data section. pub ac_runtime_length_estimate: Option, + /// Compiler-emitted facts loaded from a foundry artifact, when available. + /// Carries `methodIdentifiers`, `immutableReferences`, `linkReferences`, + /// and `storageLayout`. Subsystems can prefer this data over heuristic + /// recovery when present (see `detect_function_dispatcher_with_context`). + pub compiler: Option, } impl CfgIrBundle { @@ -1679,6 +1684,7 @@ pub fn build_cfg_ir( dispatcher_blocks: HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + compiler: None, }; let body_blocks = bundle .cfg diff --git a/crates/core/src/compiler.rs b/crates/core/src/compiler.rs new file mode 100644 index 00000000..6c9108de --- /dev/null +++ b/crates/core/src/compiler.rs @@ -0,0 +1,333 @@ +//! Compiler-emitted facts that ride along with `CfgIrBundle`. +//! +//! Today, azoth recovers many of these facts heuristically from raw bytecode: +//! it pattern-matches `PUSH4 EQ JUMPI` chains to find selectors, scans +//! `PUSH; ADD; MSTORE` sequences to find immutable writes, and infers section +//! boundaries from `CODECOPY+RETURN`. When the compiler artifact is available +//! (which is always true for the escrow contracts we obfuscate), all of those +//! facts are right there in the json — no guessing required. +//! +//! This module defines the `CompilerContext` type, a typed bundle of those +//! facts, and a loader from foundry artifact json. Existing code paths can +//! consult `Option<&CompilerContext>` to prefer artifact data when present and +//! fall back to heuristics when absent. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +/// Errors produced when loading a `CompilerContext` from an artifact. +#[derive(Debug, thiserror::Error)] +pub enum CompilerContextError { + /// Failed to read the artifact file. + #[error("artifact io: {0}")] + Io(#[from] std::io::Error), + /// JSON parse error. + #[error("artifact json: {0}")] + Json(#[from] serde_json::Error), + /// A selector hex string was malformed. + #[error("invalid selector hex `{selector}` for `{signature}`: {source}")] + BadSelector { + /// Function signature the bad selector belongs to. + signature: String, + /// The offending hex string. + selector: String, + /// Underlying parse error. + #[source] + source: hex::FromHexError, + }, + /// A bytecode hex string was malformed. + #[error("invalid bytecode hex: {0}")] + BadBytecode(#[source] hex::FromHexError), + /// A required artifact field was missing. + #[error("artifact missing field `{0}`")] + Missing(&'static str), +} + +/// A contiguous byte range within a piece of bytecode. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct ByteRange { + /// Inclusive start byte offset. + pub start: usize, + /// Length in bytes. + pub length: usize, +} + +/// A storage variable as solc lays it out. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageVariable { + /// AST id of the declaration (matches `ast_id` in `immutable_references` + /// for immutables, or the declaration id of a state variable). + #[serde(rename = "astId")] + pub ast_id: i64, + /// Fully qualified contract path. + pub contract: String, + /// Variable name. + pub label: String, + /// Byte offset within the slot (0..32). Non-zero means slot packing. + pub offset: u8, + /// Slot number as a decimal string (slots are uint256). + pub slot: String, + /// Type key, indexed into `StorageLayout::types`. + #[serde(rename = "type")] + pub type_name: String, +} + +/// Type metadata for a storage type referenced by `StorageVariable::type_name`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageType { + /// `inplace`, `mapping`, `dynamic_array`, `bytes`. + pub encoding: String, + /// Source-level type name. + pub label: String, + /// Width in bytes (decimal string). + #[serde(rename = "numberOfBytes")] + pub number_of_bytes: String, +} + +/// Solc storage layout, comprising the variable list and the type table. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageLayout { + /// State variable layout entries. + pub storage: Vec, + /// Type metadata, keyed by the `type` field of `StorageVariable`. + pub types: HashMap, +} + +/// All compiler-emitted facts attached to a contract. +#[derive(Debug, Clone)] +pub struct CompilerContext { + /// Raw deployment bytecode (init + runtime + auxdata). + pub deployment_bytecode: Vec, + /// Raw runtime bytecode (what lives on chain). + pub runtime_bytecode: Vec, + /// Public function signatures and their PUSH4 selectors. + pub method_identifiers: HashMap, + /// Immutable byte ranges in the runtime, keyed by ast id. + pub immutable_references: HashMap>, + /// Library link placeholders in the runtime, keyed by source file then library name. + pub link_references: HashMap>>, + /// Storage layout, when emitted by the compiler (foundry needs `extra_output = ["storageLayout"]`). + pub storage_layout: Option, +} + +impl CompilerContext { + /// Load from a foundry artifact json (typically `out/Foo.sol/Foo.json`). + pub fn load_foundry_artifact(path: impl AsRef) -> Result { + let raw = std::fs::read_to_string(path)?; + let v: serde_json::Value = serde_json::from_str(&raw)?; + Self::from_json(&v) + } + + /// Build a `CompilerContext` from a parsed foundry artifact json value. + pub fn from_json(v: &serde_json::Value) -> Result { + let deployment_bytecode = parse_bytecode_field(v, "bytecode")?; + let runtime_bytecode = parse_bytecode_field(v, "deployedBytecode")?; + let method_identifiers = parse_method_identifiers(v)?; + let immutable_references = parse_immutable_references(v); + let link_references = parse_link_references(v); + let storage_layout = v + .get("storageLayout") + .map(|sl| serde_json::from_value::(sl.clone())) + .transpose()?; + + Ok(Self { + deployment_bytecode, + runtime_bytecode, + method_identifiers, + immutable_references, + link_references, + storage_layout, + }) + } + + /// Returns the canonical set of public selectors as `u32` (big-endian + /// interpretation of the 4 selector bytes — matches what we get from + /// `PUSH4 ` in bytecode). + pub fn selector_set(&self) -> std::collections::HashSet { + self.method_identifiers + .values() + .map(|bytes| u32::from_be_bytes(*bytes)) + .collect() + } +} + +fn parse_bytecode_field( + v: &serde_json::Value, + field: &'static str, +) -> Result, CompilerContextError> { + let hex_str = v + .get(field) + .and_then(|b| b.get("object")) + .and_then(|o| o.as_str()) + .ok_or(CompilerContextError::Missing(field))? + .trim_start_matches("0x"); + hex::decode(hex_str).map_err(CompilerContextError::BadBytecode) +} + +fn parse_method_identifiers( + v: &serde_json::Value, +) -> Result, CompilerContextError> { + let mut out = HashMap::new(); + let Some(obj) = v.get("methodIdentifiers").and_then(|m| m.as_object()) else { + return Ok(out); + }; + for (sig, val) in obj { + let hex_str = val.as_str().unwrap_or(""); + let bytes = hex::decode(hex_str).map_err(|e| CompilerContextError::BadSelector { + signature: sig.clone(), + selector: hex_str.to_string(), + source: e, + })?; + if bytes.len() != 4 { + return Err(CompilerContextError::BadSelector { + signature: sig.clone(), + selector: hex_str.to_string(), + source: hex::FromHexError::InvalidStringLength, + }); + } + let mut arr = [0u8; 4]; + arr.copy_from_slice(&bytes); + out.insert(sig.clone(), arr); + } + Ok(out) +} + +fn parse_immutable_references(v: &serde_json::Value) -> HashMap> { + let mut out = HashMap::new(); + let Some(obj) = v + .get("deployedBytecode") + .and_then(|d| d.get("immutableReferences")) + .and_then(|i| i.as_object()) + else { + return out; + }; + for (ast_id, ranges_v) in obj { + let Some(arr) = ranges_v.as_array() else { + continue; + }; + let ranges: Vec = arr + .iter() + .filter_map(|r| serde_json::from_value::(r.clone()).ok()) + .collect(); + if !ranges.is_empty() { + out.insert(ast_id.clone(), ranges); + } + } + out +} + +fn parse_link_references( + v: &serde_json::Value, +) -> HashMap>> { + let mut out = HashMap::new(); + let Some(files) = v + .get("deployedBytecode") + .and_then(|d| d.get("linkReferences")) + .and_then(|l| l.as_object()) + else { + return out; + }; + for (file, libs_v) in files { + let Some(libs) = libs_v.as_object() else { + continue; + }; + let mut per_file = HashMap::new(); + for (lib, ranges_v) in libs { + let Some(arr) = ranges_v.as_array() else { + continue; + }; + let ranges: Vec = arr + .iter() + .filter_map(|r| serde_json::from_value::(r.clone()).ok()) + .collect(); + if !ranges.is_empty() { + per_file.insert(lib.clone(), ranges); + } + } + if !per_file.is_empty() { + out.insert(file.clone(), per_file); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const ESCROW_ERC20: &str = + "../../examples/escrow-bytecode/out/EscrowERC20.sol/EscrowERC20.json"; + const ESCROW_NATIVE: &str = + "../../examples/escrow-bytecode/out/EscrowNative.sol/EscrowNative.json"; + + #[test] + fn loads_escrow_erc20_context() { + let ctx = CompilerContext::load_foundry_artifact(ESCROW_ERC20) + .expect("load EscrowERC20 artifact"); + + assert!(!ctx.deployment_bytecode.is_empty(), "deployment bytecode empty"); + assert!(!ctx.runtime_bytecode.is_empty(), "runtime bytecode empty"); + + // EscrowERC20 has a known set of public functions; spot check a few. + let selector = ctx + .method_identifiers + .get("fund(uint256,uint256)") + .expect("fund selector"); + assert_eq!(selector, &[0xa6, 0x5e, 0x2c, 0xfd]); + + let selector = ctx + .method_identifiers + .get("withdraw()") + .expect("withdraw selector"); + assert_eq!(selector, &[0x3c, 0xcf, 0xd6, 0x0b]); + + // Four immutables: deployerAddress, expectedRecipient, expectedAmount, tokenContract. + // ast ids confirmed against the artifact: 40183, 40191, 40193, 40607. + assert_eq!(ctx.immutable_references.len(), 4, "expected 4 immutables"); + for ast_id in ["40183", "40191", "40193", "40607"] { + assert!( + ctx.immutable_references.contains_key(ast_id), + "missing immutable ast id {ast_id}", + ); + } + + // tokenContract has 7 references in the runtime. + assert_eq!(ctx.immutable_references["40607"].len(), 7); + + // No libraries in escrow. + assert!(ctx.link_references.is_empty()); + + // Storage layout includes the packed slot 7 with cancellationRequest + funded. + let layout = ctx.storage_layout.as_ref().expect("storage layout present"); + let slot_7_vars: Vec<_> = layout + .storage + .iter() + .filter(|v| v.slot == "7") + .collect(); + assert!(!slot_7_vars.is_empty(), "expected packed vars in slot 7"); + } + + #[test] + fn loads_escrow_native_context() { + let ctx = CompilerContext::load_foundry_artifact(ESCROW_NATIVE) + .expect("load EscrowNative artifact"); + assert!(!ctx.method_identifiers.is_empty()); + assert!(!ctx.immutable_references.is_empty()); + assert!(ctx.storage_layout.is_some()); + } + + #[test] + fn selector_set_round_trips() { + let ctx = CompilerContext::load_foundry_artifact(ESCROW_ERC20).unwrap(); + let set = ctx.selector_set(); + + // PUSH4 0xa65e2cfd in bytecode is u32::from_be_bytes([0xa6,0x5e,0x2c,0xfd]) + assert!(set.contains(&0xa65e2cfd)); + assert!(set.contains(&0x3ccfd60b)); // withdraw + assert!(set.contains(&0x55a373d6)); // tokenContract + + // A random PUSH4 value that's NOT a selector should be absent. + assert!(!set.contains(&0xdeadbeef)); + } +} diff --git a/crates/core/src/detection/dispatcher.rs b/crates/core/src/detection/dispatcher.rs index 89bcea87..0ce0faa9 100644 --- a/crates/core/src/detection/dispatcher.rs +++ b/crates/core/src/detection/dispatcher.rs @@ -1,6 +1,7 @@ //! Detects Solidity function dispatcher patterns to extract selectors and target addresses. use crate::Opcode; +use crate::compiler::CompilerContext; use crate::decoder::Instruction; use serde::{Deserialize, Serialize}; @@ -52,6 +53,42 @@ enum StackValue { Unknown, } +/// Same as [`detect_function_dispatcher`], but additionally validates detected +/// selectors against the compiler-emitted `methodIdentifiers` table when one +/// is available. +/// +/// The pattern-matching detector can return spurious selectors when an +/// unrelated `PUSH4` happens to land next to an `EQ`/`JUMPI`. With a +/// `CompilerContext` in hand, we know the canonical set of public selectors +/// from the artifact, so we can drop anything that isn't in that set without +/// changing detection logic for selectors that are. +/// +/// When `context` is `None`, this behaves identically to +/// [`detect_function_dispatcher`]. +pub fn detect_function_dispatcher_with_context( + instructions: &[Instruction], + context: Option<&CompilerContext>, +) -> Option { + let mut info = detect_function_dispatcher(instructions)?; + + if let Some(ctx) = context { + let canonical = ctx.selector_set(); + let before = info.selectors.len(); + info.selectors + .retain(|s| canonical.contains(&s.selector)); + let dropped = before - info.selectors.len(); + if dropped > 0 { + tracing::debug!( + "compiler context filtered {dropped} false-positive selector(s) \ + (kept {} of {before})", + info.selectors.len() + ); + } + } + + Some(info) +} + /// Detects Solidity function dispatcher pattern and extracts selector-to-address mappings /// via symbolic stack tracking. pub fn detect_function_dispatcher(instructions: &[Instruction]) -> Option { @@ -430,4 +467,103 @@ mod tests { assert_eq!(selector.selector, 0x12345678); assert_eq!(selector.target_address, 0x20); } + + /// Build a dispatcher containing two selectors: one fake (`0x12345678`) and one + /// real (`0xa65e2cfd`, the `fund(uint256,uint256)` selector for EscrowERC20). + /// Used by the context-filtering tests below. + fn dispatcher_with_two_selectors() -> Vec { + let mut seq = Vec::new(); + seq.extend_from_slice(&[ + (0x00, Opcode::PUSH(1), Some("80")), + (0x02, Opcode::PUSH(1), Some("40")), + (0x04, Opcode::MSTORE, None), + (0x05, Opcode::CALLVALUE, None), + (0x06, Opcode::DUP(1), None), + (0x07, Opcode::ISZERO, None), + (0x08, Opcode::PUSH(2), Some("0012")), + (0x0b, Opcode::JUMPI, None), + // Newer extraction pattern. + (0x0c, Opcode::CALLDATALOAD, None), + (0x0d, Opcode::PUSH(1), Some("e0")), + (0x0f, Opcode::SHR, None), + // First selector: 0x12345678 (NOT a real escrow selector — the + // pattern matcher will pick it up; the canonical method_identifiers + // table does not contain it). + (0x10, Opcode::DUP(1), None), + (0x11, Opcode::PUSH(4), Some("12345678")), + (0x16, Opcode::EQ, None), + (0x17, Opcode::PUSH(2), Some("0040")), + (0x1a, Opcode::JUMPI, None), + // Second selector: 0xa65e2cfd (a real EscrowERC20 selector for + // fund(uint256,uint256)). + (0x1b, Opcode::DUP(1), None), + (0x1c, Opcode::PUSH(4), Some("a65e2cfd")), + (0x21, Opcode::EQ, None), + (0x22, Opcode::PUSH(2), Some("0050")), + (0x25, Opcode::JUMPI, None), + (0x26, Opcode::PUSH(1), Some("00")), + (0x28, Opcode::POP, None), + (0x29, Opcode::JUMPDEST, None), + (0x2a, Opcode::STOP, None), + ]); + build(&seq) + } + + #[test] + fn without_context_keeps_all_detected_selectors() { + use super::detect_function_dispatcher_with_context; + let instructions = dispatcher_with_two_selectors(); + let info = detect_function_dispatcher_with_context(&instructions, None) + .expect("dispatcher present"); + // Heuristic detection finds both — the fake AND the real one. + let selectors: Vec = info.selectors.iter().map(|s| s.selector).collect(); + assert!(selectors.contains(&0x12345678), "fake selector should be present without context"); + assert!(selectors.contains(&0xa65e2cfd), "real selector should be present without context"); + } + + #[test] + fn with_context_filters_out_unknown_selectors() { + use super::detect_function_dispatcher_with_context; + use crate::compiler::CompilerContext; + + let ctx = CompilerContext::load_foundry_artifact( + "../../examples/escrow-bytecode/out/EscrowERC20.sol/EscrowERC20.json", + ) + .expect("load EscrowERC20 artifact"); + + let instructions = dispatcher_with_two_selectors(); + let info = detect_function_dispatcher_with_context(&instructions, Some(&ctx)) + .expect("dispatcher present"); + + let selectors: Vec = info.selectors.iter().map(|s| s.selector).collect(); + // Fake selector is dropped because it isn't in methodIdentifiers. + assert!( + !selectors.contains(&0x12345678), + "fake selector should be filtered out when context provided", + ); + // Real selector survives because it IS in methodIdentifiers. + assert!( + selectors.contains(&0xa65e2cfd), + "real selector should be kept when context provided", + ); + } + + #[test] + fn with_context_preserves_all_real_selectors() { + use super::detect_function_dispatcher_with_context; + use crate::compiler::CompilerContext; + + // Use the original single-selector test fixture (selector 0x12345678 is fake). + let ctx = CompilerContext::load_foundry_artifact( + "../../examples/escrow-bytecode/out/EscrowERC20.sol/EscrowERC20.json", + ) + .expect("load EscrowERC20 artifact"); + + let instructions = sample_dispatcher_instructions(false); + let info = detect_function_dispatcher_with_context(&instructions, Some(&ctx)) + .expect("dispatcher present"); + + // The single fake selector should be filtered out, leaving zero. + assert!(info.selectors.is_empty(), "fake selectors should be filtered"); + } } diff --git a/crates/core/src/detection/mod.rs b/crates/core/src/detection/mod.rs index a1cd2b6f..cafa9930 100644 --- a/crates/core/src/detection/mod.rs +++ b/crates/core/src/detection/mod.rs @@ -6,7 +6,7 @@ pub mod sections; pub use dispatcher::{ DispatcherInfo, ExtractionPattern, FunctionSelector, detect_function_dispatcher, - find_extraction_pattern, + detect_function_dispatcher_with_context, find_extraction_pattern, }; pub use sections::{ diff --git a/crates/verification/src/semantics.rs b/crates/verification/src/semantics.rs index c7d16af9..8e19ecc6 100644 --- a/crates/verification/src/semantics.rs +++ b/crates/verification/src/semantics.rs @@ -1216,6 +1216,7 @@ pub mod tests { dispatcher_blocks: std::collections::HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + compiler: None, }; let analyzer = SemanticAnalyzer::new(cfg_bundle); @@ -1271,6 +1272,7 @@ pub mod tests { dispatcher_blocks: std::collections::HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + compiler: None, }; let analyzer = SemanticAnalyzer::new(cfg_bundle); @@ -1300,6 +1302,7 @@ pub mod tests { dispatcher_blocks: std::collections::HashSet::new(), arithmetic_chain_data: None, ac_runtime_length_estimate: None, + compiler: None, }; let analyzer = SemanticAnalyzer::new(cfg_bundle);