Two-phase SMT abstraction for symbolic division/modulo and multiplication - #1075
Two-phase SMT abstraction for symbolic division/modulo and multiplication#1075gustavo-grieco wants to merge 157 commits into
Conversation
The 2-phase refactor's sendScript requires every command to return "success", which the empty solver (no-op process, no print-success) can't satisfy, so it failed before check-sat; the merge from main added SMT content that exposed this. Short-circuit EmptySolver in getOneSol to return Unknown with the same message main produces (and that the 'empty solver is always unknown' CLI test expects). The 'maybe it got killed' diagnostic stays on the real error paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the abstraction+refinement div/mod encoding to unsigned Div/Mod, mirroring the existing signed (SDiv/SMod) path: - Declare abst_evm_bvudiv / abst_evm_bvurem uninterpreted functions and route Div/Mod through divModOp so they honor AbstractDivMod (the concrete path stays byte-identical via op2CheckZero). - Split DivModKind into signed/unsigned variants so the absolute-value and sign-reconstruction machinery is never applied to unsigned operands, and signed/unsigned groups never share a congruence pool. - For unsigned, the magnitude is the raw operand (no smtAbsolute) and the reconstruction is just the EVM divide-by-zero guard around the result var. The ground-truth axiom is zero-guarded (ite (= b 0) 0 (bvudiv a b)) to match EVM's x/0 = 0 semantics, since SMT-LIB's native bvudiv/bvurem return non-zero on a zero divisor. Add unsigned tests to the Abstract-Arith group, including a vault-preview-shares example (ERC-4626-style (assets << 64) / price) that the shift-bounds encoding proves instantly but native bvudiv leaves at "unknown". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the arithmetic abstraction to multiplication, so vault-style properties involving symbolic*symbolic products become provable while keeping hevm SOUND. Encoding (src/EVM/SMT/DivModEncoding.hs, src/EVM/SMT.hs): - Route only symbolic*symbolic Mul through an uninterpreted abst_evm_bvmul (concrete/0/1/power-of-two factors stay native). No mul ground truth, so the solver never bit-blasts a symbolic product. - Sound lemmas only: commutativity; 0/1 identities; div x mul link (quotient*divisor <= dividend); div monotonicity; and mul monotonicity guarded by a bound-free no-overflow predicate (high half of the 512-bit product is zero). Bounding (e.g. require(x < 2**128)) is supplied in Solidity, not hardcoded here. Soundness (src/EVM/Solvers.hs): - A QED is a real proof (the lemmas over-approximate real EVM arithmetic). - Because abst_evm_bvmul is uninterpreted, a SAT model may use product values inconsistent with real multiplication, so a counterexample cannot be trusted: when the mul abstraction is active, SAT is downgraded to Unknown. div-only counterexamples (div is exact via its ground truth) are unaffected. Tests (test/test.hs, group "Mul-Abstraction"): mul monotonicity, 0/1 identities, div x mul link, ERC-4626 share-price monotonicity (QED with abstraction, unknown natively); plus soundness cases: unbounded overflow must not yield a bogus QED, the cross-divisor round-trip's spurious model must not be reported as a counterexample, and div/constant-mul counterexamples remain reported. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the set of vault-style properties the multiplication abstraction
proves, beyond plain monotonicity. Both lemmas are sound (true of real EVM
arithmetic) and were each validated to unlock their target via the CLI.
- Division anti-monotonicity in the divisor: y1 <= y2 && y1 != 0 => x/y2 <= x/y1.
Unlocks divisor-ordering ("a cheaper price gives at least as many units").
- mulDiv bound: for (x*y)/z, if y <= z (or x <= z) then the result is <= x
(resp. <= y), guarded by no-overflow of x*z (resp. y*z). Unlocks the common
DeFi pattern, e.g. a fee of feeBps/10000 never exceeds the principal.
A third lemma (multiplication cancellation, m(x,z)<=m(y,z)=>x<=y) was tried and
reverted: it does not help bound-style properties such as (x*s)/s <= x, because
the abstract product is unpinned, so the division result is unbounded and the
no-overflow guard never discharges. Those need the product pinned (i.e.
bit-blasted), which the abstraction deliberately avoids.
Soundness is unchanged: QED remains a real proof, and properties that the
lemmas cannot discharge (or that are false) stay Unknown rather than producing
a bogus proof or counterexample.
Tests (group "Mul-Abstraction"): div-divisor-monotone, muldiv-fee-cap (QED),
and muldiv-fee-uncapped-not-unsound (must stay Unknown).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI (solc 0.8.31) canonicalises `a * b` with the shared operand FIRST (e.g. Mul (SLoad slot) (ReadWord arg)), whereas solc 0.8.2 put it second. mul-monotonicity paired products by a shared *second* operand only, so under the 0.8.31 ordering the lemma was not generated, the abstraction could not prove the property, and the spurious model was (soundly) downgraded to Unknown — making `vault-shares-monotonic` fail with "expected QED, got Unknown". Fix: feed both operand orders into the monotonicity pairing (multiplication is commutative and we already assert it, so the generated term links back to the one in the props). The division lemmas remain order-sensitive, as they must. Verified on the exact failing CI bytecode: now QED (was Unknown); full local regression unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two changes that let realistic vault queries (operand magnitudes ~2^100, e.g. token amounts up to 1e30) prove in seconds instead of timing out: 1. mulNoOverflow now uses the cheap *sufficient* condition `x <= 2^128-1 && y <= 2^128-1` instead of the exact bound-free predicate `extract 511 256 (bvmul (zext x) (zext y)) = 0`. The exact form forced the solver to bit-blast a full 512-bit multiply for every lemma instance, which was the dominant cost: monotonicity over operands bounded by 2^96+ timed out, while 2^64 proved instantly. The sufficient condition is sound (when it holds there is genuinely no overflow) and is the same width-budget idea Halmos uses. All realistic token amounts, share counts and conversion rates are far below 2^128. 2. New const-mul monotonicity lemma (mkConstMulMono): for a literal constant c, `x <= y => c*x <= c*y`. Multiplication by a constant stays a native bvmul, but a symbolic operand times a large constant (e.g. 1e27 in sUSDS rate conversions) is still costly to bit-blast and compare. Since c is concrete we compute the exact no-overflow bound floor((2^256-1)/c) at encode time, so the guard is a single constant comparison. This is what unblocks `value * 1e27 / rate`-style conversions, which div-monotonicity could not close without first ordering the two const-mul dividends. Adds a constmul-div-monotone test mirroring spark-psm convertToAssets(sUSDS). Existing Mul-Abstraction tests are unaffected (the 2^128-bounded ones discharge the cheap guard exactly; soundness tests stay Unknown/Cex). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When an abstract product a*b has a factor that is also used as a divisor elsewhere in the query, synthesize the exact division (a*b)/factor and add it to the div set. The existing sound lemmas then bridge nested divisions: mulDiv-bound gives (a*b)/b <= a, and div-monotonicity carries that bound across a shared divisor. This proves the cross-divisor round-trip convertToAssetValue(convertToShares(x)) <= x, i.e. ((x*ts)/ta)*ta/ts <= x — the stateless core of inflation-attack safety — which single-level lemmas could not discharge (the bridging term (x*ts)/ts was simply absent from the query). Soundness: the synthetic terms are exact EVM divisions and every lemma asserted about them is a true fact, so a QED remains a real proof. Verified that the downgrade-soundness checks (mul-overflow-not-unsound, muldiv-fee-uncapped) still report Unknown and the counterexample tests still report Cex — the added lemmas are consistent (an inconsistency would have turned those into spurious QEDs). The former roundtrip-spurious-cex-suppressed test now proves the property, so it becomes the positive roundtrip-no-inflation test; downgrade soundness stays covered by the two *-not-unsound tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The round-trip (cancellation synthesis) and the const-mul-div monotonicity (large 1e27 constant multiply) are reliably discharged by z3 but not by bitwuzla. Both solvers are sound; this is purely a capability pick. Since withBitwuzlaSolver runs with no SMT timeout, an unprovable-for-bitwuzla query would hang the test rather than fail fast, so prefer the solver verified to close these. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e round-trip Reverts the round-trip and const-mul-div tests from z3 back to bitwuzla. The earlier switch was made when bitwuzla was missing from the local PATH and every query spuriously returned unknown; with bitwuzla actually present it discharges all of these in well under a second (and is the bit-vector solver hevm defaults to). On the checked-arithmetic round-trip bitwuzla is in fact the *stronger* solver — z3 is the one that struggles there. Adds roundtrip-checked-realcode: the inflation-attack round-trip on real-code- shaped Solidity — ordinary checked arithmetic (so each `*` carries the 0.8 overflow guard div(mul(a,b),a)) and the conversions are real functions called as in the contract. Confirms the cancellation synthesis survives the overflow-guard divisions, i.e. the property proves on the code as written, not only on raw assembly ops. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t test - Reword the no-overflow guard comment in generic terms (large operands ~2^100) instead of naming specific token/amount domains. - Drop the spark-specific note from the const-mul-div test comment. - Remove the redundant roundtrip-checked-realcode test: roundtrip-no-inflation already covers the cross-divisor round-trip; the cancellation synthesis still discharges Solidity's overflow-guard divisions (described in the encoding). No functional change to the abstraction; comments and test surface only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Multiplication by a literal constant stays a native bvmul, but the surrounding division is abstracted (uninterpreted), so a divide-by-the-same-constant wrapper like `amount * 1e18 / 1e18` (e.g. ERC-4626 precision scaling) is not provably the identity. This lemma pins it: for a division (c*x)/c by the same literal c, assert the result is x, guarded by the exact no-overflow bound floor((2^256-1)/c) computed at encode time (a single constant comparison, like const-mul-mono). Sound: (c*x)/c == x exactly whenever c*x does not overflow, which the guard ensures. The lemma only fires when the divisor literal equals the multiplier literal, so a near-miss like (x*1e18)/(1e18+1) is left uninterpreted (verified: it still yields a counterexample, not a spurious QED). Adds a const-cancel test. This discharges constant-precision wrappers in isolation; it does not by itself close the largest composite queries (e.g. a full previewDeposit/convertToAssets round-trip), where the surrounding structure remains beyond the solver. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two sound lemmas that discharge precision-scaling closed forms (exact-value spec-conformance), which the abstraction otherwise leaves uninterpreted: 1. Generalize const cancellation from (c*x)/c == x to (c1*x)/c2 == (c1/c2)*x when the literal divisor c2 divides the literal multiplier c1 (c2 | c1). The same-constant case (c1==c2, k=1) is unchanged; the general case covers lossless scaling between decimals, e.g. amount*1e18/1e6 == amount*1e12. Guarded by the exact no-overflow bound floor((2^256-1)/c1). 2. Nested-division collapse: (A/c1)/c2 == A/(c1*c2) for literals c1,c2 whose product fits in 256 bits. A floor identity, sound unconditionally; collapses chained constant divisions like x*rate/1e9/1e18 to x*rate/1e27. Both verified sound (correct form proves; a wrong constant yields a counterexample, never a spurious QED) and non-regressing. Adds const-cancel-scaled and nested-div-collapse tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror of the generalized const-cancel (which needs c2 | c1): this covers the c1 | c2 direction (multiply by a small constant, divide by a large one), e.g. x*1e6/1e18 == x/1e12 -- precision scaling DOWN (the convert-to-usdc step). Sound because, under the no-overflow guard x <= (2^256-1)/c1, c1*x is exact and floor(c1*x/(c1*k)) = floor(x/k). Adds fraction-reduce and fraction-reduce-cex unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ceilDiv sibling of const-cancel. OpenZeppelin Math.ceilDiv(c1*x, c2) is (c1*x == 0) ? 0 : (c1*x - 1)/c2 + 1, so the abstracted divide is over the (c1*x - 1) dividend. When c2 | c1 the product is always a multiple of c2, so ceil == floor and the whole ceilDiv collapses to (c1/c2)*x. The lemma pins the inner divide: for x >= 1, (c1*x - 1)/c2 == (c1/c2)*x - 1 (sound: c1*x = c2*m*x, floor((c2*m*x - 1)/c2) = m*x - 1), under the encode-time no-overflow bound floor((2^256-1)/c1). Discharges round-up multiply-up swap-out closed forms (e.g. previewSwapExactOut usds->usdc == amountOut*1e12). Adds ceildiv-cancel and ceildiv-cancel-cex unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e div set Two related additions to the multiplication abstraction: 1. Scaled-product telescoping (sound, no-overflow guarded): for products sharing a factor a whose other factors differ by a literal k that the common divisor c divides, floor(a*b/c) == floor(a*(b-k)/c) + a*(k/c). This is the only lemma that relates two DISTINCT abstract products, so it discharges value-change accounting identities such as susds*rate/1e27 - susds == susds*(rate-1e27)/1e27 (rate and rate-1e27 otherwise give unrelated abst_evm_bvmul terms). Sound because a*b = a*(b-k) + a*k and a*k is an exact multiple of c, shifting the floor by exactly a*(k/c). Validated: SMT soundness (m=1 and m=2) and wrong-RHS rejection, plus necessity/efficacy against the existing lemma set. 2. Collapse a nested constant division (A/c1)/c2 into a synthetic single divide A/(c1*c2) added to the div set, so the single-divide lemmas (telescoping, const-cancel, fraction-reduce) match code that splits precision across two divides — e.g. _getSUsdsValue's x*rate/1e9/1e18, whose collapsed x*rate/1e27 is what those lemmas key on. Sound: nested-div-collapse already asserts the two forms equal, so the synthetic divide is exact. Additive (never removes a term), so it cannot introduce a false QED. Tests: scaled-product-telescope (QED) and scaled-product-telescope-not-unsound (wrong difference stays Unknown, never QED) in the Mul-Abstraction group. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Split the 624-line DivModEncoding into three modules, following the EVM.Expr simplifier idiom (pattern-directed rules + per-constructor emit), so the lemmas live in one reviewable file: - EVM.SMT.AbstractBase: shared SMT primitives, signed helpers, the div/mod taxonomy, term collectors/matchers, and `saturate` (the abstract-term closure that was tangled in mulEncoding's `let`). - EVM.SMT.AbstractLemmas: the lemma catalogue and review surface. A `LemmaInst` sum type, `collectLemmas` (triggers), and `emitLemma` (SMT emission + soundness notes), one banner per lemma. GHC exhaustiveness now forces every constructor to have an emitter, replacing the old silent positional concat. - EVM.SMT.DivModEncoding: slimmed to orchestration (mulEncoding) plus the div/mod ground-truth encoding. Behavior-preserving: emitter bodies are verbatim and the emission order is unchanged, so generated queries are identical. Public API unchanged. Validated: lib + exe build clean (GHC 9.6.6); seven representative lemma properties (const-cancel, scaled const-cancel, frac-reduce, nested-div, mul-mono, mul-div-bound, telescope) prove under --abstract-arith, and a false identity correctly fails, so soundness is preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Ah, thankfully the majority of the LoC change is in |
msooseth
left a comment
There was a problem hiding this comment.
My light review :) First of all, this is very-very cool! I just wanna make sure I can read it all and understand it all, so we can merge!
I think most of the things I highlighted could be fixed via LLM relatively easily? I hope at least! Basically, stuff I think can be maybe trimmed down? Perhaps the SMT helper functions could be extracted to another module, and shared? And the lemmas should be checked if they are all needed. I'd like to make sure only to review the ones that are needed :) They are hard to check so I wanna economise on my time :) Perhaps an LLM can figure out which ones are absolutely necessary and then we can review those only :)
Review feedback on #1075: sp/zero/one/wordAsBV now live once in EVM.SMT.SMTLIB (re-exported by EVM.SMT, so downstream imports are unchanged); the abstract UF declarations sit under a single comment; obvious comments removed and wordy ones tightened across AbstractBase, AbstractLemmas and DivModEncoding, keeping the per-lemma soundness notes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflicts: hevm.cabal (keep both text and vector in the test/rpc-tests build-depends), cli/cli.hs and src/EVM/Effects.hs (keep both maxDynSize from main and abstractArith from this branch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Combines the two stacked proof-of-concept PRs into a single branch against
main, for easier review:Together they add an opt-in SMT arithmetic abstraction (
--abstract-arith) that lets hevm prove multiply-then-divide properties — vault share prices, swap quotes, fixed-point math — that otherwise never return because the solver bit-blasts a 256-bit symbolic multiplier/divider.The problem
For symbolic
a,bthe only decision procedure SMT has fora * b/a / bover(_ BitVec 256)is bit-blasting: a full 256×256 multiplier (~10^5 gates) or a divider, per operation. Multiply two unknowns and divide by a third — the shape at the heart of vault accounting — and the query explodes (unknown/OOM).The approach (two phases)
bvmul/bvudiv/bvsdiv/bvurem/bvsremover symbolic operands with uninterpreted functions (abst_evm_*), so the solver never bit-blasts them.(a/b)*b ≤ a, mulDiv bounds, and the constant-scaling family — const-cancellation(c1·x)/c2 == (c1/c2)·x, fraction-reduce, nested-division collapse, ceilDiv-cancel, and scaled-product telescoping.Soundness
Every lemma is true of ordinary arithmetic, so anything proved from them holds for the real operations (in effect you prove the property about a more general contract and the deployed one is a special case). Because
abst_evm_bvmulhas no ground truth, a satisfying model may assign it values real multiplication never would — so SAT is downgraded to Unknown whenever an abstract product is present (hasAbstractMul); the abstraction never reports a false counterexample.Structure
The lemma machinery lives in three modules so the lemmas are reviewable in one place:
EVM.SMT.AbstractBase— SMT primitives, signed helpers, the div/mod taxonomy, term collectors/matchers, andsaturate(closes the set of div/mul terms the lemmas range over).EVM.SMT.AbstractLemmas— the lemma catalogue: aLemmaInstsum type,collectLemmas(triggers), andemitLemma(SMT emission + per-lemma soundness note), one banner per lemma; GHC exhaustiveness ties each constructor to its emitter.EVM.SMT.DivModEncoding— orchestration (mulEncoding) + the div/mod ground-truth encoding.Usage
Opt-in via
hevm test --abstract-arith(off by default); also drivable through echidna's verification mode.Validation
Representative properties prove under
--abstract-arith(const-cancel, scaled const-cancel, fraction-reduce, nested-div, mul/div monotonicity, mulDiv bound, telescoping), and deliberately false identities correctly fail, confirming no lemma over-fires. The abstraction has been used to verify the full stateless conversion math of a production vault (Spark PSM3) and a large subset of the ABDK Math 64.64 property suite.Supersedes #996 and #1073 (left open for now).
Try it on a real target: spark-psm
The Spark PSM3 verification mentioned above is public and wired for one-command repro, so it is easy to experiment with and without the abstraction on real production code:
make verify-hevmrunsforge build --astfollowed byhevm test --root . --match "<M>" --solver bitwuzla --abstract-arith --smt-timeout 300 --max-iterations 5000; setHEVM=/path/to/hevmto pick the binary. Everyprove_assumes only the abstraction's operand budget,require(x <= type(uint128).max)per input. The properties intest/symbolic/ProveOriginals.t.soldelegate to the repo's unmodifiedtestFuzz_*functions, so what is proven is Spark's own test content;test/symbolic/README.mddocuments the full property set, the lemmas each one exercises, and the eleven known-out-of-reach cases.🤖 Generated with Claude Code