feat: add loop fixpoint and abstract transfer to interval analysis

Replace destination-node-kind widening with dominator-classified
natural-loop fixpoints. LoopStructure.from_function identifies back
edges via the dominator relation and gives each header a stable
identity, per-edge input ownership, and a replaceable generation-fact
set. Widening reads intervals from the previous and current complete
states without opening SMT query sessions. The engine's loop count now
uses the same LoopStructure instead of a separate DFS-stack heuristic.

Make operation-level interval transfer abstract-first and
solver-optional. Integer binary operations derive a conservative
interval during transfer and return a tracked ABSTRACT result without
opening a query session unless a caller requests refinement.
abstract_transfer.transfer_binary_interval intersects every result with
the destination integer type range and returns type top for unsupported
cases rather than guessing a narrower range.

Add unit and e2e coverage for loop fixpoints, abstract transfer, and
range refinement.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Marcelo Morales
2026-07-16 12:38:05 -04:00
co-authored by Claude Opus 4.8
parent c033b2b2e1
commit e1a1e69e4d
30 changed files with 4095 additions and 842 deletions
@@ -0,0 +1,264 @@
# Stage 4 Loop Fixpoint Report
## Scope and outcome
Stage 4 replaces destination-node-kind widening with dominator-defined natural-loop fixpoints. Each
loop header now has a stable identity, per-edge input ownership, a generation lifecycle, and one
replaceable generation-fact set. Widening reads the abstract intervals already stored in the
previous and current complete states; it performs no SMT range queries and does not mutate the
Stage 2 function encoding or query-session infrastructure.
The fixed-bound loop reaches its exit with `sum_2 = [0,45]` and `i_2 = [10,10]`. Its analysis phase
creates zero query sessions, compared with the Stage 3 widening baseline of 132 sessions. Four
explicit post-analysis `sum_2` queries create and close 12 isolated sessions.
Stage 1 fact identity, Stage 2 query isolation, Stage 3 complete state joins, public analysis entry
points, `solve_range`, nonlinear solving, property behavior, and the general FIFO worklist topology
remain unchanged.
## Back-edge classification
`LoopStructure.from_function` classifies an edge `source -> destination` as a natural back edge only
when `destination` is in `source.dominators`. This is the standard available CFG certificate that
the destination dominates the edge source. Incoming edges to the same header that do not satisfy
that predicate are entry edges.
There is no `successor.type == NodeType.IFLOOP` fallback. A preheader-to-`IFLOOP` edge initializes
the header and never invokes widening. Edge and loop collections are sorted by numeric node IDs, so
classification does not depend on function-node, predecessor, successor, or worklist arrival order.
The engine's function loop count now uses the same `LoopStructure` rather than a separate DFS-stack
heuristic.
## Loop identities and generations
The stable identities are:
- `LoopHeaderId(EncodingId, header_node_id)` for one header;
- `ControlFlowEdgeId(source_node_id, destination_node_id)` for one incoming edge;
- `LoopVariableId(header_name, entry_names, back_names)` for one static loop phi binding.
SSA entry and back names are classified using their definition nodes: a definition is loop-carried
when the header dominates that definition node. Names are sorted before becoming identity fields.
`LoopHeaderFixpoint` owns:
- the latest complete contribution from every entry edge;
- the latest complete contribution from every back edge;
- the previous complete header input;
- the current complete header input;
- the latest transferred header output and its generation;
- the current generation number;
- exactly one live tuple of generation-owned facts.
Generation zero is the entry approximation. A genuine back-edge update starts the next generation
only if joining its widened result changes the complete `SemanticStateId`. A duplicate or subsumed
input is a semantic no-op and neither advances the generation nor reruns the header.
If another latch arrives while the header is already queued, the tracker recomputes the same pending
generation from its saved previous input and previous output plus the deterministically combined
edge contributions. It does not compare against a stale output as a new generation.
## Widening lifecycle
`LoopWideningContext` carries the header ID, candidate generation, previous complete input, current
complete candidate input, previous transferred output, and static phi bindings. The default
analysis compatibility method remains available, while loop-aware analyses implement
`apply_loop_widening`.
Interval widening follows two paths:
1. A statically certified finite progression uses abstract unrolling for at most 64 generations.
Certification requires a constant monotone guard, checked arithmetic, and the same non-zero
constant step for every back alternative of the controlling phi. Once the conservative trip
bound is reached, an extra recurrence caused only by the non-relational join is rejected.
2. Other loops use threshold widening. Only the bound back-edge SSA names are widened, using the
previous header-phi interval from the previous output and the candidate back interval from the
current input. Thresholds are function literals plus the value's signed or unsigned type bounds.
Both paths preserve the remainder of the current complete state, including storage summaries,
dependencies, comparison metadata, explicit path facts, context, overflow metadata, and path
totality. No base-name matching remains.
Loop-header phis are recomputed from their currently active entry/back SSA alternatives. They are
not installed as cyclic immutable equations. Static loop metadata distinguishes an inactive back
alternative during generation zero from a missing active value. A missing active value yields the
full type interval rather than an under-approximation.
## Generation-fact ownership and replacement
Interval widening emits `LOOP_GENERATION`/`RANGE_BOUND` facts with the `LoopHeaderId`, generation,
context, variable name, and abstract interval in their identity. Their payload is the abstract
`NumericInterval`; they are diagnostic ownership records, not backend assertions.
The header tracker replaces its live fact tuple only when a generation changes or a pending
generation expands. It never registers these facts in `FunctionEncoding`, `State`, the property
registry, or the reusable backend. If an attempted generation is a semantic no-op, the previous
generation and facts remain live. Focused tests prove that generation-one IDs are absent after
generation two replaces them.
`SMTSolver.register_loop_generation_fact` continues to reject direct backend registration. This
prevents a caller from bypassing the header owner with permanent assertions.
## Previous and current state handling
The previous and current states are independent deep copies. The interval transformer reads:
- the previous phi value from `LoopWideningContext.previous_output`;
- the current back-edge value from `LoopWideningContext.current_input`.
Focused tests compare their distinct `SemanticStateId` values and the separately retained previous
output ID. Widening no longer calls `solve_range_result`, so there are no widening query sessions in
which one state could accidentally be materialized for both values.
## Telemetry
Opt-in telemetry adds `loop_fixpoints` metrics for:
- headers classified;
- entry- and back-edge propagations;
- generation advances;
- same-generation pending updates;
- semantic no-ops;
- generation-fact replacements;
- current and maximum live generation facts across headers.
The lifetime probe now snapshots analysis-only query-session totals before optional diagnostic range
queries and supports concise summary output. Telemetry remains disabled by default and is not a
semantic input.
## Files and paths changed
- `engine/loop.py`: natural-loop metadata, identities, widening context/result, and header tracker.
- `engine/direction.py`: natural-edge propagation and generation-aware header ownership.
- `engine/analysis.py`: loop-aware compatibility hooks.
- `engine/engine.py`: function preparation and consistent natural-loop counting.
- `analyses/interval/analysis/loop.py`: phi bindings and finite-progression certificates.
- `analyses/interval/analysis/analysis.py`: abstract-only bounded and threshold widening.
- `analyses/interval/operations/phi.py`: active loop-phi interval transfer without cyclic equations.
- `analyses/interval/operations/registry.py`: loop metadata injection for phi transfer.
- `smt_solver/facts.py`: stable `LoopHeaderId` and loop provenance.
- `smt_solver/solver.py`: explicit rejection of solver-owned loop-generation facts.
- `smt_solver/telemetry.py`: generation lifecycle metrics.
- `tests/unit/analyses/data_flow/test_fact_ownership.py`: updated rejection contract for the
loop-header owner.
- `tests/unit/analyses/data_flow/test_loop_fixpoint.py`: focused Stage 4 behavior tests.
- `tests/e2e/data_flow/interval/contracts/Test_LoopFixpoint.sol`: real multiple-latch and nested loops.
- `tests/e2e/data_flow/interval/solver_lifetime_probe.py`: analysis/query split and concise output.
- `analyses/interval/LIMITATIONS.md`: current loop behavior and remaining limits.
## Focused tests
The Stage 4 tests prove:
1. the preheader edge is not a back edge;
2. the latch edge is detected by dominance in either node-list order;
3. `LoopHeaderId` is stable;
4. generations advance only for a changed complete header approximation;
5. previous input, current input, and previous output retain their respective semantic IDs;
6. only the latest generation-fact IDs remain owned by the tracker;
7. two back edges produce the same complete state in either arrival order;
8. a duplicate back-edge input causes no generation advance or widening call;
9. interval widening preserves storage, dependencies, comparisons, explicit facts, context, and
path totality while leaving the encoding and query-session count unchanged;
10. all Stage 1-3 unit tests remain green;
11. every explicit range query closes its Stage 2 sessions without persistent assertions.
## Verification results
Completed successfully:
```text
.venv/bin/pytest -q tests/unit/analyses/data_flow/
62 passed
.venv/bin/ruff check <all modified Python files>
All checks passed
.venv/bin/python -m compileall -q <modified data-flow and test paths>
completed successfully
ty check --python .venv <modified Stage 4 core files>
All checks passed
git diff --check
completed successfully
```
All modified engine and interval-analysis functions pass a targeted cyclomatic-complexity limit of
eight. The existing worklist driver and operation telemetry classifier were split into private
helpers without changing queue order, transfer order, counters, or operation categories.
## Direct probes
All probes used Solidity 0.8.20 and opt-in telemetry.
| Scenario | Result | Lifecycle |
|---|---|---|
| fixed bound | 39 iterations; reachable `sum_2=[0,45]`, `i_2=[10,10]` | analysis 0/0 sessions; four explicit ranges 12/12 |
| two back edges | 15/15 nodes reached; 52 iterations; two latch edges, four generations | analysis 0/0 sessions |
| nested loops | 14/14 nodes reached; 44 iterations; two headers | analysis 0/0 sessions |
| branch arithmetic | joined `result_3=[10, MAX_UINT256-10]` | 3/3 explicit-query sessions |
| storage load/store | `x_1=[42,42]` | 3/3 explicit-query sessions |
| internal call | `_addTen(5)` result `TMP_3=[15,15]` | 3/3 explicit-query sessions |
| checked addition | successful result proven `[0,MAX_UINT256]` | 3/3 explicit-query sessions |
| symbolic multiplication, 50 ms | tagged timeout fallback `[0,MAX_UINT256]` | 3/3 sessions, timeout statuses retained |
Every probe ends with zero active sessions, zero cleanup imbalances, zero persistent backend
assertions, zero duplicate reusable assertions, and zero internal unclassified additions. The
nonlinear strategy and timeout tagging are unchanged.
The fixed loop intentionally takes 39 rather than the Stage 3 total of 19 worklist iterations: it
performs ten certified abstract generations instead of widening the accumulator immediately to top.
The solver cost falls from 132 analysis sessions to zero.
## Observable semantic changes
- A loop-entry edge no longer invokes widening merely because its destination is `IFLOOP`.
- Natural back edges to headers of any CFG node kind can be tracked when dominance proves them.
- Loop phi intervals reflect active SSA alternatives instead of starting unconstrained.
- The fixed-bound accumulator remains `[0,45]`; unsupported or ambiguous active phi alternatives
become full range.
- Multiple latches are retained as separate edge contributions and joined deterministically.
- Widening uses abstract bounds and creates no solver sessions or backend loop assertions.
No join rule, node revisit rule, worklist queue policy, nonlinear solver, property rule, snapshot
expectation, or public query interface was changed. Stage 4 intentionally replaces the old
destination-kind widening trigger and solver-backed bound lookup with the natural-loop lifecycle
and abstract widening described above.
## Snapshot and environment blocker
No snapshot was updated. The interval snapshot suite remains blocked during fixture import because
the existing environment does not provide the undeclared `rich` package:
```text
ModuleNotFoundError: No module named 'rich'
```
No dependency was installed or added. The global `ty` executable was used because `.venv/bin/ty` is
not present; the modified Stage 4 core files pass. Including `smt_solver/telemetry.py` in the same
type-check invocation reaches its pre-existing `rich.table` imports and reports that environment
blocker.
## Remaining limitations
- The finite progression certificate is deliberately narrow: checked constant steps, constant
monotone guards, and at most 64 conservative generations.
- Different latch steps, symbolic bounds, unchecked wraparound, or non-monotone updates use
threshold widening and may become full range quickly.
- Generation facts are diagnostic abstract ownership records; query sessions materialize the
ordinary derived state range facts, not a second loop-fact channel.
- There is no narrowing, relational invariant, path-disjunctive state, or general symbolic loop
summary.
- The body state produced immediately before a rejected extra recurrence can be wider than the
converged header and exit state; it is not propagated back into the header.
## Recommended Stage 5 entry point
Stage 5 should begin in the binary abstract transfer, not in loop ownership. Add sound interval
transformers for nonlinear operations when operand intervals are finite, preserving the existing
tagged solver result as an optional refinement. Multiplication should first compute a signedness- and
overflow-aware abstract product interval, then use isolated Stage 2 sessions only when exact solving
is requested and useful. The Stage 4 header tracker can consume those improved abstract values
without any lifecycle change.
@@ -0,0 +1,359 @@
# Stage 5: Abstract Nonlinear Transfer and Bounded Refinement
## Outcome
Operation-level interval analysis is abstract-first and solver-optional. Integer binary operations
derive a conservative interval during transfer. A tracked operation result, including a sound type
top, is returned as `ABSTRACT` without opening a query session unless a caller explicitly requests
refinement.
An unconstrained checked `uint256 x * y` now returns tagged abstract
`[0, MAX_UINT256]` in about 0.03 ms with zero solver sessions. A branch-bounded `x * 2`, where
`x=[10,99]`, returns `[20,198]` with zero solver sessions. The Stage 4 fixed loop still completes in
39 iterations, opens zero widening sessions, and returns `sum_2=[0,45]`.
Stage 1 fact identity, Stage 2 function/session ownership, Stage 3 state joins, and Stage 4 loop
generation semantics are unchanged. Stage 5 does not alter worklist topology, node revisit policy,
join, widening, property behavior, or public analysis entry points.
## Abstract transfer design
`abstract_transfer.py` defines:
- `AbstractTransferKind.EXACT`, `INTERVAL`, and `TOP`;
- `AbstractTransferResult`, containing the interval, precision kind, wrap possibility, and whether
the transformer supports the case; and
- `transfer_binary_interval`, the backend-independent transfer entry point.
Every returned interval is intersected with, or replaced by, the destination integer type range.
Unsupported cases return type top with `supported=False`; they do not guess a narrower range.
### Addition and subtraction
For operand intervals `A=[a_l,a_u]` and `B=[b_l,b_u]`:
```text
A + B = [a_l + b_l, a_u + b_u]
A - B = [a_l - b_u, a_u - b_l]
```
The same-operand subtraction identity returns `[0,0]`.
### Multiplication
For different operands:
```text
products = {a_l*b_l, a_l*b_u, a_u*b_l, a_u*b_u}
A * B = [min(products), max(products)]
```
For the same operand, the square relation is retained:
```text
lower = 0 when a_l <= 0 <= a_u
min(a_l*a_l, a_u*a_u) otherwise
upper = max(a_l*a_l, a_u*a_u)
```
This avoids the false negative lower bound produced by treating two occurrences of the same signed
value as independent intervals.
### Division
Division uses Solidity's truncation toward zero. Candidate dividends are the two endpoints and zero
when present. Candidate divisors are the two nonzero endpoints and the closest-to-zero values
`-1`/`1` when present. The hull of the candidate quotients is conservative; exhaustive 2- and 3-bit
enumeration confirmed it for signed and unsigned intervals.
The existing state-owned nonzero-divisor continuation fact remains authoritative. Signed division
also records the `MIN / -1` no-overflow continuation fact, including unchecked code, because that
Solidity operation is exceptional rather than wrapping.
### Modulo
Modulo follows the dividend's sign. For divisor magnitude `d` and dividend magnitude `a`, the
remainder magnitude is bounded by:
```text
min(max_abs(A), max_abs(B) - 1)
```
Unsigned and nonnegative results use `[0,magnitude]`; nonpositive signed results use
`[-magnitude,0]`; a signed interval crossing zero uses the symmetric hull. Singleton operands are
evaluated exactly. Division by zero remains excluded by the state-owned continuation fact.
### Exponentiation
Fixed nonnegative exponents up to 1024 use endpoint/parity algebra. Bounded nonnegative base and
exponent intervals up to that cap evaluate endpoint candidates plus zero/one transition points.
Bases `-1`, `0`, and `1` have direct rules. A large singleton exponent with a singleton unchecked
base uses Python's modular `pow` and exact two's-complement decoding.
Negative, unbounded, or otherwise unsupported symbolic exponent cases return tagged top. Symbolic
power is not registered as an SMT equation: Z3 has no backend-neutral bit-vector exponent operator,
and the prior multiplication-shaped encoding had different semantics.
### Shifts
Shift counts from zero through the bit width are enumerated; all greater counts share Solidity's
width-or-greater result. Unsigned right shift is logical, signed right shift is arithmetic, and left
shift truncates bits even in a checked scope. A wrapping singleton is decoded exactly. A nonconvex
left-shift image returns top.
Only constant shifts receive an immutable SMT equation. Symbolic shifts remain abstract because
coercing the right operand to the left width can truncate a wider Solidity shift count and assert a
different equation.
### Bitwise operations
Singleton `AND`, `OR`, and `XOR` are exact. Same-operand identities and zero identities are exact.
For nonnegative intervals, `AND` uses `[0,min(a_u,b_u)]`; `OR` and `XOR` use a mask through the
highest possible set bit. General signed non-singleton bitwise cases return tagged top.
## Checked and unchecked semantics
For addition, subtraction, multiplication, and bounded power:
- when the mathematical interval fits the destination type, it is returned directly;
- checked arithmetic intersects the mathematical interval with the type range, so wrapped outputs
are never included as successful results;
- an empty checked intersection becomes top because the current operation domain has no local
bottom value; state-owned continuation facts still exclude infeasible addition, subtraction, and
multiplication executions; and
- unchecked singleton overflow is wrapped and decoded exactly, while a non-singleton modular image
that cannot be represented soundly by one interval becomes top.
Shifts always use truncating bit-vector semantics. Division and modulo use Solidity exceptional
conditions rather than unchecked wrapping.
The multiplication tests enumerate every operand-interval pair at 2- and 3-bit widths for signed
and unsigned, checked and unchecked modes. Every concrete successful product is contained by the
abstract interval.
## Abstract-first range policy
`solve_variable_range` sends an `abstract_range` to the Stage 2 range API when a tracked value is
finite or is the result of an operation. `Z3Solver.solve_range_result` then returns:
```text
feasibility = NOT_ATTEMPTED
lower_status = ABSTRACT
upper_status = ABSTRACT
sessions = ()
```
The returned fallback interval is the tracked abstract interval, including when it equals the full
type range. This makes solver-proven full range, abstract full range, and timeout full-range fallback
observably different in `RangeResult`, diagnostics, JSON, and telemetry.
Callers can request optional refinement with `RangeQueryConfig.refine_abstract=True`. The tracked
lower and upper bounds are then materialized as two typed `QUERY_LOCAL` facts using signed or
unsigned bit-vector comparisons. This prevents a model from being clamped after optimization and
incorrectly labeled `PROVEN`: every refined bound is now proved under the abstract interval, the
complete selected semantic state, and other query-local facts.
The refinement facts are ephemeral. Tests verify that three feasibility/lower/upper sessions
materialize six copies total while the `FunctionEncoding` fact IDs and `SemanticStateId` remain
unchanged.
## Total refinement budget
`QueryBudget` is a monotonic expression-wide wall-clock budget. One annotation budget is shared by:
1. the optional feasibility query;
2. the independent lower objective;
3. the independent upper objective; and
4. deferred unchecked overflow and underflow feasibility checks.
Range feasibility is established at most once. Minimum and maximum use fresh Stage 2
`QuerySession`/`Optimize` instances and preserve independent statuses. Before every session, the
remaining budget is recomputed. No session is admitted with less than 50 ms remaining. Each Z3
check receives a conservative fraction of the remaining budget and a watchdog interrupt, preventing
one soft timeout from consuming the budgets intended for all later objectives. Checks are serialized
because the installed Z3 binding uses the shared default context.
When a bound cannot run or finishes with `TIMEOUT`, `UNKNOWN`, or `ERROR`, only that side receives
the sound tracked fallback. A proven opposite bound is retained. Proven UNSAT feasibility still
returns the existing unreachable/bottom convention; unknown or timeout feasibility does not.
The deterministic fake-clock test spends 2 ms on feasibility, the remaining 8 ms on a successful
lower bound, skips the upper objective, and returns lower `PROVEN` plus upper `TIMEOUT`. A real
100 ms forced nonlinear refinement completed in about 76 ms, opened and closed two sessions, and
skipped the final objective. A 20 ms budget was rejected before session creation and returned a
tagged timeout fallback in about 0.05 ms.
## Status and telemetry
Stage 5 adds `FeasibilityStatus.NOT_ATTEMPTED`, `QueryBudget`, and `BoundOutcome` without changing
the Stage 2 meanings of `SAT`, `UNSAT`, `UNKNOWN`, `TIMEOUT`, `ERROR`, `PROVEN`, `ABSTRACT`, and
`NOT_ATTEMPTED`.
Opt-in telemetry now records:
- abstract-only decisions and refinement attempts;
- sessions avoided;
- configured total budget and wall elapsed time;
- budget exhaustion and partial results; and
- feasibility, lower, and upper status counts.
Session creation/closure, materialization counts, compatibility facts, assertion copies, and cleanup
imbalances remain owned by the Stage 2 telemetry. Telemetry stays disabled by default and is not a
semantic source of truth.
## Files and paths changed
- `analyses/interval/operations/binary/abstract_transfer.py`: backend-neutral transfer rules.
- `analyses/interval/operations/binary/arithmetic.py`: operation integration, continuation facts,
abstract intervals, and safe suppression of unsupported power/symbolic-shift equations.
- `analysis.py`: abstract-first selection, tracked fallback, optional refinement-bound facts.
- `run_analysis.py`: one annotation-wide budget shared with deferred overflow checks.
- `smt_solver/query.py`: budgets, bound outcomes, diagnostics, and `NOT_ATTEMPTED` feasibility.
- `smt_solver/solver.py` and `smt_solver/__init__.py`: backend-neutral budget/result API exposure.
- `smt_solver/strategies/z3_solver.py`: budgeted feasibility and independent objective sessions,
watchdog classification, partial-bound combination, and abstract results.
- `smt_solver/telemetry.py`: range-refinement metrics.
- `tests/e2e/data_flow/interval/solver_lifetime_probe.py`: abstract/refinement status, budget, timing,
and session reporting plus forced-refinement mode.
- `tests/unit/analyses/data_flow/test_abstract_transfer.py`: multiplication soundness and
representative nonlinear transfer tests.
- `tests/unit/analyses/data_flow/test_range_refinement.py`: no-session, budget, partial-result, and
ownership tests.
- `tests/e2e/data_flow/interval/contracts/Test_AbstractTransfer.sol`: direct constant and bounded
operation probes.
- `analyses/interval/LIMITATIONS.md`: Stage 5 precision and solver-policy limits.
No snapshot expectation was modified.
## Remaining `Optimize` call sites
There are three constructors in `z3_solver.py`, representing two semantic paths:
1. `Z3Solver(use_optimizer=True)` and its `reset()` branch retain the legacy reusable optimizer API,
including `maximize()` and `minimize()`. The interval range path does not use this object.
2. `_create_optimizer_backend()` creates one fresh `Optimize` owned by one lower- or upper-bound
`QuerySession`. This is the only `Optimize` constructor used by Stage 5 refinement.
No objective is retained across independent range queries.
## Query-count and timing comparison
The pre-implementation Stage 4 audit and current probes used opt-in telemetry:
| Scenario | Stage 4 query behavior | Stage 5 result |
|---|---|---|
| unconstrained checked `uint256 x*y` | 3 sessions, about 100 ms, timeout fallback | abstract top, 0 sessions, about 0.03 ms |
| branch-bounded `x=[10,99]; x*2` | 3 sessions, about 45 ms | abstract `[20,198]`, 0 sessions, about 0.03 ms |
| fixed-loop `sum_2` at four states | 12 explicit sessions; analysis already used 0 | four abstract `[0,45]` results, 0 sessions, about 0.07 ms total |
| forced nonlinear refinement, 100 ms | each subquery could receive a full timeout | 2 balanced sessions, about 76 ms total, final objective skipped |
The timing values are machine-local observations, not performance assertions. Query counts and
statuses are deterministic architectural results.
## Verification
### Unit and static checks
```text
.venv/bin/pytest -q tests/unit/analyses/data_flow/
113 passed
.venv/bin/ruff check <all modified Python files>
passed
.venv/bin/python -m compileall -q slither/analyses/data_flow \
tests/unit/analyses/data_flow tests/e2e/data_flow/interval/solver_lifetime_probe.py
passed
ty check --python .venv \
abstract_transfer.py analysis.py query.py
passed
git diff --check
passed
```
A broader `ty` run over `arithmetic.py`, `run_analysis.py`, `z3_solver.py`, and `telemetry.py` is not
a clean repository gate: it reports 60 broad typing diagnostics, principally in existing SlithIR
operand unions, nullable interval domains/models, the coarse `SMTTerm` Z3 union, and the undeclared
`rich.table` module. Stage 5 did not add ignores or install dependencies to mask that debt.
An additional exhaustive audit enumerated all 2- and 3-bit intervals for division, modulo, power,
shifts, and bitwise operations under the relevant signed/unsigned and checked/unchecked modes. Every
concrete successful result was contained by the abstract interval.
### Direct probes
All probes used Solidity 0.8.20. Every row ended with zero active sessions, zero cleanup imbalances,
zero persistent backend assertions, zero duplicate reusable assertions, and zero internal reusable
unclassified additions.
| Scenario | Result | Query lifecycle |
|---|---|---|
| unconstrained checked multiplication | abstract unsigned top | 0 sessions |
| signed checked multiplication | abstract signed top | 0 sessions |
| unchecked multiplication | abstract unsigned top, wrap possible | 0 sessions |
| branch-bounded multiplication | `[20,198]` | 0 sessions |
| constant power `3**2` | `[9,9]` | 0 sessions |
| constant bitwise `10 & 12` | `[8,8]` | 0 sessions |
| constant division `10/5` | `[2,2]` | 0 sessions |
| constant modulo `10%5` | `[0,0]` | 0 sessions |
| constant left shift `10<<2` | `[40,40]` | 0 sessions |
| signed right shift `-10>>2` | `[-3,-3]` | 0 sessions |
| fixed loop | 39 iterations; `sum_2=[0,45]` | analysis 0; four range results 0 |
| storage store/load | `x_1=[42,42]` | 0 sessions |
| internal chained call | final result `[30,30]` | analysis 0; explicit result query 3/3 |
| concrete high-level chained call | final result `[30,30]` | analysis 0; explicit result query 3/3 |
| forced refinement, 20 ms | tagged timeout top | 0 sessions; rejected by admission policy |
| forced refinement, 100 ms | tagged timeout top | 2/2 sessions; about 76 ms wall time |
### Snapshot blocker
The snapshot command was attempted without update mode:
```text
SOLC_VERSION=0.8.20 .venv/bin/pytest -q tests/e2e/data_flow/interval/
25 setup errors: ModuleNotFoundError: No module named 'rich'
```
The missing dependency is imported by `source_view.py`, before any snapshot comparison runs. It was
not installed or added as part of Stage 5. Direct engine probes provide the integration evidence in
this environment.
## Observable behavior changes
- Finite operation results and operation-derived top now return tagged abstract bounds without
solver feasibility or optimization.
- Optional refinement proves bounds under typed query-local copies of the tracked abstract interval.
- Minimum and maximum retain independent statuses and one successful side is no longer discarded.
- Timeout, unknown, error, abstract, and proven full ranges remain distinct.
- Symbolic power no longer receives an incorrect multiplication equation.
- Symbolic shifts no longer receive an equation after potentially lossy width matching.
- Signed `MIN / -1` continuation semantics are recorded explicitly.
These changes restore sound operation semantics or make proof provenance explicit. No expected
snapshot output was changed automatically.
## Remaining precision limits
1. `require` and branch relations remain state-owned facts and do not always narrow operand
intervals before abstract transfer. The result can therefore be top even when optional SMT
refinement could recover a tighter bound.
2. A one-interval domain cannot represent most modular images. Non-singleton unchecked wrap returns
top rather than an unsound narrow hull.
3. General signed non-singleton bitwise operations return top.
4. Symbolic or very large power returns top unless a safe singleton rule applies; there is no
exponent SMT relation.
5. Symbolic shifts are abstract-only; they intentionally lose relational solver refinement.
6. A guaranteed checked failure can remain top when the operation layer has no local bottom. The
existing continuation facts preserve infeasibility for addition, subtraction, multiplication,
division, and modulo, but there is no general power-overflow fact.
7. An abstract-only result has feasibility `NOT_ATTEMPTED`; it is not a solver proof that the selected
semantic state is reachable.
8. The 50 ms session admission floor and fractional backend allocation are conservative. They favor
prompt, tagged fallback over attempting a session unlikely to complete.
9. The reusable `Z3Solver(use_optimizer=True)` compatibility surface remains, although interval
refinement uses only fresh session-owned optimizers.
These limits are explicit conservative losses of precision. They do not require changes to Stage 3
joins, Stage 4 widening, or the worklist to maintain soundness.
@@ -1,5 +1,31 @@
# Interval Analysis Limitations
## Abstract nonlinear transfer
Binary integer operations are evaluated abstract-first. Multiplication uses the four endpoint
products (or the square relation for the same operand), checked arithmetic intersects the
mathematical result with the destination type, and unchecked wrapping returns an exact wrapped
singleton or type top when a single interval cannot represent the modular image. Division,
remainder, bounded exponentiation, shifts, and bitwise operations have conservative interval
rules. Unsupported signed bitwise and symbolic-power cases return tagged type top.
Symbolic exponentiation is deliberately not registered as an SMT equation because the backend has
no native bit-vector exponent operator. Similarly, a shift with a symbolic right operand is kept
abstract when width matching would truncate the shift count. This loses relational refinement but
avoids asserting an equation with different semantics from Solidity.
The abstract transformer consumes operand intervals, not arbitrary relational facts. A `require`
condition remains a state-owned solver fact but does not necessarily rewrite the operand's interval;
in such a state the abstract result may remain top. Optional refinement can use the complete state,
plus typed query-local copies of the current abstract bounds, subject to the expression-wide query
budget.
Optional range refinement admits a Z3 session only when at least 50 ms remains. Each backend check
receives a conservative fraction of the remaining wall budget and a watchdog interrupt; no later
objective starts after the budget is exhausted. Z3 interrupts are serialized because the installed
backend uses a shared default context. This policy intentionally favors a tagged abstract fallback
over repeated nonlinear timeouts.
## Computed Constants in Non-Linear Operations
When using a computed constant (e.g., `x * (-2)` where `-2` is `TMP = 0 - 2`) in multiplication or division, the variable's range is not narrowed optimally. The result range is correct, but the input variable retains its full type range.
@@ -56,11 +82,13 @@ BOTTOM is the correct lattice element: the path after a revert is unreachable (n
## Loop Analysis (Widening)
Loops require special handling to guarantee analysis termination. Without intervention, loop iterations could produce ever-increasing bounds that never stabilize.
Loops use dominator-defined natural back edges and generation-scoped header fixpoints. A preheader
edge initializes the header; only an edge whose destination dominates its source can trigger
widening.
### Threshold Widening
We use **threshold widening** to ensure termination while preserving precision. Thresholds are collected from the function's numeric literals, bounded by type extremes:
Thresholds are collected from numeric literals and type extremes:
```
thresholds = [type_min, ...constants_from_function..., type_max]
@@ -68,89 +96,27 @@ thresholds = [type_min, ...constants_from_function..., type_max]
For uint256: `[0, ...constants..., 2^256 - 1]`
When a bound grows past a threshold, it jumps to the next threshold. If values don't stabilize, widening eventually reaches the type extremes (guaranteed fixpoint).
When a loop-carried bound grows, it jumps to the next threshold. Stable SSA values and non-loop
state components are retained. Widening compares the abstract interval in the previous transferred
header state with the back-edge interval in the current complete input; it does not issue solver
queries.
### Architecture
The engine and analysis layers are separated:
Each header tracker owns per-edge entry and back-edge contributions, the previous and current
complete inputs, the previous transferred output, and one live generation-fact set. Multiple
back-edge contributions are joined in stable edge order. A changed contribution to a header that is
already queued updates the pending generation rather than starting a generation against stale
output.
1. **Engine layer** (`direction.py`): Detects back edges (propagation to `NodeType.IFLOOP`) and calls `analysis.apply_widening()`
2. **Analysis layer** (`IntervalAnalysis`):
- `prepare_for_function()`: Collects thresholds from function literals
- `apply_widening()`: Applies domain-specific widening logic
3. **Phi handler** (`phi.py`): At loop headers, creates unconstrained variables
Loop-header phi intervals are derived from currently active SSA alternatives. They are not asserted
as cyclic immutable equations. If an active alternative is unavailable, the phi becomes full range.
```python
# Engine detects back edge, delegates to analysis
is_back_edge = successor.type == NodeType.IFLOOP
if is_back_edge:
state_to_propagate = analysis.apply_widening(state_to_propagate, son_state.pre, set())
```
### Fixed finite progressions
This maintains abstraction: the engine knows *when* to widen (back edges) but delegates *how* to widen to each concrete analysis.
### Selective Widening (Attempted)
The implementation attempts **selective widening**: only widen variables whose bounds actually grew, preserve stable ones. However, this doesn't achieve the desired effect due to SMT architecture limitations.
**Algorithm (in `apply_widening`):**
```
for each variable v:
current_bounds = query_smt(current_state, v)
previous_bounds = query_smt(previous_state, v) # matched by base name
if current_bounds ⊆ previous_bounds:
# Stable - keep current variable
result[v] = current[v]
else:
# Grew - widen to unconstrained
result[v] = create_unconstrained_variable()
```
**Why It Doesn't Work:**
The issue is that phi nodes at loop headers must create **unconstrained** variables:
1. If we constrain phi variables to incoming values (e.g., `i_2 == i_1`), the SMT constraints are permanent
2. When the loop counter increments (`i_3 = i_2 + 1`), the back-edge value can't feed back because `i_2` is stuck at 0
3. This makes loop exits unreachable (the solver finds the path unsatisfiable)
Since phi creates unconstrained variables:
- All loop variables start at `[0, MAX]`
- Selective widening compares `[0, MAX]` to `[0, MAX]` → always stable
- No precision is gained
**Example - stable variable that should stay `[0, 0]`:**
```solidity
function test_constant_increment() public pure returns (uint256) {
uint256 result = 0;
for (uint256 i = 0; i < 5; i++) {
result = result; // never changes!
}
return result;
}
```
**Actual analysis output:**
```
Line 21: result_1 ∈ [0, 0] # Before loop - precise ✓
Line 22: result_2 ∈ [0, MAX] # Loop header phi - widened ✗
i_2 ∈ [5, MAX] # At exit - narrowed ✓
Line 23: result_2 ∈ [0, MAX] # In body - still widened ✗
result_3 ∈ [0, MAX] # After assignment - widened ✗
Line 25: result_2 ∈ [0, MAX] # At return - widened ✗ (should be [0, 0])
```
Even though `result = result` never changes the value, the analysis reports `[0, MAX]` because the phi at the loop header creates an unconstrained variable.
**Potential Fixes (require architectural changes):**
1. Track bounds as metadata instead of SMT constraints
2. Use solver push/pop to scope constraints per iteration
3. Use unique SMT variable names per iteration (e.g., `i_2_iter1`, `i_2_iter2`)
### Results
Loop analysis produces **sound but conservative** results:
A loop with a statically certified checked induction-variable step and a constant monotone guard is
unrolled abstractly for at most 64 generations. Once its conservative trip bound is exhausted, the
engine rejects the extra recurrence introduced by the non-relational interval join. For example:
```solidity
function test_fixed_bound_loop() public pure returns (uint256) {
@@ -162,42 +128,17 @@ function test_fixed_bound_loop() public pure returns (uint256) {
}
```
**Actual analysis output:**
```
Line 10: sum_1 ∈ [0, 0] # Before loop - precise
Line 11: i_1 ∈ [0, 0] # Loop init - precise
sum_2 ∈ [0, MAX] # Loop header phi - widened
i_2 ∈ [10, MAX] # At exit check - narrowed by i >= 10
Line 12: i_2 ∈ [0, 9] # In body - narrowed by i < 10
sum_3 ∈ [0, MAX] # After sum += i - widened
i_3 ∈ [1, 10] # After i++ - narrowed
Line 15: sum_2 ∈ [0, MAX] # At return - widened (actual: 45)
i_2 ∈ [10, MAX] # At return - narrowed (actual: 10)
sum_2 at the reachable exit: [0, 45]
i_2 at the reachable exit: [10, 10]
```
**What works:**
- `i_2` in loop body: `[0, 9]` ✓ (correctly narrowed by `i < 10`)
- `i_2` at return: `[10, MAX]` ✓ (correctly narrowed by exit `i >= 10`)
**What doesn't work:**
- `sum_2` everywhere: `[0, MAX]` ✗ (should be `[0, 45]` or at return exactly `45`)
### Trade-offs
| Aspect | Behavior |
|--------|----------|
| **Termination** | Guaranteed - widening forces fixpoint |
| **Soundness** | Maintained - intervals are over-approximations |
| **Precision** | Conservative - loop-modified variables lose bounds |
| **Loop counters** | Narrowed by exit conditions (correct final range) |
| **Accumulators** | Full type range (no symbolic loop summarization) |
### Limitations
1. **No narrowing phase**: Don't recover precision after widening by propagating exit conditions backward
2. **No loop unrolling**: Fixed-bound loops aren't unrolled for precise analysis
3. **No symbolic summarization**: Can't express "sum = 0 + 1 + ... + 9 = 45"
4. **SMT overhead**: Each widening comparison requires SMT solver queries for bounds
1. There is no narrowing phase after threshold widening.
2. Unchecked, symbolic-bound, non-monotone, or more-than-64-generation loops use threshold widening.
3. Different back-edge steps are joined conservatively and may quickly reach full range.
4. There is no relational or disjunctive loop invariant and no general symbolic summation.
## Storage Operations (sstore/sload) - Convex Hull
@@ -8,6 +8,10 @@ from slither.analyses.data_flow.analyses.interval.analysis.domain import (
DomainVariant,
IntervalDomain,
)
from slither.analyses.data_flow.analyses.interval.analysis.loop import (
PRECISE_LOOP_GENERATION_LIMIT,
IntervalLoopMetadata,
)
from slither.analyses.data_flow.analyses.interval.core.state import ComparisonInfo, State
from slither.analyses.data_flow.analyses.interval.core.tracked_variable import (
NumericInterval,
@@ -25,6 +29,11 @@ from slither.analyses.data_flow.analyses.interval.operations.type_utils import (
from slither.analyses.data_flow.engine.analysis import Analysis
from slither.analyses.data_flow.engine.direction import Direction, Forward
from slither.analyses.data_flow.engine.domain import Domain
from slither.analyses.data_flow.engine.loop import (
LoopVariableId,
LoopWideningContext,
LoopWideningResult,
)
from slither.analyses.data_flow.logger import get_logger
from slither.analyses.data_flow.smt_solver.facts import (
AnalysisContextId,
@@ -34,11 +43,11 @@ from slither.analyses.data_flow.smt_solver.facts import (
FactOriginKind,
FactOwnerKind,
FactProvenance,
LoopHeaderId,
StaticOperationId,
)
from slither.analyses.data_flow.smt_solver.solver import SMTSolver
from slither.analyses.data_flow.smt_solver.telemetry import get_telemetry
from slither.analyses.data_flow.smt_solver.query import BoundStatus
from slither.core.cfg.node import Node, NodeType
from slither.core.declarations.function import Function
from slither.core.solidity_types.elementary_type import ElementaryType
@@ -64,6 +73,7 @@ class IntervalAnalysis(Analysis):
self._thresholds: list[int] = []
self._timeout_ms: int = timeout_ms
self._root_context = AnalysisContextId.unbound()
self._loop_metadata = IntervalLoopMetadata()
@property
def solver(self) -> SMTSolver:
@@ -83,6 +93,8 @@ class IntervalAnalysis(Analysis):
self._root_context = AnalysisContextId.root(function)
self._solver.bind_function_encoding(self._root_context.encoding_id)
self._thresholds = self._collect_thresholds(function)
self._loop_metadata = IntervalLoopMetadata.from_function(function)
self._registry.configure_loop_metadata(self._loop_metadata)
logger.debug(
"Collected {count} thresholds for {name}: {thresholds}",
count=len(self._thresholds),
@@ -129,6 +141,10 @@ class IntervalAnalysis(Analysis):
"""Return the sorted list of widening thresholds."""
return self._thresholds
def loop_variables(self, header_id: LoopHeaderId) -> tuple[LoopVariableId, ...]:
"""Return the loop-carried SSA bindings for one stable header."""
return self._loop_metadata.variables_for(header_id)
def transfer_function(
self,
node: Node,
@@ -265,77 +281,68 @@ class IntervalAnalysis(Analysis):
if telemetry is None or not telemetry.enabled:
return
telemetry.record_transfer_op(self._operation_category(operation), handled=True)
@staticmethod
def _operation_category(operation: Operation) -> str:
"""Classify one transfer operation for opt-in telemetry."""
from slither.slithir.operations import Assignment
from slither.slithir.operations.binary import Binary
from slither.slithir.operations.condition import Condition
from slither.slithir.operations.high_level_call import HighLevelCall
from slither.slithir.operations.internal_call import InternalCall
from slither.slithir.operations.library_call import LibraryCall
from slither.slithir.operations.solidity_call import SolidityCall
from slither.slithir.operations.unary import Unary
op_type = type(operation)
operation_type = type(operation)
if operation_type is Binary:
return IntervalAnalysis._binary_operation_category(operation)
if operation_type is Unary:
return "arithmetic"
if operation_type is SolidityCall:
return IntervalAnalysis._solidity_call_category(operation)
if operation_type in (HighLevelCall, InternalCall, LibraryCall):
return "call"
if operation_type is Condition:
return "comparison"
if operation_type is Assignment:
return "assignment"
return "assignment"
# Categorize by operation type
if op_type == Binary:
# Further categorize binary operations
binary_op = operation
op_type_enum = getattr(binary_op, "type", None)
if op_type_enum is not None:
op_name_str = str(op_type_enum.name) if hasattr(op_type_enum, "name") else ""
if op_name_str in (
"ADDITION",
"SUBTRACTION",
"MULTIPLICATION",
"DIVISION",
"MODULO",
"POWER",
):
telemetry.record_transfer_op("arithmetic", handled=True)
elif op_name_str in (
"LESS",
"GREATER",
"LESS_EQUAL",
"GREATER_EQUAL",
"EQUAL",
"NOT_EQUAL",
):
telemetry.record_transfer_op("comparison", handled=True)
elif op_name_str in (
"AND",
"OR",
"LEFT_SHIFT",
"RIGHT_SHIFT",
"CARET",
"OROR",
"ANDAND",
):
telemetry.record_transfer_op("bitwise", handled=True)
else:
telemetry.record_transfer_op("arithmetic", handled=True)
else:
telemetry.record_transfer_op("arithmetic", handled=True)
elif op_type == Unary:
telemetry.record_transfer_op("arithmetic", handled=True)
elif op_type == SolidityCall:
# Check for memory/storage operations
func_name = getattr(operation, "function", None)
func_str = str(func_name) if func_name else ""
if "mstore" in func_str or "mload" in func_str:
telemetry.record_transfer_op("memory", handled=True)
elif "sstore" in func_str or "sload" in func_str:
telemetry.record_transfer_op("storage", handled=True)
else:
telemetry.record_transfer_op("call", handled=True)
elif op_type in (HighLevelCall, InternalCall, LibraryCall):
telemetry.record_transfer_op("call", handled=True)
elif op_type == Assignment:
telemetry.record_transfer_op("assignment", handled=True)
elif op_type == Condition:
telemetry.record_transfer_op("comparison", handled=True)
else:
# Phi, TypeConversion, etc.
telemetry.record_transfer_op("assignment", handled=True)
@staticmethod
def _binary_operation_category(operation: Operation) -> str:
operation_kind = getattr(operation, "type", None)
operation_name = str(getattr(operation_kind, "name", ""))
if operation_name in {
"LESS",
"GREATER",
"LESS_EQUAL",
"GREATER_EQUAL",
"EQUAL",
"NOT_EQUAL",
}:
return "comparison"
if operation_name in {
"AND",
"OR",
"LEFT_SHIFT",
"RIGHT_SHIFT",
"CARET",
"OROR",
"ANDAND",
}:
return "bitwise"
return "arithmetic"
@staticmethod
def _solidity_call_category(operation: Operation) -> str:
function = getattr(operation, "function", None)
function_name = str(function) if function else ""
if "mstore" in function_name or "mload" in function_name:
return "memory"
if "sstore" in function_name or "sload" in function_name:
return "storage"
return "call"
def apply_condition(self, domain: Domain, condition: Condition, branch_taken: bool) -> Domain:
"""Apply branch-specific narrowing based on a condition.
@@ -404,9 +411,7 @@ class IntervalAnalysis(Analysis):
) -> bool:
"""Apply every non-relational restriction for the selected branch."""
for refinement in comparison_info.refinements:
reachable = (
refinement.true_reachable if branch_taken else refinement.false_reachable
)
reachable = refinement.true_reachable if branch_taken else refinement.false_reachable
if not reachable:
return False
interval = refinement.true_interval if branch_taken else refinement.false_interval
@@ -420,267 +425,129 @@ class IntervalAnalysis(Analysis):
return condition_term
return self._solver.Not(condition_term)
def apply_widening(
self, current_state: Domain, previous_state: Domain, widening_thresholds: set
) -> Domain:
"""Selective threshold widening for loop back edges.
def apply_loop_widening(self, context: LoopWideningContext) -> LoopWideningResult:
"""Widen only loop-carried SSA values using their abstract intervals."""
current = self._concrete_interval_domain(context.current_input)
if current is None:
return LoopWideningResult(context.current_input)
precise = self._precise_loop_result(context, current)
if precise is not None:
return precise
previous = self._concrete_interval_domain(context.previous_output)
if previous is None:
return self._loop_result(context, current)
widened = self._widen_loop_variables(context, current, previous)
return self._loop_result(context, widened)
Only widens variables whose bounds actually grew between iterations.
Stable variables (bounds unchanged) are preserved with original bounds.
@staticmethod
def _concrete_interval_domain(domain: Domain | None) -> IntervalDomain | None:
"""Return a reachable interval state suitable for abstract widening."""
if not isinstance(domain, IntervalDomain):
return None
if domain.variant is not DomainVariant.STATE or domain.state is None:
return None
return domain
SSA names differ across iterations (result_2 vs result_3), so we match
variables by base name (stripping the SSA suffix).
def _precise_loop_result(
self,
context: LoopWideningContext,
current: IntervalDomain,
) -> LoopWideningResult | None:
"""Use bounded unrolling only for a statically certified progression."""
progression = self._loop_metadata.progression_for(context.header_id)
if progression is None or current.state is None:
return None
maximum = progression.maximum_iterations(current.state)
if maximum is None or maximum > PRECISE_LOOP_GENERATION_LIMIT:
return None
if progression.exhaustively_unrolled(context):
return LoopWideningResult(context.previous_input.deep_copy())
return self._loop_result(context, current)
Args:
current_state: The state being propagated (from loop body).
previous_state: The existing state at the loop header.
widening_thresholds: Unused (thresholds stored on analysis).
Returns:
Widened state with selectively widened variables.
"""
if not isinstance(current_state, IntervalDomain):
return current_state
if current_state.variant != DomainVariant.STATE or current_state.state is None:
return current_state
if not isinstance(previous_state, IntervalDomain):
return current_state
# First iteration (BOTTOM) - no widening needed
if previous_state.variant == DomainVariant.BOTTOM:
return current_state
if previous_state.state is None:
return current_state
# Build base name -> variable mappings for both states
previous_by_base = self._build_base_name_map(previous_state.state)
widened_state = current_state.state.deep_copy()
for variable_name in current_state.state.variable_names():
current_variable = current_state.state.get_variable(variable_name)
if current_variable is None:
def _widen_loop_variables(
self,
context: LoopWideningContext,
current: IntervalDomain,
previous: IntervalDomain,
) -> IntervalDomain:
"""Apply threshold widening to only statically bound back-edge values."""
if current.state is None or previous.state is None:
raise ValueError("Loop widening requires concrete current and previous states")
widened = current.state.deep_copy()
for binding in context.variables:
previous_value = previous.state.get_variable(binding.header_name)
if previous_value is None:
continue
for name in binding.back_names:
candidate = current.state.get_variable(name)
if candidate is None:
continue
interval = self._widened_interval(candidate, previous_value)
widened.set_variable(name, candidate.with_interval(interval))
return IntervalDomain.with_state(widened)
# Match by base name (strip SSA suffix)
base_name = self._extract_base_name(variable_name)
previous_variable = previous_by_base.get(base_name)
if previous_variable is None:
# New variable - keep as-is
widened_state.set_variable(variable_name, current_variable)
continue
# Compare bounds to check if value grew
if self._bounds_are_stable(current_variable, previous_variable, current_state):
# Stable - keep current variable (preserves its constraints)
widened_state.set_variable(variable_name, current_variable)
else:
# Grew - widen to unconstrained (full type range)
# NOTE: We cannot add explicit bounds constraints because SMT
# constraints are permanent. Asserting bounds makes exit branches
# unreachable. Full widening to type range is sound but imprecise.
widened_variable = self._create_unconstrained_variable(current_variable)
widened_state.set_variable(variable_name, widened_variable)
return IntervalDomain.with_state(widened_state)
def _extract_base_name(self, ssa_name: str) -> str:
"""Extract base variable name from SSA name (strip _N suffix)."""
# SSA names are like "result_3", "i_2", "TMP_5"
# We want to extract "result", "i", "TMP"
parts = ssa_name.rsplit("_", 1)
if len(parts) == 2 and parts[1].isdigit():
return parts[0]
return ssa_name
def _build_base_name_map(self, state: State) -> dict[str, TrackedSMTVariable]:
"""Build mapping from base name to variable (latest SSA version)."""
base_map: dict[str, TrackedSMTVariable] = {}
for variable_name in state.variable_names():
base_name = self._extract_base_name(variable_name)
variable = state.get_variable(variable_name)
if variable is not None:
# If multiple SSA versions, keep any (they should have same bounds)
base_map[base_name] = variable
return base_map
def _bounds_are_stable(
def _widened_interval(
self,
current_variable: TrackedSMTVariable,
previous_variable: TrackedSMTVariable,
current_state: IntervalDomain,
) -> bool:
"""Check if current bounds are contained within previous bounds.
Returns True if current ⊆ previous (no growth).
"""
current_bounds = self._query_variable_bounds(current_variable, current_state)
previous_bounds = self._query_variable_bounds(previous_variable, current_state)
if current_bounds is None or previous_bounds is None:
return False # Unknown - assume unstable
current_min, current_max = current_bounds
previous_min, previous_max = previous_bounds
# Stable if current is contained within previous
return current_min >= previous_min and current_max <= previous_max
def _query_variable_bounds(
self,
variable: TrackedSMTVariable,
domain: IntervalDomain,
) -> tuple[int, int] | None:
"""Query min/max bounds for a variable using SMT solver."""
if domain.state is None:
return None
result = self._solver.solve_range_result(
variable.term,
state_id=domain.state.semantic_id(),
state_facts=domain.state.get_facts(),
timeout_ms=self._timeout_ms,
signed=bool(variable.base.metadata.get("is_signed", False)),
)
if result.lower_status is not BoundStatus.PROVEN or (
result.upper_status is not BoundStatus.PROVEN
):
return None
if result.lower is None or result.upper is None:
return None
return (result.lower, result.upper)
def _widen_variable_to_threshold(
self,
current_variable: TrackedSMTVariable,
previous_variable: TrackedSMTVariable,
current_state: IntervalDomain,
) -> TrackedSMTVariable:
"""Create new variable with bounds widened to next threshold."""
current_bounds = self._query_variable_bounds(current_variable, current_state)
previous_bounds = self._query_variable_bounds(previous_variable, current_state)
if current_bounds is None or previous_bounds is None:
return self._create_unconstrained_variable(current_variable)
widened_min, widened_max = self._compute_widened_bounds(current_bounds, previous_bounds)
return self._create_variable_with_bounds(
current_variable,
widened_min,
widened_max,
current_state,
)
def _compute_widened_bounds(
self,
current_bounds: tuple[int, int],
previous_bounds: tuple[int, int],
) -> tuple[int, int]:
"""Compute widened bounds using threshold list."""
current_min, current_max = current_bounds
previous_min, previous_max = previous_bounds
widened_min = current_min
widened_max = current_max
# If lower bound decreased, widen to next threshold below
if current_min < previous_min:
widened_min = self._next_threshold_below(current_min)
# If upper bound increased, widen to next threshold above
if current_max > previous_max:
widened_max = self._next_threshold_above(current_max)
return (widened_min, widened_max)
def _next_threshold_below(self, value: int) -> int:
"""Find largest threshold ≤ value."""
for threshold in reversed(self._thresholds):
if threshold <= value:
return threshold
return self._thresholds[0] if self._thresholds else 0
def _next_threshold_above(self, value: int) -> int:
"""Find smallest threshold ≥ value."""
for threshold in self._thresholds:
if threshold >= value:
return threshold
return self._thresholds[-1] if self._thresholds else (1 << 256) - 1
def _create_unconstrained_variable(self, template: TrackedSMTVariable) -> TrackedSMTVariable:
"""Create an unconstrained variable with same type as template."""
is_signed = bool(template.base.metadata.get("is_signed", False))
width_metadata = template.base.metadata.get("bit_width", 256)
bit_width = width_metadata if isinstance(width_metadata, int) else 256
variable = TrackedSMTVariable.create(
self._solver,
template.name,
template.sort,
is_signed=is_signed,
bit_width=bit_width,
)
return variable.with_overflow_predicates(
no_overflow=template.no_overflow,
no_underflow=template.no_underflow,
operation_id=template.overflow_operation_id,
is_unchecked=template.is_unchecked,
)
def _create_variable_with_bounds(
self,
template: TrackedSMTVariable,
lower_bound: int,
upper_bound: int,
current_state: IntervalDomain,
) -> TrackedSMTVariable:
"""Create a variable constrained to [lower_bound, upper_bound]."""
is_signed = bool(template.base.metadata.get("is_signed", False))
width_metadata = template.base.metadata.get("bit_width", 256)
bit_width = width_metadata if isinstance(width_metadata, int) else 256
new_variable = TrackedSMTVariable.create(
self._solver,
template.name,
template.sort,
is_signed=is_signed,
bit_width=bit_width,
)
# Add bounds constraints
lower_term = self._solver.create_constant(lower_bound, template.sort)
upper_term = self._solver.create_constant(upper_bound, template.sort)
if is_signed:
lower_constraint = self._solver.bv_sge(new_variable.term, lower_term)
upper_constraint = self._solver.bv_sle(new_variable.term, upper_term)
else:
lower_constraint = self._solver.bv_uge(new_variable.term, lower_term)
upper_constraint = self._solver.bv_ule(new_variable.term, upper_term)
for role, constraint in (
("widened_lower_bound", lower_constraint),
("widened_upper_bound", upper_constraint),
):
fact = Fact(
fact_id=FactId(
owner=FactOwnerKind.LOOP_GENERATION,
kind=FactKind.RANGE_BOUND,
provenance=FactProvenance(
context_id=current_state.context_id,
origin_kind=FactOriginKind.LOOP,
),
semantic_key=(role, template.name),
current: TrackedSMTVariable,
previous: TrackedSMTVariable,
) -> NumericInterval:
"""Widen growing bounds to the nearest literal or type threshold."""
current_interval = current.interval
previous_interval = previous.interval
type_interval = current.type_interval
thresholds = sorted(
{
type_interval.lower,
type_interval.upper,
*(
value
for value in self._thresholds
if type_interval.lower <= value <= type_interval.upper
),
formula=constraint,
)
self._solver.register_loop_generation_fact(fact)
}
)
lower = current_interval.lower
upper = current_interval.upper
if lower < previous_interval.lower:
lower = max(value for value in thresholds if value <= lower)
if upper > previous_interval.upper:
upper = min(value for value in thresholds if value >= upper)
return NumericInterval(lower, upper)
return new_variable
def _loop_result(
self,
context: LoopWideningContext,
domain: IntervalDomain,
) -> LoopWideningResult:
"""Attach replaceable generation facts to one widened abstract state."""
if domain.state is None:
return LoopWideningResult(domain)
facts: list[Fact[object]] = []
for binding in context.variables:
for name in binding.back_names:
variable = domain.state.get_variable(name)
if variable is None or variable.interval == variable.type_interval:
continue
interval = variable.interval
facts.append(
Fact(
fact_id=FactId(
owner=FactOwnerKind.LOOP_GENERATION,
kind=FactKind.RANGE_BOUND,
provenance=FactProvenance(
context_id=domain.context_id,
origin_kind=FactOriginKind.LOOP,
loop_header_id=context.header_id,
loop_generation=context.generation,
),
semantic_key=(
"abstract_loop_range",
name,
str(interval.lower),
str(interval.upper),
),
),
formula=interval,
)
)
return LoopWideningResult(domain, tuple(facts))
@@ -0,0 +1,280 @@
"""Static loop metadata used by interval widening."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from slither.analyses.data_flow.analyses.interval.analysis.domain import (
DomainVariant,
IntervalDomain,
)
from slither.analyses.data_flow.engine.loop import (
LoopStructure,
LoopVariableId,
LoopWideningContext,
)
from slither.analyses.data_flow.smt_solver.facts import LoopHeaderId
from slither.slithir.operations.binary import Binary, BinaryType
from slither.slithir.operations.condition import Condition
from slither.slithir.operations.phi import Phi
from slither.slithir.variables.constant import Constant
if TYPE_CHECKING:
from slither.analyses.data_flow.analyses.interval.core.state import State
from slither.core.cfg.node import Node
from slither.core.declarations.function import Function
from slither.slithir.operations.operation import Operation
PRECISE_LOOP_GENERATION_LIMIT = 64
def _variable_name(value: object) -> str:
"""Return an SSA or ordinary name for metadata-only inspection."""
ssa_name = getattr(value, "ssa_name", None)
if isinstance(ssa_name, str):
return ssa_name
name = getattr(value, "name", None)
if not isinstance(name, str):
raise TypeError(f"Loop operand {value!r} has no variable name")
return name
@dataclass(frozen=True)
class LoopProgression:
"""Certified monotone induction variable controlling one loop exit."""
variable: LoopVariableId
comparison: BinaryType
bound: int
step: int
def maximum_iterations(self, state: State) -> int | None:
"""Return a conservative finite trip bound from the entry interval."""
entry_interval = None
for name in self.variable.entry_names:
tracked = state.get_variable(name)
if tracked is None:
return None
entry_interval = (
tracked.interval
if entry_interval is None
else entry_interval.hull(tracked.interval)
)
if entry_interval is None:
return None
if self.step > 0:
target = self.bound + int(self.comparison is BinaryType.LESS_EQUAL)
distance = max(0, target - entry_interval.lower)
return (distance + self.step - 1) // self.step
target = self.bound - int(self.comparison is BinaryType.GREATER_EQUAL)
distance = max(0, entry_interval.upper - target)
magnitude = -self.step
return (distance + magnitude - 1) // magnitude
def exhaustively_unrolled(self, context: LoopWideningContext) -> bool:
"""Return whether a bounded loop has reached its certified trip limit."""
current = context.current_input
previous_output = context.previous_output
if not isinstance(current, IntervalDomain) or not isinstance(
previous_output, IntervalDomain
):
return False
if current.variant is not DomainVariant.STATE or current.state is None:
return False
if previous_output.variant is not DomainVariant.STATE or previous_output.state is None:
return False
maximum = self.maximum_iterations(current.state)
if maximum is None or maximum > PRECISE_LOOP_GENERATION_LIMIT:
return False
if context.generation <= maximum:
return False
header_value = previous_output.state.get_variable(self.variable.header_name)
if header_value is None:
return False
if self.step > 0:
return header_value.interval.upper >= self.bound
return header_value.interval.lower <= self.bound
class IntervalLoopMetadata:
"""Loop-carried SSA bindings and certified finite progressions."""
def __init__(
self,
variables: dict[LoopHeaderId, tuple[LoopVariableId, ...]] | None = None,
progressions: dict[LoopHeaderId, LoopProgression] | None = None,
) -> None:
self._variables = dict(variables or {})
self._progressions = dict(progressions or {})
@classmethod
def from_function(cls, function: Function) -> IntervalLoopMetadata:
"""Derive metadata solely from stable SSA and dominator information."""
structure = LoopStructure.from_function(function)
nodes = {node.node_id: node for node in function.nodes}
definitions = cls._definitions(function)
variables = {}
progressions = {}
for loop in structure.loops:
header = nodes[loop.header_id.node_id]
bindings = cls._loop_variables(header, definitions)
variables[loop.header_id] = bindings
progression = cls._progression(header, bindings, definitions)
if progression is not None:
progressions[loop.header_id] = progression
return cls(variables, progressions)
@staticmethod
def _definitions(function: Function) -> dict[str, tuple[Operation, Node]]:
definitions = {}
for node in function.nodes:
for operation in node.irs_ssa or ():
lvalue = getattr(operation, "lvalue", None)
if lvalue is not None:
definitions[_variable_name(lvalue)] = (operation, node)
return definitions
@staticmethod
def _loop_variables(
header: Node,
definitions: dict[str, tuple[Operation, Node]],
) -> tuple[LoopVariableId, ...]:
variables = []
for operation in header.irs_ssa or ():
if not isinstance(operation, Phi) or operation.lvalue is None:
continue
entry_names = []
back_names = []
for rvalue in operation.rvalues:
name = _variable_name(rvalue)
definition = definitions.get(name)
is_back = definition is not None and header in definition[1].dominators
(back_names if is_back else entry_names).append(name)
if entry_names and back_names:
variables.append(
LoopVariableId(
_variable_name(operation.lvalue),
tuple(sorted(entry_names)),
tuple(sorted(back_names)),
)
)
return tuple(sorted(variables))
@classmethod
def _progression(
cls,
header: Node,
variables: tuple[LoopVariableId, ...],
definitions: dict[str, tuple[Operation, Node]],
) -> LoopProgression | None:
comparison = cls._condition_comparison(header)
if comparison is None:
return None
for variable in variables:
normalized = cls._normalize_comparison(comparison, variable.header_name)
if normalized is None:
continue
comparison_type, bound = normalized
steps = {
cls._progression_step(definitions.get(name), variable.header_name)
for name in variable.back_names
}
if None in steps or len(steps) != 1:
continue
step = steps.pop()
if step is None or not cls._moves_toward_exit(comparison_type, step):
continue
return LoopProgression(variable, comparison_type, bound, step)
return None
@staticmethod
def _condition_comparison(header: Node) -> Binary | None:
operations = header.irs_ssa or ()
conditions = [operation for operation in operations if isinstance(operation, Condition)]
if len(conditions) != 1:
return None
condition_name = _variable_name(conditions[0].value)
return next(
(
operation
for operation in operations
if isinstance(operation, Binary)
and operation.lvalue is not None
and _variable_name(operation.lvalue) == condition_name
),
None,
)
@staticmethod
def _normalize_comparison(
operation: Binary,
variable_name: str,
) -> tuple[BinaryType, int] | None:
left_name = _variable_name(operation.variable_left)
right_name = _variable_name(operation.variable_right)
if left_name == variable_name and isinstance(operation.variable_right, Constant):
value = operation.variable_right.value
return (operation.type, value) if isinstance(value, int) else None
if right_name != variable_name or not isinstance(operation.variable_left, Constant):
return None
value = operation.variable_left.value
if not isinstance(value, int):
return None
reversed_types = {
BinaryType.LESS: BinaryType.GREATER,
BinaryType.LESS_EQUAL: BinaryType.GREATER_EQUAL,
BinaryType.GREATER: BinaryType.LESS,
BinaryType.GREATER_EQUAL: BinaryType.LESS_EQUAL,
}
reversed_type = reversed_types.get(operation.type)
return (reversed_type, value) if reversed_type is not None else None
@staticmethod
def _progression_step(
definition: tuple[Operation, Node] | None,
header_name: str,
) -> int | None:
if definition is None:
return None
raw_operation, node = definition
if not isinstance(raw_operation, Binary):
return None
operation = raw_operation
if not bool(getattr(node.scope, "is_checked", False)):
return None
left = operation.variable_left
right = operation.variable_right
if operation.type is BinaryType.ADDITION:
if _variable_name(left) == header_name and isinstance(right, Constant):
return right.value if isinstance(right.value, int) else None
if _variable_name(right) == header_name and isinstance(left, Constant):
return left.value if isinstance(left.value, int) else None
if (
operation.type is BinaryType.SUBTRACTION
and _variable_name(left) == header_name
and isinstance(right, Constant)
and isinstance(right.value, int)
):
return -right.value
return None
@staticmethod
def _moves_toward_exit(comparison: BinaryType, step: int) -> bool:
ascending = comparison in (BinaryType.LESS, BinaryType.LESS_EQUAL)
descending = comparison in (BinaryType.GREATER, BinaryType.GREATER_EQUAL)
return (ascending and step > 0) or (descending and step < 0)
def variables_for(self, header_id: LoopHeaderId) -> tuple[LoopVariableId, ...]:
"""Return loop-carried bindings for one header."""
return self._variables.get(header_id, ())
def is_loop_header(self, header_id: LoopHeaderId) -> bool:
"""Return whether the stable header belongs to a classified natural loop."""
return header_id in self._variables
def progression_for(self, header_id: LoopHeaderId) -> LoopProgression | None:
"""Return the certified controlling progression, when available."""
return self._progressions.get(header_id)
@@ -101,8 +101,7 @@ class State:
if any(fact.fact_id.owner is not FactOwnerKind.STATE_LOCAL for fact in facts):
raise ValueError("State construction accepts only STATE_LOCAL facts")
if any(
fact.fact_id.provenance.origin_kind is FactOriginKind.ABSTRACT_STATE
for fact in facts
fact.fact_id.provenance.origin_kind is FactOriginKind.ABSTRACT_STATE for fact in facts
):
raise ValueError("Abstract range facts are derived from tracked values")
@@ -208,9 +207,7 @@ class State:
def get_branch_facts(self) -> tuple[Fact[SMTTerm], ...]:
"""Derive the branch view from the final active explicit fact set."""
return tuple(
fact
for fact in self.get_explicit_facts()
if fact.fact_id.kind is FactKind.BRANCH_GUARD
fact for fact in self.get_explicit_facts() if fact.fact_id.kind is FactKind.BRANCH_GUARD
)
def get_branch_fact_ids(self) -> frozenset[FactId]:
@@ -514,6 +511,7 @@ class State:
provenance.origin_kind.value,
repr(provenance.operation_id),
repr(provenance.cfg_edge),
repr(provenance.loop_header_id),
repr(provenance.loop_generation),
repr(provenance.property_id),
*fact_id.semantic_key,
@@ -0,0 +1,399 @@
"""Sound non-relational interval transfer for integer binary operations."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from slither.analyses.data_flow.analyses.interval.core.tracked_variable import NumericInterval
from slither.slithir.operations.binary import BinaryType
class AbstractTransferKind(Enum):
"""Precision class of one sound abstract operation result."""
EXACT = "exact"
INTERVAL = "interval"
TOP = "top"
@dataclass(frozen=True)
class AbstractTransferResult:
"""One sound interval result and the reason for its precision."""
interval: NumericInterval
kind: AbstractTransferKind
may_wrap: bool = False
supported: bool = True
def transfer_binary_interval(
operation: BinaryType,
left: NumericInterval,
right: NumericInterval,
*,
bit_width: int,
is_signed: bool,
is_checked: bool,
same_operand: bool = False,
) -> AbstractTransferResult:
"""Return a conservative interval for one Solidity integer operation."""
type_interval = NumericInterval.type_range(bit_width, is_signed)
if operation is BinaryType.SUBTRACTION and same_operand:
return _exact(0)
if operation is BinaryType.DIVISION and same_operand:
return _exact(1)
if operation is BinaryType.MODULO and same_operand:
return _exact(0)
if operation in (BinaryType.AND, BinaryType.OR) and same_operand:
return _interval_result(left, type_interval)
if operation is BinaryType.CARET and same_operand:
return _exact(0)
if operation is BinaryType.ADDITION:
raw = NumericInterval(left.lower + right.lower, left.upper + right.upper)
return _finish_integer_arithmetic(raw, type_interval, bit_width, is_signed, is_checked)
if operation is BinaryType.SUBTRACTION:
raw = NumericInterval(left.lower - right.upper, left.upper - right.lower)
return _finish_integer_arithmetic(raw, type_interval, bit_width, is_signed, is_checked)
if operation is BinaryType.MULTIPLICATION:
if same_operand:
products = (left.lower * left.lower, left.upper * left.upper)
raw = NumericInterval(
0 if left.lower <= 0 <= left.upper else min(products),
max(products),
)
return _finish_integer_arithmetic(
raw, type_interval, bit_width, is_signed, is_checked
)
products = (
left.lower * right.lower,
left.lower * right.upper,
left.upper * right.lower,
left.upper * right.upper,
)
raw = NumericInterval(min(products), max(products))
return _finish_integer_arithmetic(raw, type_interval, bit_width, is_signed, is_checked)
if operation is BinaryType.DIVISION:
return _division(left, right, type_interval)
if operation is BinaryType.MODULO:
return _modulo(left, right, type_interval, is_signed)
if operation is BinaryType.POWER:
return _power(
left,
right,
type_interval,
bit_width=bit_width,
is_signed=is_signed,
is_checked=is_checked,
)
if operation in (BinaryType.LEFT_SHIFT, BinaryType.RIGHT_SHIFT):
return _shift(
operation,
left,
right,
type_interval,
bit_width=bit_width,
is_signed=is_signed,
)
if operation in (BinaryType.AND, BinaryType.OR, BinaryType.CARET):
return _bitwise(
operation,
left,
right,
type_interval,
bit_width=bit_width,
is_signed=is_signed,
)
return _top(type_interval, supported=False)
def _finish_integer_arithmetic(
raw: NumericInterval,
type_interval: NumericInterval,
bit_width: int,
is_signed: bool,
is_checked: bool,
) -> AbstractTransferResult:
if _contains(type_interval, raw):
return _interval_result(raw, type_interval)
if is_checked:
successful = raw.intersection(type_interval)
if successful is None:
# The interval domain has no operation-local bottom value. The checked
# continuation facts retain infeasibility; top is the only sound value.
return _top(type_interval)
return _interval_result(successful, type_interval)
if raw.lower == raw.upper:
return AbstractTransferResult(
NumericInterval(
_wrap_and_decode(raw.lower, bit_width, is_signed),
_wrap_and_decode(raw.lower, bit_width, is_signed),
),
AbstractTransferKind.EXACT,
may_wrap=True,
)
# A single interval cannot in general represent modular images without
# becoming disjunctive. Top is sound and avoids an incorrect narrow hull.
return _top(type_interval, may_wrap=True)
def _division(
left: NumericInterval,
right: NumericInterval,
type_interval: NumericInterval,
) -> AbstractTransferResult:
divisors = _nonzero_extreme_values(right)
if not divisors:
return _top(type_interval)
dividends = {left.lower, left.upper}
if left.lower <= 0 <= left.upper:
dividends.add(0)
values = [_truncating_division(a, b) for a in dividends for b in divisors]
return _interval_result(NumericInterval(min(values), max(values)), type_interval)
def _modulo(
left: NumericInterval,
right: NumericInterval,
type_interval: NumericInterval,
is_signed: bool,
) -> AbstractTransferResult:
divisors = _nonzero_extreme_values(right)
if not divisors:
return _top(type_interval)
if left.lower == left.upper and right.lower == right.upper and right.lower != 0:
quotient = _truncating_division(left.lower, right.lower)
return _exact(left.lower - quotient * right.lower)
max_divisor_magnitude = max(abs(value) for value in divisors)
max_remainder_magnitude = min(
max(abs(left.lower), abs(left.upper)),
max_divisor_magnitude - 1,
)
if not is_signed:
raw = NumericInterval(0, min(left.upper, max_remainder_magnitude))
elif left.lower >= 0:
raw = NumericInterval(0, min(left.upper, max_remainder_magnitude))
elif left.upper <= 0:
raw = NumericInterval(max(left.lower, -max_remainder_magnitude), 0)
else:
raw = NumericInterval(-max_remainder_magnitude, max_remainder_magnitude)
return _interval_result(raw, type_interval)
def _power(
base: NumericInterval,
exponent: NumericInterval,
type_interval: NumericInterval,
*,
bit_width: int,
is_signed: bool,
is_checked: bool,
) -> AbstractTransferResult:
if exponent.lower < 0:
return _top(type_interval, supported=False)
if base.lower == base.upper and base.lower in (-1, 0, 1):
return _small_base_power(base.lower, exponent, type_interval)
if exponent.lower == exponent.upper:
if exponent.lower > 1024:
if base.lower == base.upper and not is_checked:
wrapped = pow(base.lower, exponent.lower, 1 << bit_width)
return _exact(_wrap_and_decode(wrapped, bit_width, is_signed))
return _top(type_interval, supported=False)
raw = _fixed_exponent_interval(base, exponent.lower)
return _finish_integer_arithmetic(raw, type_interval, bit_width, is_signed, is_checked)
# Bounded unsigned exponentiation is monotone in the common non-negative
# case. Cap concrete endpoint evaluation to avoid constructing enormous
# Python integers for an unconstrained uint256 exponent.
if base.lower >= 0 and exponent.upper <= 1024:
bases = {base.lower, base.upper}
powers = {exponent.lower, exponent.upper}
if base.lower <= 1 <= base.upper:
bases.add(1)
if exponent.lower <= 1 <= exponent.upper:
powers.add(1)
values = [pow(value, power) for value in bases for power in powers]
raw = NumericInterval(min(values), max(values))
return _finish_integer_arithmetic(raw, type_interval, bit_width, is_signed, is_checked)
return _top(type_interval, supported=False)
def _small_base_power(
base: int,
exponent: NumericInterval,
type_interval: NumericInterval,
) -> AbstractTransferResult:
if base == 1:
return _exact(1)
if base == 0:
return _exact(0) if exponent.lower > 0 else _interval_result(NumericInterval(0, 1), type_interval)
if exponent.lower == exponent.upper:
return _exact(-1 if exponent.lower % 2 else 1)
return _interval_result(NumericInterval(-1, 1), type_interval)
def _fixed_exponent_interval(base: NumericInterval, exponent: int) -> NumericInterval:
if exponent == 0:
return NumericInterval(1, 1)
if exponent % 2:
return NumericInterval(pow(base.lower, exponent), pow(base.upper, exponent))
maximum = max(pow(abs(base.lower), exponent), pow(abs(base.upper), exponent))
minimum = 0 if base.lower <= 0 <= base.upper else min(
pow(abs(base.lower), exponent),
pow(abs(base.upper), exponent),
)
return NumericInterval(minimum, maximum)
def _shift(
operation: BinaryType,
left: NumericInterval,
right: NumericInterval,
type_interval: NumericInterval,
*,
bit_width: int,
is_signed: bool,
) -> AbstractTransferResult:
if right.lower < 0:
return _top(type_interval, supported=False)
shift_values = range(right.lower, min(right.upper, bit_width - 1) + 1)
representative_shifts = list(shift_values)
if right.upper >= bit_width:
representative_shifts.append(bit_width)
results = [
_fixed_shift(
operation,
left,
shift,
type_interval,
bit_width=bit_width,
is_signed=is_signed,
)
for shift in representative_shifts
]
if not results:
return _top(type_interval)
if any(result.kind is AbstractTransferKind.TOP for result in results):
return _top(type_interval, may_wrap=operation is BinaryType.LEFT_SHIFT)
interval = results[0].interval
for result in results[1:]:
interval = interval.hull(result.interval)
combined = _interval_result(interval, type_interval)
return AbstractTransferResult(
combined.interval,
combined.kind,
may_wrap=any(result.may_wrap for result in results),
)
def _fixed_shift(
operation: BinaryType,
left: NumericInterval,
shift: int,
type_interval: NumericInterval,
*,
bit_width: int,
is_signed: bool,
) -> AbstractTransferResult:
if operation is BinaryType.RIGHT_SHIFT:
if shift >= bit_width:
if not is_signed:
return _exact(0)
values = (-1 if left.lower < 0 else 0, -1 if left.upper < 0 else 0)
return _interval_result(NumericInterval(min(values), max(values)), type_interval)
raw = NumericInterval(left.lower >> shift, left.upper >> shift)
return _interval_result(raw, type_interval)
if shift >= bit_width:
return _exact(0)
raw = NumericInterval(left.lower << shift, left.upper << shift)
# Solidity shifts truncate bits even in checked scopes.
return _finish_integer_arithmetic(raw, type_interval, bit_width, is_signed, False)
def _bitwise(
operation: BinaryType,
left: NumericInterval,
right: NumericInterval,
type_interval: NumericInterval,
*,
bit_width: int,
is_signed: bool,
) -> AbstractTransferResult:
if left.lower == left.upper and right.lower == right.upper:
left_bits = left.lower % (1 << bit_width)
right_bits = right.lower % (1 << bit_width)
if operation is BinaryType.AND:
value = left_bits & right_bits
elif operation is BinaryType.OR:
value = left_bits | right_bits
else:
value = left_bits ^ right_bits
return _exact(_wrap_and_decode(value, bit_width, is_signed))
zero = NumericInterval(0, 0)
if left == zero:
return _exact(0) if operation is BinaryType.AND else _interval_result(right, type_interval)
if right == zero:
return _exact(0) if operation is BinaryType.AND else _interval_result(left, type_interval)
if not is_signed or (left.lower >= 0 and right.lower >= 0):
if operation is BinaryType.AND:
raw = NumericInterval(0, min(left.upper, right.upper))
else:
highest = max(left.upper, right.upper).bit_length()
raw = NumericInterval(0, (1 << highest) - 1 if highest else 0)
return _interval_result(raw, type_interval)
return _top(type_interval, supported=False)
def _nonzero_extreme_values(interval: NumericInterval) -> set[int]:
values = {value for value in (interval.lower, interval.upper) if value != 0}
if interval.lower <= -1 <= interval.upper:
values.add(-1)
if interval.lower <= 1 <= interval.upper:
values.add(1)
return values
def _truncating_division(left: int, right: int) -> int:
quotient = abs(left) // abs(right)
return -quotient if (left < 0) != (right < 0) else quotient
def _wrap_and_decode(value: int, bit_width: int, is_signed: bool) -> int:
modulus = 1 << bit_width
wrapped = value % modulus
if is_signed and wrapped >= 1 << (bit_width - 1):
wrapped -= modulus
return wrapped
def _contains(outer: NumericInterval, inner: NumericInterval) -> bool:
return outer.lower <= inner.lower and inner.upper <= outer.upper
def _interval_result(
interval: NumericInterval,
type_interval: NumericInterval,
) -> AbstractTransferResult:
interval = interval.intersection(type_interval) or type_interval
kind = AbstractTransferKind.EXACT if interval.lower == interval.upper else (
AbstractTransferKind.TOP if interval == type_interval else AbstractTransferKind.INTERVAL
)
return AbstractTransferResult(interval, kind)
def _exact(value: int) -> AbstractTransferResult:
return AbstractTransferResult(NumericInterval(value, value), AbstractTransferKind.EXACT)
def _top(
type_interval: NumericInterval,
*,
may_wrap: bool = False,
supported: bool = True,
) -> AbstractTransferResult:
return AbstractTransferResult(
type_interval,
AbstractTransferKind.TOP,
may_wrap=may_wrap,
supported=supported,
)
@@ -13,6 +13,11 @@ from slither.analyses.data_flow.analyses.interval.core.tracked_variable import (
from slither.analyses.data_flow.analyses.interval.operations.base import (
BaseOperationHandler,
)
from slither.analyses.data_flow.analyses.interval.operations.binary.abstract_transfer import (
AbstractTransferKind,
AbstractTransferResult,
transfer_binary_interval,
)
from slither.analyses.data_flow.analyses.interval.operations.type_utils import (
get_bit_width,
get_variable_name,
@@ -47,6 +52,19 @@ class ConstraintContext:
bit_width: int
@dataclass(frozen=True)
class ArithmeticEvaluation:
"""Terms and type metadata produced for one binary operation."""
result: TrackedSMTVariable
left: SMTTerm
right: SMTTerm
result_term: SMTTerm | None
bit_width: int
is_signed: bool
both_constants: bool
ARITHMETIC_OPERATIONS = frozenset(
{
BinaryType.ADDITION,
@@ -99,8 +117,9 @@ class ArithmeticHandler(BaseOperationHandler):
left_term,
right_term,
result_type,
is_self_operation,
bit_width,
is_self_operation=is_self_operation,
bit_width=bit_width,
constant_right=self._constant_integer(operation.variable_right),
)
if result_term is not None:
self._register_equation(
@@ -110,44 +129,101 @@ class ArithmeticHandler(BaseOperationHandler):
result_var.term == result_term,
"arithmetic_result",
)
evaluation = ArithmeticEvaluation(
result_var,
left_term,
right_term,
result_term,
bit_width,
signed,
both_constants,
)
self._add_continuation_constraints(operation, node, domain, evaluation)
result_var = self._apply_abstract_result(operation, node, domain, evaluation)
domain.state.set_variable(result_name, result_var)
self._record_operand_dependencies(operation, result_name, domain)
def _add_continuation_constraints(
self,
operation: Binary,
node: Node,
domain: IntervalDomain,
evaluation: ArithmeticEvaluation,
) -> None:
"""Record operation-local success conditions in the current state."""
if operation.type in (BinaryType.DIVISION, BinaryType.MODULO):
self._add_divisor_nonzero_constraint(
operation,
node,
right_term,
bit_width,
evaluation.right,
evaluation.bit_width,
domain=domain,
)
if (
operation.type is BinaryType.DIVISION
and evaluation.is_signed
and not evaluation.both_constants
):
self._add_state_fact(
operation,
node,
domain,
self.solver.bv_sdiv_no_overflow(evaluation.left, evaluation.right),
"signed_division_no_overflow",
kind=FactKind.CHECKED_ARITHMETIC,
)
if node.scope.is_checked and evaluation.result_term is not None:
if evaluation.both_constants:
return
context = ConstraintContext(
evaluation.left,
evaluation.right,
evaluation.result_term,
evaluation.is_signed,
evaluation.bit_width,
)
overflow_predicates = self._compute_overflow_predicates(
operation.type, left_term, right_term, signed, both_constants
)
is_checked = node.scope.is_checked
should_check = is_checked and result_term is not None and not both_constants
if should_check:
context = ConstraintContext(left_term, right_term, result_term, signed, bit_width)
self._assert_checked_constraints(operation, node, context, domain)
result_interval = self._compute_abstract_result_interval(
def _apply_abstract_result(
self,
operation: Binary,
node: Node,
domain: IntervalDomain,
evaluation: ArithmeticEvaluation,
) -> TrackedSMTVariable:
"""Attach the abstract interval and deferred unchecked predicates."""
is_checked = node.scope.is_checked
abstract_result = self._compute_abstract_result_interval(
operation,
domain,
bit_width,
signed,
is_checked,
evaluation.bit_width,
evaluation.is_signed,
is_checked=is_checked,
)
result_var = result_var.with_interval(result_interval)
result = evaluation.result.with_interval(abstract_result.interval)
# Store predicates and is_checked flag for deferred overflow checking
# Overflow is only possible in unchecked contexts (assembly, unchecked blocks)
result_var = result_var.with_overflow_predicates(
overflow_predicates = self._compute_overflow_predicates(
operation.type,
evaluation.left,
evaluation.right,
is_signed=evaluation.is_signed,
both_constants=evaluation.both_constants,
)
if not is_checked and not abstract_result.may_wrap:
overflow_predicates = {"no_overflow": None, "no_underflow": None}
has_deferred_overflow_check = any(
predicate is not None for predicate in overflow_predicates.values()
)
return result.with_overflow_predicates(
**overflow_predicates,
operation_id=StaticOperationId.from_operation(operation, node),
is_unchecked=not is_checked and not both_constants,
is_unchecked=(
not is_checked
and not evaluation.both_constants
and has_deferred_overflow_check
),
)
domain.state.set_variable(result_name, result_var)
self._record_operand_dependencies(operation, result_name, domain)
def _prepare_terms(
self,
@@ -171,37 +247,40 @@ class ArithmeticHandler(BaseOperationHandler):
domain: IntervalDomain,
bit_width: int,
is_signed: bool,
*,
is_checked: bool,
) -> NumericInterval:
"""Compute the existing interval transformer for linear add/sub operations."""
) -> AbstractTransferResult:
"""Compute a sound non-relational transfer before optional SMT refinement."""
type_interval = NumericInterval.type_range(bit_width, is_signed)
if self._is_same_operand(operation) and operation.type is BinaryType.SUBTRACTION:
return NumericInterval(0, 0)
left = self._operand_interval(operation.variable_left, domain, type_interval)
right = self._operand_interval(operation.variable_right, domain, type_interval)
right = self._operand_interval(
operation.variable_right,
domain,
type_interval,
allow_wide=operation.type
in (BinaryType.POWER, BinaryType.LEFT_SHIFT, BinaryType.RIGHT_SHIFT),
)
if left is None or right is None:
return type_interval
if operation.type is BinaryType.ADDITION:
raw = NumericInterval(left.lower + right.lower, left.upper + right.upper)
elif operation.type is BinaryType.SUBTRACTION:
raw = NumericInterval(left.lower - right.upper, left.upper - right.lower)
else:
return type_interval
if type_interval.lower <= raw.lower and raw.upper <= type_interval.upper:
return raw
if not is_checked:
return type_interval
lower = max(type_interval.lower, raw.lower)
upper = min(type_interval.upper, raw.upper)
if lower > upper:
return type_interval
return NumericInterval(lower, upper)
return AbstractTransferResult(type_interval, kind=AbstractTransferKind.TOP)
result = transfer_binary_interval(
operation.type,
left,
right,
bit_width=bit_width,
is_signed=is_signed,
is_checked=is_checked,
same_operand=self._is_same_operand(operation),
)
self._record_abstract_transfer(operation.type, result, is_checked)
return result
@staticmethod
def _operand_interval(
operand: RVALUE,
domain: IntervalDomain,
type_interval: NumericInterval,
*,
allow_wide: bool = False,
) -> NumericInterval | None:
if isinstance(operand, Constant):
value = operand.value
@@ -209,7 +288,7 @@ class ArithmeticHandler(BaseOperationHandler):
value = int(value)
if not isinstance(value, int):
return None
if not type_interval.lower <= value <= type_interval.upper:
if not allow_wide and not type_interval.lower <= value <= type_interval.upper:
return None
return NumericInterval(value, value)
tracked = domain.state.get_variable(get_variable_name(operand))
@@ -217,6 +296,30 @@ class ArithmeticHandler(BaseOperationHandler):
return None
return tracked.interval
@staticmethod
def _record_abstract_transfer(
operation_type: BinaryType,
result: AbstractTransferResult,
is_checked: bool,
) -> None:
from slither.analyses.data_flow.smt_solver.telemetry import get_telemetry
telemetry = get_telemetry()
if telemetry is None or not telemetry.enabled:
return
telemetry.count(f"abstract_transfer_{operation_type.name.lower()}")
telemetry.count(f"abstract_transfer_{result.kind.value}")
telemetry.count("abstract_transfer_checked" if is_checked else "abstract_transfer_unchecked")
@staticmethod
def _constant_integer(operand: RVALUE) -> int | None:
if not isinstance(operand, Constant):
return None
value = operand.value
if isinstance(value, bool):
return int(value)
return value if isinstance(value, int) else None
def _record_operand_dependencies(
self,
operation: Binary,
@@ -263,6 +366,7 @@ class ArithmeticHandler(BaseOperationHandler):
operation_type: BinaryType,
left_term: SMTTerm,
right_term: SMTTerm,
*,
is_signed: bool,
both_constants: bool,
) -> dict[str, SMTTerm | None]:
@@ -482,6 +586,7 @@ class ArithmeticHandler(BaseOperationHandler):
node: Node,
divisor: SMTTerm,
bit_width: int,
*,
domain: IntervalDomain,
) -> None:
"""Add divisor nonzero constraint as path constraint (division by zero reverts)."""
@@ -536,8 +641,10 @@ class ArithmeticHandler(BaseOperationHandler):
left: SMTTerm,
right: SMTTerm,
result_type: ElementaryType,
*,
is_self_operation: bool = False,
bit_width: int = 256,
constant_right: int | None = None,
) -> SMTTerm | None:
"""Compute the result term for the operation."""
signed = is_signed_type(result_type)
@@ -556,14 +663,27 @@ class ArithmeticHandler(BaseOperationHandler):
BinaryType.MULTIPLICATION: lambda: self.solver.bv_mul(left, right),
BinaryType.DIVISION: lambda: self._division(left, right, signed),
BinaryType.MODULO: lambda: self._modulo(left, right, signed),
BinaryType.LEFT_SHIFT: lambda: self.solver.bv_shl(left, right),
BinaryType.RIGHT_SHIFT: lambda: self._right_shift(left, right, signed),
BinaryType.AND: lambda: self.solver.bv_and(left, right),
BinaryType.OR: lambda: self.solver.bv_or(left, right),
BinaryType.CARET: lambda: self.solver.bv_xor(left, right),
BinaryType.POWER: lambda: self._power(left, right),
}
if operation_type in (BinaryType.LEFT_SHIFT, BinaryType.RIGHT_SHIFT):
if constant_right is None or constant_right < 0:
return None
shift = self.solver.create_constant(
min(constant_right, bit_width),
Sort(SortKind.BITVEC, [bit_width]),
)
if operation_type is BinaryType.LEFT_SHIFT:
return self.solver.bv_shl(left, shift)
return self._right_shift(left, shift, signed)
if operation_type is BinaryType.POWER:
# There is no backend-neutral bit-vector exponent operator. The
# abstract transfer remains authoritative instead of registering
# a wrapped equation that lacks checked-overflow semantics.
return None
handler = dispatch.get(operation_type)
if handler is None:
return None
@@ -621,11 +741,3 @@ class ArithmeticHandler(BaseOperationHandler):
if is_signed:
return self.solver.bv_ashr(left, right)
return self.solver.bv_lshr(left, right)
def _power(self, base: SMTTerm, exponent: SMTTerm) -> SMTTerm:
"""Power operation (simplified - returns base * exponent for now).
Full power implementation requires iterative multiplication.
"""
# TODO: Implement proper power operation with iterative multiplication
return self.solver.bv_mul(base, exponent)
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
from slither.analyses.data_flow.analyses.interval.core.tracked_variable import (
NumericInterval,
@@ -23,11 +23,19 @@ from slither.slithir.operations.phi import Phi
if TYPE_CHECKING:
from slither.analyses.data_flow.analyses.interval.analysis.loop import (
IntervalLoopMetadata,
)
from slither.analyses.data_flow.analyses.interval.analysis.domain import (
IntervalDomain,
)
from slither.analyses.data_flow.analyses.interval.core.state import State
from slither.core.cfg.node import Node
from slither.slithir.utils.utils import RVALUE
from slither.slithir.operations.operation import Operation
from slither.slithir.utils.utils import LVALUE, RVALUE
from slither.analyses.data_flow.smt_solver.solver import SMTSolver
from slither.analyses.data_flow.smt_solver.facts import LoopHeaderId
class PhiHandler(BaseOperationHandler):
@@ -37,20 +45,26 @@ class PhiHandler(BaseOperationHandler):
interprocedural SSA, function entry Phi nodes merge values from ALL
call sites.
Strategy:
1. If lvalue already tracked from parameter binding -> preserve it
2. At loop headers: selective widening based on comparing incoming to existing
3. If rvalues are tracked -> create variable equal to one of them (disjunction)
4. If no rvalues tracked -> create unconstrained variable
Loop-header values are recomputed from the currently reachable incoming SSA
alternatives. The loop fixpoint owns widening and generation state.
"""
def __init__(self, solver: SMTSolver) -> None:
super().__init__(solver)
self._loop_metadata: IntervalLoopMetadata | None = None
def configure_loop_metadata(self, metadata: IntervalLoopMetadata) -> None:
"""Bind traversal-independent loop SSA classifications."""
self._loop_metadata = metadata
def handle(
self,
operation: Phi,
operation: Operation,
domain: IntervalDomain,
node: Node,
) -> None:
"""Process Phi operation by merging incoming values."""
operation, state = self._require_phi_state(operation, domain)
if operation.lvalue is None:
return
@@ -58,16 +72,15 @@ class PhiHandler(BaseOperationHandler):
if not isinstance(lvalue_type, ElementaryType):
return
result_name = get_variable_name(operation.lvalue)
is_loop_header = node.type == NodeType.IFLOOP
result_name = get_variable_name(cast("LVALUE", operation.lvalue))
is_loop_header = self._is_loop_header(node)
# At loop headers: create unconstrained (widening handled by apply_widening)
if is_loop_header:
self._handle_loop_header_phi(domain, result_name, lvalue_type)
self._handle_loop_header_phi(operation, domain, node, result_name, lvalue_type)
return
# If already tracked (from parameter binding), preserve those constraints
existing = domain.state.get_variable(result_name)
existing = state.get_variable(result_name)
if existing is not None:
return
@@ -98,7 +111,26 @@ class PhiHandler(BaseOperationHandler):
equation_variables,
)
domain.state.set_variable(result_name, result_variable)
state.set_variable(result_name, result_variable)
def _is_loop_header(self, node: Node) -> bool:
"""Use natural-loop metadata when the engine has prepared the handler."""
if self._loop_metadata is None:
return node.type is NodeType.IFLOOP
return self._loop_metadata.is_loop_header(LoopHeaderId.from_node(node))
@staticmethod
def _require_phi_state(
operation: Operation,
domain: IntervalDomain,
) -> tuple[Phi, State]:
"""Validate the registry dispatch and concrete transfer state."""
if not isinstance(operation, Phi):
raise TypeError("PhiHandler requires a Phi operation")
state = domain.state
if state is None:
raise ValueError("Phi transfer requires a concrete interval state")
return operation, state
@staticmethod
def _incoming_interval(incoming: list[TrackedSMTVariable]) -> NumericInterval:
@@ -110,35 +142,55 @@ class PhiHandler(BaseOperationHandler):
def _handle_loop_header_phi(
self,
operation: Phi,
domain: IntervalDomain,
node: Node,
result_name: str,
lvalue_type: ElementaryType,
) -> None:
"""Handle phi at loop header.
Creates unconstrained variable. Selective widening is handled by
apply_widening() on back edges.
NOTE: We cannot add constraints here because SMT constraints are permanent.
get_or_declare_const returns the same SMT variable, so constraints from
earlier iterations accumulate. This makes loop exits unreachable if we
constrain phi variables to incoming values.
"""
"""Recompute a loop phi without creating a cyclic immutable equation."""
state = domain.state
if state is None:
raise ValueError("Loop phi transfer requires a concrete interval state")
sort = type_to_sort(lvalue_type)
is_signed = is_signed_type(lvalue_type)
bit_width = get_bit_width(lvalue_type)
existing = domain.state.get_variable(result_name)
# First iteration: create unconstrained variable
if existing is None:
result_variable = TrackedSMTVariable.create(
self.solver, result_name, sort, is_signed=is_signed, bit_width=bit_width
result_variable = TrackedSMTVariable.create(
self.solver, result_name, sort, is_signed=is_signed, bit_width=bit_width
)
incoming = self._active_loop_incoming(operation, domain, node, result_name)
if incoming:
result_variable = result_variable.with_interval(
self._incoming_interval(incoming),
is_total=True,
)
domain.state.set_variable(result_name, result_variable)
return
state.set_variable(result_name, result_variable)
# Later iterations: keep existing (widening handled by apply_widening)
def _active_loop_incoming(
self,
operation: Phi,
domain: IntervalDomain,
node: Node,
result_name: str,
) -> list[TrackedSMTVariable]:
"""Resolve every currently active phi alternative or return no precision."""
state = domain.state
if state is None:
raise ValueError("Loop phi lookup requires a concrete interval state")
if self._loop_metadata is None:
incoming = self._get_incoming_variables(operation.rvalues, domain)
return incoming if len(incoming) == len(operation.rvalues) else []
bindings = self._loop_metadata.variables_for(LoopHeaderId.from_node(node))
binding = next((item for item in bindings if item.header_name == result_name), None)
if binding is None:
return []
active_names = binding.entry_names
if state.get_variable(result_name) is not None:
active_names = (*active_names, *binding.back_names)
incoming = [state.get_variable(name) for name in active_names]
if any(variable is None for variable in incoming):
return []
return [variable for variable in incoming if variable is not None]
def _get_incoming_variables(
self,
@@ -146,10 +198,13 @@ class PhiHandler(BaseOperationHandler):
domain: IntervalDomain,
) -> list[TrackedSMTVariable]:
"""Get tracked variables for Phi incoming values."""
state = domain.state
if state is None:
raise ValueError("Phi input lookup requires a concrete interval state")
tracked_variables = []
for rvalue in rvalues:
rvalue_name = get_variable_name(rvalue)
tracked = domain.state.get_variable(rvalue_name)
tracked = state.get_variable(rvalue_name)
if tracked is not None:
tracked_variables.append(tracked)
return tracked_variables
@@ -160,10 +215,13 @@ class PhiHandler(BaseOperationHandler):
domain: IntervalDomain,
) -> list[TrackedSMTVariable]:
"""Return all static phi alternatives, declaring absent symbols locally."""
state = domain.state
if state is None:
raise ValueError("Phi equation lookup requires a concrete interval state")
variables = []
for rvalue in rvalues:
name = get_variable_name(rvalue)
tracked = domain.state.get_variable(name)
tracked = state.get_variable(name)
value_type = getattr(rvalue, "type", None)
if tracked is None and isinstance(value_type, ElementaryType):
tracked = TrackedSMTVariable.create(
@@ -95,6 +95,9 @@ from slither.analyses.data_flow.analyses.interval.operations.unpack import (
from slither.analyses.data_flow.logger import get_logger
if TYPE_CHECKING:
from slither.analyses.data_flow.analyses.interval.analysis.loop import (
IntervalLoopMetadata,
)
from slither.analyses.data_flow.smt_solver.solver import SMTSolver
logger = get_logger()
@@ -103,7 +106,7 @@ logger = get_logger()
class OperationHandlerRegistry:
"""Maps operation types to handlers."""
def __init__(self, solver: "SMTSolver"):
def __init__(self, solver: SMTSolver):
self._solver = solver
self._handlers: dict[type[Operation], BaseOperationHandler] = {}
self._register_handlers()
@@ -135,10 +138,16 @@ class OperationHandlerRegistry:
def get_handler(self, op_type: type[Operation]) -> BaseOperationHandler:
"""Get handler for operation type."""
if op_type not in self._handlers:
implemented = [handler.__name__ for handler in self._handlers.keys()]
implemented = [handler.__name__ for handler in self._handlers]
logger.error_and_raise(
f"Operation '{op_type.__name__}' is not implemented. "
f"Implemented: {implemented}",
f"Operation '{op_type.__name__}' is not implemented. Implemented: {implemented}",
NotImplementedError,
)
return self._handlers[op_type]
def configure_loop_metadata(self, metadata: IntervalLoopMetadata) -> None:
"""Provide stable loop-carried bindings to the phi handler."""
handler = self._handlers[Phi]
if not isinstance(handler, PhiHandler):
raise TypeError("Phi registry entry is not a PhiHandler")
handler.configure_loop_metadata(metadata)
+71 -12
View File
@@ -16,7 +16,9 @@ from slither.analyses.data_flow.smt_solver.facts import (
)
from slither.analyses.data_flow.smt_solver.query import (
FeasibilityStatus,
QueryBudget,
QueryPurpose,
RangeInterval,
)
from slither.analyses.data_flow.smt_solver.solver import SMTSolver
from slither.analyses.data_flow.smt_solver.telemetry import SolverTelemetry, get_telemetry
@@ -44,6 +46,8 @@ class RangeQueryConfig:
skip_optimization: bool = False
debug: bool = False
cache: RangeQueryCache | None = None
refine_abstract: bool = False
budget: QueryBudget | None = None
@dataclass
@@ -77,6 +81,7 @@ def _check_overflow_possible(
state_facts: tuple[Fact[SMTTerm], ...] = (),
query_facts: tuple[Fact[SMTTerm], ...] = (),
timeout_ms: int = 500,
budget: QueryBudget | None = None,
) -> bool:
"""Check if overflow or underflow is possible for a variable.
@@ -104,14 +109,17 @@ def _check_overflow_possible(
0,
state_id.context_id if state_id is not None else None,
)
effective_timeout = budget.remaining_ms() if budget is not None else timeout_ms
if effective_timeout == 0:
return True
result = solver.check_feasibility(
state_id=state_id,
state_facts=state_facts,
query_facts=(*query_facts, predicate_fact),
purpose=QueryPurpose.OVERFLOW,
timeout_ms=timeout_ms,
timeout_ms=effective_timeout,
)
return result.status is FeasibilityStatus.SAT
return result.status is not FeasibilityStatus.UNSAT
# =============================================================================
@@ -233,7 +241,13 @@ def _solve_range_with_solver(
config: RangeQueryConfig,
) -> tuple[dict | None, dict | None]:
"""Use solver's range solving to find min/max values."""
if ctx.telemetry:
budget = config.budget or QueryBudget(config.timeout_ms)
abstract_range = _preferred_abstract_range(ctx.smt_var, config)
query_facts = (
*ctx.query_facts,
*_abstract_refinement_facts(ctx, meta, config.refine_abstract),
)
if ctx.telemetry and abstract_range is None:
ctx.telemetry.count("optimize_min")
ctx.telemetry.count("optimize_max")
@@ -242,9 +256,12 @@ def _solve_range_with_solver(
term=ctx.smt_var.term,
state_id=ctx.state_id,
state_facts=ctx.state_facts,
query_facts=ctx.query_facts,
query_facts=query_facts,
timeout_ms=config.timeout_ms,
signed=meta.is_signed,
fallback_range=_tracked_fallback_range(ctx.smt_var),
abstract_range=abstract_range,
budget=budget,
)
if result.feasibility is FeasibilityStatus.UNSAT:
@@ -261,15 +278,10 @@ def _solve_range_with_solver(
decoded_max = _decode_model_value(result.upper, meta)
# Check if overflow is possible given constraints
has_overflow = _check_overflow_possible(
ctx.solver,
ctx.smt_var,
state_id=ctx.state_id,
state_facts=ctx.state_facts,
query_facts=ctx.query_facts,
timeout_ms=config.timeout_ms,
has_overflow = (
ctx.smt_var.is_unchecked
and (ctx.smt_var.no_overflow is not None or ctx.smt_var.no_underflow is not None)
)
min_result = {
"value": decoded_min,
"overflow": has_overflow,
@@ -288,6 +300,53 @@ def _solve_range_with_solver(
return None, None
def _abstract_refinement_facts(
ctx: RangeSolveContext,
meta: VariableMetadata,
enabled: bool,
) -> tuple[Fact[SMTTerm], ...]:
"""Materialize state-owned interval limits for an optional refinement."""
if not enabled or not ctx.smt_var.is_total:
return ()
interval = ctx.smt_var.interval
lower = ctx.solver.create_constant(interval.lower, ctx.smt_var.sort)
upper = ctx.solver.create_constant(interval.upper, ctx.smt_var.sort)
if meta.is_signed:
predicates = (
ctx.solver.bv_sge(ctx.smt_var.term, lower),
ctx.solver.bv_sle(ctx.smt_var.term, upper),
)
else:
predicates = (
ctx.solver.bv_uge(ctx.smt_var.term, lower),
ctx.solver.bv_ule(ctx.smt_var.term, upper),
)
context_id = ctx.state_id.context_id if ctx.state_id is not None else None
return tuple(
make_query_fact(predicate, "abstract_refinement_bound", index, context_id)
for index, predicate in enumerate(predicates)
)
def _preferred_abstract_range(
smt_var: TrackedSMTVariable,
config: RangeQueryConfig,
) -> RangeInterval | None:
"""Select terminal abstract transfer when refinement is unnecessary."""
if config.refine_abstract or not smt_var.is_total:
return None
interval = smt_var.interval
if interval != smt_var.type_interval or smt_var.overflow_operation_id is not None:
return RangeInterval(interval.lower, interval.upper)
return None
def _tracked_fallback_range(smt_var: TrackedSMTVariable) -> RangeInterval:
"""Return the strongest state-owned interval valid for failed objectives."""
interval = smt_var.interval if smt_var.is_total else smt_var.type_interval
return RangeInterval(interval.lower, interval.upper)
# =============================================================================
# RANGE SOLVING
# =============================================================================
+18 -3
View File
@@ -13,6 +13,12 @@ from slither.slithir.operations.condition import Condition
from slither.slithir.operations.operation import Operation
if TYPE_CHECKING:
from slither.analyses.data_flow.engine.loop import (
LoopVariableId,
LoopWideningContext,
LoopWideningResult,
)
from slither.analyses.data_flow.smt_solver.facts import LoopHeaderId
from slither.core.declarations.function import Function
@@ -52,9 +58,7 @@ class Analysis(ABC):
def bottom_value(self) -> Domain:
"""Return the bottom value of the domain for initialization."""
def apply_condition(
self, domain: Domain, condition: Condition, branch_taken: bool
) -> Domain:
def apply_condition(self, domain: Domain, condition: Condition, branch_taken: bool) -> Domain:
"""Apply branch-specific filtering based on a condition.
Override to implement path-sensitive analysis that constrains
@@ -88,6 +92,17 @@ class Analysis(ABC):
"""
return current_state
def apply_loop_widening(self, context: "LoopWideningContext") -> "LoopWideningResult":
"""Widen one dominator-classified loop-header generation."""
from slither.analyses.data_flow.engine.loop import LoopWideningResult
state = self.apply_widening(context.current_input, context.previous_input, set())
return LoopWideningResult(state)
def loop_variables(self, header_id: "LoopHeaderId") -> tuple["LoopVariableId", ...]:
"""Return static loop-carried bindings for one natural-loop header."""
return ()
def prepare_for_function(self, function: "Function") -> None:
"""Prepare analysis for a specific function.
+92 -38
View File
@@ -8,10 +8,17 @@ from abc import ABC, abstractmethod
from collections import deque
from typing import TYPE_CHECKING
from slither.analyses.data_flow.engine.domain import Domain
if TYPE_CHECKING:
from slither.analyses.data_flow.engine.analysis import A, Analysis, AnalysisState
from slither.core.declarations.function import Function
from slither.analyses.data_flow.engine.loop import (
ControlFlowEdgeId,
LoopHeaderFixpoint,
LoopStructure,
)
from slither.analyses.data_flow.smt_solver.telemetry import get_telemetry
from slither.core.cfg.node import Node, NodeType
from slither.slithir.operations.condition import Condition
@@ -29,6 +36,10 @@ class Direction(ABC):
def IS_FORWARD(self) -> bool:
"""Return True for forward analysis, False for backward."""
def prepare_for_function(self, function: "Function") -> None:
"""Reset function-specific traversal metadata."""
return None
@abstractmethod
def apply_transfer_function(
self,
@@ -58,6 +69,26 @@ class Forward(Direction):
def __init__(self) -> None:
"""Initialize forward direction."""
self._loops = LoopStructure()
self._loop_fixpoints: dict[int, LoopHeaderFixpoint] = {}
def prepare_for_function(self, function: "Function") -> None:
"""Classify natural loops before worklist traversal starts."""
self._loops = LoopStructure.from_function(function)
self._loop_fixpoints = {}
telemetry = get_telemetry()
if telemetry is not None and telemetry.enabled:
telemetry.record_loop_headers(len(self._loops.loops))
@property
def loop_structure(self) -> LoopStructure:
"""Return deterministic loop metadata for the active function."""
return self._loops
@property
def loop_fixpoints(self) -> tuple[LoopHeaderFixpoint, ...]:
"""Return loop trackers ordered by header node ID."""
return tuple(self._loop_fixpoints[node_id] for node_id in sorted(self._loop_fixpoints))
@property
def IS_FORWARD(self) -> bool:
@@ -72,65 +103,88 @@ class Forward(Direction):
worklist: deque[Node],
global_state: dict[int, "AnalysisState[A]"],
) -> None:
"""Transfer one node and propagate each complete outgoing state."""
input_domain = current_state.pre
output_domain = input_domain.deep_copy()
# Apply transfer function to an exclusive output builder.
condition_op: Condition | None = None
for operation in node.irs_ssa or [None]:
analysis.transfer_function(node=node, domain=output_domain, operation=operation)
# Track the Condition operation if present
if isinstance(operation, Condition):
condition_op = operation
# Set post state
global_state[node.node_id].post = output_domain
self._record_loop_output(node, output_domain)
# Check if this is a conditional node with exactly 2 successors
is_conditional = (
node.type in (NodeType.IF, NodeType.IFLOOP)
and condition_op is not None
and len(node.sons) == 2
)
# Propagate to successors with condition filtering if applicable
for i, successor in enumerate(node.sons):
if not successor or successor.node_id not in global_state:
continue
outgoing = output_domain
if is_conditional and condition_op is not None:
outgoing = analysis.apply_condition(output_domain, condition_op, i == 0)
changed = self._propagate_edge(
analysis,
node,
successor,
outgoing,
global_state[successor.node_id],
)
self._schedule(successor, changed, worklist)
son_state = global_state[successor.node_id]
def _record_loop_output(self, node: Node, output: "Domain") -> None:
tracker = self._loop_fixpoints.get(node.node_id)
if tracker is not None:
tracker.record_output(output)
# Detect back edge: propagating to a loop header (IFLOOP)
is_back_edge = successor.type == NodeType.IFLOOP
def _propagate_edge(
self,
analysis: "Analysis",
source: Node,
destination: Node,
outgoing: "Domain",
destination_state: "AnalysisState",
) -> bool:
loop = self._loops.header(destination.node_id)
if loop is None:
return destination_state.pre.join(outgoing)
tracker = self._loop_fixpoints.setdefault(
destination.node_id,
LoopHeaderFixpoint(loop.header_id, analysis.loop_variables(loop.header_id)),
)
edge = ControlFlowEdgeId(source.node_id, destination.node_id)
is_back_edge = self._loops.is_back_edge(edge)
telemetry = get_telemetry()
if telemetry is not None and telemetry.enabled and is_back_edge:
telemetry.record_back_edge()
generation_before = tracker.generation
facts_before = tracker.generation_fact_ids
update = tracker.update(edge, outgoing, is_back_edge, analysis)
if telemetry is not None and telemetry.enabled:
telemetry.record_loop_update(
header_key=repr(tracker.header_id),
is_back_edge=is_back_edge,
changed=update.changed,
generation_advanced=tracker.generation > generation_before,
facts_replaced=tracker.generation_fact_ids != facts_before,
live_generation_facts=len(tracker.generation_fact_ids),
)
if update.changed and update.state is not None:
destination_state.pre = update.state
return update.changed
if is_conditional:
# sons[0] is then branch, sons[1] is else branch
branch_taken = i == 0
filtered_domain = analysis.apply_condition(
output_domain, condition_op, branch_taken
)
# Widen on back edges before joining
if is_back_edge:
filtered_domain = analysis.apply_widening(filtered_domain, son_state.pre, set())
changed = son_state.pre.join(filtered_domain)
else:
state_to_propagate = output_domain
# Widen on back edges before joining
if is_back_edge:
state_to_propagate = analysis.apply_widening(
state_to_propagate, son_state.pre, set()
)
changed = son_state.pre.join(state_to_propagate)
if changed and successor not in worklist:
worklist.append(successor)
telemetry = get_telemetry()
if telemetry is not None and telemetry.enabled:
telemetry.record_worklist_enqueue(len(worklist))
elif not changed:
telemetry = get_telemetry()
if telemetry is not None and telemetry.enabled:
telemetry.record_transfer_rerun_prevented()
@staticmethod
def _schedule(successor: Node, changed: bool, worklist: deque[Node]) -> None:
telemetry = get_telemetry()
if changed and successor not in worklist:
worklist.append(successor)
if telemetry is not None and telemetry.enabled:
telemetry.record_worklist_enqueue(len(worklist))
elif not changed and telemetry is not None and telemetry.enabled:
telemetry.record_transfer_rerun_prevented()
class Backward(Direction):
+102 -113
View File
@@ -4,18 +4,26 @@ Implements the generic fixpoint computation framework for
both forward and backward data flow analyses.
"""
from __future__ import annotations
import time
from collections import defaultdict, deque
from typing import Generic
from typing import TYPE_CHECKING, Generic
from slither.analyses.data_flow.engine.analysis import A, Analysis, AnalysisState
from slither.analyses.data_flow.engine.loop import LoopStructure
from slither.analyses.data_flow.logger import get_logger
from slither.analyses.data_flow.smt_solver.telemetry import get_telemetry
from slither.core.cfg.node import Node
from slither.core.declarations.function import Function
if TYPE_CHECKING:
from slither.analyses.data_flow.smt_solver.telemetry import SolverTelemetry
logger = get_logger()
MAX_ANALYSIS_ITERATIONS = 10_000
PROGRESS_INTERVAL_SECONDS = 5.0
class Engine(Generic[A]):
@@ -47,7 +55,7 @@ class Engine(Generic[A]):
self.last_progress_time: float = 0.0
@classmethod
def new(cls, analysis: Analysis, function: Function) -> "Engine[A]":
def new(cls, analysis: Analysis, function: Function) -> Engine[A]:
"""Create a new engine for analyzing a function.
Args:
@@ -63,6 +71,7 @@ class Engine(Generic[A]):
# Allow analysis to prepare for this function (e.g., collect thresholds)
analysis.prepare_for_function(function)
analysis.direction().prepare_for_function(function)
# Create state mapping for nodes in this single function only
for node in function.nodes:
@@ -111,125 +120,21 @@ class Engine(Generic[A]):
)
def _count_loops(self, function: Function) -> int:
"""Count loops by detecting back edges in the CFG."""
if not function.nodes:
return 0
visited: set[int] = set()
in_stack: set[int] = set()
back_edges = 0
def dfs(node: Node) -> None:
nonlocal back_edges
visited.add(node.node_id)
in_stack.add(node.node_id)
for successor in node.sons:
if successor.node_id not in visited:
dfs(successor)
elif successor.node_id in in_stack:
# Back edge found - indicates a loop
back_edges += 1
in_stack.remove(node.node_id)
entry = function.entry_point
if entry is not None:
dfs(entry)
return back_edges
"""Count stable dominator-classified natural-loop headers."""
return len(LoopStructure.from_function(function).loops)
def run_analysis(self) -> None:
"""Run the worklist algorithm until fixpoint is reached."""
worklist: deque[Node] = deque()
# Instrumentation constants
MAX_ITERATIONS = 10000
PROGRESS_INTERVAL = 5.0 # seconds
# Initialize timing
self.start_time = time.time()
self.last_progress_time = self.start_time
self.iteration_count = 0
self.node_visit_count.clear()
if self.analysis.direction().IS_FORWARD:
entry_point = self.function.entry_point
if entry_point is not None:
worklist.append(entry_point)
telemetry = get_telemetry()
if telemetry is not None and telemetry.enabled:
telemetry.record_worklist_enqueue(len(worklist))
logger.info("Starting analysis of {name}", name=self.function.name)
else:
raise NotImplementedError("Backward analysis is not implemented")
# Get telemetry instance
telemetry = get_telemetry()
worklist = self._initialize_worklist(telemetry)
while worklist:
# Track iteration count
self.iteration_count += 1
while worklist and self._run_iteration(worklist, telemetry):
pass
# Record telemetry
if telemetry is not None and telemetry.enabled:
telemetry.record_worklist_iteration()
# Safety limit check
if self.iteration_count > MAX_ITERATIONS:
logger.error(
"Exceeded {max} iterations! Worklist size: {size}",
max=MAX_ITERATIONS,
size=len(worklist),
)
top_nodes = sorted(self.node_visit_count.items(), key=lambda x: x[1], reverse=True)[
:10
]
for node_id, count in top_nodes:
logger.error("Node {node_id}: {count} visits", node_id=node_id, count=count)
break
# Progress logging every PROGRESS_INTERVAL seconds
current_time = time.time()
if current_time - self.last_progress_time > PROGRESS_INTERVAL:
elapsed = current_time - self.start_time
logger.info(
"Progress: {iterations} iterations, worklist={size}, {elapsed:.1f}s elapsed",
iterations=self.iteration_count,
size=len(worklist),
elapsed=elapsed,
)
self.last_progress_time = current_time
node = worklist.popleft()
# Track node visits
self.node_visit_count[node.node_id] += 1
if telemetry is not None and telemetry.enabled:
telemetry.record_worklist_pop(
node.node_id,
self.node_visit_count[node.node_id],
len(worklist) + 1,
self._state_local_constraint_count(self.state[node.node_id].pre),
)
if self.node_visit_count[node.node_id] == 50:
logger.warning("Node {node_id} visited 50 times!", node_id=node.node_id)
if self.node_visit_count[node.node_id] == 100:
logger.error("Node {node_id} visited 100 times!", node_id=node.node_id)
current_state = AnalysisState(
pre=self.state[node.node_id].pre, post=self.state[node.node_id].post
)
self.analysis.direction().apply_transfer_function(
analysis=self.analysis,
current_state=current_state,
node=node,
worklist=worklist,
global_state=self.state,
)
# Final statistics
total_time = time.time() - self.start_time
logger.info(
"Analysis of {name} complete: {iterations} iterations in {time:.2f}s",
@@ -237,11 +142,95 @@ class Engine(Generic[A]):
iterations=self.iteration_count,
time=total_time,
)
# Record fixpoint reached
if telemetry is not None and telemetry.enabled:
telemetry.record_fixpoint_reached()
def _initialize_worklist(self, telemetry: SolverTelemetry | None) -> deque[Node]:
"""Create the initial FIFO queue without changing traversal semantics."""
if not self.analysis.direction().IS_FORWARD:
raise NotImplementedError("Backward analysis is not implemented")
worklist: deque[Node] = deque()
entry_point = self.function.entry_point
if entry_point is None:
return worklist
worklist.append(entry_point)
if telemetry is not None and telemetry.enabled:
telemetry.record_worklist_enqueue(len(worklist))
logger.info("Starting analysis of {name}", name=self.function.name)
return worklist
def _run_iteration(
self,
worklist: deque[Node],
telemetry: SolverTelemetry | None,
) -> bool:
"""Run one FIFO worklist iteration and report whether to continue."""
self.iteration_count += 1
if telemetry is not None and telemetry.enabled:
telemetry.record_worklist_iteration()
if self.iteration_count > MAX_ANALYSIS_ITERATIONS:
self._log_iteration_limit(worklist)
return False
self._log_progress_if_due(worklist)
node = worklist.popleft()
self._record_node_visit(node, worklist, telemetry)
current_state = AnalysisState(
pre=self.state[node.node_id].pre,
post=self.state[node.node_id].post,
)
self.analysis.direction().apply_transfer_function(
analysis=self.analysis,
current_state=current_state,
node=node,
worklist=worklist,
global_state=self.state,
)
return True
def _log_iteration_limit(self, worklist: deque[Node]) -> None:
logger.error(
"Exceeded {max} iterations! Worklist size: {size}",
max=MAX_ANALYSIS_ITERATIONS,
size=len(worklist),
)
top_nodes = sorted(self.node_visit_count.items(), key=lambda item: item[1], reverse=True)[
:10
]
for node_id, count in top_nodes:
logger.error("Node {node_id}: {count} visits", node_id=node_id, count=count)
def _log_progress_if_due(self, worklist: deque[Node]) -> None:
current_time = time.time()
if current_time - self.last_progress_time <= PROGRESS_INTERVAL_SECONDS:
return
logger.info(
"Progress: {iterations} iterations, worklist={size}, {elapsed:.1f}s elapsed",
iterations=self.iteration_count,
size=len(worklist),
elapsed=current_time - self.start_time,
)
self.last_progress_time = current_time
def _record_node_visit(
self,
node: Node,
worklist: deque[Node],
telemetry: SolverTelemetry | None,
) -> None:
self.node_visit_count[node.node_id] += 1
visit_count = self.node_visit_count[node.node_id]
if telemetry is not None and telemetry.enabled:
telemetry.record_worklist_pop(
node.node_id,
visit_count,
len(worklist) + 1,
self._state_local_constraint_count(self.state[node.node_id].pre),
)
if visit_count == 50:
logger.warning("Node {node_id} visited 50 times!", node_id=node.node_id)
if visit_count == 100:
logger.error("Node {node_id} visited 100 times!", node_id=node.node_id)
@staticmethod
def _state_local_constraint_count(domain: object) -> int:
"""Count state-local constraints without coupling the engine to a domain."""
+257
View File
@@ -0,0 +1,257 @@
"""Natural-loop identities and generation-scoped header fixpoints."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from slither.analyses.data_flow.engine.domain import Domain
from slither.analyses.data_flow.smt_solver.facts import Fact, FactId, LoopHeaderId
if TYPE_CHECKING:
from slither.analyses.data_flow.engine.analysis import Analysis
from slither.core.cfg.node import Node
from slither.core.declarations.function import Function
@dataclass(frozen=True, order=True)
class ControlFlowEdgeId:
"""Stable identity of one directed CFG edge."""
source_id: int
destination_id: int
@dataclass(frozen=True, order=True)
class LoopVariableId:
"""Static phi binding for one loop-carried source variable."""
header_name: str
entry_names: tuple[str, ...]
back_names: tuple[str, ...]
@dataclass(frozen=True)
class NaturalLoop:
"""One dominator-defined natural-loop header and its incoming edges."""
header_id: LoopHeaderId
back_edges: frozenset[ControlFlowEdgeId]
entry_edges: frozenset[ControlFlowEdgeId]
class LoopStructure:
"""Deterministic natural-loop classification for one function CFG."""
def __init__(self, loops: tuple[NaturalLoop, ...] = ()) -> None:
self._loops = {loop.header_id.node_id: loop for loop in loops}
self._back_edges = frozenset(edge for loop in loops for edge in loop.back_edges)
@classmethod
def from_function(cls, function: Function) -> LoopStructure:
"""Classify an edge as back only when its destination dominates its source."""
nodes = sorted(function.nodes, key=lambda node: node.node_id)
back_by_header: dict[int, set[ControlFlowEdgeId]] = {}
for source in nodes:
for destination in sorted(source.sons, key=lambda node: node.node_id):
if destination in source.dominators:
edge = ControlFlowEdgeId(source.node_id, destination.node_id)
back_by_header.setdefault(destination.node_id, set()).add(edge)
by_id = {node.node_id: node for node in nodes}
loops = tuple(
cls._make_loop(by_id[header_id], edges)
for header_id, edges in sorted(back_by_header.items())
)
return cls(loops)
@staticmethod
def _make_loop(header: Node, back_edges: set[ControlFlowEdgeId]) -> NaturalLoop:
header_id = LoopHeaderId.from_node(header)
entry_edges = frozenset(
ControlFlowEdgeId(father.node_id, header.node_id)
for father in header.fathers
if ControlFlowEdgeId(father.node_id, header.node_id) not in back_edges
)
return NaturalLoop(header_id, frozenset(back_edges), entry_edges)
@property
def loops(self) -> tuple[NaturalLoop, ...]:
"""Return loops ordered by stable header identity."""
return tuple(sorted(self._loops.values(), key=lambda loop: loop.header_id))
def header(self, node_id: int) -> NaturalLoop | None:
"""Return natural-loop metadata for a header node ID."""
return self._loops.get(node_id)
def is_back_edge(self, edge: ControlFlowEdgeId) -> bool:
"""Return whether the edge closes a dominator-defined natural loop."""
return edge in self._back_edges
@dataclass(frozen=True)
class LoopWideningContext:
"""Complete previous/current header states for one candidate generation."""
header_id: LoopHeaderId
generation: int
previous_input: Domain
current_input: Domain
previous_output: Domain | None
variables: tuple[LoopVariableId, ...]
@dataclass(frozen=True)
class LoopWideningResult:
"""One candidate header approximation and its generation-owned facts."""
state: Domain
generation_facts: tuple[Fact[object], ...] = ()
@dataclass(frozen=True)
class LoopUpdate:
"""Result of incorporating one incoming edge into a loop header."""
changed: bool
state: Domain | None = None
@dataclass
class LoopHeaderFixpoint:
"""Own incoming edge contributions and one live loop generation."""
header_id: LoopHeaderId
variables: tuple[LoopVariableId, ...]
generation: int = 0
previous_input: Domain | None = None
current_input: Domain | None = None
current_output: Domain | None = None
_output_generation: int | None = None
_entry_inputs: dict[ControlFlowEdgeId, Domain] = field(default_factory=dict)
_back_inputs: dict[ControlFlowEdgeId, Domain] = field(default_factory=dict)
_generation_facts: tuple[Fact[object], ...] = ()
def update(
self,
edge: ControlFlowEdgeId,
incoming: Domain,
is_back_edge: bool,
analysis: Analysis,
) -> LoopUpdate:
"""Join one edge contribution and advance only for a changed header state."""
inputs = self._back_inputs if is_back_edge else self._entry_inputs
if not self._update_input(inputs, edge, incoming):
return LoopUpdate(False)
candidate = self._combine_inputs()
if self.current_input is None:
initial = analysis.bottom_value()
if not initial.join(candidate):
return LoopUpdate(False)
self.current_input = initial
return LoopUpdate(True, initial.deep_copy())
if not is_back_edge:
return self._join_entry_candidate(candidate)
if self._output_generation != self.generation:
return self._update_pending_generation(candidate, analysis)
return self._start_generation(candidate, analysis)
def _start_generation(self, candidate: Domain, analysis: Analysis) -> LoopUpdate:
"""Start a generation from the latest transferred header state."""
if self.current_input is None:
raise RuntimeError("Loop generation started before tracker initialization")
next_generation = self.generation + 1
context = LoopWideningContext(
self.header_id,
next_generation,
self.current_input.deep_copy(),
candidate,
self.current_output.deep_copy() if self.current_output is not None else None,
self.variables,
)
result = analysis.apply_loop_widening(context)
merged = self.current_input.deep_copy()
if not merged.join(result.state):
return LoopUpdate(False)
self.previous_input = self.current_input.deep_copy()
self.current_input = merged
self.generation = next_generation
self._generation_facts = result.generation_facts
return LoopUpdate(True, merged.deep_copy())
def _update_pending_generation(
self,
candidate: Domain,
analysis: Analysis,
) -> LoopUpdate:
"""Join another latch into a generation whose header is already queued."""
if self.previous_input is None or self.current_input is None:
return LoopUpdate(False)
context = LoopWideningContext(
self.header_id,
self.generation,
self.previous_input.deep_copy(),
candidate,
self.current_output.deep_copy() if self.current_output is not None else None,
self.variables,
)
result = analysis.apply_loop_widening(context)
merged = self.previous_input.deep_copy()
merged.join(result.state)
expanded = self.current_input.deep_copy()
if not expanded.join(merged):
return LoopUpdate(False)
self.current_input = expanded
self._generation_facts = result.generation_facts
return LoopUpdate(True, expanded.deep_copy())
def _join_entry_candidate(self, candidate: Domain) -> LoopUpdate:
if self.current_input is None:
raise RuntimeError("Loop entry joined before tracker initialization")
merged = self.current_input.deep_copy()
if not merged.join(candidate):
return LoopUpdate(False)
if self._output_generation != self.generation:
self.current_input = merged
return LoopUpdate(True, merged.deep_copy())
self.previous_input = self.current_input.deep_copy()
self.current_input = merged
self.generation += 1
self._generation_facts = ()
return LoopUpdate(True, merged.deep_copy())
@staticmethod
def _update_input(
inputs: dict[ControlFlowEdgeId, Domain],
edge: ControlFlowEdgeId,
incoming: Domain,
) -> bool:
existing = inputs.get(edge)
if existing is None:
inputs[edge] = incoming.deep_copy()
return True
return existing.join(incoming)
def _combine_inputs(self) -> Domain:
ordered = sorted((*self._entry_inputs.items(), *self._back_inputs.items()))
if not ordered:
raise RuntimeError("Loop header has no incoming contributions")
combined = ordered[0][1].deep_copy()
for _, incoming in ordered[1:]:
combined.join(incoming)
return combined
def record_output(self, output: Domain) -> None:
"""Associate the latest complete header output with the current generation."""
self.current_output = output.deep_copy()
self._output_generation = self.generation
@property
def generation_fact_ids(self) -> frozenset[FactId]:
"""Return only the currently owned generation fact identities."""
return frozenset(fact.fact_id for fact in self._generation_facts)
@property
def generation_facts(self) -> tuple[Fact[object], ...]:
"""Return the single live generation-owned fact set."""
return self._generation_facts
+107 -72
View File
@@ -33,6 +33,7 @@ if TYPE_CHECKING:
)
from slither.analyses.data_flow.analyses.interval.core.state import State
from slither.analyses.data_flow.smt_solver.facts import Fact, SemanticStateId
from slither.analyses.data_flow.smt_solver.query import QueryBudget
from slither.analyses.data_flow.smt_solver.cache import RangeQueryCache
from slither.analyses.data_flow.smt_solver.solver import SMTSolver
from slither.core.cfg.node import Node
@@ -668,31 +669,24 @@ def _create_annotation(
timeout_ms: int | None = None,
) -> LineAnnotation | None:
"""Create a LineAnnotation from analysis data."""
from slither.analyses.data_flow.analysis import (
RangeQueryConfig,
solve_variable_range,
)
effective_timeout = timeout_ms if timeout_ms is not None else config.timeout_ms
range_config = RangeQueryConfig(
state_id=state.semantic_id(),
state_facts=state.get_facts(),
timeout_ms=effective_timeout,
skip_optimization=config.skip_solving,
min_result, max_result, budget, effective_timeout = _solve_annotation_bounds(
smt_var,
state,
solver,
config,
cache=cache,
timeout_ms=timeout_ms,
)
min_result, max_result = solve_variable_range(solver, smt_var, range_config)
if not min_result or not max_result:
return None
# Check for unreachable path
if min_result.get("unreachable"):
display_name = _simplify_var_name(var_name, keep_ssa=config.show_ssa)
if var_name.startswith("TMP_") and tmp_expressions:
expr = tmp_expressions.get(var_name)
if expr:
display_name = f"{display_name} = {expr}"
display_name = _annotation_display_name(
var_name,
config.show_ssa,
tmp_expressions,
)
return LineAnnotation(
variable_name=display_name,
range_min="⊥",
@@ -712,27 +706,17 @@ def _create_annotation(
# Only mark actual return statements as returns (not TMP variables)
is_return = "return" in var_name.lower() and not var_name.startswith("TMP_")
# Simplify variable name for display (keep SSA suffix if requested)
display_name = _simplify_var_name(var_name, keep_ssa=config.show_ssa)
# For TMP variables, add the expression they represent
if var_name.startswith("TMP_") and tmp_expressions:
expr = tmp_expressions.get(var_name)
if expr:
display_name = f"{display_name} = {expr}"
display_name = _annotation_display_name(var_name, config.show_ssa, tmp_expressions)
# All annotations at same indent (column=0)
column = 0
# Check overflow possibility for unchecked arithmetic (deferred to annotation time)
branch_facts = state.get_branch_facts()
branch_state_id = state.semantic_id_for_facts(branch_facts, "overflow_precondition")
can_overflow, can_underflow = _check_overflow_possible(
can_overflow, can_underflow = _annotation_overflow_status(
solver,
smt_var,
state,
effective_timeout,
branch_state_id,
branch_facts,
budget=budget,
)
# Record precision metrics
@@ -753,43 +737,86 @@ def _create_annotation(
)
def _solve_annotation_bounds(
smt_var: "TrackedSMTVariable",
state: "State",
solver: "SMTSolver",
config: AnalysisConfig,
*,
cache: "RangeQueryCache",
timeout_ms: int | None,
) -> tuple[dict | None, dict | None, "QueryBudget", int]:
"""Solve one annotation with a budget shared by deferred predicate checks."""
from slither.analyses.data_flow.analysis import RangeQueryConfig, solve_variable_range
from slither.analyses.data_flow.smt_solver.query import QueryBudget
effective_timeout = timeout_ms if timeout_ms is not None else config.timeout_ms
budget = QueryBudget(effective_timeout)
range_config = RangeQueryConfig(
state_id=state.semantic_id(),
state_facts=state.get_facts(),
timeout_ms=effective_timeout,
skip_optimization=config.skip_solving,
cache=cache,
budget=budget,
)
minimum, maximum = solve_variable_range(solver, smt_var, range_config)
return minimum, maximum, budget, effective_timeout
def _annotation_display_name(
var_name: str,
keep_ssa: bool,
tmp_expressions: dict[str, str] | None,
) -> str:
"""Build the source-facing name for one annotation."""
display_name = _simplify_var_name(var_name, keep_ssa=keep_ssa)
if not var_name.startswith("TMP_") or not tmp_expressions:
return display_name
expression = tmp_expressions.get(var_name)
return f"{display_name} = {expression}" if expression else display_name
def _annotation_overflow_status(
solver: "SMTSolver",
smt_var: "TrackedSMTVariable",
state: "State",
timeout_ms: int,
*,
budget: "QueryBudget",
) -> tuple[bool, bool]:
"""Check deferred arithmetic predicates against branch-owned facts."""
branch_facts = state.get_branch_facts()
state_id = state.semantic_id_for_facts(branch_facts, "overflow_precondition")
return _check_overflow_possible(
solver,
smt_var,
timeout_ms,
state_id,
branch_facts,
budget=budget,
)
def _check_overflow_possible(
solver: "SMTSolver",
smt_var: "TrackedSMTVariable",
timeout_ms: int,
state_id: "SemanticStateId",
state_facts: tuple["Fact", ...],
*,
budget: "QueryBudget | None" = None,
) -> tuple[bool, bool]:
"""Check if overflow/underflow is possible for unchecked arithmetic.
Called at annotation time (after analysis) to avoid slowing down the
analysis. Uses a timeout to skip expensive checks. When the solver
returns UNKNOWN (timeout), we conservatively report overflow as
possible — only an explicit UNSAT proves the operation is safe.
Branch guards (e.g. from ``if (a > b)``) are included so relational
guards like ``if`` suppress false positives the same way ``require``
does. Checked-arithmetic path constraints (``result <= left``) are
deliberately excluded — they would trivially prove no overflow for
the very operation being checked.
Args:
solver: The SMT solver instance.
smt_var: The tracked variable with overflow predicates.
timeout_ms: Timeout in milliseconds for each check.
state_id: Semantic identity of the branch-guard precondition view.
state_facts: Typed branch-guard facts from the state
containing this arithmetic operation.
Returns:
Tuple of (can_overflow, can_underflow) booleans.
"""
"""Check deferred predicates, treating every non-UNSAT outcome as possible."""
from slither.analyses.data_flow.smt_solver.facts import make_query_fact
from slither.analyses.data_flow.smt_solver.query import (
FeasibilityStatus,
QueryPurpose,
)
if not smt_var.is_unchecked:
return False, False
can_overflow = False
can_underflow = False
@@ -800,14 +827,18 @@ def _check_overflow_possible(
0,
state_id.context_id,
)
result = solver.check_feasibility(
state_id=state_id,
state_facts=state_facts,
query_facts=(query_fact,),
purpose=QueryPurpose.OVERFLOW,
timeout_ms=timeout_ms,
)
can_overflow = result.status is not FeasibilityStatus.UNSAT
remaining_ms = budget.remaining_ms() if budget is not None else timeout_ms
if remaining_ms == 0:
can_overflow = True
else:
result = solver.check_feasibility(
state_id=state_id,
state_facts=state_facts,
query_facts=(query_fact,),
purpose=QueryPurpose.OVERFLOW,
timeout_ms=remaining_ms,
)
can_overflow = result.status is not FeasibilityStatus.UNSAT
if smt_var.no_underflow is not None:
query_fact = make_query_fact(
@@ -816,14 +847,18 @@ def _check_overflow_possible(
0,
state_id.context_id,
)
result = solver.check_feasibility(
state_id=state_id,
state_facts=state_facts,
query_facts=(query_fact,),
purpose=QueryPurpose.UNDERFLOW,
timeout_ms=timeout_ms,
)
can_underflow = result.status is not FeasibilityStatus.UNSAT
remaining_ms = budget.remaining_ms() if budget is not None else timeout_ms
if remaining_ms == 0:
can_underflow = True
else:
result = solver.check_feasibility(
state_id=state_id,
state_facts=state_facts,
query_facts=(query_fact,),
purpose=QueryPurpose.UNDERFLOW,
timeout_ms=remaining_ms,
)
can_underflow = result.status is not FeasibilityStatus.UNSAT
return can_overflow, can_underflow
@@ -13,6 +13,7 @@ from .query import (
FeasibilityStatus,
FunctionEncoding,
QueryDiagnostics,
QueryBudget,
QueryMaterialization,
QueryPurpose,
QuerySession,
@@ -33,6 +34,7 @@ __all__ = [
"FeasibilityResult",
"FeasibilityStatus",
"FunctionEncoding",
"QueryBudget",
"QueryDiagnostics",
"QueryMaterialization",
"QueryPurpose",
+27 -3
View File
@@ -4,7 +4,11 @@ from __future__ import annotations
from dataclasses import dataclass, field, replace
from enum import Enum
from typing import Generic, TypeVar
from typing import Generic, Protocol, TypeVar, cast
class _NodeWithId(Protocol):
node_id: int
class FactOwnerKind(Enum):
@@ -92,7 +96,7 @@ class StaticOperationId:
raise ValueError("CFG node has no function for deterministic operation identity")
return cls(
encoding_id=EncodingId.from_function(function),
node_id=int(node.node_id),
node_id=int(cast("_NodeWithId", node).node_id),
ir_position=position,
)
@@ -104,11 +108,30 @@ class StaticOperationId:
raise ValueError("CFG node has no function for deterministic operation identity")
return cls(
encoding_id=EncodingId.from_function(function),
node_id=int(node.node_id),
node_id=int(cast("_NodeWithId", node).node_id),
ir_position=ir_position,
)
@dataclass(frozen=True, order=True)
class LoopHeaderId:
"""Stable identity of one natural-loop header in a function encoding."""
encoding_id: EncodingId
node_id: int
@classmethod
def from_node(cls, node: object) -> LoopHeaderId:
"""Build an identity from the header's function and CFG node ID."""
function = getattr(node, "function", None)
if function is None:
raise ValueError("Loop header has no function for deterministic identity")
return cls(
EncodingId.from_function(function),
int(cast("_NodeWithId", node).node_id),
)
@dataclass(frozen=True)
class AnalysisContextId:
"""Identity of a function encoding instantiated in an analysis context."""
@@ -176,6 +199,7 @@ class FactProvenance:
origin_kind: FactOriginKind
operation_id: StaticOperationId | None = None
cfg_edge: tuple[int, int] | None = None
loop_header_id: LoopHeaderId | None = None
loop_generation: int | None = None
property_id: str | None = None
@@ -2,6 +2,7 @@
from __future__ import annotations
import math
import time
from collections.abc import Callable
from dataclasses import dataclass
@@ -44,6 +45,7 @@ class FeasibilityStatus(Enum):
UNKNOWN = "unknown"
TIMEOUT = "timeout"
ERROR = "error"
NOT_ATTEMPTED = "not_attempted"
class BoundStatus(Enum):
@@ -209,6 +211,9 @@ class QueryDiagnostics:
"""Aggregate diagnostics for a public feasibility or range result."""
sessions: tuple[QuerySessionDiagnostics, ...] = ()
total_budget_ms: int | None = None
wall_elapsed_ms: float = 0.0
budget_exhausted: bool = False
@property
def elapsed_ms(self) -> float:
@@ -224,11 +229,77 @@ class QueryDiagnostics:
"""Serialize all independent sessions for diagnostics and future caches."""
return {
"elapsed_ms": self.elapsed_ms,
"total_budget_ms": self.total_budget_ms,
"wall_elapsed_ms": self.wall_elapsed_ms,
"budget_exhausted": self.budget_exhausted,
"cleanup_balanced": self.cleanup_balanced,
"sessions": [session.to_dict() for session in self.sessions],
}
class QueryBudget:
"""One monotonic wall-clock budget shared by an expression's query sessions."""
def __init__(
self,
total_ms: int,
*,
clock: Callable[[], float] = time.perf_counter,
) -> None:
if total_ms < 0:
raise ValueError("Query budget cannot be negative")
self.total_ms = total_ms
self._clock = clock
self._started = clock()
self._deadline = self._started + total_ms / 1000
self._forced_exhausted = False
@property
def elapsed_ms(self) -> float:
"""Return total orchestration wall time since this budget was created."""
return max(0.0, (self._clock() - self._started) * 1000)
@property
def exhausted(self) -> bool:
"""Return whether no solver time remains."""
return self._forced_exhausted or self.total_ms == 0 or self._clock() >= self._deadline
def remaining_ms(self, cap_ms: int | None = None) -> int:
"""Return a positive backend timeout, or zero when the deadline passed."""
if self._forced_exhausted:
return 0
remaining = max(0.0, (self._deadline - self._clock()) * 1000)
allocated = math.ceil(remaining)
if cap_ms is not None:
allocated = min(allocated, max(0, cap_ms))
return allocated
def exhaust(self) -> None:
"""Close a budget when the remainder cannot safely admit another session."""
self._forced_exhausted = True
def diagnostics(
self,
sessions: tuple[QuerySessionDiagnostics, ...] = (),
) -> QueryDiagnostics:
"""Snapshot aggregate budget and owned-session diagnostics."""
return QueryDiagnostics(
sessions=sessions,
total_budget_ms=self.total_ms,
wall_elapsed_ms=self.elapsed_ms,
budget_exhausted=self.exhausted,
)
@dataclass(frozen=True)
class BoundOutcome:
"""One independent bound result, optionally backed by an opened session."""
value: int | None
status: BoundStatus
diagnostics: QuerySessionDiagnostics | None = None
@dataclass(frozen=True)
class FeasibilityResult:
"""Typed satisfiability result that preserves timeout and error distinctions."""
@@ -14,6 +14,7 @@ from slither.analyses.data_flow.smt_solver.query import (
FeasibilityResult,
FunctionEncoding,
QueryMaterialization,
QueryBudget,
QueryPurpose,
QuerySession,
RangeInterval,
@@ -358,10 +359,12 @@ class SMTSolver(ABC):
self.function_encoding.bind(encoding_id)
def register_loop_generation_fact(self, fact: Fact[SMTTerm]) -> None:
"""Reject loop facts until Stage 2 provides generation-scoped sessions."""
"""Reject loop facts because loop-header fixpoints own their lifecycle."""
if fact.fact_id.owner is not FactOwnerKind.LOOP_GENERATION:
raise ValueError("Loop facts must use the LOOP_GENERATION owner")
raise NotImplementedError("Loop-generation facts require generation-scoped query sessions")
raise NotImplementedError(
"Loop-generation facts are owned by LoopHeaderFixpoint, not the solver"
)
def add_query_local_assumption(self, fact: Fact[SMTTerm]) -> None:
"""Add a classified assumption to the current push/pop query frame."""
@@ -708,6 +711,7 @@ class SMTSolver(ABC):
signed: bool = False,
fallback_range: RangeInterval | None = None,
abstract_range: RangeInterval | None = None,
budget: QueryBudget | None = None,
) -> RangeResult:
"""Return independently typed feasibility, lower, and upper outcomes."""
pass
@@ -728,7 +732,7 @@ class SMTSolver(ABC):
Args:
term: The bitvector term to optimize.
extra_constraints: Additional constraints for this query only.
timeout_ms: Timeout in milliseconds for each optimization.
timeout_ms: Total wall-clock budget for all range-query sessions.
signed: If True, optimize using signed interpretation.
Returns:
@@ -1,6 +1,7 @@
"""Z3 solver strategy implementation."""
import os
import threading
import time
from z3 import (
@@ -51,15 +52,18 @@ from slither.analyses.data_flow.smt_solver.solver import SMTSolver
from slither.analyses.data_flow.smt_solver.telemetry import get_telemetry
from slither.analyses.data_flow.smt_solver.facts import Fact, SemanticStateId
from slither.analyses.data_flow.smt_solver.query import (
BoundOutcome,
BoundStatus,
FeasibilityResult,
FeasibilityStatus,
QueryDiagnostics,
QueryBudget,
QueryPurpose,
QuerySession,
QuerySessionDiagnostics,
RangeInterval,
RangeResult,
empty_state_id,
)
from slither.analyses.data_flow.smt_solver.types import (
CheckSatResult,
@@ -76,6 +80,7 @@ DUMP_CONSTRAINTS = os.environ.get("DUMP_CONSTRAINTS", "0") == "1"
DUMP_FILE = "/tmp/constraints_dump.txt"
_dump_file_handle = None
_constraint_history: list[str] = [] # Keep track of constraints for dumping
_QUERY_CHECK_LOCK = threading.Lock()
def _get_dump_file():
@@ -98,6 +103,9 @@ def _dump(msg: str):
class Z3Solver(SMTSolver):
"""Z3 implementation of SMT solver interface."""
minimum_session_budget_ms = 50
backend_timeout_fraction = 0.25
def __init__(self, use_optimizer: bool = False) -> None:
"""
Initialize Z3 solver.
@@ -768,45 +776,72 @@ class Z3Solver(SMTSolver):
signed: bool = False,
fallback_range: RangeInterval | None = None,
abstract_range: RangeInterval | None = None,
budget: QueryBudget | None = None,
) -> RangeResult:
"""Solve lower and upper bounds in independent disposable sessions."""
"""Solve bounds in isolated sessions sharing one hard wall-clock budget."""
budget = budget or QueryBudget(timeout_ms)
fallback_range = fallback_range or self._full_type_range(term, signed)
feasibility = self._range_feasibility(
state_id=state_id,
state_facts=state_facts,
query_facts=query_facts,
compatibility_constraints=compatibility_constraints,
timeout_ms=timeout_ms,
)
if feasibility.status is FeasibilityStatus.UNSAT:
return self._unsat_range_result(feasibility)
if abstract_range is not None:
return self._abstract_range_result(feasibility, abstract_range)
result = self._abstract_range_result(
state_id,
state_facts,
abstract_range,
budget,
)
self._record_range_refinement(result, abstract_only=True)
return result
feasibility_timeout = budget.remaining_ms(cap_ms=100)
if 0 < feasibility_timeout < self.minimum_session_budget_ms:
budget.exhaust()
feasibility_timeout = 0
if feasibility_timeout == 0:
feasibility = self._budget_exhausted_feasibility(state_id, budget)
else:
feasibility = self._range_feasibility(
state_id=state_id,
state_facts=state_facts,
query_facts=query_facts,
compatibility_constraints=compatibility_constraints,
timeout_ms=feasibility_timeout,
budget=budget,
)
if feasibility.status is FeasibilityStatus.UNSAT:
result = self._unsat_range_result(feasibility, budget)
self._record_range_refinement(result, abstract_only=False)
return result
objective = self._prepare_objective_term(term, signed)
lower = self._execute_bound_query(
term,
objective,
lower = self._execute_budgeted_bound_query(
budget,
term=term,
objective_term=objective,
maximize=False,
purpose=QueryPurpose.LOWER_BOUND,
timeout_ms=timeout_ms,
state_id=feasibility.state_id,
state_facts=state_facts,
query_facts=query_facts,
compatibility_constraints=compatibility_constraints,
)
upper = self._execute_bound_query(
term,
objective,
upper = self._execute_budgeted_bound_query(
budget,
term=term,
objective_term=objective,
maximize=True,
purpose=QueryPurpose.UPPER_BOUND,
timeout_ms=timeout_ms,
state_id=feasibility.state_id,
state_facts=state_facts,
query_facts=query_facts,
compatibility_constraints=compatibility_constraints,
)
return self._combine_range_outcomes(feasibility, lower, upper, fallback_range)
result = self._combine_range_outcomes(
feasibility,
lower,
upper,
fallback_range,
budget,
)
self._record_range_refinement(result, abstract_only=False)
return result
def _range_feasibility(
self,
@@ -816,6 +851,7 @@ class Z3Solver(SMTSolver):
query_facts: tuple[Fact[SMTTerm], ...],
compatibility_constraints: tuple[SMTTerm, ...],
timeout_ms: int,
budget: QueryBudget | None = None,
) -> FeasibilityResult:
"""Establish feasibility once for both objective sessions."""
session = self.create_query_session(
@@ -826,7 +862,7 @@ class Z3Solver(SMTSolver):
query_facts=query_facts,
compatibility_constraints=compatibility_constraints,
)
status, reason = self._execute_feasibility_session(session)
status, reason = self._execute_feasibility_session(session, budget)
del reason
return FeasibilityResult(
status=status,
@@ -838,6 +874,7 @@ class Z3Solver(SMTSolver):
def _execute_feasibility_session(
self,
session: QuerySession[SMTTerm],
budget: QueryBudget | None = None,
) -> tuple[FeasibilityStatus, str | None]:
"""Materialize, execute, classify, and always close one Solver session."""
status = FeasibilityStatus.ERROR
@@ -846,16 +883,38 @@ class Z3Solver(SMTSolver):
with session:
solver = self._create_feasibility_backend(session.timeout_ms)
session.attach_backend(solver)
solver.add(*(fact.formula for fact in session.materialization.facts))
started = time.perf_counter()
result = solver.check()
elapsed_ms = (time.perf_counter() - started) * 1000
status, reason = self._classify_feasibility(
solver,
result,
elapsed_ms,
session.timeout_ms,
solver.add(
*(
self._translate_for_backend(fact.formula, solver)
for fact in session.materialization.facts
)
)
effective_timeout = (
min(session.timeout_ms, budget.remaining_ms())
if budget is not None
else session.timeout_ms
)
if budget is not None and 0 < effective_timeout < self.minimum_session_budget_ms:
budget.exhaust()
effective_timeout = 0
elif budget is not None:
effective_timeout = max(
1,
int(effective_timeout * self.backend_timeout_fraction),
)
session.timeout_ms = effective_timeout
started = time.perf_counter()
if effective_timeout == 0:
status, reason = FeasibilityStatus.TIMEOUT, "total budget exhausted"
else:
result = self._check_with_deadline(solver, effective_timeout)
elapsed_ms = (time.perf_counter() - started) * 1000
status, reason = self._classify_feasibility(
solver,
result,
elapsed_ms,
effective_timeout,
)
session.close(feasibility_status=status, reason=reason)
except Exception as error: # Z3 exceptions become typed query errors.
status = FeasibilityStatus.ERROR
@@ -884,7 +943,8 @@ class Z3Solver(SMTSolver):
state_facts: tuple[Fact[SMTTerm], ...],
query_facts: tuple[Fact[SMTTerm], ...],
compatibility_constraints: tuple[SMTTerm, ...],
) -> tuple[int | None, BoundStatus, QuerySessionDiagnostics]:
budget: QueryBudget | None = None,
) -> BoundOutcome:
"""Execute one minimum or maximum objective in a fresh Optimize instance."""
session = self.create_query_session(
purpose=purpose,
@@ -901,20 +961,44 @@ class Z3Solver(SMTSolver):
with session:
optimizer = self._create_optimizer_backend(timeout_ms)
session.attach_backend(optimizer)
optimizer.add(*(fact.formula for fact in session.materialization.facts))
objective = optimizer.maximize if maximize else optimizer.minimize
objective(objective_term)
started = time.perf_counter()
result = optimizer.check()
elapsed_ms = (time.perf_counter() - started) * 1000
status, reason = self._classify_bound(
optimizer,
result,
elapsed_ms,
timeout_ms,
optimizer.add(
*(
self._translate_for_backend(fact.formula, optimizer)
for fact in session.materialization.facts
)
)
backend_term = self._translate_for_backend(term, optimizer)
backend_objective = self._translate_for_backend(objective_term, optimizer)
objective = optimizer.maximize if maximize else optimizer.minimize
objective(backend_objective)
effective_timeout = (
min(timeout_ms, budget.remaining_ms())
if budget is not None
else timeout_ms
)
if budget is not None and 0 < effective_timeout < self.minimum_session_budget_ms:
budget.exhaust()
effective_timeout = 0
elif budget is not None:
effective_timeout = max(
1,
int(effective_timeout * self.backend_timeout_fraction),
)
session.timeout_ms = effective_timeout
started = time.perf_counter()
if effective_timeout == 0:
status, reason = BoundStatus.TIMEOUT, "total budget exhausted"
else:
result = self._check_with_deadline(optimizer, effective_timeout)
elapsed_ms = (time.perf_counter() - started) * 1000
status, reason = self._classify_bound(
optimizer,
result,
elapsed_ms,
effective_timeout,
)
if status is BoundStatus.PROVEN:
value = self._bound_model_value(optimizer, term)
value = self._bound_model_value(optimizer, backend_term)
if value is None:
status = BoundStatus.ERROR
reason = "optimizer model did not contain a bitvector value"
@@ -924,7 +1008,40 @@ class Z3Solver(SMTSolver):
reason = f"{type(error).__name__}: {error}"
if session.diagnostics.bound_status is not BoundStatus.ERROR:
raise RuntimeError("QuerySession recorded the wrong backend error kind") from error
return value, status, session.diagnostics
return BoundOutcome(value, status, session.diagnostics)
def _execute_budgeted_bound_query(
self,
budget: QueryBudget,
term: SMTTerm,
objective_term: SMTTerm,
*,
maximize: bool,
purpose: QueryPurpose,
state_id: SemanticStateId,
state_facts: tuple[Fact[SMTTerm], ...],
query_facts: tuple[Fact[SMTTerm], ...],
compatibility_constraints: tuple[SMTTerm, ...],
) -> BoundOutcome:
"""Run one objective only when the expression deadline has time left."""
timeout_ms = budget.remaining_ms()
if 0 < timeout_ms < self.minimum_session_budget_ms:
budget.exhaust()
timeout_ms = 0
if timeout_ms == 0:
return BoundOutcome(None, BoundStatus.TIMEOUT)
return self._execute_bound_query(
term,
objective_term,
maximize=maximize,
purpose=purpose,
timeout_ms=timeout_ms,
state_id=state_id,
state_facts=state_facts,
query_facts=query_facts,
compatibility_constraints=compatibility_constraints,
budget=budget,
)
@staticmethod
def _create_feasibility_backend(timeout_ms: int) -> Solver:
@@ -940,6 +1057,40 @@ class Z3Solver(SMTSolver):
optimizer.set("timeout", timeout_ms)
return optimizer
@staticmethod
def _translate_for_backend(term: SMTTerm, backend: object) -> SMTTerm:
"""Translate a term only when a backend uses a distinct Z3 context."""
context = getattr(backend, "ctx", None)
translate = getattr(term, "translate", None)
term_context = getattr(term, "ctx", None)
same_context = term_context is context
if term_context is not None and context is not None:
same_context = term_context.ref() == context.ref()
if context is None or translate is None or same_context:
return term
return translate(context)
@staticmethod
def _check_with_deadline(backend: object, timeout_ms: int) -> object:
"""Interrupt the active Z3 check when its soft timeout overruns."""
check = getattr(backend, "check", None)
if not callable(check):
raise TypeError("Z3 query backend does not provide check()")
context = getattr(backend, "ctx", None)
interrupt = getattr(backend, "interrupt", None)
if interrupt is None and context is not None:
interrupt = getattr(context, "interrupt", None)
if interrupt is None or timeout_ms <= 0:
return check()
with _QUERY_CHECK_LOCK:
watchdog = threading.Timer(timeout_ms / 1000, interrupt)
watchdog.daemon = True
watchdog.start()
try:
return check()
finally:
watchdog.cancel()
@staticmethod
def _classify_feasibility(
query_solver: object,
@@ -1005,7 +1156,10 @@ class Z3Solver(SMTSolver):
return value.as_long() if is_bv_value(value) else None
@staticmethod
def _unsat_range_result(feasibility: FeasibilityResult) -> RangeResult:
def _unsat_range_result(
feasibility: FeasibilityResult,
budget: QueryBudget,
) -> RangeResult:
"""Return the existing bottom convention after proven UNSAT feasibility."""
return RangeResult(
lower=None,
@@ -1016,37 +1170,43 @@ class Z3Solver(SMTSolver):
fallback_range=None,
encoding_id=feasibility.encoding_id,
state_id=feasibility.state_id,
diagnostics=feasibility.diagnostics,
diagnostics=budget.diagnostics(feasibility.diagnostics.sessions),
)
@staticmethod
def _abstract_range_result(
feasibility: FeasibilityResult,
self,
state_id: SemanticStateId | None,
state_facts: tuple[Fact[SMTTerm], ...],
interval: RangeInterval,
budget: QueryBudget,
) -> RangeResult:
"""Return an explicitly abstract range without creating objectives."""
"""Return an explicitly abstract range without opening any session."""
state_id = state_id or empty_state_id(self.function_encoding.encoding_id)
if frozenset(fact.fact_id for fact in state_facts) != state_id.active_fact_ids:
raise ValueError("Abstract range state facts do not match SemanticStateId")
return RangeResult(
lower=interval.lower,
upper=interval.upper,
feasibility=feasibility.status,
feasibility=FeasibilityStatus.NOT_ATTEMPTED,
lower_status=BoundStatus.ABSTRACT,
upper_status=BoundStatus.ABSTRACT,
fallback_range=interval,
encoding_id=feasibility.encoding_id,
state_id=feasibility.state_id,
diagnostics=feasibility.diagnostics,
encoding_id=self.function_encoding.encoding_id,
state_id=state_id,
diagnostics=budget.diagnostics(),
)
@staticmethod
def _combine_range_outcomes(
feasibility: FeasibilityResult,
lower: tuple[int | None, BoundStatus, QuerySessionDiagnostics],
upper: tuple[int | None, BoundStatus, QuerySessionDiagnostics],
lower: BoundOutcome,
upper: BoundOutcome,
fallback: RangeInterval,
budget: QueryBudget,
) -> RangeResult:
"""Preserve one proven side and fall back only for a failed objective."""
lower_value, lower_status, lower_diagnostics = lower
upper_value, upper_status, upper_diagnostics = upper
lower_value, lower_status = lower.value, lower.status
upper_value, upper_status = upper.value, upper.status
used_fallback = lower_status is not BoundStatus.PROVEN or (
upper_status is not BoundStatus.PROVEN
)
@@ -1054,10 +1214,10 @@ class Z3Solver(SMTSolver):
lower_value = fallback.lower
if upper_status is not BoundStatus.PROVEN:
upper_value = fallback.upper
sessions = (
*feasibility.diagnostics.sessions,
lower_diagnostics,
upper_diagnostics,
objective_sessions = tuple(
diagnostics
for diagnostics in (lower.diagnostics, upper.diagnostics)
if diagnostics is not None
)
return RangeResult(
lower=lower_value,
@@ -1068,9 +1228,32 @@ class Z3Solver(SMTSolver):
fallback_range=fallback if used_fallback else None,
encoding_id=feasibility.encoding_id,
state_id=feasibility.state_id,
diagnostics=QueryDiagnostics(sessions),
diagnostics=budget.diagnostics(
(*feasibility.diagnostics.sessions, *objective_sessions)
),
)
def _budget_exhausted_feasibility(
self,
state_id: SemanticStateId | None,
budget: QueryBudget,
) -> FeasibilityResult:
"""Represent a feasibility check skipped because no budget remained."""
resolved_state = state_id or empty_state_id(self.function_encoding.encoding_id)
return FeasibilityResult(
status=FeasibilityStatus.TIMEOUT,
encoding_id=self.function_encoding.encoding_id,
state_id=resolved_state,
diagnostics=budget.diagnostics(),
)
@staticmethod
def _record_range_refinement(result: RangeResult, *, abstract_only: bool) -> None:
"""Record one aggregate refinement decision without driving semantics."""
telemetry = get_telemetry()
if telemetry is not None and telemetry.enabled:
telemetry.record_range_refinement(result, abstract_only=abstract_only)
def _full_type_range(self, term: SMTTerm, signed: bool) -> RangeInterval:
"""Return the sound type interval used when an objective is inconclusive."""
if not self.is_bitvector(term):
@@ -185,6 +185,23 @@ class QuerySessionMetrics:
bound_statuses: dict[str, int] = field(default_factory=dict)
@dataclass
class RangeRefinementMetrics:
"""Aggregate abstract-first and total-budget range decisions."""
requests: int = 0
abstract_only: int = 0
refinements_attempted: int = 0
sessions_avoided: int = 0
budget_exhausted: int = 0
partial_results: int = 0
configured_total_budget_ms: int = 0
wall_elapsed_ms: float = 0.0
feasibility_statuses: dict[str, int] = field(default_factory=dict)
lower_statuses: dict[str, int] = field(default_factory=dict)
upper_statuses: dict[str, int] = field(default_factory=dict)
@dataclass
class StateJoinMetrics:
"""Opt-in observations of Stage 3 join and convergence behavior."""
@@ -195,6 +212,21 @@ class StateJoinMetrics:
transfer_reruns_prevented: int = 0
@dataclass
class LoopFixpointMetrics:
"""Opt-in observations of Stage 4 natural-loop generations."""
headers_classified: int = 0
entry_edge_propagations: int = 0
back_edge_propagations: int = 0
generation_advances: int = 0
pending_generation_updates: int = 0
semantic_noops: int = 0
generation_fact_replacements: int = 0
live_generation_facts: int = 0
max_live_generation_facts: int = 0
@dataclass
class TransferFunctionMetrics:
"""Transfer function operation statistics."""
@@ -233,7 +265,9 @@ class EvaluationMetrics:
solver_lifetime: SolverLifetimeMetrics = field(default_factory=SolverLifetimeMetrics)
facts: FactOwnershipMetrics = field(default_factory=FactOwnershipMetrics)
query_sessions: QuerySessionMetrics = field(default_factory=QuerySessionMetrics)
range_refinements: RangeRefinementMetrics = field(default_factory=RangeRefinementMetrics)
state_joins: StateJoinMetrics = field(default_factory=StateJoinMetrics)
loop_fixpoints: LoopFixpointMetrics = field(default_factory=LoopFixpointMetrics)
transfer_functions: TransferFunctionMetrics = field(default_factory=TransferFunctionMetrics)
precision: PrecisionMetrics = field(default_factory=PrecisionMetrics)
@@ -295,6 +329,8 @@ class SolverTelemetry:
# Exact fingerprints are retained only while telemetry is enabled.
_assertion_fingerprints: set[str] = field(default_factory=set)
_loop_generation_fact_counts: dict[str, int] = field(default_factory=dict)
def count(self, operation: str, amount: int = 1) -> None:
"""Increment counter for an operation."""
if not self.enabled:
@@ -396,6 +432,43 @@ class SolverTelemetry:
return
self.evaluation.state_joins.transfer_reruns_prevented += 1
def record_loop_headers(self, count: int) -> None:
"""Record the number of dominator-classified loop headers."""
if not self.enabled:
return
self.evaluation.loop_fixpoints.headers_classified = count
def record_loop_update(
self,
*,
header_key: str,
is_back_edge: bool,
changed: bool,
generation_advanced: bool,
facts_replaced: bool,
live_generation_facts: int,
) -> None:
"""Record one incoming natural-loop edge and its lifecycle result."""
if not self.enabled:
return
metrics = self.evaluation.loop_fixpoints
if is_back_edge:
metrics.back_edge_propagations += 1
else:
metrics.entry_edge_propagations += 1
metrics.generation_advances += int(generation_advanced)
metrics.pending_generation_updates += int(
is_back_edge and changed and not generation_advanced
)
metrics.semantic_noops += int(not changed)
metrics.generation_fact_replacements += int(facts_replaced)
self._loop_generation_fact_counts[header_key] = live_generation_facts
metrics.live_generation_facts = sum(self._loop_generation_fact_counts.values())
metrics.max_live_generation_facts = max(
metrics.max_live_generation_facts,
metrics.live_generation_facts,
)
def record_assertion(
self,
fingerprint: str,
@@ -485,6 +558,27 @@ class SolverTelemetry:
self._increment_group(metrics.bound_statuses, bound.value)
metrics.timeout_results += int(bound.value == "timeout")
def record_range_refinement(self, result: Any, *, abstract_only: bool) -> None:
"""Record one completed range decision, including zero-session results."""
if not self.enabled:
return
metrics = self.evaluation.range_refinements
diagnostics = result.diagnostics
metrics.requests += 1
metrics.abstract_only += int(abstract_only)
metrics.refinements_attempted += int(not abstract_only)
metrics.sessions_avoided += int(abstract_only)
metrics.budget_exhausted += int(diagnostics.budget_exhausted)
metrics.configured_total_budget_ms += diagnostics.total_budget_ms or 0
metrics.wall_elapsed_ms += diagnostics.wall_elapsed_ms
proven = sum(
status.value == "proven" for status in (result.lower_status, result.upper_status)
)
metrics.partial_results += int(proven == 1)
self._increment_group(metrics.feasibility_statuses, result.feasibility.value)
self._increment_group(metrics.lower_statuses, result.lower_status.value)
self._increment_group(metrics.upper_statuses, result.upper_status.value)
@staticmethod
def _increment_group(groups: dict[str, int], key: str) -> None:
"""Increment one telemetry grouping."""
@@ -714,6 +808,7 @@ class SolverTelemetry:
self.evaluation = EvaluationMetrics()
self._query_times_ms.clear()
self._assertion_fingerprints.clear()
self._loop_generation_fact_counts.clear()
def get_summary(self) -> dict[str, dict]:
"""Get summary of all telemetry data."""
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Test_AbstractTransfer {
function constantPower() public pure returns (uint8) {
return 3 ** 2;
}
function constantBitwise() public pure returns (uint8) {
return 10 & 12;
}
function boundedCheckedMul(uint8 left, uint8 right) public pure returns (uint8) {
require(left >= 2 && left <= 5);
require(right >= 3 && right <= 7);
return left * right;
}
function boundedDivision(uint8 value) public pure returns (uint8) {
require(value >= 10 && value <= 20);
return value / 2;
}
function boundedModulo(uint8 value) public pure returns (uint8) {
require(value >= 10 && value <= 20);
return value % 6;
}
function boundedPower(uint8 value) public pure returns (uint8) {
require(value >= 2 && value <= 3);
return value ** 2;
}
function boundedShift(uint8 value) public pure returns (uint8) {
require(value >= 8 && value <= 31);
return value >> 2;
}
function boundedBitwise(uint8 value) public pure returns (uint8) {
require(value <= 15);
return value & 3;
}
}
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Test_LoopFixpoint {
function test_multiple_back_edges() public pure returns (uint256) {
uint256 i = 0;
uint256 sum = 0;
while (i < 10) {
if (i % 2 == 0) {
i += 1;
sum += i;
continue;
}
i += 2;
sum += i;
continue;
}
return sum;
}
function test_nested_loops() public pure returns (uint256) {
uint256 sum = 0;
for (uint256 i = 0; i < 3; i++) {
for (uint256 j = 0; j < 2; j++) {
sum += i + j;
}
}
return sum;
}
}
@@ -29,6 +29,7 @@ from slither.analyses.data_flow.analyses.interval.analysis.domain import (
from slither.analyses.data_flow.analyses.interval.core.state import State
from slither.analyses.data_flow.engine.engine import Engine
from slither.analyses.data_flow.smt_solver import Z3Solver
from slither.analyses.data_flow.smt_solver.query import RangeInterval
from slither.analyses.data_flow.smt_solver.telemetry import (
enable_telemetry,
reset_telemetry,
@@ -49,6 +50,16 @@ def parse_args() -> argparse.Namespace:
help="Use current state-local constraints or omit them for a diagnostic control",
)
parser.add_argument("--timeout-ms", type=int, default=3000)
parser.add_argument(
"--force-refinement",
action="store_true",
help="Refine tracked abstract intervals instead of returning them directly",
)
parser.add_argument(
"--summary-only",
action="store_true",
help="Print lifecycle totals and selected query results without per-node state dumps",
)
return parser.parse_args()
@@ -93,12 +104,23 @@ def query_matching_variables(
continue
seen.add(key)
started = time.perf_counter()
tracked_interval = RangeInterval(variable.interval.lower, variable.interval.upper)
abstract_sufficient = variable.is_total and (
variable.interval != variable.type_interval
or variable.overflow_operation_id is not None
)
result = solver.solve_range_result(
variable.term,
state_id=state_id,
state_facts=state_facts,
timeout_ms=args.timeout_ms,
signed=bool(variable.base.metadata.get("is_signed", False)),
fallback_range=tracked_interval,
abstract_range=(
tracked_interval
if abstract_sufficient and not args.force_refinement
else None
),
)
queries.append(
{
@@ -111,6 +133,16 @@ def query_matching_variables(
"lower": result.lower,
"upper": result.upper,
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
"total_budget_ms": result.diagnostics.total_budget_ms,
"budget_elapsed_ms": round(result.diagnostics.wall_elapsed_ms, 3),
"budget_exhausted": result.diagnostics.budget_exhausted,
"sessions": len(result.diagnostics.sessions),
"backend_elapsed_ms": round(result.diagnostics.elapsed_ms, 3),
"fallback_range": (
result.fallback_range.to_dict()
if result.fallback_range is not None
else None
),
}
)
return queries
@@ -178,6 +210,7 @@ def run_probe(args: argparse.Namespace) -> dict:
started = time.perf_counter()
engine.run_analysis()
analysis_elapsed_ms = (time.perf_counter() - started) * 1000
analysis_query_sessions = telemetry.get_evaluation_metrics().to_dict()["query_sessions"].copy()
queries = []
if args.query_variable:
pattern = re.compile(args.query_variable)
@@ -208,15 +241,61 @@ def run_probe(args: argparse.Namespace) -> dict:
"lifetime": lifetime,
"fact_ownership": evaluation["facts"],
"query_sessions": evaluation["query_sessions"],
"range_refinements": evaluation["range_refinements"],
"analysis_query_sessions": analysis_query_sessions,
"state_joins": evaluation["state_joins"],
"loop_fixpoints": evaluation["loop_fixpoints"],
"active_query_sessions": solver.active_query_sessions,
"query_summary": query_summary,
}
def summarize_probe(result: dict) -> dict:
"""Return the concise evidence used by direct lifecycle checks."""
lifetime = result["lifetime"]
facts = result["fact_ownership"]
return {
"contract": result["contract"],
"function": result["function"],
"cfg_nodes": result["cfg_nodes"],
"iterations": result["iterations"],
"analysis_elapsed_ms": result["analysis_elapsed_ms"],
"visited_nodes": len(lifetime["node_visits"]),
"final_live_assertions": result["final_live_assertions"],
"final_duplicate_assertions": result["final_duplicate_assertions"],
"internal_unclassified_additions": facts["unclassified_additions"],
"analysis_query_sessions": _summarize_sessions(result["analysis_query_sessions"]),
"query_sessions": _summarize_sessions(result["query_sessions"]),
"range_refinements": result["range_refinements"],
"loop_fixpoints": result["loop_fixpoints"],
"queries": result["queries"],
"active_query_sessions": result["active_query_sessions"],
}
def _summarize_sessions(metrics: dict) -> dict:
"""Drop high-cardinality identities from session lifecycle output."""
keys = (
"created",
"closed",
"active",
"cleanup_imbalances",
"timeout_results",
"compatibility_query_facts",
"by_purpose",
"feasibility_statuses",
"bound_statuses",
)
return {key: metrics[key] for key in keys}
def main() -> None:
"""Run the command-line probe."""
print(json.dumps(run_probe(parse_args()), indent=2, sort_keys=True))
args = parse_args()
result = run_probe(args)
if args.summary_only:
result = summarize_probe(result)
print(json.dumps(result, indent=2, sort_keys=True))
if __name__ == "__main__":
@@ -0,0 +1,443 @@
"""Focused soundness tests for nonlinear abstract interval transfer."""
from __future__ import annotations
from collections.abc import Iterable
import pytest
from slither.analyses.data_flow.analyses.interval.core.tracked_variable import (
NumericInterval,
)
from slither.analyses.data_flow.analyses.interval.operations.binary.abstract_transfer import (
AbstractTransferKind,
transfer_binary_interval,
)
from slither.slithir.operations.binary import BinaryType
def _interval(lower: int, upper: int | None = None) -> NumericInterval:
return NumericInterval(lower, lower if upper is None else upper)
def _all_intervals(values: Iterable[int]) -> tuple[NumericInterval, ...]:
concrete = tuple(values)
return tuple(
NumericInterval(lower, upper)
for lower in concrete
for upper in concrete
if lower <= upper
)
def _decode_wrapped(value: int, bit_width: int, is_signed: bool) -> int:
modulus = 1 << bit_width
wrapped = value % modulus
if is_signed and wrapped >= 1 << (bit_width - 1):
wrapped -= modulus
return wrapped
def _concrete_multiplication_results(
left: NumericInterval,
right: NumericInterval,
*,
bit_width: int,
is_signed: bool,
is_checked: bool,
) -> set[int]:
type_interval = NumericInterval.type_range(bit_width, is_signed)
results: set[int] = set()
for left_value in range(left.lower, left.upper + 1):
for right_value in range(right.lower, right.upper + 1):
product = left_value * right_value
if is_checked:
if type_interval.lower <= product <= type_interval.upper:
results.add(product)
else:
results.add(_decode_wrapped(product, bit_width, is_signed))
return results
@pytest.mark.parametrize("bit_width", [2, 3])
@pytest.mark.parametrize("is_signed", [False, True])
@pytest.mark.parametrize("is_checked", [False, True])
def test_multiplication_is_sound_for_every_small_width_interval(
bit_width: int,
is_signed: bool,
is_checked: bool,
) -> None:
"""Every concrete successful product is contained in the abstract result."""
type_interval = NumericInterval.type_range(bit_width, is_signed)
intervals = _all_intervals(range(type_interval.lower, type_interval.upper + 1))
for left in intervals:
for right in intervals:
result = transfer_binary_interval(
BinaryType.MULTIPLICATION,
left,
right,
bit_width=bit_width,
is_signed=is_signed,
is_checked=is_checked,
)
concrete = _concrete_multiplication_results(
left,
right,
bit_width=bit_width,
is_signed=is_signed,
is_checked=is_checked,
)
assert type_interval.lower <= result.interval.lower
assert result.interval.upper <= type_interval.upper
assert result.supported
assert all(
result.interval.lower <= value <= result.interval.upper for value in concrete
), (left, right, result, concrete)
if not concrete:
# Operation-local bottom is deliberately represented by top; the
# checked continuation facts retain the infeasibility information.
assert result.interval == type_interval
@pytest.mark.parametrize(
("left", "right", "is_signed", "expected"),
[
(_interval(2, 4), _interval(3, 5), False, _interval(6, 20)),
(_interval(-3, 4), _interval(-2, 5), True, _interval(-15, 20)),
(_interval(5), _interval(10), False, _interval(50)),
],
)
def test_finite_checked_multiplication_is_algebraically_bounded(
left: NumericInterval,
right: NumericInterval,
is_signed: bool,
expected: NumericInterval,
) -> None:
result = transfer_binary_interval(
BinaryType.MULTIPLICATION,
left,
right,
bit_width=8,
is_signed=is_signed,
is_checked=True,
)
assert result.interval == expected
assert result.kind is (
AbstractTransferKind.EXACT
if expected.lower == expected.upper
else AbstractTransferKind.INTERVAL
)
assert not result.may_wrap
def test_checked_multiplication_keeps_only_successful_nonoverflowing_values() -> None:
result = transfer_binary_interval(
BinaryType.MULTIPLICATION,
_interval(100, 200),
_interval(2, 3),
bit_width=8,
is_signed=False,
is_checked=True,
)
assert result.interval == _interval(200, 255)
assert result.kind is AbstractTransferKind.INTERVAL
assert not result.may_wrap
def test_checked_signed_square_uses_same_operand_relation() -> None:
result = transfer_binary_interval(
BinaryType.MULTIPLICATION,
_interval(-3, 4),
_interval(-3, 4),
bit_width=8,
is_signed=True,
is_checked=True,
same_operand=True,
)
assert result.interval == _interval(0, 16)
def test_unchecked_signed_square_returns_top_when_wrapping_can_change_sign() -> None:
result = transfer_binary_interval(
BinaryType.MULTIPLICATION,
_interval(-128, 127),
_interval(-128, 127),
bit_width=8,
is_signed=True,
is_checked=False,
same_operand=True,
)
assert result.interval == _interval(-128, 127)
assert result.kind is AbstractTransferKind.TOP
assert result.may_wrap
def test_unchecked_multiplication_wraps_constants_and_tops_nonconvex_images() -> None:
constant = transfer_binary_interval(
BinaryType.MULTIPLICATION,
_interval(200),
_interval(2),
bit_width=8,
is_signed=False,
is_checked=False,
)
wrapping = transfer_binary_interval(
BinaryType.MULTIPLICATION,
_interval(200, 255),
_interval(2),
bit_width=8,
is_signed=False,
is_checked=False,
)
unconstrained = transfer_binary_interval(
BinaryType.MULTIPLICATION,
_interval(0, 255),
_interval(0, 255),
bit_width=8,
is_signed=False,
is_checked=False,
)
assert constant.interval == _interval(144)
assert constant.kind is AbstractTransferKind.EXACT
assert constant.may_wrap
assert wrapping.interval == unconstrained.interval == _interval(0, 255)
assert wrapping.kind is unconstrained.kind is AbstractTransferKind.TOP
assert wrapping.may_wrap and unconstrained.may_wrap
@pytest.mark.parametrize(
("operation", "left", "right", "is_signed", "expected"),
[
(BinaryType.DIVISION, _interval(10, 20), _interval(2, 5), False, _interval(2, 10)),
(
BinaryType.DIVISION,
_interval(-20, 20),
_interval(-5, -2),
True,
_interval(-10, 10),
),
(BinaryType.MODULO, _interval(10, 20), _interval(3, 5), False, _interval(0, 4)),
(BinaryType.MODULO, _interval(-20, -10), _interval(3, 5), True, _interval(-4, 0)),
(BinaryType.MODULO, _interval(-7), _interval(3), True, _interval(-1)),
],
)
def test_division_and_modulo_representative_intervals(
operation: BinaryType,
left: NumericInterval,
right: NumericInterval,
is_signed: bool,
expected: NumericInterval,
) -> None:
result = transfer_binary_interval(
operation,
left,
right,
bit_width=8,
is_signed=is_signed,
is_checked=True,
)
assert result.interval == expected
assert result.supported
@pytest.mark.parametrize(
("operation", "expected"),
[
(BinaryType.DIVISION, _interval(1)),
(BinaryType.MODULO, _interval(0)),
(BinaryType.AND, _interval(3, 9)),
(BinaryType.OR, _interval(3, 9)),
(BinaryType.CARET, _interval(0)),
],
)
def test_same_operand_identities(operation: BinaryType, expected: NumericInterval) -> None:
result = transfer_binary_interval(
operation,
_interval(3, 9),
_interval(3, 9),
bit_width=8,
is_signed=False,
is_checked=True,
same_operand=True,
)
assert result.interval == expected
def test_zero_only_divisor_conservatively_returns_top() -> None:
result = transfer_binary_interval(
BinaryType.DIVISION,
_interval(1, 10),
_interval(0),
bit_width=8,
is_signed=False,
is_checked=True,
)
assert result.interval == _interval(0, 255)
assert result.kind is AbstractTransferKind.TOP
@pytest.mark.parametrize(
"case",
[
(_interval(2, 3), _interval(2), 8, False, True, _interval(4, 9)),
(_interval(-3, 2), _interval(2), 8, True, True, _interval(0, 9)),
(_interval(2, 3), _interval(2, 3), 8, False, True, _interval(4, 27)),
(_interval(0), _interval(0, 3), 8, False, True, _interval(0, 1)),
(_interval(20), _interval(2), 8, False, False, _interval(144)),
],
)
def test_power_representative_intervals(
case: tuple[NumericInterval, NumericInterval, int, bool, bool, NumericInterval],
) -> None:
base, exponent, bit_width, is_signed, is_checked, expected = case
result = transfer_binary_interval(
BinaryType.POWER,
base,
exponent,
bit_width=bit_width,
is_signed=is_signed,
is_checked=is_checked,
)
assert result.interval == expected
def test_unbounded_power_is_tagged_unsupported_top() -> None:
result = transfer_binary_interval(
BinaryType.POWER,
_interval(2, 3),
_interval(0, 2048),
bit_width=16,
is_signed=False,
is_checked=True,
)
assert result.interval == _interval(0, (1 << 16) - 1)
assert result.kind is AbstractTransferKind.TOP
assert not result.supported
@pytest.mark.parametrize(
("operation", "left", "right", "is_signed", "expected"),
[
(BinaryType.LEFT_SHIFT, _interval(1, 3), _interval(1), False, _interval(2, 6)),
(BinaryType.RIGHT_SHIFT, _interval(8, 16), _interval(1, 2), False, _interval(2, 8)),
(BinaryType.RIGHT_SHIFT, _interval(-8, -1), _interval(1), True, _interval(-4, -1)),
(BinaryType.RIGHT_SHIFT, _interval(-8, 7), _interval(8), True, _interval(-1, 0)),
],
)
def test_shift_representative_intervals(
operation: BinaryType,
left: NumericInterval,
right: NumericInterval,
is_signed: bool,
expected: NumericInterval,
) -> None:
result = transfer_binary_interval(
operation,
left,
right,
bit_width=8,
is_signed=is_signed,
is_checked=True,
)
assert result.interval == expected
def test_left_shift_wraps_constants_and_tops_nonconvex_images() -> None:
constant = transfer_binary_interval(
BinaryType.LEFT_SHIFT,
_interval(200),
_interval(1),
bit_width=8,
is_signed=False,
is_checked=True,
)
interval = transfer_binary_interval(
BinaryType.LEFT_SHIFT,
_interval(100, 200),
_interval(1),
bit_width=8,
is_signed=False,
is_checked=True,
)
assert constant.interval == _interval(144)
assert constant.kind is AbstractTransferKind.EXACT
assert constant.may_wrap
assert interval.interval == _interval(0, 255)
assert interval.kind is AbstractTransferKind.TOP
assert interval.may_wrap
@pytest.mark.parametrize(
("operation", "left", "right", "expected"),
[
(BinaryType.AND, _interval(0b1010), _interval(0b1100), _interval(0b1000)),
(BinaryType.OR, _interval(0b1010), _interval(0b0101), _interval(0b1111)),
(BinaryType.CARET, _interval(0b1010), _interval(0b1100), _interval(0b0110)),
(BinaryType.AND, _interval(0, 15), _interval(0, 3), _interval(0, 3)),
(BinaryType.OR, _interval(1, 2), _interval(4), _interval(0, 7)),
],
)
def test_bitwise_representative_intervals(
operation: BinaryType,
left: NumericInterval,
right: NumericInterval,
expected: NumericInterval,
) -> None:
result = transfer_binary_interval(
operation,
left,
right,
bit_width=8,
is_signed=False,
is_checked=True,
)
assert result.interval == expected
assert result.supported
@pytest.mark.parametrize("operation", [BinaryType.AND, BinaryType.OR, BinaryType.CARET])
def test_nonconstant_signed_bitwise_conservatively_returns_unsupported_top(
operation: BinaryType,
) -> None:
result = transfer_binary_interval(
operation,
_interval(-8, 3),
_interval(-2, 5),
bit_width=8,
is_signed=True,
is_checked=True,
)
assert result.interval == _interval(-128, 127)
assert result.kind is AbstractTransferKind.TOP
assert not result.supported
def test_unhandled_binary_operation_conservatively_returns_unsupported_top() -> None:
result = transfer_binary_interval(
BinaryType.LESS,
_interval(1, 3),
_interval(2, 4),
bit_width=8,
is_signed=False,
is_checked=True,
)
assert result.interval == _interval(0, 255)
assert result.kind is AbstractTransferKind.TOP
assert not result.supported
@@ -266,7 +266,7 @@ def test_nonpersistent_ownership_boundaries_do_not_fall_back_to_assertions() ->
assert solver.register_property_obligation(property_fact)
assert solver.get_assertions() == []
with pytest.raises(NotImplementedError, match="generation-scoped query sessions"):
with pytest.raises(NotImplementedError, match="owned by LoopHeaderFixpoint"):
solver.register_loop_generation_fact(loop_fact)
assert solver.get_assertions() == []
@@ -0,0 +1,324 @@
"""Tests for dominator-defined loop fixpoints and generation ownership."""
from __future__ import annotations
from dataclasses import dataclass, field
from slither.analyses.data_flow.analyses.interval.analysis.analysis import (
IntervalAnalysis,
)
from slither.analyses.data_flow.analyses.interval.analysis.domain import (
DomainVariant,
IntervalDomain,
)
from slither.analyses.data_flow.analyses.interval.core.state import ComparisonInfo, State
from slither.analyses.data_flow.analyses.interval.core.tracked_variable import (
NumericInterval,
TrackedSMTVariable,
)
from slither.analyses.data_flow.engine.analysis import Analysis
from slither.analyses.data_flow.engine.direction import Direction, Forward
from slither.analyses.data_flow.engine.domain import Domain
from slither.analyses.data_flow.engine.loop import (
ControlFlowEdgeId,
LoopHeaderFixpoint,
LoopStructure,
LoopVariableId,
LoopWideningContext,
LoopWideningResult,
)
from slither.analyses.data_flow.smt_solver import Z3Solver
from slither.analyses.data_flow.smt_solver.facts import (
AnalysisContextId,
EncodingId,
Fact,
FactId,
FactKind,
FactOriginKind,
FactOwnerKind,
FactProvenance,
LoopHeaderId,
StaticOperationId,
)
from slither.analyses.data_flow.smt_solver.types import Sort, SortKind
ENCODING_ID = EncodingId("loop_fixpoint.sol", "C.f()")
CONTEXT_ID = AnalysisContextId(ENCODING_ID)
HEADER_ID = LoopHeaderId(ENCODING_ID, 2)
@dataclass
class _Filename:
relative: str = "loop_fixpoint.sol"
@dataclass
class _SourceMapping:
filename: _Filename = field(default_factory=_Filename)
@dataclass
class _FunctionStub:
canonical_name: str = "C.f()"
source_mapping: _SourceMapping = field(default_factory=_SourceMapping)
nodes: list[_NodeStub] = field(default_factory=list)
@dataclass(eq=False)
class _NodeStub:
node_id: int
function: _FunctionStub
sons: list[_NodeStub] = field(default_factory=list)
fathers: list[_NodeStub] = field(default_factory=list)
dominators: set[_NodeStub] = field(default_factory=set)
def _loop_cfg() -> tuple[_FunctionStub, dict[int, _NodeStub]]:
function = _FunctionStub()
nodes = {node_id: _NodeStub(node_id, function) for node_id in range(1, 6)}
edges = ((1, 2), (2, 3), (2, 5), (3, 4), (4, 2))
for source_id, destination_id in edges:
source = nodes[source_id]
destination = nodes[destination_id]
source.sons.append(destination)
destination.fathers.append(source)
nodes[1].dominators = {nodes[1]}
nodes[2].dominators = {nodes[1], nodes[2]}
nodes[3].dominators = {nodes[1], nodes[2], nodes[3]}
nodes[4].dominators = {nodes[1], nodes[2], nodes[3], nodes[4]}
nodes[5].dominators = {nodes[1], nodes[2], nodes[5]}
function.nodes = list(nodes.values())
return function, nodes
def _solver() -> Z3Solver:
solver = Z3Solver()
solver.bind_function_encoding(ENCODING_ID)
return solver
def _tracked(
solver: Z3Solver,
name: str,
lower: int,
upper: int,
*,
total: bool = True,
) -> TrackedSMTVariable:
variable = TrackedSMTVariable.create(
solver,
name,
Sort(SortKind.BITVEC, [8]),
bit_width=8,
)
return variable.with_interval(NumericInterval(lower, upper), is_total=total)
def _domain(solver: Z3Solver, lower: int, upper: int) -> IntervalDomain:
return IntervalDomain.with_state(
State({"x": _tracked(solver, "x", lower, upper)}, context_id=CONTEXT_ID)
)
class _RecordingAnalysis(Analysis):
def __init__(self) -> None:
self.contexts: list[LoopWideningContext] = []
self._direction = Forward()
def domain(self) -> Domain:
return IntervalDomain.with_state(State(context_id=CONTEXT_ID))
def direction(self) -> Direction:
return self._direction
def transfer_function(self, node: object, domain: Domain, operation: object) -> None:
return None
def bottom_value(self) -> Domain:
return IntervalDomain.bottom(CONTEXT_ID)
def apply_loop_widening(self, context: LoopWideningContext) -> LoopWideningResult:
self.contexts.append(context)
fact = Fact(
fact_id=FactId(
owner=FactOwnerKind.LOOP_GENERATION,
kind=FactKind.RANGE_BOUND,
provenance=FactProvenance(
context_id=CONTEXT_ID,
origin_kind=FactOriginKind.LOOP,
loop_header_id=context.header_id,
loop_generation=context.generation,
),
semantic_key=("generation", str(context.generation)),
),
formula=context.generation,
)
return LoopWideningResult(context.current_input, (fact,))
def test_natural_loop_classification_excludes_preheader_and_is_stable() -> None:
function, nodes = _loop_cfg()
first = LoopStructure.from_function(function)
function.nodes.reverse()
second = LoopStructure.from_function(function)
preheader = ControlFlowEdgeId(1, 2)
back_edge = ControlFlowEdgeId(4, 2)
assert not first.is_back_edge(preheader)
assert first.is_back_edge(back_edge)
assert first.loops == second.loops
assert first.loops[0].entry_edges == frozenset({preheader})
assert first.loops[0].back_edges == frozenset({back_edge})
assert first.loops[0].header_id == LoopHeaderId.from_node(nodes[2])
assert LoopHeaderId.from_node(nodes[2]) == LoopHeaderId.from_node(nodes[2])
def test_generation_advances_only_for_changed_header_approximation() -> None:
solver = _solver()
analysis = _RecordingAnalysis()
tracker = LoopHeaderFixpoint(HEADER_ID, ())
entry = ControlFlowEdgeId(1, 2)
back = ControlFlowEdgeId(4, 2)
assert tracker.update(entry, _domain(solver, 0, 0), False, analysis).changed
tracker.record_output(_domain(solver, 0, 0))
assert tracker.update(back, _domain(solver, 1, 1), True, analysis).changed
assert tracker.generation == 1
assert not tracker.update(back, _domain(solver, 1, 1), True, analysis).changed
assert tracker.generation == 1
assert len(analysis.contexts) == 1
def test_widening_context_keeps_previous_and_current_semantic_states_separate() -> None:
solver = _solver()
analysis = _RecordingAnalysis()
tracker = LoopHeaderFixpoint(HEADER_ID, ())
entry_state = _domain(solver, 0, 0)
output_state = _domain(solver, 0, 1)
tracker.update(ControlFlowEdgeId(1, 2), entry_state, False, analysis)
tracker.record_output(output_state)
tracker.update(ControlFlowEdgeId(4, 2), _domain(solver, 2, 3), True, analysis)
context = analysis.contexts[0]
assert context.previous_input.semantic_id() == entry_state.semantic_id()
assert context.current_input.semantic_id() != context.previous_input.semantic_id()
assert context.previous_output is not None
assert context.previous_output.semantic_id() == output_state.semantic_id()
assert context.previous_input is not context.current_input
def test_generation_facts_replace_obsolete_generation() -> None:
solver = _solver()
analysis = _RecordingAnalysis()
tracker = LoopHeaderFixpoint(HEADER_ID, ())
entry = ControlFlowEdgeId(1, 2)
back = ControlFlowEdgeId(4, 2)
tracker.update(entry, _domain(solver, 0, 0), False, analysis)
tracker.record_output(_domain(solver, 0, 0))
tracker.update(back, _domain(solver, 1, 1), True, analysis)
first_ids = tracker.generation_fact_ids
tracker.record_output(_domain(solver, 0, 1))
tracker.update(back, _domain(solver, 1, 2), True, analysis)
assert tracker.generation == 2
assert first_ids.isdisjoint(tracker.generation_fact_ids)
assert {fact.fact_id.provenance.loop_generation for fact in tracker.generation_facts} == {2}
def _run_multiple_back_edges(order: tuple[ControlFlowEdgeId, ...]) -> IntervalDomain:
solver = _solver()
analysis = _RecordingAnalysis()
tracker = LoopHeaderFixpoint(HEADER_ID, ())
states = {
ControlFlowEdgeId(4, 2): _domain(solver, 1, 5),
ControlFlowEdgeId(5, 2): _domain(solver, 10, 20),
}
tracker.update(ControlFlowEdgeId(1, 2), _domain(solver, 0, 0), False, analysis)
tracker.record_output(_domain(solver, 0, 0))
for edge in order:
tracker.update(edge, states[edge], True, analysis)
assert isinstance(tracker.current_input, IntervalDomain)
assert tracker.generation == 1
return tracker.current_input
def test_multiple_back_edges_join_independently_of_arrival_order() -> None:
first = ControlFlowEdgeId(4, 2)
second = ControlFlowEdgeId(5, 2)
left = _run_multiple_back_edges((first, second))
right = _run_multiple_back_edges((second, first))
assert left.semantic_id() == right.semantic_id()
assert left.state is not None
assert left.state.get_variable("x").interval == NumericInterval(0, 20)
def test_interval_widening_preserves_complete_state_and_solver_ownership() -> None:
solver = _solver()
analysis = IntervalAnalysis(solver, timeout_ms=10)
entry = _tracked(solver, "x_entry", 0, 0)
back = _tracked(solver, "x_back", 1, 5, total=False)
marker = _tracked(solver, "marker", 7, 7)
operation_id = StaticOperationId(ENCODING_ID, 9, 0)
comparison = ComparisonInfo(back.term < 10, operation_id)
fact = Fact(
fact_id=FactId(
owner=FactOwnerKind.STATE_LOCAL,
kind=FactKind.PATH_CONDITION,
provenance=FactProvenance(
context_id=CONTEXT_ID,
origin_kind=FactOriginKind.CFG_EDGE,
operation_id=operation_id,
),
semantic_key=("preserved",),
),
formula=back.term != 0,
)
current = IntervalDomain.with_state(
State(
{"x_entry": entry, "x_back": back, "marker": marker},
{"condition": comparison},
facts=(fact,),
dependencies={"x_back": {"source"}},
storage_slots={"0": ["marker"]},
context_id=CONTEXT_ID,
)
)
previous_output = IntervalDomain.with_state(
State({"x_phi": _tracked(solver, "x_phi", 0, 0)}, context_id=CONTEXT_ID)
)
context = LoopWideningContext(
HEADER_ID,
1,
_domain(solver, 0, 0),
current,
previous_output,
(LoopVariableId("x_phi", ("x_entry",), ("x_back",)),),
)
encoding_before = solver.function_encoding.fact_ids()
result = analysis.apply_loop_widening(context)
assert isinstance(result.state, IntervalDomain)
assert result.state.variant is DomainVariant.STATE
assert result.state.state is not None
widened = result.state.state
assert widened.get_variable("x_back").interval == NumericInterval(1, 255)
assert not widened.get_variable("x_back").is_total
assert widened.get_variable("marker").interval == NumericInterval(7, 7)
assert widened.get_dependencies("x_back") == {"source"}
assert widened.get_storage_writes("0") == ["marker"]
assert not widened.storage_may_be_unwritten("0")
assert widened.get_comparison("condition") == comparison
assert widened.get_explicit_fact_ids() == frozenset({fact.fact_id})
assert widened.context_id == CONTEXT_ID
assert result.generation_facts
assert all(
fact.fact_id.owner is FactOwnerKind.LOOP_GENERATION for fact in result.generation_facts
)
assert all(fact.fact_id not in widened.get_fact_ids() for fact in result.generation_facts)
assert solver.function_encoding.fact_ids() == encoding_before
assert solver.active_query_sessions == 0
@@ -381,6 +381,9 @@ def test_proven_timeout_and_abstract_full_ranges_are_tagged_differently() -> Non
BoundStatus.ABSTRACT,
BoundStatus.ABSTRACT,
)
assert abstract.feasibility is FeasibilityStatus.NOT_ATTEMPTED
assert abstract.diagnostics.sessions == ()
assert abstract_solver.active_query_sessions == 0
assert proven.fallback_range is None
assert timed_out.fallback_range == abstract.fallback_range == RangeInterval(0, 255)
assert proven.to_dict()["lower_status"] == "proven"
@@ -0,0 +1,256 @@
"""Stage 5 abstract-first range refinement and total-budget tests."""
from __future__ import annotations
from typing import Any
from slither.analyses.data_flow.analyses.interval.core.state import State
from slither.analyses.data_flow.analyses.interval.core.tracked_variable import (
NumericInterval,
TrackedSMTVariable,
)
from slither.analyses.data_flow.analysis import RangeQueryConfig, solve_variable_range
from slither.analyses.data_flow.smt_solver import Z3Solver
from slither.analyses.data_flow.smt_solver.facts import (
AnalysisContextId,
EncodingId,
StaticOperationId,
)
from slither.analyses.data_flow.smt_solver.query import (
BoundOutcome,
BoundStatus,
FeasibilityResult,
FeasibilityStatus,
QueryBudget,
QueryDiagnostics,
RangeInterval,
empty_state_id,
)
from slither.analyses.data_flow.smt_solver.telemetry import (
disable_telemetry,
enable_telemetry,
reset_telemetry,
)
from slither.analyses.data_flow.smt_solver.types import Sort, SortKind
ENCODING_ID = EncodingId("range_refinement.sol", "C.f()")
CONTEXT_ID = AnalysisContextId(ENCODING_ID)
def _solver() -> Z3Solver:
solver = Z3Solver()
solver.bind_function_encoding(ENCODING_ID)
return solver
def _tracked(
solver: Z3Solver,
interval: NumericInterval,
*,
operation_result: bool,
) -> TrackedSMTVariable:
variable = TrackedSMTVariable.create(
solver,
"x",
Sort(SortKind.BITVEC, [8]),
bit_width=8,
).with_interval(interval)
if operation_result:
variable = variable.with_overflow_predicates(
operation_id=StaticOperationId(ENCODING_ID, 1, 0)
)
return variable
def test_abstract_top_operation_avoids_every_solver_session() -> None:
telemetry = enable_telemetry()
reset_telemetry()
solver = _solver()
variable = _tracked(solver, NumericInterval(0, 255), operation_result=True)
state = State({"x": variable}, context_id=CONTEXT_ID)
before_encoding = solver.function_encoding.fact_ids()
before_state = state.semantic_id()
try:
lower, upper = solve_variable_range(
solver,
variable,
RangeQueryConfig(
state_id=before_state,
state_facts=state.get_facts(),
timeout_ms=1000,
),
)
assert lower == {
"value": 0,
"overflow": False,
"bound_status": "abstract",
"feasibility": "not_attempted",
}
assert upper == {
"value": 255,
"overflow": False,
"bound_status": "abstract",
"feasibility": "not_attempted",
}
sessions = telemetry.evaluation.query_sessions
assert sessions.created == sessions.closed == sessions.active == 0
refinements = telemetry.evaluation.range_refinements
assert refinements.abstract_only == refinements.sessions_avoided == 1
assert solver.function_encoding.fact_ids() == before_encoding
assert state.semantic_id() == before_state
finally:
disable_telemetry()
def test_finite_abstract_interval_avoids_refinement_by_default() -> None:
telemetry = enable_telemetry()
reset_telemetry()
solver = _solver()
variable = _tracked(solver, NumericInterval(10, 20), operation_result=False)
state = State({"x": variable}, context_id=CONTEXT_ID)
try:
lower, upper = solve_variable_range(
solver,
variable,
RangeQueryConfig(
state_id=state.semantic_id(),
state_facts=state.get_facts(),
timeout_ms=1000,
),
)
assert lower is not None and upper is not None
assert (lower["value"], upper["value"]) == (10, 20)
assert lower["bound_status"] == upper["bound_status"] == "abstract"
assert telemetry.evaluation.query_sessions.created == 0
finally:
disable_telemetry()
def test_optional_refinement_uses_tracked_fallback_and_balances_sessions() -> None:
telemetry = enable_telemetry()
reset_telemetry()
solver = _solver()
variable = _tracked(solver, NumericInterval(10, 20), operation_result=False)
state = State({"x": variable}, context_id=CONTEXT_ID)
encoding_before = solver.function_encoding.fact_ids()
state_before = state.semantic_id()
try:
lower, upper = solve_variable_range(
solver,
variable,
RangeQueryConfig(
state_id=state.semantic_id(),
state_facts=state.get_facts(),
timeout_ms=1000,
refine_abstract=True,
),
)
assert lower is not None and upper is not None
assert (lower["value"], upper["value"]) == (10, 20)
assert lower["bound_status"] == upper["bound_status"] == "proven"
sessions = telemetry.evaluation.query_sessions
assert sessions.created == sessions.closed == 3
assert sessions.active == sessions.cleanup_imbalances == 0
assert sessions.query_facts_materialized == 6
assert solver.function_encoding.fact_ids() == encoding_before
assert state.semantic_id() == state_before
finally:
disable_telemetry()
class _FakeClock:
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
def advance_ms(self, milliseconds: int) -> None:
self.now += milliseconds / 1000
class _BudgetSolver(Z3Solver):
minimum_session_budget_ms = 0
backend_timeout_fraction = 1.0
def __init__(self, clock: _FakeClock) -> None:
super().__init__()
self.clock = clock
self.allocations: list[tuple[str, int]] = []
def _range_feasibility(self, *, timeout_ms: int, **query: Any) -> FeasibilityResult:
del query
self.allocations.append(("feasibility", timeout_ms))
self.clock.advance_ms(2)
state_id = empty_state_id(self.function_encoding.encoding_id)
return FeasibilityResult(
FeasibilityStatus.SAT,
self.function_encoding.encoding_id,
state_id,
QueryDiagnostics(),
)
def _execute_bound_query(
self,
term: Any,
objective_term: Any,
*,
purpose: Any,
timeout_ms: int,
**query: Any,
) -> BoundOutcome:
del term, objective_term, query
self.allocations.append((purpose.value, timeout_ms))
self.clock.advance_ms(timeout_ms)
return BoundOutcome(3, BoundStatus.PROVEN)
def test_one_total_budget_retains_lower_and_skips_expired_upper() -> None:
clock = _FakeClock()
solver = _BudgetSolver(clock)
solver.bind_function_encoding(ENCODING_ID)
term = solver.get_or_declare_const("x", Sort(SortKind.BITVEC, [8])).term
budget = QueryBudget(10, clock=clock)
result = solver.solve_range_result(
term,
timeout_ms=1000,
fallback_range=RangeInterval(0, 255),
budget=budget,
)
assert solver.allocations == [("feasibility", 10), ("lower_bound", 8)]
assert result.lower == 3
assert result.lower_status is BoundStatus.PROVEN
assert result.upper == 255
assert result.upper_status is BoundStatus.TIMEOUT
assert result.fallback_range == RangeInterval(0, 255)
assert result.diagnostics.total_budget_ms == 10
assert result.diagnostics.budget_exhausted
assert result.diagnostics.wall_elapsed_ms == 10
assert solver.active_query_sessions == 0
def test_tiny_budget_is_not_spent_opening_a_z3_session() -> None:
telemetry = enable_telemetry()
reset_telemetry()
solver = _solver()
term = solver.get_or_declare_const("tiny", Sort(SortKind.BITVEC, [8])).term
try:
result = solver.solve_range_result(term, timeout_ms=20)
assert result.lower_status is BoundStatus.TIMEOUT
assert result.upper_status is BoundStatus.TIMEOUT
assert result.diagnostics.budget_exhausted
sessions = telemetry.evaluation.query_sessions
assert sessions.created == sessions.closed == 0
finally:
disable_telemetry()