Skip to content

Commit 85dfa64

Browse files
authored
feat(symbolic): prove bounded mul-div monotonicity (#15696)
1 parent c6fd6fe commit 85dfa64

5 files changed

Lines changed: 423 additions & 50 deletions

File tree

crates/evm/symbolic/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,7 @@ Known incomplete, bounded, or approximate surfaces include:
588588
| Symbolic hashing and `KECCAK256` | Concrete hashes are computed exactly. Symbolic `KECCAK256` is represented by deterministic opaque terms plus Solidity-storage-layout heuristics for common mapping and dynamic-array keys. Proof obligations that depend on cryptographic facts such as non-zero hashes, collision resistance, or preimage resistance are not proof-grade and may report incomplete or produce replay-filtered candidates. |
589589
| Symbolic storage base values | Writes and later reads through symbolic keys are modeled, with Solidity-layout heuristics for common mapping/dynamic-array keys. Reads of previously-unwritten symbolic keys are abstract storage variables by default, or zero under the zero-init storage layout; the engine does not enumerate arbitrary concrete backend storage slots for a symbolic key. Proofs involving unknown existing storage are scoped to the selected `symbolic.storage_layout`. |
590590
| Precompiles | Canonical precompiles are recognized according to the active EVM version; KZG `0x0a` is Cancun+ only and falls back to normal empty-account behavior on earlier hardforks. Concrete inputs for modeled precompiles execute the corresponding revm precompile with effectively unlimited gas. Symbolic identity is byte-precise; symbolic hash/ecrecover/modexp outputs are deterministic opaque terms or fixed-length symbolic outputs, not full cryptographic/algebraic models. Symbolic BN254 inputs and symbolic BLAKE2f final flags report incomplete because precompile success depends on validity checks the symbolic model does not prove. KZG `0x0a` concrete inputs execute the revm KZG precompile exactly. Symbolic KZG calls model broad exact failures such as invalid input length and version/hash mismatches where known, plus selected replayable success/failure witnesses. Any remaining feasible symbolic KZG space reports incomplete rather than being treated as proved safe. Symbolic length headers, symbolic modexp output lengths, out-of-bounds symbolic inputs, future/custom precompiles, and precompile gas/OOG behavior are not fully modeled. |
591-
| Hard arithmetic | Bit-vector arithmetic is modeled through SMT. Some expensive arithmetic has bounded helpers, but unsupported `EXP` base/exponent shapes and other solver-intractable forms can report incomplete or timeout. |
591+
| Hard arithmetic | Bit-vector arithmetic is modeled through SMT. Forge proves unsigned monotonic product and same-divisor quotient comparisons when path bounds show that every product fits in 256 bits. Other expensive arithmetic has bounded helpers, but unsupported `EXP` base/exponent shapes and solver-intractable nonlinear identities can report incomplete or timeout. |
592592
| Cheatcode surface | The common testing cheatcodes listed below are modeled for safe concrete/symbolic forms. Unsupported Foundry/VM compatibility cheatcodes, value-bearing cheatcode calls, delegatecall prank forms, symbolic `expectCall` gas, unsupported symbolic `vm.bound` ranges, and unsupported symbolic `assumeNoRevert` decodes/overlaps report incomplete. |
593593
| Approximate/no-op cheatcodes | Some recognized Foundry helpers are accepted but not semantically checked under symbolic execution, including non-observable gas metering helpers, access-list/warm/cool helpers, `allowCheatcodes`, `sleep`, and breakpoints. Observable EVM-version helpers, gas snapshot/read helpers, and safe-memory expectation helpers report incomplete instead of fabricating results or silently accepting assertions. |
594594
| Fork mutation during symbolic execution | Fork-backed setup is allowed before symbolic execution. Creating forks, selecting a different fork, or rolling/mutating fork blocks during symbolic execution is restricted and reports incomplete unless it stays on the already active fork in the supported form. |

crates/evm/symbolic/src/runtime/solver.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ pub(crate) use hard_arith_fallback::{
1212
};
1313
#[cfg(test)]
1414
pub(crate) use monotonic_product::product_monotonic_unsat;
15-
use monotonic_product::product_monotonic_unsat_normalized;
15+
use monotonic_product::{product_monotonic_unsat_normalized, remove_implied_monotonic_constraints};
1616
pub(crate) use opt::normalize_constraints_for_solver;
1717
use opt::{constraints_are_directly_unsat, sorted_bool_exprs_are_subset, write_smt_assertions};
1818
#[cfg(test)]
@@ -460,7 +460,7 @@ impl SmtLibSubprocessSolver {
460460
defer_hard_arith_without_witness: bool,
461461
) -> Result<bool, SymbolicError> {
462462
self.sat_queries += 1;
463-
let smt_constraints = normalize_constraints_for_solver(cx, constraints);
463+
let smt_constraints = normalize_sat_constraints(cx, constraints);
464464
let cache_key = smt_constraints.clone();
465465
if let Some(result) = self.sat_cache.get(&cache_key) {
466466
self.sat_cache_hits += 1;
@@ -476,14 +476,14 @@ impl SmtLibSubprocessSolver {
476476
if defer_hard_arith_without_witness
477477
&& let Some((condition, base)) = constraints.split_last()
478478
&& {
479-
let normalized_base = normalize_constraints_for_solver(cx, base);
479+
let normalized_base = normalize_sat_constraints(cx, base);
480480
self.sat_cache.get(&normalized_base) == Some(&true)
481481
}
482482
&& {
483483
let mut complement = Vec::with_capacity(constraints.len());
484484
complement.extend(base.iter().cloned());
485485
complement.push(condition.clone().not(cx));
486-
let normalized_complement = normalize_constraints_for_solver(cx, &complement);
486+
let normalized_complement = normalize_sat_constraints(cx, &complement);
487487
self.has_cached_unsat_subset(&normalized_complement)
488488
}
489489
{
@@ -711,6 +711,11 @@ impl SmtLibSubprocessSolver {
711711
}
712712
}
713713

714+
/// Normalizes satisfiability constraints and removes soundly implied nonlinear comparisons.
715+
fn normalize_sat_constraints(cx: &mut SymCx, constraints: &[SymBoolExpr]) -> Vec<SymBoolExpr> {
716+
remove_implied_monotonic_constraints(normalize_constraints_for_solver(cx, constraints))
717+
}
718+
714719
/// Returns a hard-arithmetic fallback model only after validating it against original constraints.
715720
fn validated_hard_arith_fallback_model(
716721
cx: &SymCx,

crates/evm/symbolic/src/runtime/solver/monotonic_product.rs

Lines changed: 209 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
use super::*;
1+
use super::{opt::ConstraintContext, *};
22

33
type LessThanFacts<'a> = HashSet<(&'a SymExpr, &'a SymExpr)>;
4+
type LessOrEqualFacts<'a> = HashSet<(&'a SymExpr, &'a SymExpr)>;
45
type PositiveFacts<'a> = HashSet<&'a SymExpr>;
56

7+
#[derive(Default)]
8+
struct OrderFacts<'a> {
9+
less_than: LessThanFacts<'a>,
10+
less_or_equal: LessOrEqualFacts<'a>,
11+
positive: PositiveFacts<'a>,
12+
}
13+
614
/// Returns whether monotonic product facts make these constraints unsatisfiable.
715
#[cfg(test)]
816
pub(crate) fn product_monotonic_unsat(cx: &mut SymCx, constraints: &[SymBoolExpr]) -> bool {
@@ -12,51 +20,217 @@ pub(crate) fn product_monotonic_unsat(cx: &mut SymCx, constraints: &[SymBoolExpr
1220

1321
/// Returns whether normalized monotonic product facts make constraints unsatisfiable.
1422
pub(crate) fn product_monotonic_unsat_normalized(constraints: &[SymBoolExpr]) -> bool {
15-
let mut less_than = HashSet::default();
16-
let mut positive = HashSet::default();
17-
for constraint in constraints {
18-
collect_order_facts(constraint, &mut less_than, &mut positive);
19-
}
23+
let facts = order_facts(constraints.iter());
24+
let bounds = ConstraintContext::new(constraints);
2025

2126
constraints.iter().any(|constraint| {
22-
product_less_than_negation(constraint).is_some_and(|(left_a, left_b, right_a, right_b)| {
23-
product_less_than_known(left_a, left_b, right_a, right_b, &less_than, &positive)
24-
})
27+
reversed_strict_comparison(constraint)
28+
.is_some_and(|(left, right)| expr_less_or_equal(right, left, &facts, &bounds))
29+
|| product_less_than_negation(constraint).is_some_and(
30+
|(left_a, left_b, right_a, right_b)| {
31+
product_less_than_known(
32+
left_a,
33+
left_b,
34+
right_a,
35+
right_b,
36+
&facts.less_than,
37+
&facts.positive,
38+
&bounds,
39+
)
40+
},
41+
)
2542
})
2643
}
2744

28-
fn collect_order_facts<'a>(
29-
expr: &'a SymBoolExpr,
30-
less_than: &mut LessThanFacts<'a>,
31-
positive: &mut PositiveFacts<'a>,
32-
) {
45+
/// Removes hard-arithmetic comparisons implied by the remaining path constraints.
46+
///
47+
/// This keeps a sound monotonic success path from falling through to the heuristic witness
48+
/// search, whose satisfiable models are useful for counterexamples but cannot establish a proof.
49+
pub(super) fn remove_implied_monotonic_constraints(
50+
mut constraints: Vec<SymBoolExpr>,
51+
) -> Vec<SymBoolExpr> {
52+
// Remove constraints one at a time so two candidates cannot justify each other and then both
53+
// disappear from the final query.
54+
let mut index = 0;
55+
while index < constraints.len() {
56+
if !constraints[index].contains_hard_arith() {
57+
index += 1;
58+
continue;
59+
}
60+
let Some((left, right)) = less_or_equal_comparison(&constraints[index]) else {
61+
index += 1;
62+
continue;
63+
};
64+
let base = constraints
65+
.iter()
66+
.enumerate()
67+
.filter(|(candidate, _)| *candidate != index)
68+
.map(|(_, constraint)| constraint.clone())
69+
.collect::<Vec<_>>();
70+
let facts = order_facts(base.iter());
71+
let bounds = ConstraintContext::new(&base);
72+
if expr_less_or_equal(left, right, &facts, &bounds) {
73+
constraints.remove(index);
74+
} else {
75+
index += 1;
76+
}
77+
}
78+
constraints
79+
}
80+
81+
fn order_facts<'a>(constraints: impl IntoIterator<Item = &'a SymBoolExpr>) -> OrderFacts<'a> {
82+
let mut facts = OrderFacts::default();
83+
for constraint in constraints {
84+
collect_order_facts(constraint, &mut facts);
85+
}
86+
facts
87+
}
88+
89+
fn collect_order_facts<'a>(expr: &'a SymBoolExpr, facts: &mut OrderFacts<'a>) {
3390
match expr.kind() {
3491
SymBoolExprKind::And(values) => {
3592
for value in values.iter() {
36-
collect_order_facts(value, less_than, positive);
93+
collect_order_facts(value, facts);
3794
}
3895
}
3996
SymBoolExprKind::Cmp(SymCmpOp::Ult, left, right) => {
40-
less_than.insert((left, right));
97+
facts.less_than.insert((left, right));
98+
facts.less_or_equal.insert((left, right));
4199
if left.as_const().is_some_and(|value| value.is_zero()) {
42-
positive.insert(right);
100+
facts.positive.insert(right);
43101
}
44102
}
45103
SymBoolExprKind::Cmp(SymCmpOp::Ugt, left, right) => {
46-
less_than.insert((right, left));
104+
facts.less_than.insert((right, left));
105+
facts.less_or_equal.insert((right, left));
47106
if right.as_const().is_some_and(|value| value.is_zero()) {
48-
positive.insert(left);
107+
facts.positive.insert(left);
49108
}
50109
}
110+
SymBoolExprKind::Cmp(SymCmpOp::Ule, left, right) => {
111+
facts.less_or_equal.insert((left, right));
112+
}
113+
SymBoolExprKind::Cmp(SymCmpOp::Uge, left, right) => {
114+
facts.less_or_equal.insert((right, left));
115+
}
116+
SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => {
117+
facts.less_or_equal.insert((left, right));
118+
facts.less_or_equal.insert((right, left));
119+
}
51120
SymBoolExprKind::Not(value) => {
52121
if let Some(expr) = nonzero_expr(value) {
53-
positive.insert(expr);
122+
facts.positive.insert(expr);
123+
}
124+
if let SymBoolExprKind::Cmp(op, left, right) = value.kind() {
125+
match op {
126+
// !(a < b) => b <= a
127+
SymCmpOp::Ult => {
128+
facts.less_or_equal.insert((right, left));
129+
}
130+
// !(a > b) => a <= b
131+
SymCmpOp::Ugt => {
132+
facts.less_or_equal.insert((left, right));
133+
}
134+
// !(a <= b) => b < a
135+
SymCmpOp::Ule => {
136+
facts.less_than.insert((right, left));
137+
facts.less_or_equal.insert((right, left));
138+
}
139+
// !(a >= b) => a < b
140+
SymCmpOp::Uge => {
141+
facts.less_than.insert((left, right));
142+
facts.less_or_equal.insert((left, right));
143+
}
144+
SymCmpOp::Eq | SymCmpOp::Slt | SymCmpOp::Sgt => {}
145+
}
54146
}
55147
}
56-
SymBoolExprKind::Const(_) | SymBoolExprKind::Cmp(_, _, _) => {}
148+
SymBoolExprKind::Const(_) | SymBoolExprKind::Cmp(SymCmpOp::Slt | SymCmpOp::Sgt, _, _) => {}
149+
}
150+
}
151+
152+
/// Returns a strict comparison that contradicts `right <= left`.
153+
fn reversed_strict_comparison(expr: &SymBoolExpr) -> Option<(&SymExpr, &SymExpr)> {
154+
match expr.kind() {
155+
SymBoolExprKind::Cmp(SymCmpOp::Ult, left, right) => Some((left, right)),
156+
SymBoolExprKind::Cmp(SymCmpOp::Ugt, left, right) => Some((right, left)),
157+
SymBoolExprKind::Not(value) => match value.kind() {
158+
SymBoolExprKind::Cmp(SymCmpOp::Ule, left, right) => Some((right, left)),
159+
SymBoolExprKind::Cmp(SymCmpOp::Uge, left, right) => Some((left, right)),
160+
_ => None,
161+
},
162+
_ => None,
163+
}
164+
}
165+
166+
/// Returns the unsigned weak ordering asserted by this constraint.
167+
fn less_or_equal_comparison(expr: &SymBoolExpr) -> Option<(&SymExpr, &SymExpr)> {
168+
match expr.kind() {
169+
SymBoolExprKind::Cmp(SymCmpOp::Ule, left, right) => Some((left, right)),
170+
SymBoolExprKind::Cmp(SymCmpOp::Uge, left, right) => Some((right, left)),
171+
SymBoolExprKind::Not(value) => match value.kind() {
172+
SymBoolExprKind::Cmp(SymCmpOp::Ult, left, right) => Some((right, left)),
173+
SymBoolExprKind::Cmp(SymCmpOp::Ugt, left, right) => Some((left, right)),
174+
_ => None,
175+
},
176+
_ => None,
177+
}
178+
}
179+
180+
/// Returns whether the known unsigned order and no-overflow bounds imply `left <= right`.
181+
fn expr_less_or_equal<'a>(
182+
left: &'a SymExpr,
183+
right: &'a SymExpr,
184+
facts: &OrderFacts<'a>,
185+
bounds: &ConstraintContext,
186+
) -> bool {
187+
if left == right || facts.less_or_equal.contains(&(left, right)) {
188+
return true;
189+
}
190+
191+
match (left.kind(), right.kind()) {
192+
(
193+
SymExprKind::BinOp(SymBinOp::UDiv, left_num, left_den),
194+
SymExprKind::BinOp(SymBinOp::UDiv, right_num, right_den),
195+
) if left_den == right_den => expr_less_or_equal(left_num, right_num, facts, bounds),
196+
(
197+
SymExprKind::BinOp(SymBinOp::Mul, left_a, left_b),
198+
SymExprKind::BinOp(SymBinOp::Mul, right_a, right_b),
199+
) if bounds.mul_cannot_overflow_256(left_a, left_b)
200+
&& bounds.mul_cannot_overflow_256(right_a, right_b) =>
201+
{
202+
product_less_or_equal_known(left_a, left_b, right_a, right_b, facts, bounds)
203+
}
204+
_ => false,
57205
}
58206
}
59207

208+
fn product_less_or_equal_known<'a>(
209+
left_a: &'a SymExpr,
210+
left_b: &'a SymExpr,
211+
right_a: &'a SymExpr,
212+
right_b: &'a SymExpr,
213+
facts: &OrderFacts<'a>,
214+
bounds: &ConstraintContext,
215+
) -> bool {
216+
product_less_or_equal_known_ordered(left_a, left_b, right_a, right_b, facts, bounds)
217+
|| product_less_or_equal_known_ordered(left_b, left_a, right_a, right_b, facts, bounds)
218+
|| product_less_or_equal_known_ordered(left_a, left_b, right_b, right_a, facts, bounds)
219+
|| product_less_or_equal_known_ordered(left_b, left_a, right_b, right_a, facts, bounds)
220+
}
221+
222+
fn product_less_or_equal_known_ordered<'a>(
223+
left_a: &'a SymExpr,
224+
left_b: &'a SymExpr,
225+
right_a: &'a SymExpr,
226+
right_b: &'a SymExpr,
227+
facts: &OrderFacts<'a>,
228+
bounds: &ConstraintContext,
229+
) -> bool {
230+
expr_less_or_equal(left_a, right_a, facts, bounds)
231+
&& expr_less_or_equal(left_b, right_b, facts, bounds)
232+
}
233+
60234
fn nonzero_expr(expr: &SymBoolExpr) -> Option<&SymExpr> {
61235
let SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) = expr.kind() else { return None };
62236
if left.as_const().is_some_and(|value| value.is_zero()) {
@@ -87,11 +261,18 @@ fn product_less_than_known<'a>(
87261
right_b: &'a SymExpr,
88262
less_than: &LessThanFacts<'a>,
89263
positive: &PositiveFacts<'a>,
264+
bounds: &ConstraintContext,
90265
) -> bool {
91-
product_less_than_known_ordered(left_a, left_b, right_a, right_b, less_than, positive)
92-
|| product_less_than_known_ordered(left_b, left_a, right_a, right_b, less_than, positive)
93-
|| product_less_than_known_ordered(left_a, left_b, right_b, right_a, less_than, positive)
94-
|| product_less_than_known_ordered(left_b, left_a, right_b, right_a, less_than, positive)
266+
product_less_than_known_ordered(left_a, left_b, right_a, right_b, less_than, positive, bounds)
267+
|| product_less_than_known_ordered(
268+
left_b, left_a, right_a, right_b, less_than, positive, bounds,
269+
)
270+
|| product_less_than_known_ordered(
271+
left_a, left_b, right_b, right_a, less_than, positive, bounds,
272+
)
273+
|| product_less_than_known_ordered(
274+
left_b, left_a, right_b, right_a, less_than, positive, bounds,
275+
)
95276
}
96277

97278
fn product_less_than_known_ordered<'a>(
@@ -101,13 +282,14 @@ fn product_less_than_known_ordered<'a>(
101282
right_b: &'a SymExpr,
102283
less_than: &LessThanFacts<'a>,
103284
positive: &PositiveFacts<'a>,
285+
bounds: &ConstraintContext,
104286
) -> bool {
105287
positive.contains(left_a)
106288
&& positive.contains(left_b)
107289
&& less_than.contains(&(left_a, right_a))
108290
&& less_than.contains(&(left_b, right_b))
109-
&& left_a.mul_cannot_overflow_256(left_b)
110-
&& right_a.mul_cannot_overflow_256(right_b)
291+
&& bounds.mul_cannot_overflow_256(left_a, left_b)
292+
&& bounds.mul_cannot_overflow_256(right_a, right_b)
111293
}
112294

113295
fn mul_operands(expr: &SymExpr) -> Option<(&SymExpr, &SymExpr)> {

0 commit comments

Comments
 (0)