Skip to content

Commit 2c18b31

Browse files
committed
feat: experience page
1 parent dc57f35 commit 2c18b31

3 files changed

Lines changed: 201 additions & 14 deletions

File tree

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: all build build-release check clean fmt format lint lint-fix test test-doc test-heavy test-cov test-cov-json bench install help
1+
.PHONY: all build build-release check clean fmt format lint lint-fix test test-doc test-heavy test-cov test-cov-json bench install help eval
22

33
# Clippy flags used across the project
44
CLIPPY_ALLOW := --allow clippy::new_without_default \
@@ -74,3 +74,8 @@ bench:
7474

7575
install:
7676
cargo install --path crates/cli --locked
77+
78+
eval:
79+
# note: needs heimdall-eval cloned locally next to heimdall-rs
80+
@ cd ../heimdall-eval && make eval-all DEV=1 > /dev/null 2>&1
81+
@ cat ../heimdall-eval/heimdall/evals.json

crates/vm/src/ext/exec/loop_analysis.rs

Lines changed: 182 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,177 @@
11
use crate::core::stack::StackFrame;
22

3+
/// Check if the stack diff and condition show evidence of iteration.
4+
/// A real loop must have:
5+
/// 1. A non-empty stack diff (something is changing)
6+
/// 2. The diff shows meaningful iteration patterns OR
7+
/// 3. The condition is NOT a storage-to-argument comparison (balance check)
8+
///
9+
/// This function is conservative - it returns false for patterns that look like
10+
/// `require()` checks (storage compared to function arguments).
11+
pub(crate) fn stack_diff_shows_iteration(stack_diff: &[StackFrame], condition: &str) -> bool {
12+
// Empty diff means no iteration - the stack is identical
13+
if stack_diff.is_empty() {
14+
return false;
15+
}
16+
17+
// First, check if the condition looks like a balance/require check
18+
// These are NOT loops regardless of stack diff
19+
if looks_like_require_check(condition) {
20+
return false;
21+
}
22+
23+
// Look for increment/decrement patterns in the stack diff operations
24+
for frame in stack_diff {
25+
let solidified = frame.operation.solidify();
26+
27+
// Check for common loop counter patterns in the operation expression
28+
if solidified.contains(" + 0x01")
29+
|| solidified.contains(" + 1)")
30+
|| solidified.contains(" - 0x01")
31+
|| solidified.contains(" - 1)")
32+
|| solidified.contains(" + 0x20")
33+
|| solidified.contains(" + 32)")
34+
|| solidified.contains(" - 0x20")
35+
|| solidified.contains(" - 32)")
36+
{
37+
return true;
38+
}
39+
}
40+
41+
// If we have a non-empty diff but no clear increment pattern,
42+
// check if the condition uses a simple counter variable
43+
if condition_has_simple_counter(condition) {
44+
return true;
45+
}
46+
47+
false
48+
}
49+
50+
/// Check if a condition looks like a require/balance check.
51+
/// These patterns compare storage values to function arguments and are NOT loops.
52+
fn looks_like_require_check(condition: &str) -> bool {
53+
// Strip outer parens and negations to get to the core comparison
54+
let inner = strip_negations_and_parens(condition);
55+
56+
// Check if it's a storage-to-argument comparison
57+
for op in [" < ", " > ", " <= ", " >= "] {
58+
if let Some(pos) = inner.find(op) {
59+
let lhs = inner[..pos].trim();
60+
let rhs = inner[pos + op.len()..].trim();
61+
62+
// storage[...] compared to argN is a balance check
63+
let lhs_storage = lhs.contains("storage[");
64+
let rhs_storage = rhs.contains("storage[");
65+
let lhs_arg = is_arg_ref(lhs);
66+
let rhs_arg = is_arg_ref(rhs);
67+
68+
if (lhs_storage && rhs_arg) || (lhs_arg && rhs_storage) {
69+
return true;
70+
}
71+
}
72+
}
73+
74+
false
75+
}
76+
77+
/// Check if a condition uses a simple counter variable pattern.
78+
/// Loop conditions like "i < length" or "0x20 < memory[...].length" are valid.
79+
/// Also handles inverted patterns like "length > i" or "0 > 0x01".
80+
fn condition_has_simple_counter(condition: &str) -> bool {
81+
let inner = strip_negations_and_parens(condition);
82+
83+
// Look for patterns where one side is a simple counter value
84+
// Patterns: "counter < limit", "counter <= limit", "limit > counter", "limit >= counter"
85+
for op in [" < ", " <= ", " > ", " >= "] {
86+
if let Some(pos) = inner.find(op) {
87+
let lhs = inner[..pos].trim();
88+
let rhs = inner[pos + op.len()..].trim();
89+
90+
// For < and <=, the counter is on the left
91+
// For > and >=, the counter is on the right
92+
let (counter_side, _limit_side) = if op.contains('<') {
93+
(lhs, rhs)
94+
} else {
95+
(rhs, lhs)
96+
};
97+
98+
// Counter should be simple (hex constant, decimal, or simple variable)
99+
// and NOT a storage access
100+
if !counter_side.contains("storage[") && !counter_side.contains("keccak") {
101+
// Check if it's a small constant (likely a counter) or simple var
102+
if is_small_constant(counter_side) || is_simple_var(counter_side) {
103+
return true;
104+
}
105+
}
106+
}
107+
}
108+
109+
false
110+
}
111+
112+
/// Strip leading negations and outer parentheses from a condition
113+
fn strip_negations_and_parens(condition: &str) -> &str {
114+
let mut s = condition.trim();
115+
loop {
116+
let prev = s;
117+
if s.starts_with('!') {
118+
s = s[1..].trim();
119+
}
120+
if s.starts_with('(') && s.ends_with(')') {
121+
// Check if parens are balanced
122+
let inner = &s[1..s.len() - 1];
123+
if inner.chars().filter(|&c| c == '(').count()
124+
== inner.chars().filter(|&c| c == ')').count()
125+
{
126+
s = inner.trim();
127+
} else {
128+
break;
129+
}
130+
}
131+
if s == prev {
132+
break;
133+
}
134+
}
135+
s
136+
}
137+
138+
/// Check if expression is a function argument reference (argN)
139+
fn is_arg_ref(s: &str) -> bool {
140+
let trimmed = s.trim().trim_start_matches('(').trim_end_matches(')').trim();
141+
if let Some(rest) = trimmed.strip_prefix("arg") {
142+
return rest.chars().all(|c| c.is_ascii_digit()) && !rest.is_empty();
143+
}
144+
false
145+
}
146+
147+
/// Check if a string is a small constant (likely a loop counter value)
148+
fn is_small_constant(s: &str) -> bool {
149+
let trimmed = s.trim().trim_start_matches('(').trim_end_matches(')').trim();
150+
// Hex constants like 0x20, 0x40, 0x00
151+
if let Some(hex) = trimmed.strip_prefix("0x") {
152+
if let Ok(val) = u64::from_str_radix(hex, 16) {
153+
return val <= 0x1000; // Reasonable loop counter range
154+
}
155+
}
156+
// Decimal constants
157+
if let Ok(val) = trimmed.parse::<u64>() {
158+
return val <= 4096;
159+
}
160+
false
161+
}
162+
163+
/// Check if expression looks like a simple variable (not complex expression)
164+
fn is_simple_var(s: &str) -> bool {
165+
let trimmed = s.trim().trim_start_matches('(').trim_end_matches(')').trim();
166+
// Simple patterns: i, j, var_a, memory[0x40], etc. but not complex expressions
167+
!trimmed.contains(" + ")
168+
&& !trimmed.contains(" - ")
169+
&& !trimmed.contains(" * ")
170+
&& !trimmed.contains(" / ")
171+
&& !trimmed.contains("storage[")
172+
&& !trimmed.contains("keccak")
173+
}
174+
3175
/// Check if a condition is tautologically true (e.g., "arg0 == arg0", "X == (address(X))").
4176
/// These create infinite loops and should be skipped as invalid loop conditions.
5177
/// Also handles bitmask patterns like "X == (X & 0xff...ff)" which are type-check equivalents.
@@ -170,19 +342,25 @@ fn is_balanced_parens(s: &str) -> bool {
170342

171343
/// Check if a condition is tautologically false (e.g., "0 > 1", "(0 > 0x01)").
172344
/// These cannot be valid loop conditions and should be skipped.
345+
/// NOTE: We do NOT strip leading negation here because `!(0 > 1)` = TRUE, which is valid.
173346
pub(crate) fn is_tautologically_false_condition(condition: &str) -> bool {
174347
// Strip outer whitespace first
175348
let mut trimmed = condition.trim();
176349

177-
// Remove leading negation (!) - we're looking at the inner condition
350+
// If condition starts with negation, it's NOT tautologically false
351+
// because !(false) = true, which is a valid (though potentially infinite) loop
178352
if trimmed.starts_with('!') {
179-
trimmed = trimmed[1..].trim();
353+
return false;
180354
}
181355

182356
// Strip all outer parentheses (could be nested like "((...))")
183357
while trimmed.starts_with('(') && trimmed.ends_with(')') {
184-
trimmed = &trimmed[1..trimmed.len() - 1];
185-
trimmed = trimmed.trim();
358+
let inner = &trimmed[1..trimmed.len() - 1];
359+
if is_balanced_parens(inner) {
360+
trimmed = inner.trim();
361+
} else {
362+
break;
363+
}
186364
}
187365

188366
// Pattern: "0 > X" where X > 0 (always false for unsigned)

crates/vm/src/ext/exec/mod.rs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::{
1111
jump_frame::JumpFrame,
1212
loop_analysis::{
1313
detect_induction_variable, is_tautologically_false_condition,
14-
is_tautologically_true_condition,
14+
is_tautologically_true_condition, stack_diff_shows_iteration,
1515
},
1616
util::{
1717
historical_diffs_approximately_equal, jump_condition_appears_recursive,
@@ -298,19 +298,21 @@ impl VM {
298298

299299
// If a loop was detected, capture the LoopInfo and return the trace
300300
if let Some((diff, condition)) = detected_loop_info {
301-
// Skip loops with tautologically false conditions (e.g., "0 > 1")
302-
// or tautologically true conditions (e.g., "arg0 == arg0")
303-
// These are not real loops but rather overflow checks, dead code,
304-
// or false positives from identical operand comparisons
301+
// Skip loops with invalid conditions:
302+
// - Tautologically false (e.g., "0 > 1") - overflow checks, dead code
303+
// - Tautologically true (e.g., "arg0 == arg0") - identical operand comparisons
304+
// - No iteration evidence - not a real loop (e.g., balance checks)
305305
if is_tautologically_false_condition(&condition) ||
306-
is_tautologically_true_condition(&condition)
306+
is_tautologically_true_condition(&condition) ||
307+
!stack_diff_shows_iteration(&diff, &condition)
307308
{
308309
trace!(
309-
"skipping loop with tautological condition: {}",
310+
"terminating branch -- no iteration evidence or invalid condition: {}",
310311
condition
311312
);
312-
historical_stacks.push(vm.stack.clone());
313-
// Continue execution without creating a loop
313+
// Return the trace without recording a loop
314+
// This terminates the branch to avoid infinite recursion
315+
return Ok(Some(vm_trace));
314316
} else {
315317
trace!("loop detected, capturing LoopInfo");
316318
trace!(
@@ -354,6 +356,7 @@ impl VM {
354356

355357
// check if any stack position shows a consistent pattern
356358
// (increasing/decreasing/alternating)
359+
// The pattern itself is evidence of iteration, so we trust it
357360
if stack_position_shows_pattern(&vm.stack, historical_stacks) {
358361
let condition =
359362
jump_condition.clone().unwrap_or_else(|| "true".to_string());
@@ -388,6 +391,7 @@ impl VM {
388391
}
389392
}
390393

394+
// Approximate diff equality suggests consistent iteration patterns
391395
if historical_diffs_approximately_equal(&vm.stack, historical_stacks) {
392396
let condition =
393397
jump_condition.clone().unwrap_or_else(|| "true".to_string());

0 commit comments

Comments
 (0)