diff --git a/certora_autosetup/utils/cloud_runner.py b/certora_autosetup/utils/cloud_runner.py index 8cd4d815..d318c11d 100644 --- a/certora_autosetup/utils/cloud_runner.py +++ b/certora_autosetup/utils/cloud_runner.py @@ -58,6 +58,7 @@ def __init__( cloud_server: str | None = None, disable_cache: bool = False, cancel_jobs_on_cleanup: bool = True, + stop_on_first_violation: bool = False, ): """ Initialize cloud job manager. @@ -88,12 +89,21 @@ def __init__( ) self.disable_cache = disable_cache self.cancel_jobs_on_cleanup = cancel_jobs_on_cleanup + # When True, poll a still-running job's PARTIAL rule results each cycle and cancel the + # job as soon as any rule is VIOLATED, instead of waiting for every rule to finish. The + # partial results (with the violation) are still parsed and returned. Off by default. + self.stop_on_first_violation = stop_on_first_violation self.job_wait_timeout = self.JOB_TIMEOUT_SECONDS # Progress tracking counters (read from spinner thread, written under asyncio lock) self._active_jobs = 0 self._total_completed = 0 + # id(job_spec) -> job_url for jobs currently submitted-and-waiting. Lets batch early + # termination cancel the REMOTE cloud jobs of the still-pending tasks (cancelling the local + # asyncio task alone leaves the submitted job running on the prover — orphaned/duplicate runs). + self._inflight_job_urls: dict[int, str] = {} + @property def active_jobs_count(self) -> int: """Number of jobs currently being processed.""" @@ -369,14 +379,24 @@ async def _wait_for_tasks_with_early_termination( f"Early termination triggered. Cancelling {len(pending_tasks)} remaining jobs." ) - # Cancel all remaining tasks and create cancelled results + # Cancel all remaining tasks and create cancelled results. Cancel the REMOTE cloud job + # FIRST (while its url is still in the in-flight registry) — cancelling only the local + # asyncio task leaves the submitted job running on the prover (orphaned/duplicate runs + # that later hit a wasteful server-side timeout). Read urls before .cancel() so the + # task's finally hasn't yet popped them. for pending_task in list(pending_tasks): - pending_task.cancel() pending_job_spec = task_to_job_spec[pending_task] + job_url = self._inflight_job_urls.get(id(pending_job_spec), "") + if job_url: + try: + await self._cancel_cloud_job(job_url) + except Exception as e: + self.log(f"Failed to cancel remote job {job_url}: {e}", "WARNING") + pending_task.cancel() # Create cancelled result cancelled_result = await self._create_cancelled_result( - pending_job_spec, job_url="" + pending_job_spec, job_url=job_url ) completed_results.append(cancelled_result) @@ -423,8 +443,13 @@ async def _submit_and_wait_single_job( f"✓ Successfully submitted {job_spec.contract_name} for {job_spec.phase} - job_url: {job_url}" ) - # Wait for the job to complete - result = await self.wait_for_completion(job_url, job_spec, completion_callback) + # Track the live job so batch early-termination can cancel the REMOTE job (not just our task). + self._inflight_job_urls[id(job_spec)] = job_url + try: + # Wait for the job to complete + result = await self.wait_for_completion(job_url, job_spec, completion_callback) + finally: + self._inflight_job_urls.pop(id(job_spec), None) # Apply result transformer if provided if result_transformer: @@ -821,7 +846,7 @@ async def _wait_and_parse_job_results( # Wait for job completion with configurable timeout job_wait_timeout = self.job_wait_timeout - success, prover_start_time, prover_finish_time = await self._wait_for_job_completion_with_api( + success, prover_start_time, prover_finish_time, early_stop_checks = await self._wait_for_job_completion_with_api( prover_api, job_url, job_wait_timeout ) @@ -837,6 +862,37 @@ async def _wait_and_parse_job_results( except Exception as e: self.log(f"Could not record prover runtime for usage ledger: {e}", "DEBUG") + if early_stop_checks is not None: + # stop_on_first_violation fired: the job was cancelled right after a rule VIOLATED. + # Reuse the partial checks captured at detection (robust to a gappy post-cancel refetch) + # and return a non-success result carrying the violation for the caller to act on. + job_handle.status = JobStatus.CANCELLED + rule_results = self._checks_to_rule_results(early_stop_checks, job_url) + log_with_contract( + self.component, + "info", + job_spec.contract_name, + f"Stopped on first violation after {duration:.1f}s " + f"({len(rule_results)} partial rule result(s))", + ) + return ProverResult( + job_handle=job_handle, + success=False, + report_path=None, + output_data={ + "job_url": job_url, + "rule_count": len(rule_results), + "stopped_on_first_violation": True, + "prover_start_time": prover_start_time, + "prover_finish_time": prover_finish_time, + }, + job_spec=job_spec, + rule_results=rule_results, + alerts=[], + duration=duration, + transformed_result=None, + ) + if success: # Job completed successfully, parse results job_handle.status = JobStatus.COMPLETED @@ -947,14 +1003,29 @@ async def _wait_and_parse_job_results( transformed_result=None, ) + def _partial_violated_checks(self, prover_api, job_url: str): + """Best-effort: fetch a (possibly still-running) job's partial checks and return the VIOLATED + ones. Returns (all_checks, violated_checks). Never raises — a fetch failure on an in-flight or + just-cancelled job (missing/partial files) yields ([], []) so polling simply continues.""" + try: + all_checks = list(prover_api.get_all_checks(job_url) or []) + except Exception as e: + self.log(f"partial-check fetch failed for {job_url}: {e}", "DEBUG") + return [], [] + violated = [c for c in all_checks if c.is_violated] + return all_checks, violated + async def _wait_for_job_completion_with_api( self, prover_api: ProverOutputAPI, job_url: str, timeout_seconds: int - ) -> tuple[bool, Optional[float], Optional[float]]: + ) -> tuple[bool, Optional[float], Optional[float], Optional[list]]: """Wait for job completion using ProverOutputAPI. Returns: - Tuple of (success, prover_start_time, prover_finish_time). - Times may be None if unavailable. + Tuple of (success, prover_start_time, prover_finish_time, early_stop_checks). + Times may be None if unavailable. early_stop_checks is None on a normal + completion/failure; when stop_on_first_violation fired it holds the partial + checks captured just before the job was cancelled (so the caller can report + the violation). success is False in that case. """ import asyncio @@ -996,10 +1067,10 @@ async def _wait_for_job_completion_with_api( # Note: HALTED jobs are treated as successful in PreAudit because they often contain # partial results for some rules that can still be analyzed self.log(f"Job completed successfully: {job_url}") - return True, prover_start, prover_finish + return True, prover_start, prover_finish, None elif job_info.status in [ProverJobStatus.FAILED, ProverJobStatus.CANCELED, ProverJobStatus.SERVICE_UNAVAILABLE, ProverJobStatus.UPLOAD_FAILED]: self.log(f"Job failed with status {job_info.status}: {job_url}") - return False, prover_start, prover_finish + return False, prover_start, prover_finish, None # Check if job has completed but with an unrecognized status elif hasattr(job_info, "is_completed") and job_info.is_completed: self.log( @@ -1007,8 +1078,21 @@ async def _wait_for_job_completion_with_api( f"treating as failed: {job_url}", "WARNING" ) - return False, prover_start, prover_finish - # If status is 'RUNNING', 'QUEUED', etc., continue waiting + return False, prover_start, prover_finish, None + # Status is 'RUNNING'/'QUEUED'/etc. Optionally short-circuit: if any rule has already + # VIOLATED, cancel the job now instead of waiting for the remaining rules. The partial + # checks captured here are returned so the caller reports the violation even if a + # post-cancel refetch comes back gappy. + if self.stop_on_first_violation: + all_checks, violated = self._partial_violated_checks(prover_api, job_url) + if violated: + names = ", ".join(sorted({c.rule_name for c in violated})[:3]) + self.log( + f"stop_on_first_violation: {len(violated)} check(s) VIOLATED ({names}) — " + f"cancelling {job_url}" + ) + await self._cancel_cloud_job(job_url) + return False, prover_start, prover_finish, all_checks else: self.log(f"No job info returned for {job_url}") @@ -1022,12 +1106,12 @@ async def _wait_for_job_completion_with_api( self.log( f"Authentication issue detected, assuming job completed: {job_url}" ) - return True, None, None + return True, None, None, None await asyncio.sleep(poll_interval) # Timeout reached self.log(f"Job completion timeout after {timeout_seconds}s", "WARNING") - return False, None, None + return False, None, None, None def _create_failed_result( self, job_spec: ProverJobSpec, cache_key: str, error_msg: str diff --git a/certora_autosetup/utils/prover_runner.py b/certora_autosetup/utils/prover_runner.py index 2bc10678..6a3f979f 100644 --- a/certora_autosetup/utils/prover_runner.py +++ b/certora_autosetup/utils/prover_runner.py @@ -529,7 +529,19 @@ def parse_rule_results_from_job(self, job_identifier: str) -> List[RuleResult]: try: all_checks = self.prover_api.get_all_checks(job_identifier) + except Exception as e: + self.log( + f"Failed to fetch checks for job {job_identifier}: {e}", "WARNING" + ) + return [] + return self._checks_to_rule_results(all_checks, job_identifier) + def _checks_to_rule_results(self, all_checks, job_identifier: str = "") -> List[RuleResult]: + """Convert already-fetched prover checks into RuleResult objects. Split out from + parse_rule_results_from_job so a caller that ALREADY holds checks (e.g. partial checks captured + while polling a still-running job for stop-on-first-violation) can reuse the same conversion + without a second network fetch. Never raises — a malformed check is skipped, not fatal.""" + try: # Convert checks to RuleResult objects rule_results = [] sanity_rule_count = 0 @@ -569,6 +581,9 @@ def parse_rule_results_from_job(self, job_identifier: str) -> List[RuleResult]: notifications=notifications, duration=duration, assert_type=assert_type, + node_type=check_result.node_type.value + if hasattr(check_result.node_type, "value") + else str(check_result.node_type), ) rule_results.append(rule_result) diff --git a/certora_autosetup/utils/runner_types.py b/certora_autosetup/utils/runner_types.py index d93f088a..c0f35f68 100644 --- a/certora_autosetup/utils/runner_types.py +++ b/certora_autosetup/utils/runner_types.py @@ -118,6 +118,7 @@ class RuleResult: is_sanity_rule: bool = False is_leaf: bool = True level: int = 0 + node_type: Optional[str] = None # POU NodeType, e.g. "ROOT" (per-rule top-level) vs sub-check leaves notifications: List[NotificationType] = field(default_factory=list) # All rule notifications/warnings assert_type: Optional[AssertType] = None # Type of assertion that was violated (for SAT results) diff --git a/composer/cvl/pretty_print.py b/composer/cvl/pretty_print.py index c0379657..8de51094 100644 --- a/composer/cvl/pretty_print.py +++ b/composer/cvl/pretty_print.py @@ -353,6 +353,8 @@ def _print_cvl_type(self, cvl_type: CVLType) -> str: return f"{self._print_cvl_type(cvl_type.base_type)}[{cvl_type.n}]" case "primitive": return cvl_type.type_name + case "special": + return cvl_type.type_name case "storage_type": return "storage" case "contract_type": @@ -453,7 +455,7 @@ def _print_revert_cmd(self, cmd: RevertCmd) -> str: if cmd.message: return f"revert(\"{cmd.message}\");" else: - return "revert;" + return "revert();" def _print_elif(self, else_blk: ElseBlock, ident: LineBuilder): match else_blk.type: @@ -527,6 +529,9 @@ def print_basic_block(self, block: BasicBlock) -> str: self._print_hook_def(block, builder) case "methods_block": self._print_methods_block(block, builder) + case "use_directive": + kw = "builtin rule" if block.use_kind == "builtin_rule" else block.use_kind + builder.line(f"use {kw} {block.name};") return "\n".join(builder.buffer) def _print_rule_block(self, rule: RuleBlock, ident: LineBuilder): @@ -579,6 +584,7 @@ def _print_invariant(self, inv: Invariant, ident: LineBuilder): if inv.proofs: lb.append(" {") else: + lb.append(";") # CVL 2: an invariant with no preserved block must end with `;` return with ident.indent() as nested: for proof in inv.proofs: @@ -824,7 +830,8 @@ def _print_method_signature(self, meth_sig: MethodSignature) -> str: postfix=")" ) res += f" {meth_sig.visibility}" - res += self.print_and_join(meth_sig.return_types, self._print_vm_param, prefix=" returns (", postfix=")") + if meth_sig.return_types: + res += self.print_and_join(meth_sig.return_types, self._print_vm_param, prefix=" returns (", postfix=")") if meth_sig.post_flags: res += " " + " ".join(meth_sig.post_flags) return res diff --git a/composer/cvl/schema.py b/composer/cvl/schema.py index 66704add..bc304d24 100644 --- a/composer/cvl/schema.py +++ b/composer/cvl/schema.py @@ -46,6 +46,17 @@ class StorageType(BaseModel): """ type: Literal["storage_type"] +class SpecialType(BaseModel): + """ + A CVL-only leaf type with no Solidity counterpart: the mathematical integer + `mathint`, the transaction environment `env`, an abstract `method`, and the + generic-argument placeholder `calldataarg`. These are legal as CVL variable, + parameter, and function return types but never cross the VM boundary. + """ + type: Literal["special"] + type_name: Literal["mathint", "env", "method", "calldataarg"] = Field( + description="The CVL-only type, e.g. `mathint` or `env`") + class PrimitiveType(BaseModel): """ One of the built-in primitive types allowed by solidity. This includes all of the primitives @@ -69,7 +80,7 @@ class PrimitiveType(BaseModel): ] = Field(description="The name of the type, e.g., `uint256` or `bool`") CVLType = Annotated[ - MappingType | ArrayType | StaticArrayType | ContractType | StorageType | PrimitiveType, + MappingType | ArrayType | StaticArrayType | ContractType | StorageType | SpecialType | PrimitiveType, Discriminator("type") ] @@ -509,7 +520,11 @@ class HookDef(BaseModel): block: CodeBlock = Field(description="The CVL code block to execute when the hook is triggered. Can access the identifiers bound in the pattern") class UseDirective(BaseModel): - pass + # `use invariant X;` / `use rule X;` / `use builtin rule X;` — include an imported invariant/rule + # in this spec's verification. (Needed to prove an invariant that is declared in an imported spec.) + type: Literal["use_directive"] = "use_directive" + use_kind: Literal["invariant", "rule", "builtin_rule"] = "invariant" + name: str class OverrideDirective(BaseModel): pass @@ -652,7 +667,8 @@ class MethodsBlock(BaseModel): method_entries: list[MethodEntry] = Field(description="List of method declarations and summaries") BasicBlock = Annotated[ - Invariant | FunctionDef | RuleBlock | SortDef | GhostDef | MacroDef | HookDef | MethodsBlock, + Invariant | FunctionDef | RuleBlock | SortDef | GhostDef | MacroDef | HookDef | MethodsBlock + | UseDirective, Discriminator("type") ] diff --git a/pyproject.toml b/pyproject.toml index b4b5572c..a60bfd39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,7 @@ tui-autoprove = "composer.cli.tui_autoprove:main" console-foundry = "composer.cli.console_foundry:main" tui-foundry = "composer.cli.tui_foundry:main" autoprove-report-render = "composer.spec.source.report.render:main" +detect-summaries = "summarization_detector.cli:main" tui-natspec = "composer.cli.tui_pipeline:main" cache-natspec = "composer.cli.cache_natspec:main" cache-autoprove = "composer.cli.cache_autoprove:main" diff --git a/pyrightconfig.json b/pyrightconfig.json index bd24c4d8..d19fe82c 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,5 +1,5 @@ { - "include": ["composer", "analyzer", "certora_autosetup", "sanity_analyzer"], + "include": ["composer", "analyzer", "certora_autosetup", "sanity_analyzer", "summarization_detector"], "extraPaths": ["graphcore"], "stubPath": "stubs", "typeCheckingMode": "standard", diff --git a/smtool/.gitignore b/smtool/.gitignore new file mode 100644 index 00000000..6bc71120 --- /dev/null +++ b/smtool/.gitignore @@ -0,0 +1,4 @@ +generated/ +__pycache__/ +*.pyc +.certora_internal/ diff --git a/smtool/__init__.py b/smtool/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/smtool/agent/__init__.py b/smtool/agent/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/smtool/agent/__init__.py @@ -0,0 +1 @@ + diff --git a/smtool/agent/loop.py b/smtool/agent/loop.py new file mode 100644 index 00000000..9a8299db --- /dev/null +++ b/smtool/agent/loop.py @@ -0,0 +1,257 @@ +"""smtool fill-agent loop (Phase-1 piece F) — the graphcore assembly that drives an LLM to fill a +model's holes via the smtool tools until it converges. + +Built as a pipeline-integrated sub-agent in composer's shape (bind_standard + run_to_completion on a +Builder), so it slots into AutoProver's autosetup-including workflow the way composer's other sub-agents +do (pass `env.builder_lite()` inside the pipeline's with_handler scope). A thin dev entry +(`run_smtool_agent(..., llm=...)`) lets you drive it standalone for testing. + +The SYSTEM/INITIAL prompts here are a first draft — making the agent reliably produce a correct model +from CUT source is piece C, an iterative prompt/tool-tuning effort on top of this harness. + +NB: no `from __future__ import annotations` here — bind_standard reads `SmtoolState.result`'s +annotation with a raw get_origin (no ForwardRef resolution), so the annotation must be a real type +object, not a string. (This is why composer's sub-agents build their state with real type objects.) +""" +from typing import NotRequired, Iterable + +from pydantic import BaseModel, Field +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool +from langgraph.graph import MessagesState + +from graphcore.graph import Builder, FlowInput +from composer.spec.graph_builder import bind_standard, run_to_completion + +from ..project import Project +from .tools import SmtoolDeps, smtool_tools + + +class SmtoolResult(BaseModel): + """The agent's terminal result — a short summary of the model it built.""" + summary: str = Field(description="what was modeled + the final check_consistency verdict") + + +class SmtoolState(MessagesState): + result: NotRequired[SmtoolResult] # bind_standard reads this to mint the terminal `result` tool + + +class SmtoolInput(FlowInput): + pass + + +SYSTEM_PROMPT = """\ +You build a TRUSTED symbolic CVL model of a heavy Solidity contract-under-test (CUT) by calling the +smtool tools. The deterministic driver has already emitted the skeleton (model ghosts + readers, an +CVL stub per method, the glue and the conformance rules). Your job: FILL the holes so each method's +differential conformance proof passes. + +## 1. GROUND THE MODEL IN THE REAL SOURCE +Do NOT guess behavior from prose — read the CUT's Solidity; mirror each method's EXACT revert conditions, +arithmetic, and the storage it reads/writes. +- PREFER `get_function(name)` to read a method or a helper it calls: it returns the exact body + the + in-tree functions it calls, straight from the compiled AST (precise — overloads/inheritance/library + calls resolve correctly). Follow the listed callees by calling `get_function` on each — that is how you + read the library math so a mirror is structurally exact. Pull one function at a time, as you need it. +- FALL BACK to grep_files / get_file only when get_function says a name is not found (a non-function + target, a state variable, or the AST is unavailable). get_file's `range` is + `{"start_line": N, "end_line": M}` (end exclusive). + +## 2. DISCIPLINE (the tools REJECT a violation with a reason — read it and adjust) +- The model is pure CVL: no real-contract calls; model functions may read/write model ghosts. +- A model body reverts via `if (cond) revert();` — NEVER `require`/`assert`. +- Restrictions on real/glued storage are PROVED reachable invariants (add_require_invariant) stated over + REAL getters ONLY — never a model reader (`modelReader == realGetter` is the GLUE, not an invariant). + A model axiom may only DEFINE non-glued internal state. Add an invariant ONLY for a real, non-trivial + reachability fact; if the model reverts FAITHFULLY (mirrors every real revert) it needs NO reachable + assumption — add NONE, leave assumeReachable empty. Never add a placeholder/tautology (`true`, `x==x`). +- NONDET only view/pure functions whose result the checked output does not depend on. +- ARRAYS (T[] params): CVL has NO loops and NO recursion. Model an array-param method by UNROLLING over + the array length up to the run's loop_iter (given in your task): branch `if (arr.length == n)` for each + n = 0..loop_iter and, inside that branch, operate on the fixed elements arr[0]..arr[n-1] — a batch op of + length n is just n single-element ops applied in order (reuse the single-element helper). Index arrays + only by these fixed literals; never by a symbolic loop variable. The conformance already pins the + element observables at arr[0..loop_iter-1]. +- Math mirrors reimplement the CUT library math in EXACT structural form. +- CVL arithmetic is `mathint` (`a+b`, `a*b`, `a/b` are all mathint); storing back into a `uintN` needs an + explicit cast. Use `assert_uintN(expr)`, NEVER `require_uintN(expr)`: a require_* cast ASSUMES the value + fits and silently PRUNES out-of-range (overflow) inputs, so the conformance passes VACUOUSLY there + (unsound — the linter rejects require_* casts). `assert_uintN` makes the prover CHECK the cast is total: + a provably-in-range value passes, an overflow is CAUGHT. If the real Solidity WRAPS (unchecked add/sub), + model the wrap explicitly — e.g. `assert_uint256((a + b) % 2^256)` — so the model MATCHES real (a real, + reachable success), rather than pruning it away. This is the main CVL-vs-Solidity difference. + +## 3. WHEN A CONFORMANCE PROOF IS VIOLATED (a CORRECTNESS problem — the model is WRONG) +The prover returns a COUNTEREXAMPLE: a concrete input + call trace on which the model disagrees with the +real CUT. This is NOT a performance problem — do NOT NONDET anything and do NOT reach for the difficulty +report / section 4 (those only address TIMEOUTs; a NONDET cannot fix a wrong value or a spurious revert). +Read the call trace — it gives the exact inputs and the step-by-step values where the two sides diverge — +and fix the model BODY with set_model_method_body (or retract a bad lemma). The violated assert's message +tells you WHICH kind of divergence it is; the main cases: +- REVERT conformance (msg ~ "real success must imply model success"): on the counterexample the REAL + function succeeds but your model REVERTS (or vice versa). Find the revert in `CVL` that fires wrongly + — an over-eager `if (cond) revert();`, or a bad ARITHMETIC cast (assert_uintN(...) overflow/underflow, division + by zero, a narrowing cast) — and align its conditions to the real source at those input values. + (If the failing assert is a CAST-SAFETY check, see CAST-SAFETY below — the fix depends on WHERE the + value comes from; `assert_uintN` is just the mathint->uintN cast MECHANISM, keep it, but make sure the + value is bounded FIRST by a pin / guard / invariant so the assert can never fire.) +- CAST-SAFETY (msg ~ "Cast_safety_of__mathint_to_uintN"): your model produced a `mathint` that does + not fit the `uintN`, on an input where the REAL does NOT overflow. `assert_uintN` is the right cast — do + NOT swap it for `require_uintN` (that PRUNES the overflow path = unsound under-approximation). Instead + identify WHERE the value came from and BACK the assert: + (1) A GHOST cell the glue does not pin (msg names a `CVL[k1][k2]` at a key the pins miss — usually + a DERIVED address, e.g. a fee receiver / credit target from another getter). The cell is an + unconstrained uint256. FIX: add_glue_pin(method, observable=, key_exprs=[...that key...]) — e.g. + keys `["id", "getReceiver(id)"]` (a derived address from another getter). A glue pin is model==real (sound at any key); the + ghost then inherits the real getter's field width and the assert is safe. TRY THIS FIRST — it needs + no proof. + (2) A real getter's value at a SafeCast/`toUintN` site (the REAL reverts on overflow). FIX: mirror the + revert — `if (x > 2^N - 1) revert(); ... = assert_uintN(x);`. The guard makes overflow a matching + real-revert (assert never reached). Do NOT prove no-overflow (it IS reachable — the real reverts). + (3) A nonlinear INTERMEDIATE of storage-bounded factors, where the real does NOT revert (e.g. a product + of two uint120 fields, ≤ 2^240, fits uint256). The solver just doesn't know the factors are bounded. + FIX: add_require_invariant bounding EACH factor to its field width (`getFoo(k) <= 2^N - 1`); the + solver multiplies the bounds. ENVFREE vs ENV: a raw fixed-width getter -> plain invariant, no env. + An ACCRUING (non-envfree, time-dependent) factor -> set `env=true` AND a `preserved` block relating + the envs (`require e1.block.timestamp <= e.block.timestamp;`) — the timestamp relation is what makes + it provable. Do NOT use a revert guard here (the real never reverts -> spurious model-revert + violation) and NEVER a bare `require`. + (Do not reach for an invariant over an already-field-width-bounded getter to fix (1): it is trivially + true and does not bound the unpinned GHOST — pin the ghost (1) instead.) +- RETURN value (msg ~ "returns must agree"): on a non-reverting input the model returns the wrong value. + Trace the call trace to the line of `CVL` that computes it and fix the arithmetic (mathint rounding, + assert_uintN casts / an unmodeled wrap). +- STATE effect (msg ~ "observable ... effect must agree"): after the call a model observable ghost + disagrees with the real getter — your body writes the wrong value to that ghost; fix the write. +- A HELPER LEMMA you added fails (the assert message is one YOU wrote): its claim is false on the + counterexample — retract it with remove_helper_lemma (or fix the body it was meant to decompose). + Never keep a false lemma. +(Assert messages are guidance, not a fixed vocabulary — some are agent-authored; rely on the call trace.) +If the counterexample input should have been UNREACHABLE, do not pin it away with a `require` — add a +PROVED reachable invariant over the REAL getter (add_require_invariant). A revert precondition the real +function itself enforces belongs in the model body as `if (cond) revert();`. + +## 4. WHEN A CONFORMANCE PROOF TIMES OUT (a PERFORMANCE problem — the model is likely CORRECT) +Apply this section ONLY when verify reports a TIMEOUT — NEVER to fix a VIOLATION (that is section 3, a +model-body bug; NONDET / the difficulty report do nothing for a wrong value or a spurious revert). +The SMT problem is too heavy (usually nonlinear return/effect equivalence). The verify tool's timeout +feedback INCLUDES the prover's own difficulty report — the nonlinearity HOTSPOTS ranked by % of nonlinear +ops (each with a source file:line) and the calls that were INLINED without a summary. Read it and target +those; do NOT guess or just re-inspect. APPLY one of these, then re-verify: +(a) NONDET an off-path view/pure function the method calls internally, whose result the checked + return/effect does NOT depend on (oracle / rate / price helpers are typical). This is ALWAYS a call + OUT to ANOTHER in-scene contract — you CANNOT NONDET the CUT's own functions (the tool refuses them: + the CUT's calls in the proof are the method under test and the glue/state getters, which must stay + real). Artificial example: + method `Vault.settle` internally calls view `Rates.currentRate(uint256)` and the settled amount does + not depend on it -> + add_nondet(method="settle", contract="_", name="currentRate", param_types=["uint256"], + return_types=["uint256"], mutability="view") # -> `function _.currentRate(uint256) external => NONDET;` + Each NONDET deletes a nonlinear subproblem. LINKED TARGET: if the difficulty report shows the callee + was INLINED despite a `_.fn` wildcard summary, the call is resolved to a LINKED in-scene contract and + the wildcard does NOT override it — summarize the CONCRETE contract with a matching return type: + `function .(...) external returns (...) => NONDET;` (add_nondet with contract=). +(b) PIVOT the return through the contract's OWN view getter. If a VIEW getter computes the same value the + method returns, assert the real return equals it — the prover then relates the return to a contract + getter instead of your nonlinear mirror. Artificial example: `Vault.settle(id, amt)` returns what view + `Vault.previewSettle(id, amt)` computes -> + add_helper_lemma(method="settle", rule_name="conformance_settle_return", + captures="uint256 pv = currentContract.previewSettle(e, id, amt);", + assert_expr="retSol == pv", message="return == previewSettle") + grep_files the CUT for such `preview*` / view getters. + ACCRUE-IDEMPOTENCE variant: many methods first run an internal `accrue`/`settle`/`_update` that folds + pending interest into storage, THEN compute the return off the accrued state. The matching `preview*` + getter accrues internally too, so `preview*` evaluated on the PRE-state already equals the post-accrue + return — CAPTURE it BEFORE the call (in the rule preamble / lemma captures, on pre-state `e`) and assert + the return equals that pre-state capture. This lets the prover skip proving your mirror reproduces the + accrue math at all. It is SOUND precisely because the getter re-accrues; do NOT instead delete the + accrue from your model body. +(c) CONGRUENCE: make each math mirror reproduce the real library fn OP-FOR-OP (same operations/order/ + roundings) — identical subterms let the prover match by congruence instead of expanding the math. If + the scene ALREADY summarizes that primitive (e.g. an OZ `Math.mulDiv` summary the difficulty report / + call resolution shows applied to the real side), mirror THAT summary's exact closed form (or reuse the + same summary function) rather than re-deriving an equivalent one — then the two sides are the identical + term and equality is congruence-trivial, not a nonlinear floor-vs-ceil proof. +(d) SOUND DOMAIN NARROWING. A timeout is NOT a license to pin inputs. If the proof only needs a domain + fact (e.g. an asset's underlying token is set, `u != 0`), that fact must be a PROVED reachable + invariant over the REAL getter (add_require_invariant), NEVER a bare `require` / glue pin — a pin + silently shrinks the domain the model is trusted over and is unsound. Only genuine off-path + complexity (a,b,c) should be removed. +A lemma or NONDET is a GUESS — judge each PER RULE from the report: +- KEEP any mutation that is DISCHARGING an obligation: a NONDET/lemma that made one rule VERIFY stays, + even while a different rule still fails. Never drop the mutation that made stateEffect VERIFY just + because the return still times out. +- VIOLATION → the guess is wrong (the lemma's assert doesn't hold, or the counterexample shows the checked + output depended on the NONDET'd fn): retract it (remove_helper_lemma / remove_nondet by message/name). +- TIMEOUT of the rule a lemma was meant to help → the decomposition did NOT work, and a helper lemma can + even ADD nonlinear cost: a captured view getter (e.g. a `preview*`) may itself compute the heavy math + (mulDiv), so the capture + its assert enlarge the problem. RETRACT or REPLACE that lemma and try a + different technique (a,b,c / match the scene's summary form). A timeout does NOT prove the assert false — + only that this decomposition is not tractable; removing a cost-adding lemma often makes the rule solvable. + +## 5. CVL IS NOT SOLIDITY +When unsure how to write something, or a call is REJECTED with a parse/typecheck error you don't +understand, call `cvl_manual_search` with a focused question and follow the manual. Don't guess CVL rules +from Solidity intuition. These are the mistakes that waste the most turns — internalize them, do NOT +re-ask the manual once you've been told: +- NO FUNCTION OVERLOADING. Two `function` declarations may not share a name, even with different + parameter or return types. If you need two variants, give them DISTINCT names (`fooUint`, `fooInt`). +- NO `max_int256` / `min_int256` builtins (only the `max_uint*` family exists). Write the literal: + `2^255 - 1` for max int256, `-(2^255)` for min int256. `max_uint256` etc. DO exist. +- SSA / SINGLE-ASSIGNMENT: you may NOT reassign a CVL variable after it has been read. Do not port + Solidity's `c = f(c)`. Use a fresh variable (`c2 = f(c)`) or a conditional expression + (`mathint c2 = cond ? a : b;`). Declaring `mathint x;` then assigning once in each branch of an + if/else is fine; reading-then-reassigning is not. +- MATHINT vs sized ints: arithmetic promotes to `mathint`; to STORE a `mathint` into a `uint256`/return + it, cast explicitly with `assert_uint256(...)` (reverts the rule if out of range) or + `require_uint256(...)` (assumes in range). `address` is NOT implicitly convertible to `uint256` — do + not pass one where the other is expected. + +## 6. WORKFLOW +Fill with the tools (add_model_constant, add_model_function, set_model_method_body, add_require_invariant, +add_glue_pin, add_nondet, add_helper_lemma). To FIX a helper/constant/method body, just call add_model_function / +add_model_constant / set_model_method_body AGAIN with the corrected definition — a re-add with a different +body REPLACES it. To fully RETRACT something you added: remove_model_constant (a constant/ghost) / +remove_model_function (a helper) / remove_helper_lemma / remove_nondet / remove_model_ghost_axiom (an +axiom only). To DELETE a constant/ghost you added, use remove_model_constant — NOT remove_model_ghost_axiom +(that strips only the axiom and leaves a bare ghost that can name-COLLIDE with a scene spec and break the +typecheck). Never invent a scratch/probe ghost with a short generic name (e.g. `x`) — it may clash with a +setup summary; give anything you add a specific name. (The glue is deterministic template — model==real +correspondence — you don't edit it; a real revert precondition goes in the model body's `if (cond) +revert();`, not a glue require.) +Then the verify loop, all in this one conversation: +1. check_consistency — runs the REAL CVL typechecker; fix every error it reports at the given file:line + (a reachable-invariant "pending proof" note is expected, not an error). +2. When consistent, call `verify` — it runs the PROVER and returns either ALL-VERIFIED or the failing + rules with their COUNTEREXAMPLES / TIMEOUTS / dropped invariants. +3. If verify reports failures, FIX them (VIOLATION → section 3, fix the model body from the counterexample; + TIMEOUT → section 4; dropped invariant → real-getter-only or drop the assumption), then call `verify` + AGAIN. Repeat. +4. Call `result` ONLY after `verify` reports ALL methods VERIFIED. Do not call result on unverified/failing + output. Use render_model / render_conformance to inspect.""" + + +def build_smtool_graph(builder: Builder, deps: SmtoolDeps, *, extra_tools: Iterable[BaseTool] = ()): + """Assemble the fill-agent graph on a pre-configured `builder` (env.builder_lite() in the pipeline, + or Builder().with_llm(llm) for dev). bind_standard adds the terminal `result` tool + summarizer; + we append the smtool tools (with_tools appends). Caller sets the initial prompt + compiles.""" + return (bind_standard(builder, state_type=SmtoolState) + .with_input(SmtoolInput) + .with_sys_prompt(SYSTEM_PROMPT) + .with_tools([*smtool_tools(deps), *extra_tools])) + + +async def run_smtool_agent(project: Project, task: str, *, builder: Builder | None = None, + llm: BaseChatModel | None = None, thread_id: str = "smtool", + recursion_limit: int = 80, max_prompt_tokens: int = 100_000) -> SmtoolResult | None: + """Run the fill agent to completion and return its `result`. Pipeline: pass + `builder=env.builder_lite()` (inside a with_handler scope). Dev/standalone: pass `llm=`. + `task` = the per-method instruction + context (which method, the CUT source, etc.).""" + if builder is None: + if llm is None: + raise ValueError("pass builder=env.builder_lite() (pipeline) or llm= (dev)") + builder = Builder().with_llm(llm, max_prompt_tokens=max_prompt_tokens) + graph = build_smtool_graph(builder, SmtoolDeps(project)).with_initial_prompt(task).compile_async() + res = await run_to_completion(graph, SmtoolInput(input=[task]), thread_id=thread_id, + recursion_limit=recursion_limit, description="smtool fill agent") + return res.get("result") diff --git a/smtool/agent/overapprox_loop.py b/smtool/agent/overapprox_loop.py new file mode 100644 index 00000000..aecd3e5d --- /dev/null +++ b/smtool/agent/overapprox_loop.py @@ -0,0 +1,194 @@ + +"""Over-approx fill-agent loop — the graphcore assembly that drives an LLM to author the predicate Phi +for each target until its conformance proof passes, as STRONG as the goal wants. + +Same composer shape as `agent/loop.py` (bind_standard + run_to_completion on a Builder) so it slots into +AutoProver's pipeline the same way. The system prompt is the one real difference from the model loop: +this task is GOAL-DIRECTED. Phi=`true` always verifies but yields a useless summary, so the agent must +push Phi toward the goal and only weaken it when a counterexample forces it. + +NB: no `from __future__ import annotations` — bind_standard reads `OverApproxState.result`'s annotation +with a raw get_origin (no ForwardRef resolution), so it must be a real type object, not a string. +""" +from typing import NotRequired, Iterable + +from pydantic import BaseModel, Field +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool +from langgraph.graph import MessagesState + +from graphcore.graph import Builder, FlowInput +from composer.spec.graph_builder import bind_standard, run_to_completion + +from .overapprox_tools import OverApproxDeps, overapprox_tools + + +class OverApproxResult(BaseModel): + """The agent's terminal result — a short summary of the Phi(s) it proved.""" + summary: str = Field(description="per target: the final Phi (in words) and its verify verdict " + "(VERIFIED / weaker-but-sound / not proven)") + + +class OverApproxState(MessagesState): + result: NotRequired[OverApproxResult] + + +class OverApproxInput(FlowInput): + pass + + +SYSTEM_PROMPT = """\ +You author a SOUND OVER-APPROXIMATING summary of a heavy Solidity function `f` using the over-approx +tools. You author up to TWO predicates per target: the RESULT predicate `Phi(params, res)` (always) and +the optional REVERT predicate `Psi(params)`. The deterministic driver emits, from them, the summary +`fCVL(x){ if (Psi(x)) revert(); T res; require Phi(x,res); return res; }` and two conformance rules: +`overApprox_f`: `res = f@withrevert(x); assert !reverted => Phi(x, res)` (the returned value is sound), and +— only when Psi is set — `revertConform_f`: `f@withrevert(x); assert Psi(x) => reverted` (the summary +reverts only where `f` does). Installing the summary is SOUND exactly when the emitted rules hold: the +summary then admits every value the real `f` can return AND never reverts on an input where `f` succeeds. + +## Phi vs Psi — RESULT and REVERT are separate +- `Phi(params, res)` shapes the RETURNED value on the non-reverting inputs (sections below). Required. +- `Psi(params)` is the REVERT condition: a pure boolean over the params, true where `f` reverts (e.g. + `return denom == 0;`). Setting it (set_psi) makes the summary `revert()` there — a FAITHFUL summary + (real `f` reverts ⇒ summary reverts). Leaving it UNSET means the summary never reverts: still sound, + but it hands back a value where `f` would revert, which can cause spurious counterexamples in the + consumer proof. So: whenever you can read off `f`'s revert guards (require/if-revert at the top of the + function, div-by-zero, `balance < amount`, `ts0 > ts1`), state them as Psi. Prove-direction is the + sound one — `Psi => reverted` — so an over-eager Psi (reverting where `f` succeeds) FAILS + `revertConform_f` and you weaken it, exactly like Phi. No `require` inside Psi (state it all in the + `return`). Skip Psi only when the revert condition is inexpressible or buried deep in the computation. + +## THE OBJECTIVE: the CLOSEST sound over-approximation (default), or a GOAL-TAILORED one +Two modes, decided by your task: +- DEFAULT (no specific property given): make Phi capture `f`'s real output behavior AS CLOSELY AS CVL can + express and the prover can discharge. We summarize `f` because it is prover-HOSTILE (assembly, heavy + dependencies, gas-optimized bit/memory tricks), so the exact recomputation is either inexpressible in + CVL or the very thing that times out. Your job is the tightest sound approximation up to those two + walls — CVL-expressibility and prover-tractability. This is RECONSTRUCT-THEN-RELAX (sections 2–3). +- GOAL-TAILORED (your task names a specific property the summary must preserve): make Phi capture THAT + property as strongly as it proves; you need not reconstruct the rest of `f`. +- In BOTH modes, `Phi = true` is sound but USELESS (it havocs the result). Never finalize on `true` if + you can prove something tighter. But do NOT chase marginal tightening forever — see section 5 (budget). + +## 1. GROUND Phi IN THE REAL SOURCE +Read `f`'s Solidity (grep_files / get_file) — its return expression and the library math behind it — so +Phi states properties the real output genuinely has. Do not guess from prose. + +## 2. RECONSTRUCT as faithfully as CVL allows (default mode) +Start AMBITIOUS: state the strongest true relationship between `f`'s inputs and its result that you can +write in CVL — mirror the parts of the computation CVL can express (arithmetic, comparisons, field/bit +extraction). Phi is a boolean predicate over (params, res): statements ending in `return `. You may +declare locals and use `require` ONLY to introduce a WITNESS / derived local that exists for EVERY +(params, res) — e.g. the byte-extract `uint248 v; require to_bytes31(v) == res;` pins the unique preimage +of `res`. NEVER use `require` to RESTRICT the (params, res) domain: in the conformance +`assert !reverted => Phi`, a restricting require makes the assert pass VACUOUSLY on the excluded inputs, +and the installed `require Phi` then silently DROPS those real inputs — the summary stops being an +over-approximation. If you are tempted to write a restricting require: (a) if it restates the real +function's REVERT condition (div-by-zero, insufficient balance), model it with set_psi as the REVERT +predicate `Psi` (`return denom == 0;`) so the summary reverts there like `f` — omit it entirely only when +that condition is inexpressible; (b) if it is a property OF THE RESULT, put it in the `return` (the +conformance ASSERTS the return), never a `require`; (c) a genuine input fact belongs in a proved +reachable invariant. Only a fresh-witness `require` may stay. +- CVL arithmetic is `mathint` (`a*b`, `a+b`, `a/b`); compare via mathint. Relate a `uintN`/UDVT result to + an int with the casts (`to_mathint`, `require_uintN`, `assert_uintN`) or the byte-extract idiom for a + bytesN UDVT: `uint248 v; require to_bytes31(v) == res; return (v >> 240) == 3;`. +- If a call is REJECTED with a parse/typecheck error you don't understand, call `cvl_manual_search`. + +## 3. RELAX only at the wall (the three back-off signals) +Reconstructing the whole of a prover-hostile `f` will hit a wall. Relax the OFFENDING clause only, keeping +everything you can still prove — over-approximate that residue with the strongest property you can state +AND discharge (a RANGE/BRACKET `r*r <= x && x < (r+1)*(r+1)`, a TAG/bit-field on the result, a SIGN, a +RELATION to inputs `res <= a && (res==a||res==b)`, monotonicity, nonzero). The signals: +- VIOLATED — Phi is TOO STRONG. The counterexample gives a concrete input `x` and the real `res=f(x)` + with `!Phi(x,res)`: your Phi excludes a value the real `f` returns. WEAKEN the failing clause (relax an + equality to a bracket, drop an over-eager conjunct). NEVER `require` the counterexample away — that + makes the summary UNSOUND (it must admit EVERY real output). +- TIMEOUT — Phi is TOO HEAVY. The feedback carries the prover's difficulty report (hotspots). Replace the + heavy clause with a looser property that still holds (a range instead of the closed form), or PIVOT + through a contract view getter that computes the same quantity. Weaker is always sound. +- INEXPRESSIBLE — you cannot write the clause in CVL (assembly, an opaque dependency). Drop it to the + strongest thing you CAN write about that sub-result, down to leaving it unconstrained as a last resort. + +## Φ-SHAPE LIBRARY — the vocabulary (these recur across real FV specs; most-frequent first) +Two choices first: an exact **CVL function** when a clean cheap integer formula exists (mulDiv, average); +a **ghost + axioms** when the real op is nonlinear/expensive (pow, sqrt, price/rate math). Every axiom is +TRUSTED blindly, so it MUST be true of the real code — label each "proven (by a rule)" vs "assumed". + +NONLINEAR MATH: +- mulDiv / mulWad (most common) — exact cheap formula: `require c != 0; return require_uint256(a*b/c);` + (ceil: `(a*b + c-1)/c`). Route the `Math.Rounding` variant to the up/down form. +- sqrt / roots — constrained pre-image: `uint256 r; require r*r <= x && (r+1)*(r+1) > x; return r;`. +- MONOTONICITY (relational — prove via a 2-call rule, see relational.py): `x1 >= x2 => f(x1) >= f(x2)` + (rate vs time, exp/pow vs exponent, price vs tick, amountOut vs amountIn); add the flipped twin if a + branch reverses direction. +- ENDPOINT / anchor: `f(0) == 0`, `f(1) == base`, `x^0 == 1` — the first thing to add to any math ghost. +- TWO-SIDED BOUND (±1) — bracket the exact result when only an inequality-up-to-rounding is known + (e.g. an AMM in/out amount); for a round-up/down pair assert `up >= down && up - down <= 1`. +- ROUND-TRIP INVERSE: `inv(f(x)) == x` (exact), or `f(inv(x)) <= x` (rounding-safe one-sided, e.g. vault + convert pairs). +- CAP / clamp: `f(x) <= MAX` (a rate below a documented ceiling; `fee < MAX => feeAmt(fee, a) <= a`). + +HASHING / ID DERIVATION (a hash is an opaque, collision-free map — model that, never the hash): +- INJECTIVITY (relational): `h1 != h2 => id(h1) != id(h2)` — when the proof needs only distinct-in ⇒ + distinct-out. +- LEFT-INVERSE — recover packed fields: declare `ghost fieldOf(id)` accessors and, at the derivation + site, `require fieldOf(id) == field` (id packs owner+ticks+salt / token+amount+sender). +- TAG / high-byte on an id: `uint248 v; require to_bytes31(v) == res; return (v >> 240) == EXPECTED;` — + pin a structural field of a hashed id (module tag, arity). Humans usually NONDET hashes; this is often + FINER and worth trying. +- IDENTITY / passthrough — collapse a hash that merely wraps an already-unique value: + `hashCVL(dom, structHash) { return structHash; }`. + +## 4. CVL IS NOT SOLIDITY +When unsure how to write a cast/quantifier/type, call `cvl_manual_search` and follow the manual rather +than guessing from Solidity intuition. + +## 5. BUDGET — converge, don't loop forever +Prover runs are minutes each and BOUNDED: `verify` refuses further runs once the budget is spent (it will +say so). Spend it well — reconstruct ambitiously FIRST (one strong Phi), then relax on the actual signal; +don't burn calls on tiny speculative tweaks. Each verified-then-tightened step is progress; a +verified Phi is always retained as the fallback, so if the budget runs out you still ship the tightest +one that PROVED. STOP tightening (call `result`) when any of: the reconstruction verified and you cannot +tighten it further without a wall; two successive tightenings both failed (you have converged); or the +budget is spent. Do not re-verify an unchanged Phi. + +## 6. WORKFLOW (one conversation) +1. Read `f`. set_phi with your best (ambitious) Phi — for a named goal, target that property instead. + If `f` has revert guards you can read off (require/if-revert near the top), set_psi with them too. +2. check_consistency — runs the REAL typechecker; fix every error at the reported file:line. +3. verify — runs the PROVER: ALL-VERIFIED, or failing rules with COUNTEREXAMPLES / TIMEOUTS (section 3), + or a budget-spent notice. +4. Relax the offending clause (section 3) and verify AGAIN, until it holds as tightly as the walls allow + or the budget is spent (section 5). +5. Call `result` per section 5. State, per target, the final Phi in words and whether it is the faithful + reconstruction or a relaxed/partial one (a sound approximation is the deliverable; an UNPROVEN Phi is + not — the retained fallback is always a proven one). Use render_phi / render_psi / + render_conformance / render_summary to inspect.""" + + +def build_overapprox_graph(builder: Builder, deps: OverApproxDeps, *, + extra_tools: Iterable[BaseTool] = ()): + """Assemble the over-approx fill-agent graph on a pre-configured `builder` (env.builder_lite() in the + pipeline, or Builder().with_llm(llm) for dev). bind_standard adds the terminal `result` tool + + summarizer; we append the over-approx tools + any source/manual tools.""" + return (bind_standard(builder, state_type=OverApproxState) + .with_input(OverApproxInput) + .with_sys_prompt(SYSTEM_PROMPT) + .with_tools([*overapprox_tools(deps), *extra_tools])) + + +async def run_overapprox_agent(deps: OverApproxDeps, task: str, *, builder: Builder | None = None, + llm: BaseChatModel | None = None, thread_id: str = "overapprox", + recursion_limit: int = 80, + max_prompt_tokens: int = 100_000) -> OverApproxResult | None: + """Run the fill agent to completion and return its `result`. Dev/standalone: pass `llm=`; + pipeline: pass `builder=env.builder_lite()` inside a with_handler scope.""" + if builder is None: + if llm is None: + raise ValueError("pass builder=env.builder_lite() (pipeline) or llm= (dev)") + builder = Builder().with_llm(llm, max_prompt_tokens=max_prompt_tokens) + graph = build_overapprox_graph(builder, deps).with_initial_prompt(task).compile_async() + res = await run_to_completion(graph, OverApproxInput(input=[task]), thread_id=thread_id, + recursion_limit=recursion_limit, description="overapprox fill agent") + return res.get("result") diff --git a/smtool/agent/overapprox_refine.py b/smtool/agent/overapprox_refine.py new file mode 100644 index 00000000..d9b5a441 --- /dev/null +++ b/smtool/agent/overapprox_refine.py @@ -0,0 +1,300 @@ +"""The over-approx verify→counterexample→refine loop — composer-style verify-as-a-tool in ONE +conversation, the same shape as `agent/refine.py` but for the simpler over-approx artifact set (no shared +model, no reachable invariants: the whole model is the per-target predicate Phi). + +`verify` and the full-typecheck `check_consistency` are TOOLS the agent calls inline, so the cycle — +write Phi, check_consistency, verify, read the counterexample, WEAKEN Phi (or simplify on a timeout), +verify again, … result — happens in one graph invocation with prover feedback returning as tool results. +Prover calls live HERE (minutes-long, cloud-bound), kept out of the agent's recursion budget. The bound +`OverApproxProject` persists across the conversation (set_phi mutates it in place). + +Reuses the runner (`verify.verify_all_early`), the POU counterexample/difficulty fetchers, the real +typechecker (`typecheck.typecheck_conf`), and the transcript dumper — changing no existing smtool file. +""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, Callable + +from langchain_core.callbacks import BaseCallbackHandler +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool +from langgraph.checkpoint.memory import InMemorySaver + +from graphcore.graph import Builder + +from ..overapprox_project import OverApproxProject, conformance_rule_name +from .. import verify as V +from ..typecheck import typecheck_conf +from ..cex import fetch_cex +from ..difficulty import fetch_difficulty +from .overapprox_tools import OverApproxDeps +from .overapprox_loop import build_overapprox_graph, OverApproxInput +from .refine import dump_transcript + + +@dataclass +class OverApproxRefineConfig: + """The run-config the loop needs to write + prove (everything not derivable from the project).""" + out_dir: str # where specs+confs are written (/certora so paths resolve) + setup_conf: dict # the base setup conf dict (scene files/solc/links), rewritten per target + sources_root: str # certoraRun's cwd — conf files/verify are relative to it + local: bool = False # LocalProverRunner vs CloudProverRunner + certora_run_path: str = "certoraRun" + disable_cache: bool = False + run_url: str | None = None # the ORIGINAL run — baseline verdicts + regression re-run + + +@dataclass +class OverApproxRefineResult: + """Outcome: did every provable target's conformance rule pass, plus the last per-conf verdicts.""" + success: bool + verified: list[str] = field(default_factory=list) # target fns whose overApprox_ VERIFIED + results: dict[str, V.VerifyResult] = field(default_factory=dict) + + +async def _local_typecheck(paths: list[str], cfg: OverApproxRefineConfig) -> dict[str, V.VerifyResult]: + """Run the FULL certoraRun typechecker (--compilation_steps_only, no cloud) on each conf; return a + {conf: VerifyResult} of ONLY the failures — a semantic-error gate paid before any cloud round.""" + async def one(c): + ok, tail = await asyncio.to_thread(typecheck_conf, c, cfg.sources_root, + certora_run_path=cfg.certora_run_path) + return c, ok, tail + out: dict[str, V.VerifyResult] = {} + for c, ok, tail in await asyncio.gather(*(one(c) for c in paths)): + if not ok: + out[c] = V.VerifyResult(conf=c, success=False, job_url=None, rules=[], + error="local typecheck failed:\n" + tail) + return out + + +def _make_full_typecheck(project: OverApproxProject, cfg: OverApproxRefineConfig): + """Bound closure for the agent's check_consistency: write the project + run the REAL certoraRun + typechecker on each conformance conf, returning (ok, diagnostics).""" + async def tc() -> tuple[bool, str]: + project.write(cfg.out_dir, cfg.setup_conf) + confs = project.conf_paths(cfg.out_dir) + if not confs: + return True, "" + fails = await _local_typecheck(confs, cfg) + if not fails: + return True, "" + return False, "\n\n".join(r.error or "" for r in fails.values()) + return tc + + +async def _prove_and_verify(project: OverApproxProject, cfg: OverApproxRefineConfig): + """One prover pass. FIRST a local typecheck gate (no cloud); if any conf fails, return those and skip + the cloud. Otherwise verify every conformance conf (early-stop) and enrich each failure with its + counterexample (VIOLATION → Phi too strong) or difficulty report (TIMEOUT → Phi too heavy).""" + project.write(cfg.out_dir, cfg.setup_conf) + conf_paths = project.conf_paths(cfg.out_dir) + if not conf_paths: + return {} + tc_fail = await _local_typecheck(conf_paths, cfg) + if tc_fail: + return tc_fail # skip the cloud; feed the typecheck diagnostics back + + common = dict(sources_root=cfg.sources_root, local=cfg.local, + certora_run_path=cfg.certora_run_path, disable_cache=cfg.disable_cache) + results = await V.verify_all_early(conf_paths, **common) + for r in results.values(): + if r.success or r.cancelled or not r.job_url: + continue + if any(v.status == "TIMEOUT" for v in r.failures()): + r.difficulty = (await asyncio.to_thread(fetch_difficulty, r.job_url)).format() + else: + r.cex = await asyncio.to_thread(fetch_cex, r.job_url, {v.rule for v in r.failures()} or None) + return results + + +def _refine_message(results: dict[str, V.VerifyResult]) -> str: + """The feedback fed back to the agent — Phi vocabulary: a VIOLATION means Phi is TOO STRONG on the + counterexample input (weaken it, never `require` it away); a TIMEOUT means Phi is TOO HEAVY (simplify + / pivot to a looser goal-preserving form).""" + lines = ["The over-approximation is NOT yet proven. Fix the failures below, then call verify again.", ""] + for c, r in results.items(): + name = Path(c).name + if r.success: + lines.append(f"[PASS] {name}") + continue + if r.cancelled: + lines.append(f"[not run] {name} (skipped after another target failed; will re-check)") + continue + url = f" (report: {r.job_url})" if r.job_url else "" + if r.error: + lines.append(f"[{name}]\n{r.error}{url}") + for v in r.failures(): + msg = f": {v.assert_message}" if v.assert_message else "" + lines.append(f"[FAIL] {name} rule {v.rule} {v.status}{msg}{url}") + for label, xml in (r.cex or {}).items(): + lines.append(f" counterexample [{label}] — the REAL output on this input violates Phi " + f"(Phi is too strong here; WEAKEN the failing clause, do NOT require it away):\n{xml}") + timed_out = [(Path(c).name, r) for c, r in results.items() + if any(v.status == "TIMEOUT" for v in r.failures())] + if timed_out: + lines.append("") + lines.append("TIMEOUT: Phi is too HEAVY to prove. Replace an exact/nonlinear clause with a looser " + "property that still meets the goal (a range instead of the closed form), or pivot " + "Phi through a contract view getter. The prover's difficulty report localizes the " + "cost — use it, don't guess.") + for name, r in timed_out: + if r.difficulty: + lines.append(f"[{name}] where the nonlinear cost is:") + lines.append(r.difficulty) + lines.append("") + lines.append("Apply set_phi (not just render/inspect), run check_consistency, then call verify.") + return "\n".join(lines) + + +def _format_verify_result(project: OverApproxProject, results: dict[str, V.VerifyResult]) -> str: + """The `verify` tool's message: ALL-VERIFIED, or the failures + counterexamples/timeouts to fix.""" + if not results: + return ("no provable targets (every target is void / multi-return). Nothing to verify — the " + "summaries are unconstrained. Call result.") + if all(r.success for r in results.values()): + confs = ", ".join(Path(c).name for c in results) + return (f"PROVER: ALL over-approx conformance rules VERIFIED.\nProven: {confs}.\nEach summary is a " + "SOUND over-approximation — call result now (state each target's final Phi in words).") + return "PROVER results — NOT yet proven:\n\n" + _refine_message(results) + + +def _record_verified(project: OverApproxProject, results: dict[str, V.VerifyResult]) -> None: + """Mark each target whose conformance rule VERIFIED — snapshotting its proven Phi as the fallback.""" + passed = {v.rule for r in results.values() if r.success for v in r.rules if v.passed} + for fn in project.provable_targets(): + if conformance_rule_name(fn) in passed: + project.mark_verified(fn) + + +def _baseline_passing(url: str) -> set: + """User rules that VERIFIED (did not violate) in the ORIGINAL run — the ones the summary must NOT + break. From POU get_all_checks/get_violated_rules; prover built-ins dropped. vaas-dev needs AISS_ENV=dev.""" + import os + if "vaas-dev" in url: + os.environ.setdefault("AISS_ENV", "dev") + from prover_output_utility import ProverOutputAPI + api = ProverOutputAPI(use_local=False) + def user(rn): return bool(rn) and "StaticCheck" not in rn and rn != "envfreeFuncsStaticCheck" + allr = {c.rule_name for c in api.get_all_checks(url) if user(c.rule_name or "")} + violated = {c.rule_name for c in api.get_violated_rules(url) if user(c.rule_name or "")} + return allr - violated + + +def _write_regression_conf(project: OverApproxProject, cfg: OverApproxRefineConfig, rules: list) -> str: + """Install each VERIFIED summary into the run's verify spec (`import "Summary.spec";`, exactly the + driver's consumer-install) and write a conf that re-runs `rules` on the run's ORIGINAL scene with the + summaries active. Leaves the spec edited — the caller restores it.""" + import copy, json + from ..project import _set_perf + spec_rel = cfg.setup_conf["verify"].split(":", 1)[1] + spec_path = Path(cfg.sources_root) / spec_rel + text = spec_path.read_text() + for fn in project.verified: + imp = f'import "{fn}Summary.spec";' + if imp not in text: + text = imp + "\n" + text + spec_path.write_text(text) + conf = copy.deepcopy(cfg.setup_conf) + conf["prover_args"] = [a for a in conf.get("prover_args", []) if a != "-skipFormulaChecking"] + conf["rule"] = rules + conf["msg"] = "overapprox regression (summary installed)" + conf = _set_perf(conf) + cpath = f"{cfg.out_dir}/conf/regression.conf" + Path(cpath).parent.mkdir(parents=True, exist_ok=True) + Path(cpath).write_text(json.dumps(conf, indent=4)) + return cpath + + +async def _regression_check(project: OverApproxProject, cfg: OverApproxRefineConfig): + """AFTER conformance passes: install the summaries + re-run the run's OWN rules. Returns (regressed + rules, job_url) — a rule that PASSED at baseline but now fails means the summary is TOO COARSE.""" + if not cfg.run_url: + return [], None + baseline = await asyncio.to_thread(_baseline_passing, cfg.run_url) + if not baseline: + return [], None + spec_rel = cfg.setup_conf["verify"].split(":", 1)[1] + spec_path = Path(cfg.sources_root) / spec_rel + pristine = spec_path.read_text() + try: + cpath = _write_regression_conf(project, cfg, sorted(baseline)) + common = dict(sources_root=cfg.sources_root, local=cfg.local, + certora_run_path=cfg.certora_run_path, disable_cache=cfg.disable_cache) + results = await V.verify_all_early([cpath], **common) + r = next(iter(results.values()), None) + if r is None: + return [], None + return sorted(baseline & {v.rule for v in r.failures()}), r.job_url + finally: + spec_path.write_text(pristine) # restore the run's spec (install was transient) + + +def _make_verify(project: OverApproxProject, cfg: OverApproxRefineConfig, budget: list[int]): + """Bound closure for the agent's `verify` tool: write, prove every conformance conf, enrich failures, + record the verified targets, and return it all as one message (run INLINE in the conversation). + ENFORCES a prover-run budget (`budget[0]` remaining): once spent, refuses to run and tells the agent + to finalize — the loop keeps the last VERIFIED Phi as the fallback, so this bounds cost without + losing soundness. The count lives in `budget` (a 1-elem list) so the caller can inspect it after.""" + async def verify_now() -> str: + if budget[0] <= 0: + return ("BUDGET SPENT: no prover runs remain. Do NOT set_phi/verify again — finalize now: " + "call `result`. The tightest Phi that VERIFIED is retained and will be shipped; a " + "tighter-but-unproven Phi is discarded. State the final Phi(s) in your result.") + budget[0] -= 1 + results = await _prove_and_verify(project, cfg) + _record_verified(project, results) + msg = _format_verify_result(project, results) + if results and all(r.success for r in results.values()) and cfg and cfg.run_url: # conformance PASSED -> regression gate + regressed, rurl = await _regression_check(project, cfg) + if regressed: + msg = ("PROVER: conformance PASSED, but installing the summary REGRESSED the run's own " + "rules (they VERIFIED without it) -> Phi is TOO COARSE. TIGHTEN it so these pass " + "again; do NOT call result:\n " + "\n ".join(regressed) + + (f"\n (regression run: {rurl})" if rurl else "")) + return msg + f"\n\n(prover runs remaining: {budget[0]})" + return verify_now + + +async def run_overapprox_loop(project: OverApproxProject, task: str, cfg: OverApproxRefineConfig, *, + llm: BaseChatModel | None = None, builder: Builder | None = None, + extra_tools: Iterable[BaseTool] = (), thread_id: str = "overapprox", + fill_steps: int = 120, max_prompt_tokens: int = 100_000, + max_verify_calls: int = 6, + callbacks: Iterable[BaseCallbackHandler] = (), + transcript_path: str | None = None) -> OverApproxRefineResult: + """Composer-style single-conversation fill→prove→refine for the over-approx summaries. `verify` and + the full-typecheck check_consistency are TOOLS the agent calls, so feedback arrives inline. TWO + budgets bound the run: `fill_steps` caps the agent's tool-call recursion, and `max_verify_calls` caps + the (minutes-each, cloud) PROVER runs — the verify tool refuses once spent, so tightening can't loop + forever. At the end we RESTORE each target to its last-proven Phi (so a tighter-but-failing Phi left + when the budget ran out is discarded) and take one authoritative verdict — a content-hash cache hit, + since that Phi already proved. Dev: pass `llm=`; pipeline: `builder=env.builder_lite()` (in a + with_handler scope) + `extra_tools=env source tools`.""" + if builder is None: + if llm is None: + raise ValueError("pass llm= (dev) or builder=env.builder_lite() (pipeline)") + builder = Builder().with_llm(llm, max_prompt_tokens=max_prompt_tokens) + ckpt = InMemorySaver() + budget = [max_verify_calls] + deps = OverApproxDeps(project, full_typecheck=_make_full_typecheck(project, cfg), + verify=_make_verify(project, cfg, budget)) + graph = (build_overapprox_graph(builder, deps, extra_tools=extra_tools) + .with_initial_prompt(task).compile_async(checkpointer=ckpt)) + try: + await graph.ainvoke(OverApproxInput(input=[]), + {"configurable": {"thread_id": thread_id}, "recursion_limit": fill_steps, + "callbacks": list(callbacks)}) + except Exception: + pass # budget exhausted / API error — fall through to the authoritative verdict below + finally: + if transcript_path: + await dump_transcript(ckpt, thread_id, transcript_path) + # ship the tightest PROVEN Phi, not whatever the agent last set (it may have left a failing tighten). + project.restore_best_verified() + results = await _prove_and_verify(project, cfg) # authoritative; cache hit (that Phi already proved) + _record_verified(project, results) + success = bool(results) and all(r.success for r in results.values()) + return OverApproxRefineResult(success=success, verified=sorted(project.verified), results=results) diff --git a/smtool/agent/overapprox_tools.py b/smtool/agent/overapprox_tools.py new file mode 100644 index 00000000..e57a5d10 --- /dev/null +++ b/smtool/agent/overapprox_tools.py @@ -0,0 +1,177 @@ +"""Over-approximation agent tools — the `OverApproxProject` mutation/inspection surface as graphcore +LLM tools, mirroring `agent/tools.py` but collapsed to the ONE hole this loop has: the predicate `Phi`. + +`set_phi` takes CVL SURFACE TEXT for a target's `Phi(params, res)` body (parsed via `cvl_parse`, same as +the model loop's free-form holes), so the tool schema stays tiny and the agent writes natural CVL. The +`Project` is bound out-of-band (`OverApproxDeps`) via `.bind(deps).as_tool(name)`; the mutation persists +on the bound reference across calls. `check_consistency` and `verify` are bound closures (the refine +runner supplies them), so the agent drives fill→verify→refine in ONE conversation. +""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import override, Callable, Awaitable + +from pydantic import Field +from langchain_core.tools import BaseTool + +from graphcore.tools.schemas import WithAsyncDependencies + +from ..overapprox_project import OverApproxProject +from ..project import Result + + +@dataclass(frozen=True) +class OverApproxDeps: + """The bound dependency every over-approx tool reads — the project the `set_phi` mutation operates on, + plus (optionally) `full_typecheck` (write + run the REAL certoraRun typechecker, returns (ok, diag)) + and `verify` (run the prover; returns PASS / failures+counterexamples). Both are set by the refine + loop; None in config-free contexts (then check_consistency is structural-only and verify is absent).""" + project: OverApproxProject + full_typecheck: Callable[[], Awaitable[tuple[bool, str]] | tuple[bool, str]] | None = None + verify: Callable[[], Awaitable[str]] | None = None + + +def _res(r: Result) -> str: + if r.ok: + return f"ok: {r.message}" + return f"REJECTED: {r.message}" + (f" | {'; '.join(r.violations)}" if r.violations else "") + + +class _Tool(WithAsyncDependencies[str, OverApproxDeps]): + """Base: bind an OverApproxDeps, run returns a str for the LLM.""" + + +class SetPhi(_Tool): + """Set (or replace) the predicate `Phi(, res)` body for a target function — THE hole. + Provide CVL statements ending in `return `; you MAY declare locals and use `require` + (e.g. the byte-extraction idiom `uint248 v; require to_bytes31(v) == res; return (v >> 240) == 3;`). + Phi must be a boolean predicate over the function's params and the result `res`. Make it as STRONG as + the goal wants; the prover will tell you (via a counterexample) if it is too strong, and you WEAKEN + it. Re-calling replaces the previous body.""" + fn: str = Field(description="the target function whose Phi to set, e.g. 'sqrt'") + body: str = Field(description="the Phi body as CVL statements ending in `return `") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return _res(d.project.set_phi(self.fn, self.body)) + + +class SetPsi(_Tool): + """Set (or replace) the REVERT predicate `Psi()` for a target — a PURE boolean formula over + the params that is true exactly where the real function REVERTS (e.g. `return c == 0;`, or + `return ts0 > ts1;`). This makes the summary `revert()` on those inputs (a FAITHFUL summary: if the + real `f` reverts, so does the summary), and adds the dual rule `revertConform_` proving + `Psi => realReverted` (the sound direction — the summary never reverts where `f` succeeds). OPTIONAL: + omit Psi and the summary simply never reverts (still sound, but it returns a value where `f` would + revert, which can produce spurious counterexamples downstream). No `require` here — state the whole + condition in the `return`. If the revert condition is inexpressible/deep, leave Psi unset.""" + fn: str = Field(description="the target function whose revert predicate to set, e.g. 'divmul'") + body: str = Field(description="the Psi body: CVL ending in `return ` (no require)") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return _res(d.project.set_psi(self.fn, self.body)) + + +class CheckConsistency(_Tool): + """Coherence check (no prover run): every target's Phi is filled and type-checks, and — when a full + typechecker is bound — the whole conformance bundle passes the REAL certoraRun CVL typecheck (catches + the semantic errors the standalone jar misses). Returns the problems ([] == consistent). CALL THIS + before `verify`.""" + + @override + async def run(self) -> str: + with self.tool_deps() as d: + problems = d.project.check_consistency() + if problems: + return "problems:\n- " + "\n- ".join(problems) + if d.full_typecheck is not None: + res = d.full_typecheck() + ok, diag = await res if asyncio.iscoroutine(res) else res + if not ok: + return "typecheck failed — fix the CVL at the reported file:line:\n" + diag + return "consistent" + + +class Verify(_Tool): + """Run the PROVER on the current Phi(s) (the trust anchor, cloud, minutes/call): verifies each + target's `overApprox_` conformance rule — that the REAL function's output satisfies Phi. Returns + ALL-VERIFIED, or the failing rules with their COUNTEREXAMPLES (a concrete input `x` whose real output + violates Phi → Phi is too strong there, WEAKEN it) / TIMEOUTs (Phi too heavy → simplify/pivot). + Call once check_consistency is clean; FIX what it reports and call verify AGAIN; only call `result` + after verify reports ALL targets verified (or you have justified a weaker-but-sound Phi).""" + + @override + async def run(self) -> str: + with self.tool_deps() as d: + if d.verify is None: + return "verify is not available in this run" + return await d.verify() + + +class RenderPhi(_Tool): + """Return a target's current Phi spec as CVL text.""" + fn: str = Field(description="the target function whose Phi spec to render") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return d.project.render_phi(self.fn) + + +class RenderPsi(_Tool): + """Return a target's current revert predicate Ψ spec as CVL text (empty/absent if Ψ is unset).""" + fn: str = Field(description="the target function whose revert predicate spec to render") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return d.project.render_psi(self.fn) + + +class RenderConformance(_Tool): + """Return a target's conformance spec as CVL text (the `overApprox_` rule proved against the real + function).""" + fn: str = Field(description="the target function whose conformance spec to render") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return d.project.render_conformance(self.fn) + + +class RenderSummary(_Tool): + """Return a target's installable summary spec as CVL text (`CVL` + the methods{} binding) — the + deliverable a downstream proof imports once conformance passes.""" + fn: str = Field(description="the target function whose summary spec to render") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return d.project.render_summary(self.fn) + + +_TOOLS: list[tuple[type[_Tool], str]] = [ + (SetPhi, "set_phi"), + (SetPsi, "set_psi"), + (CheckConsistency, "check_consistency"), + (RenderPhi, "render_phi"), + (RenderPsi, "render_psi"), + (RenderConformance, "render_conformance"), + (RenderSummary, "render_summary"), +] + + +def overapprox_tools(deps: OverApproxDeps) -> list[BaseTool]: + """The over-approx tool set, each bound to `deps`. The `verify` tool is included only when a verify + closure is bound (the refine runner sets it) — so the agent drives fill→verify→refine in ONE + conversation with prover feedback arriving inline. CUT source-access tools (grep/read) + the CVL + manual search are supplied separately as `extra_tools`.""" + tools = [cls.bind(deps).as_tool(name) for cls, name in _TOOLS] + if deps.verify is not None: + tools.append(Verify.bind(deps).as_tool("verify")) + return tools diff --git a/smtool/agent/refine.py b/smtool/agent/refine.py new file mode 100644 index 00000000..3e73ea65 --- /dev/null +++ b/smtool/agent/refine.py @@ -0,0 +1,308 @@ +"""Piece D: the verify -> CEX -> refine loop — deterministic (graphcore-style) orchestration that +turns the fill agent's *structurally-consistent* model into a *prover-verified* one. + +The fill agent (piece F) can only reach "check_consistency is clean" — a typecheck + discipline gate, +NOT a proof. This loop adds the trust anchor: it writes the project, PROVES the shared reachable +invariants (dropping the ones that don't hold), VERIFIES every conformance conf, and — on any failure — +feeds a compact counterexample summary back to the SAME agent so it refines (fix the model body, or +add a *provable* reachable invariant), then re-verifies. The agent only ever proposes; the prover +disposes. Repeats until all rules pass or the round budget is spent. + +Prover calls live HERE, not in the agent's tool loop: they are minutes-long and cloud-bound, so keeping +them out of the agent's recursion budget gives clean, individually-loggable rounds and an explicit +round cap. The bound `Project` persists across rounds (mutations apply in place), so each agent +invocation sees the current model via render_model/render_conformance even without message continuity. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, Callable + +from langchain_core.callbacks import BaseCallbackHandler +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool + +import asyncio +import json + +from langgraph.checkpoint.memory import InMemorySaver + +from graphcore.graph import Builder + +from ..project import Project +from .. import verify as V +from .. import driver +from ..typecheck import typecheck_conf +from ..cex import fetch_cex +from ..difficulty import fetch_difficulty +from .tools import SmtoolDeps +from .loop import build_smtool_graph, SmtoolInput + + +@dataclass +class RefineConfig: + """The run-config the loop needs to write + prove (everything not derivable from the Project). + Mirrors what `generate_hub.verify_hub` uses: consume_setup gives sources_root / setup conf / cut.""" + out_dir: str # where specs+confs are written (must be /certora so paths resolve) + setup_conf: dict # the base setup conf dict, rewritten per method (setup.conf) + sources_root: str # certoraRun's cwd — conf files/verify are relative to it + cut: str # the contract-under-test (setup.cut) + local: bool = False # LocalProverRunner vs CloudProverRunner + certora_run_path: str = "certoraRun" + disable_cache: bool = False + + +@dataclass +class RefineResult: + """Outcome of the loop: did every conformance rule pass, in how many rounds, plus the last verdicts.""" + success: bool + rounds: int + kept_invariants: list[str] = field(default_factory=list) + dropped_invariants: list[str] = field(default_factory=list) + results: dict[str, V.VerifyResult] = field(default_factory=dict) + + +def _conf_paths(cfg: RefineConfig, project: Project) -> list[str]: + """The conformance .conf path project.write emitted for each modeled method.""" + return [f"{cfg.out_dir}/conf/{project.inp.conformance_prefix}{driver._cap(m)}Conformance.conf" + for m in project.conformance] + + +async def _local_typecheck(paths: list[str], cfg: RefineConfig) -> dict[str, V.VerifyResult]: + """Run the FULL certoraRun typechecker (--compilation_steps_only, no cloud) on each conf. Returns a + {conf: VerifyResult} of ONLY the ones that failed — a semantic-error gate we pay before any cloud + round. A conformance conf imports the model + reachable + setup, so this typechecks the whole bundle + with file:line diagnostics the standalone jar (check_consistency) can't produce.""" + async def one(c): + ok, tail = await asyncio.to_thread(typecheck_conf, c, cfg.sources_root, + certora_run_path=cfg.certora_run_path) + return c, ok, tail + out: dict[str, V.VerifyResult] = {} + for c, ok, tail in await asyncio.gather(*(one(c) for c in paths)): + if not ok: + out[c] = V.VerifyResult(conf=c, success=False, job_url=None, rules=[], + error="local typecheck failed:\n" + tail) + return out + + +def _make_full_typecheck(project: Project, cfg: RefineConfig): + """A bound closure for the agent's check_consistency: write the project + run the REAL certoraRun + typechecker, returning (ok, diagnostics). Typechecks ONE conformance conf — it imports the shared + model + reachable spec, so a single compile covers the agent's main free-form surface (the mirrors + and CVL bodies) and catches the semantic errors (assign-after-access, mathint) the standalone jar + misses. (Per-method conformance-spec errors are rare — that CVL is driver-generated — and the loop's + verify pass covers them.)""" + async def tc() -> tuple[bool, str]: + project.write(cfg.out_dir, cfg.setup_conf) + confs = _conf_paths(cfg, project) + if not confs: + return True, "" + return await asyncio.to_thread(typecheck_conf, confs[0], cfg.sources_root, + certora_run_path=cfg.certora_run_path) + return tc + + +async def _prove_and_verify(project: Project, cfg: RefineConfig): + """One prover pass. FIRST a local typecheck gate (no cloud) — if any conf fails to typecheck, return + those failures and skip the cloud entirely (the agent gets file:line errors fast). Only when the + bundle typechecks do we prove+prune the shared reachable invariants once and verify every conformance + conf on the prover. Returns (kept, dropped, results).""" + project.write(cfg.out_dir, cfg.setup_conf) + conf_paths = _conf_paths(cfg, project) + tc_targets = list(conf_paths) + if project.reachable_invariant_names(): + tc_targets.append(f"{cfg.out_dir}/conf/{cfg.cut}Reachable.conf") + tc_fail = await _local_typecheck(tc_targets, cfg) + if tc_fail: + return [], [], tc_fail # skip the cloud; feed the typecheck diagnostics back to the agent + + kept: list[str] = [] + dropped: list[str] = [] + common = dict(sources_root=cfg.sources_root, local=cfg.local, + certora_run_path=cfg.certora_run_path, disable_cache=cfg.disable_cache) + if project.reachable_invariant_names(): + reach = f"{cfg.out_dir}/conf/{cfg.cut}Reachable.conf" + kept, dropped, _ = await V.prune_reachable(project, reach, **common) + project.write(cfg.out_dir, cfg.setup_conf) # reflect the pruning before conformance runs + # verify_all_early: stop + cancel the rest as soon as one conf fails, instead of blocking on the + # slowest job (a violated method needn't wait 40 min for another still running). + results = await V.verify_all_early(conf_paths, **common) + # enrich each real prover VIOLATION with its counterexample (targeted POU fetch, no zipOutput), so the + # refine message shows the concrete inputs/values on which the model diverges. Skip CANCELLED confs + # (not run to completion) — they have no counterexample. + for r in results.values(): + if r.success or r.cancelled or not r.job_url: + continue + # a TIMEOUT is a performance problem (no counterexample) — fetch the prover's own difficulty + # signal (ranked nonlinearity hotspots + inlined-without-summary calls) so the refine message + # points the agent at the concrete off-path cost. A VIOLATION has a counterexample instead. + if any(v.status == "TIMEOUT" for v in r.failures()): + r.difficulty = (await asyncio.to_thread(fetch_difficulty, r.job_url)).format() + else: + r.cex = await asyncio.to_thread(fetch_cex, r.job_url, {v.rule for v in r.failures()} or None) + return kept, dropped, results + + +def _refine_message(dropped: list[str], results: dict[str, V.VerifyResult]) -> str: + """The counterexample fed back to the agent: exactly what failed (the typechecker's file:line text, + or the prover's violated rule + assert message + report URL), and the tools to fix it. We do NOT + enumerate specific CVL rules — the typechecker output says what is wrong, and `cvl_manual_search` + is the agent's reference for how CVL works.""" + lines = ["The model is NOT yet verified. Fix the failures below, then I will re-check.", ""] + if dropped: + lines.append(f"REACHABLE INVARIANT(S) DROPPED (did not verify against the real CUT): {dropped}. " + "A reachable invariant must hold of the REAL contract alone (real getters only, no " + "model readers). Either add a genuinely provable one, or make the model body robust " + "without that assumption.") + for c, r in results.items(): + name = Path(c).name + if r.success: + lines.append(f"[PASS] {name}") + continue + if r.cancelled: + # not run to completion — the round stopped at the first failure below; re-checked next round + lines.append(f"[not run] {name} (skipped after another method failed; will re-check)") + continue + url = f" (report: {r.job_url})" if r.job_url else "" + if r.error: + lines.append(f"[{name}]\n{r.error}{url}") + for v in r.failures(): + msg = f": {v.assert_message}" if v.assert_message else "" + lines.append(f"[FAIL] {name} rule {v.rule} {v.status}{msg}{url}") + for label, xml in (r.cex or {}).items(): + # the counterexample: concrete inputs + the call trace on which the model diverges from real + lines.append(f" counterexample [{label}]:\n{xml}") + lines += ["", + "Read the diagnostics above. For a TYPECHECK error, fix the CVL at the reported file:line; " + "if you are unsure why it is rejected, call cvl_manual_search to look up the rule. For a " + "prover VIOLATION, read the counterexample call trace: it gives the CONCRETE inputs and the " + "step-by-step values where the model's result diverges from the real contract. Trace which " + "line of your model produced the wrong value and correct it (or add/prove a reachable " + "invariant that rules the input out)."] + timed_out = [(Path(c).name, r) for c, r in results.items() + if any(v.status == "TIMEOUT" for v in r.failures())] + if timed_out: + lines.append( + f"TIMEOUT ({', '.join(n for n, _ in timed_out)}): a PERFORMANCE problem (the model is likely " + "correct). Apply a technique from section 4 of your instructions — NONDET an off-path " + "view/pure fn, PIVOT the return through a contract view getter (add_helper_lemma), or align a " + "mirror for CONGRUENCE. The prover's OWN difficulty report below localizes the cost — use it " + "instead of guessing. Do NOT just re-render/inspect; you MUST apply a mutation before result.") + for name, r in timed_out: + if r.difficulty: + lines.append(f"[{name}] where the nonlinear cost is:") + lines.append(r.difficulty) + lines.append("Apply mutations (not just render/inspect), run check_consistency, then call result.") + return "\n".join(lines) + + +def _fmt_msg(item) -> str: + """Render one load_timeline item (a BaseMessage or a SummarizationMarker) as readable text — full + content + tool calls for AI turns, full tool RESULTS for tool turns (the typechecker message, the + RAG answer, the mutation verdicts — untruncated, since that is the diagnostic value).""" + content = getattr(item, "content", None) + if content is None: # SummarizationMarker (or other non-message) + return f"----- [{type(item).__name__}] -----" + role = type(item).__name__.replace("Message", "").upper() + text = content if isinstance(content, str) else json.dumps(content, default=str) + out = [f"===== {role} ====="] + if text.strip(): + out.append(text) + for tc in getattr(item, "tool_calls", None) or []: + out.append(f" >>> call {tc.get('name')}({json.dumps(tc.get('args', {}), default=str)})") + return "\n".join(out) + + +async def dump_transcript(checkpointer, thread_id: str, path: str) -> None: + """Write the FULL agent conversation (system/human/AI+reasoning+tool-calls/tool-results, across all + rounds) to `path`, via composer's checkpoint walker (survives summarization). Best-effort.""" + try: + from composer.io.thread_timeline import load_timeline + items = await load_timeline(checkpointer, thread_id) + Path(path).write_text("\n\n".join(_fmt_msg(it) for it, _cp in items)) + except Exception as e: + Path(path).write_text(f"(transcript unavailable: {type(e).__name__}: {e})") + + +def _format_verify_result(kept: list[str], dropped: list[str], + results: dict[str, V.VerifyResult]) -> str: + """The `verify` tool's message back to the agent: ALL-VERIFIED, or the failures + counterexamples/ + timeouts to fix (reusing _refine_message's per-failure rendering).""" + if results and all(r.success for r in results.values()) and not dropped: + confs = ", ".join(Path(c).name for c in results) + return ("PROVER: ALL conformance rules VERIFIED" + + (f" (reachable invariants proven: {kept})" if kept else "") + + f".\nProven: {confs}.\nThe model is fully verified — call result now.") + return "PROVER results — the model is NOT yet fully verified:\n\n" + _refine_message(dropped, results) + + +def _make_verify(project: Project, cfg: RefineConfig): + """A bound closure for the agent's `verify` tool: write the project, prove+prune the shared reachable + invariants, verify every conformance conf (early-stop), enrich violations with their counterexamples, + and return it all as one message. This is the trust anchor, run INLINE in the agent's conversation + (composer-style) so prover feedback arrives as a tool result — no orchestrated re-invocation.""" + async def verify_now() -> str: + kept, dropped, results = await _prove_and_verify(project, cfg) + return _format_verify_result(kept, dropped, results) + return verify_now + + +def _array_guidance(project) -> str: + """A per-run prefix telling the agent the run's loop_iter and how to unroll array-param methods — + emitted ONLY when some modeled method takes an array (T[]) param, else "". CVL has no loops/recursion, + so the model addresses each array by fixed indices 0..loop_iter-1 (the run's bound).""" + li = project.inp.loop_iter + arr_methods = [f.name for f in project.inp.functions + if f.is_model_method and any(p.type.endswith("[]") for p in f.params)] + if not arr_methods: + return "" + return (f"NOTE — this run's loop_iter is {li}: every array is bounded to length <= {li}. The methods " + f"{', '.join(arr_methods)} take array params. CVL has no loops/recursion, so unroll each over " + f"its length: branch on `arr.length` for each value 0..{li} and, in the branch of length n, act " + f"on the fixed elements arr[0]..arr[n-1] (e.g. a batch op is n single ops). The conformance " + f"pins the observables at arr[0..{li-1}] for you.\n\n") + + +async def run_refine_loop(project: Project, fill_task: str, cfg: RefineConfig, *, + llm: BaseChatModel | None = None, builder: Builder | None = None, + extra_tools: Iterable[BaseTool] = (), thread_id: str = "smtool", + fill_steps: int = 90, max_rounds: int = 4, max_prompt_tokens: int = 100_000, + callbacks: Iterable[BaseCallbackHandler] = (), + transcript_path: str | None = None, + on_round: Callable[[int, list[str], list[str], dict], None] | None = None + ) -> RefineResult: + """Composer-style single-conversation fill→prove→refine: `verify` (and the full-typecheck + check_consistency) are TOOLS the agent calls, so the whole cycle — fill, check_consistency, verify, + read the counterexample, fix, verify again, … result — happens in ONE graph invocation with feedback + arriving inline as tool results. (The previous orchestrated re-invocation re-seeded the initial prompt + each round and buried the counterexample; verify-as-a-tool avoids that structurally.) `fill_steps` is + the recursion budget for that conversation; `max_rounds` is vestigial (kept for call compatibility). + After the agent finishes, one authoritative verify records the verdict (a content-hash cache hit if + the model is unchanged since the agent's last verify). Dev: pass `llm=`. Pipeline: pass + `builder=env.builder_lite()` (inside a with_handler scope) + `extra_tools=env source tools`.""" + if builder is None: + if llm is None: + raise ValueError("pass llm= (dev) or builder=env.builder_lite() (pipeline)") + builder = Builder().with_llm(llm, max_prompt_tokens=max_prompt_tokens) + fill_task = _array_guidance(project) + fill_task + ckpt = InMemorySaver() + deps = SmtoolDeps(project, full_typecheck=_make_full_typecheck(project, cfg), + verify=_make_verify(project, cfg), sources_root=cfg.sources_root) + graph = (build_smtool_graph(builder, deps, extra_tools=extra_tools) + .with_initial_prompt(fill_task).compile_async(checkpointer=ckpt)) + try: + await graph.ainvoke(SmtoolInput(input=[]), + {"configurable": {"thread_id": thread_id}, "recursion_limit": fill_steps, + "callbacks": list(callbacks)}) + except Exception: + pass # budget exhausted / API error — fall through to the authoritative verdict below + finally: + if transcript_path: + await dump_transcript(ckpt, thread_id, transcript_path) + # authoritative final verdict (cache hit if the model is unchanged since the agent's last verify) + kept, dropped, results = await _prove_and_verify(project, cfg) + if on_round is not None: + on_round(0, kept, dropped, results) + success = bool(results) and all(r.success for r in results.values()) and not dropped + return RefineResult(success, 1, kept, dropped, results) diff --git a/smtool/agent/resolution.py b/smtool/agent/resolution.py new file mode 100644 index 00000000..b6661ce5 --- /dev/null +++ b/smtool/agent/resolution.py @@ -0,0 +1,201 @@ +"""Resolution classifier (Tool 2) — given a CLUSTER of detector hotspots + the run's properties, decide +PER FUNCTION how to summarize: EXACT_CVL / OVER_APPROX / SYMBOLIC_MODEL / LEAVE_REAL. + +Batch-by-cluster so the agent sees the connections: dedup shared leaves, and pick the RIGHT LEVEL (a +shared leaf, a mid-level parent, or a whole dependency) instead of classifying each method blindly. + +The agent reads the SOURCE itself, so it judges "nonlinear math vs ugly implementation" natively — no +mechanical hints. The detector's `Cluster` is INJECTED (not imported), so there is no circular dependency +(summarization_detector already imports smtool.difficulty). The run's PROPERTIES are collected here via +POU (`collect_rules` -> treeView `get_all_checks`) — pass `run_url=` and they auto-populate. + +Same composer shape as overapprox_loop (bind_standard + run_to_completion), but a single pass — no prover, +no refine loop. NB: no `from __future__ import annotations` (bind_standard reads `result`'s annotation raw). +""" +from dataclasses import dataclass, field +from enum import Enum +from typing import Iterable, NotRequired + +from pydantic import BaseModel, Field +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool +from langgraph.graph import MessagesState + +from graphcore.graph import Builder, FlowInput +from ..ir import Signature +from ..overapprox import OverApproxTarget +from composer.spec.graph_builder import bind_standard + + +class Technique(str, Enum): + EXACT_CVL = "exact_cvl" # reimplement EXACTLY in CVL (res == f_cvl_exact): precise, sound by + # equality (no invariant). Worth it ONLY when a cleaner/more-tractable + # CVL form exists — ugly impl (assembly/bit-packing) or a simple closed + # form. On already-clean math it buys nothing (same nonlinearity). + OVER_APPROX = "over_approx" # drop the exact value: havoc+Phi, or a deterministic-ghost + a + # monotone/injective/zero axiom. Use when the properties tolerate it. + SYMBOLIC_MODEL = "symbolic_model" # whole-contract model — a DEPENDENCY only, NEVER the CUT. + LEAVE_REAL = "leave_real" # do not summarize (cheap enough, or no sound summary helps). + + +class Flag(BaseModel): + function: str = Field(description="function to act on — 'Contract.method' or a bare free-function name") + technique: Technique = Field(description="how to summarize it (or leave real)") + rationale: str = Field(description="1-2 sentences: WHAT makes it costly (nonlinear MATH vs ugly " + "IMPLEMENTATION), and WHY this technique fits given the properties") + relation: str = Field(default="", description="OVER_APPROX only: the relation to preserve in English " + "(e.g. 'monotone in assets', 'injective', 'zero-preserving'); " + "'' otherwise") + + +class ResolutionResult(BaseModel): + flags: list[Flag] = Field(description="holistic recommendations for the WHOLE cluster: dedup shared " + "leaves, flag the RIGHT level (leaf/parent/dependency), and mark a " + "method LEAVE_REAL when summarizing a shared leaf already covers it") + + +class ResolutionState(MessagesState): + result: NotRequired[ResolutionResult] + + +class ResolutionInput(FlowInput): + pass + + +@dataclass +class Cluster: + """One connected hotspot group, injected from the detector (NOT imported).""" + cut: str + functions: list[str] # the hotspot methods + leaves: dict[str, int] = field(default_factory=dict) # shared nonlinear/hashing leaf -> fan-in + call_edges: list[tuple] = field(default_factory=list) # (caller, callee) — the descent structure + + +SYSTEM_PROMPT = """\ +You are a RESOLUTION CLASSIFIER for formal-verification summarization. You are given a CLUSTER of costly +functions from one prover run (identified by a difficulty detector), the run's PROPERTIES (rule names), +and source-read tools. Decide, PER function, HOW it should be summarized — or that it should be left real. + +FIRST read the relevant source (the cluster's functions and their shared leaves) before deciding. + +## The four techniques +- EXACT_CVL: reimplement the function EXACTLY in CVL (the summary returns f_cvl_exact, proven by an + equality conformance). Precise, sound with NO invariant. Pick it ONLY when a clean CVL form is MORE + TRACTABLE than the Solidity — i.e. the pain is an UGLY IMPLEMENTATION (inline assembly, bit-packing via + shifts/masks, 512-bit mulmod tricks) or there is a simple closed form. On already-clean math (e.g. + `(x*y)/d`) EXACT_CVL buys nothing — the same nonlinearity remains. +- OVER_APPROX: give up the exact value. Two flavors: a pure havoc+predicate (value irrelevant), or a + DETERMINISTIC ghost carrying a monotonicity/injectivity/zero axiom (relations preserved, exact value + dropped). Pick it when the pain is INTRINSIC NONLINEAR MATH (mulDiv, pow, exp/ln, EC) AND the properties + do NOT need the exact value — only relations/bounds. State the relation to keep in `relation`. +- SYMBOLIC_MODEL: replace a whole contract with a CVL model. ONLY valid for a DEPENDENCY contract, NEVER + the CUT ('%s') — you cannot model away the contract you are verifying. +- LEAVE_REAL: do not summarize (already cheap, or no sound summary preserves the properties). + +## The decisive question: what do the PROPERTIES need? +Over-approx is sound but LOOSE — if a property reasons about the exact value or exact relation of a +function, an over-approx gives spurious counterexamples. So: +- property needs the exact value / exact conversion -> EXACT_CVL (if a cleaner CVL form exists) or LEAVE_REAL +- property needs only a relation (monotone, injective, congruence like `preview==deposit`) -> OVER_APPROX + with that relation (a deterministic ghost preserves congruence for free) +- property is indifferent to this function -> OVER_APPROX (havoc), or LEAVE_REAL if cheap + +## Be HOLISTIC (this is why you get the whole cluster at once) +- If several methods share a LEAF (see the fan-in), flag the LEAF once and mark the methods LEAVE_REAL — + summarizing the shared leaf subsumes them. Do NOT flag both a method and its leaf. +- Prefer the cleanest boundary: a shared leaf, or a mid-level parent with a clean typed signature. +- Deduplicate: one flag per function you actually want acted on. +- CALLABLE over HARNESS: conformance must CALL the summarized function. A `public`/`external` method is + directly callable; a FREE (file-level) function or an `internal`/`private` library function is NOT — it + would need a wrapper harness (not automated yet). So when the cost sits in a free/internal function, + do NOT target it directly: summarize its NEAREST `public`/`external` caller instead (it subsumes the + cost, e.g. an injective ghost over that caller removes the inner hash) and mark the inner function + LEAVE_REAL. Only target a free/internal function directly if NO public/external caller in the cluster + reaches it. Read the source to determine each function's visibility. + +Return `flags`: one entry per function to act on (including LEAVE_REAL for methods a leaf covers), each +with the technique, a short rationale (name the cost: nonlinear math vs ugly impl), and — for OVER_APPROX +— the relation to preserve. +""" + + +def _cluster_brief(cluster: Cluster, rules: list) -> str: + leaves = "\n".join(f" - {fn} (shared by {n} method(s))" for fn, n in sorted( + cluster.leaves.items(), key=lambda kv: -kv[1])) or " (none)" + edges = "\n".join(f" {a} -> {b}" for a, b in cluster.call_edges) or " (none)" + return (f"CUT (contract under verification): {cluster.cut}\n\n" + f"Hotspot functions in this cluster:\n " + "\n ".join(cluster.functions) + "\n\n" + f"Shared nonlinear/hashing LEAVES they inline (fan-in):\n{leaves}\n\n" + f"Call edges (descent structure):\n{edges}\n\n" + f"The run's PROPERTIES (rule names — these decide what precision is needed):\n " + + "\n ".join(rules) + "\n\n" + "Read the source of the functions/leaves, then classify each per the system prompt. " + "Be holistic: dedup shared leaves, flag the right level, mark covered methods LEAVE_REAL.") + + +def build_resolution_graph(builder: Builder, cut: str, *, extra_tools: Iterable[BaseTool] = ()): + """Assemble the classifier graph. bind_standard adds the terminal `result` tool + summarizer; we append + the source-read tools (the agent's only other tools — it reads code, it does not run the prover).""" + return (bind_standard(builder, state_type=ResolutionState) + .with_input(ResolutionInput) + .with_sys_prompt(SYSTEM_PROMPT % cut) + .with_tools(list(extra_tools))) + + +_BUILTIN_RULES = {"envfreeFuncsStaticCheck"} # prover built-ins, not user properties + + +def collect_rules(url: str) -> list: + """The run's USER CVL rule names, from its treeView via POU (get_all_checks) — the property list the + classifier keys on. Drops prover built-ins (envfreeFuncsStaticCheck / *StaticCheck). vaas-dev URLs + need AISS_ENV=dev for POU auth (set here from the host, so callers needn't).""" + import os + if "vaas-dev" in url: + os.environ.setdefault("AISS_ENV", "dev") + from prover_output_utility import ProverOutputAPI + api = ProverOutputAPI(use_local=False) + seen, out = set(), [] + for c in api.get_all_checks(url): + rn = (getattr(c, "rule_name", "") or "").strip() + if rn and rn not in seen and rn not in _BUILTIN_RULES and "StaticCheck" not in rn: + seen.add(rn) + out.append(rn) + return out + + +def target_from_flag(flag: Flag, sig: Signature, *, cut: str, + setup_spec_import=None) -> OverApproxTarget | None: + """Adapter B (classifier -> summary builder): a Flag + the function's NATIVE `Signature` (scene-sourced + via `smtool.scene.signature_from_scene` / `Signature.from_scene` — NOT a parsed string) -> a builder + input. Routes by technique: OVER_APPROX / EXACT_CVL -> an OverApproxTarget (Phi left for the agent; the + flag's relation/rationale as the GOAL, EXACT_CVL adds an exact-reimplementation goal). LEAVE_REAL and + SYMBOLIC_MODEL -> None (nothing here / a different builder = driver).""" + if flag.technique in (Technique.LEAVE_REAL, Technique.SYMBOLIC_MODEL): + return None + goal = flag.relation or flag.rationale + if flag.technique == Technique.EXACT_CVL: + goal = "Reimplement EXACTLY in CVL (Phi should pin res == the clean closed form). " + goal + return OverApproxTarget(cut=cut, sig=sig, goal=goal, setup_spec_import=setup_spec_import) + + +async def classify_cluster(cluster: Cluster, rules: list | None = None, *, run_url: str | None = None, + llm: BaseChatModel | None = None, + builder: Builder | None = None, source_tools: Iterable[BaseTool] = (), + thread_id: str = "resolution", recursion_limit: int = 40, + callbacks=(), max_prompt_tokens: int = 100_000) -> ResolutionResult | None: + """Classify one cluster in a single read-and-judge pass. Dev: pass `llm=`; pipeline: pass + `builder=env.builder_lite()`. Returns the holistic per-function flags.""" + if builder is None: + if llm is None: + raise ValueError("pass builder=env.builder_lite() (pipeline) or llm= (dev)") + builder = Builder().with_llm(llm, max_prompt_tokens=max_prompt_tokens) + if not rules: # Tool 2 owns rule collection (POU treeView) + rules = collect_rules(run_url) if run_url else [] + task = _cluster_brief(cluster, rules) + graph = build_resolution_graph(builder, cluster.cut, extra_tools=source_tools) \ + .with_initial_prompt(task).compile_async() + # standalone/dev path: native ainvoke (no pipeline IO-handler context), like smtool/demo/run_agent.py + final = await graph.ainvoke(ResolutionInput(input=[task]), + {"configurable": {"thread_id": thread_id}, + "recursion_limit": recursion_limit, "callbacks": list(callbacks)}) + return final.get("result") diff --git a/smtool/agent/tools.py b/smtool/agent/tools.py new file mode 100644 index 00000000..931bc18f --- /dev/null +++ b/smtool/agent/tools.py @@ -0,0 +1,476 @@ +"""smtool's mutation/inspection functions exposed as graphcore LLM tools (piece A). + +Each tool is a pydantic model whose fields are the LLM-visible args (JSON schema auto-derived) and whose +`run()` calls the underlying smtool function. The `Project` is bound out-of-band as a dependency +(`SmtoolDeps`) via `WithAsyncDependencies.bind(deps).as_tool(name)` — the LLM never supplies it, and +because every mutation mutates the project in place (`mutations._commit`), the bound reference persists +across tool calls. No graph state is threaded. + +Free-form holes (bodies, mirrors, expressions) take CVL SURFACE TEXT (str) and are parsed to +composer.cvl.schema via `cvl_parse` (piece B). Text keeps the tool schemas tiny (~hundreds of tokens vs +~75k for AST-as-JSON) and lets the agent write natural CVL; the parsed AST still flows through the full +discipline linter. A CVL syntax error comes back as a REJECTED result the agent can fix. + +Build the tool list with `smtool_tools(deps)`; run them in an agent loop via smtool.agent.loop. +""" +from __future__ import annotations + +import asyncio +import difflib +from dataclasses import dataclass +from typing import override, Callable, Awaitable + +from pydantic import Field +from langchain_core.tools import BaseTool + +from graphcore.tools.schemas import WithAsyncDependencies + +from ..project import Project, Result +from ..cvl_parse import parse_expression, parse_commands, CVLParseError +from .. import mutations as mut + + +@dataclass(frozen=True) +class SmtoolDeps: + """The bound dependency every smtool tool reads — the Project the mutations operate on, and + (optionally) `full_typecheck`: a bound closure that writes the project + runs the REAL certoraRun + typechecker and returns (ok, diagnostics). When present, check_consistency runs it after the fast + structural checks, so the agent catches the semantic CVL errors the standalone jar misses + (assign-after-access, mathint casts) DURING fill, before calling result. Set by the refine loop + (it has the run-config); None in config-free contexts (then check_consistency is structural-only).""" + project: Project + full_typecheck: Callable[[], Awaitable[tuple[bool, str]] | tuple[bool, str]] | None = None + verify: Callable[[], Awaitable[str]] | None = None # run the prover; returns PASS / failures+CEX + sources_root: str | None = None # scene root, for the AST-backed get_function tool + + +def _res(r: Result) -> str: + """Render a mutation Result for the LLM: 'ok: ...' or 'REJECTED: ... | '.""" + if r.ok: + return f"ok: {r.message}" + return f"REJECTED: {r.message}" + (f" | {'; '.join(r.violations)}" if r.violations else "") + + +# How many compressed re-renders of one target before we return the FULL spec again — bounds how far the +# agent must look back to reconstruct, and re-syncs it after a history summarization drops earlier renders. +_RENDER_FULL_EVERY = 4 + + +def _render_dedup(project: Project, target: str, current: str, force_full: bool = False) -> str: + """Compress a repeat render. The agent re-renders its own specs constantly (measured ~1/4 of all + turns), re-dumping the whole spec into history each time — pure redundancy. We remember the last text + returned per target and, on a repeat, return only what CHANGED: `UNCHANGED` when identical, else a + unified diff. This is purely an OBSERVATION compression — the model is untouched, and the agent's + history already holds the last full text it was shown. The agent can force the whole spec any time + with `force_full`; failing that, every `_RENDER_FULL_EVERY` compressed replies we return it anyway + (bounds look-back; re-syncs after a context summarization the agent may not notice).""" + cache = getattr(project, "_render_cache", None) + if cache is None: + cache = {} + project._render_cache = cache # scratch, survives _commit (not a copied model field) + prev, n = cache.get(target, (None, 0)) + if force_full or prev is None or n >= _RENDER_FULL_EVERY: + cache[target] = (current, 0) + return current + if prev == current: + cache[target] = (current, n + 1) + return f"[{target}: UNCHANGED since your last render ({current.count(chr(10)) + 1} lines) — " \ + f"your last-seen text still holds. Re-render only after a mutation changes it.]" + diff = "\n".join(difflib.unified_diff(prev.splitlines(), current.splitlines(), + fromfile="last", tofile="now", lineterm="")) + if not diff or len(diff) >= len(current): + cache[target] = (current, 0) # a near-total rewrite: full text is smaller / clearer + return current + cache[target] = (current, n + 1) + return f"[{target}: CHANGED since your last render — unified diff (last -> now) below; " \ + f"mutate then re-render if you need the whole spec again.]\n{diff}" + + +class _Tool(WithAsyncDependencies[str, SmtoolDeps]): + """Base: bind a SmtoolDeps, run returns a str for the LLM.""" + + +# ---------------------------------------------------------------- model content (holes) +class AddModelConstant(_Tool): + """Add a named numeric constant to the model (HOLE-K), e.g. RAY. Emitted as a persistent ghost with + a defining axiom `name == value`. Use for library constants the model math references.""" + name: str = Field(description="constant name, e.g. 'RAY'") + ctype: str = Field(description="CVL type, e.g. 'uint256'") + value: str = Field(description="the constant's value as a CVL expression, e.g. '10 ^ 27'") + + @override + async def run(self) -> str: + try: + v = parse_expression(self.value) + except CVLParseError as e: + return f"REJECTED: CVL parse error in value: {e}" + with self.tool_deps() as d: + return _res(mut.add_model_constant(d.project, name=self.name, ctype=self.ctype, value_expr=v)) + + +class AddModelFunction(_Tool): + """Add a model helper CVL function (HOLE-M) — a math mirror (a library fn in its exact structural + form) or any internal helper the CVL bodies call. CVL-only (no real-contract calls); may + read/write model ghosts.""" + name: str = Field(description="function name, e.g. 'mulDivCVL'") + params: list[tuple[str, str]] = Field(description="params as [type, name] pairs") + returns: list[str] = Field(description="return type names") + body: str = Field(description="the function body as CVL statements, e.g. 'uint256 c = a * b; return c;'") + + @override + async def run(self) -> str: + try: + cmds = parse_commands(self.body, self.params) + except CVLParseError as e: + return f"REJECTED: CVL parse error in body: {e}" + with self.tool_deps() as d: + return _res(mut.add_model_function(d.project, name=self.name, params=self.params, + returns=self.returns, commands=cmds)) + + +class SetModelMethodBody(_Tool): + """Fill a model method body (HOLE-F): the CVL body — a permissive revert-guard, the state + effect (ghost writes), and the return via the math mirrors. CVL-only; may read/write ghosts.""" + method: str = Field(description="the CUT method whose model body to fill, e.g. 'draw'") + body: str = Field(description="the body as CVL statements (declarations, if/revert, ghost writes, return)") + + @override + async def run(self) -> str: + try: + cmds = parse_commands(self.body) + except CVLParseError as e: + return f"REJECTED: CVL parse error in body: {e}" + with self.tool_deps() as d: + return _res(mut.set_model_method_body(d.project, method=self.method, commands=cmds)) + + +class AddModelGhostAxiom(_Tool): + """Add a definitional axiom to a NON-glued internal model ghost (HOLE-A). REJECTED if it constrains a + glued/pinned-to-real ghost (that must be a proved reachable invariant, not a model axiom).""" + ghost_name: str = Field(description="the model ghost to axiomatize") + axiom: str = Field(description="the axiom as a CVL boolean expression") + initial: bool = Field(default=False, description="an `init_state` axiom?") + + @override + async def run(self) -> str: + try: + e = parse_expression(self.axiom) + except CVLParseError as ex: + return f"REJECTED: CVL parse error in axiom: {ex}" + with self.tool_deps() as d: + return _res(mut.add_model_ghost_axiom(d.project, ghost_name=self.ghost_name, + axiom_expr=e, initial=self.initial)) + + +# ---------------------------------------------------------------- reachability / conformance +def _is_trivial_invariant(e) -> bool: + """A vacuous invariant: a bare boolean literal, or an X==X / X!=X tautology over structurally-equal + operands. It constrains nothing, and with no params it also renders as invalid CVL.""" + d = e.model_dump() + if d.get("type") == "bool_literal": + return True + if d.get("type") == "binary_op" and d.get("operator") in ("eq", "ne"): + return d.get("left") == d.get("right") + return False + + +class AddRequireInvariant(_Tool): + """Declare a real-CUT reachable invariant AND requireInvariant it in the shared assumeReachable, + atomically (never a bare/unproven assumption). Idempotent across methods. The invariant is a + candidate — it must be discharged by the reachable conf before conformance results are trusted.""" + inv_name: str = Field(description="invariant name") + inv_params: list[tuple[str, str]] = Field(description="invariant params as [type, name] pairs (do NOT " + "include env here — set env=true instead)") + inv_expr: str = Field(description="the invariant as a CVL boolean expression over REAL getters") + require_args: list[str] = Field(description="args to pass in the requireInvariant call (do NOT include " + "'e' — it is added automatically when env=true)") + env: bool = Field(default=False, description="true if the fact must call a NON-envfree getter (a " + "time-dependent quantity that accrues to block.timestamp) — the invariant then takes " + "a leading `env e`. A raw fixed-width storage field is envfree; leave false.") + preserved: str = Field(default="", description="CVL statements for a `preserved with (env e1) { ... }` " + "block relating the transition env to the invariant env — e.g. " + "'require e1.block.timestamp <= e.block.timestamp;' — what makes a time-dependent " + "(env) invariant provable. Reference both `e` and `e1`.") + + @override + async def run(self) -> str: + try: + e = parse_expression(self.inv_expr) + pres = parse_commands(self.preserved) if self.preserved.strip() else None + except CVLParseError as ex: + return f"REJECTED: CVL parse error: {ex}" + if _is_trivial_invariant(e): + return (f"REJECTED: `{self.inv_expr}` is trivially true (a bare literal or an X==X / X!=X " + f"tautology) — it constrains nothing. An invariant must state a REAL, non-trivial " + f"reachability fact over real getters. If the model reverts faithfully and needs NO " + f"reachable assumption, add NO invariant at all — an empty assumeReachable is correct.") + with self.tool_deps() as d: + return _res(mut.add_requireInvariant(d.project, inv_name=self.inv_name, + inv_params=self.inv_params, inv_expr=e, + require_args=self.require_args, env=self.env, preserved=pres)) + + +class AddGluePin(_Tool): + """Pin an observable's model ghost to its REAL getter at a specific key, in a method's glue. The + SOUND way to bound a ghost cell the deterministic pins miss — typically a DERIVED address (a credit + target the method writes, e.g. a fee receiver obtained from another getter). It emits ONLY + `CVLReader(keys) == (keys)` (model==real), so it can never be unsound at any key. Use on a + cast-safety CEX over a ghost read/write at an UNPINNED key: pinning it makes the ghost inherit the + real getter's field width — no invariant, no guard needed.""" + method: str = Field(description="the method whose glue to add the pin to") + observable: str = Field(description="the real getter whose ghost to pin, e.g. 'getBalanceOf'") + key_exprs: list[str] = Field(description="the keys to pin at, as CVL expressions — e.g. " + "['id', 'getReceiver(id)'] for a key that is a derived address") + + @override + async def run(self) -> str: + try: + keys = [parse_expression(k) for k in self.key_exprs] + except CVLParseError as ex: + return f"REJECTED: CVL parse error in key_exprs: {ex}" + with self.tool_deps() as d: + return _res(mut.add_glue_pin(d.project, method=self.method, observable=self.observable, + key_exprs=keys)) + + +class AddNondet(_Tool): + """Add a NONDET summary for a VIEW/PURE function in a method's conformance (a property-directed + speedup). REJECTED for non-view/pure targets (NONDET drops side effects — unsound on state-changing + fns; cross-checked against the scene when available).""" + method: str = Field(description="the method whose conformance to add the NONDET to") + contract: str | None = Field(description="contract qualifier, '_' for wildcard, or None") + name: str = Field(description="the function to NONDET") + param_types: list[str] = Field(description="param type names") + return_types: list[str] = Field(description="return type names") + mutability: str = Field(description="declared mutability (must be view/pure)") + visibility: str = Field(default="external", description="external | internal") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return _res(mut.add_nondet(d.project, method=self.method, contract=self.contract, + name=self.name, param_types=self.param_types, + return_types=self.return_types, mutability=self.mutability, + visibility=self.visibility)) + + +class AddHelperLemma(_Tool): + """Insert a checked assert (a proof-decomposition lemma, e.g. accrue-idempotence) into a conformance + rule, plus optional capture declarations. Never a require — only an added obligation; every call in + the captures/assert must be view/pure.""" + method: str = Field(description="the method whose rule to add the lemma to") + rule_name: str = Field(description="the conformance rule to insert into") + captures: str = Field(default="", description="pre-SUT capture declarations as CVL statements") + post_captures: str = Field(default="", description="post-SUT capture declarations as CVL statements") + assert_expr: str = Field(default="", description="the lemma assertion as a CVL boolean expression") + message: str = Field(default="helper lemma", description="the assert message") + + @override + async def run(self) -> str: + try: + caps = parse_commands(self.captures) if self.captures else [] + post = parse_commands(self.post_captures) if self.post_captures else [] + aexp = parse_expression(self.assert_expr) if self.assert_expr else None + except CVLParseError as ex: + return f"REJECTED: CVL parse error: {ex}" + with self.tool_deps() as d: + return _res(mut.add_helper_lemma(d.project, method=self.method, rule_name=self.rule_name, + captures=caps, post_captures=post, assert_expr=aexp, + message=self.message)) + + +# ---------------------------------------------------------------- removals (retract a wrong guess) +class RemoveHelperLemma(_Tool): + """Remove a helper lemma (matched by its `message`) from a conformance rule — use when the lemma's + assertion turned out FALSE (it VIOLATED), e.g. a preview-getter pivot that isn't exactly the return. + Also prunes capture declarations the lemma left unused.""" + method: str = Field(description="the method whose rule holds the lemma") + rule_name: str = Field(description="the conformance rule the lemma was inserted into") + message: str = Field(description="the lemma's assert message (as given to add_helper_lemma)") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return _res(mut.remove_helper_lemma(d.project, self.method, self.rule_name, + message=self.message)) + + +class RemoveModelGhostAxiom(_Tool): + """Remove a definitional axiom (matched by its expression) from a NON-glued model ghost — retract a + wrong axiom you added with add_model_ghost_axiom.""" + ghost_name: str = Field(description="the model ghost the axiom is on") + axiom: str = Field(description="the axiom's CVL boolean expression (as given to add_model_ghost_axiom)") + + @override + async def run(self) -> str: + try: + e = parse_expression(self.axiom) + except CVLParseError as ex: + return f"REJECTED: CVL parse error in axiom: {ex}" + with self.tool_deps() as d: + return _res(mut.remove_model_ghost_axiom(d.project, ghost_name=self.ghost_name, axiom_expr=e)) + + +class RemoveNondet(_Tool): + """Remove a NONDET summary for a function (by name) from a method's conformance — use when the NONDET + was unsound, i.e. the checked output DID depend on it and the rule VIOLATED.""" + method: str = Field(description="the method whose conformance holds the NONDET") + name: str = Field(description="the function name whose NONDET entry to remove") + contract: str | None = Field(default=None, description="contract qualifier to scope the removal, or None for any") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return _res(mut.remove_nondet(d.project, self.method, name=self.name, contract=self.contract)) + + +class RemoveModelConstant(_Tool): + """Remove a model constant/ghost YOU added with add_model_constant — the inverse. Removes the whole + declaration (use this, not remove_model_ghost_axiom, to fully retract a constant — the latter only + strips the axiom and leaves a bare, possibly name-colliding ghost). Refuses the template's observable + ghosts.""" + name: str = Field(description="the constant/ghost name to remove (as given to add_model_constant)") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return _res(mut.remove_model_constant(d.project, name=self.name)) + + +class RemoveModelFunction(_Tool): + """Remove a model helper function YOU added with add_model_function — the inverse. Refuses the + template functions (per-binding readers and the CVL bodies).""" + name: str = Field(description="the model function name to remove (as given to add_model_function)") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return _res(mut.remove_model_function(d.project, name=self.name)) + + +# ---------------------------------------------------------------- inspection (no mutation) +class CheckConsistency(_Tool): + """Coherence check (no prover run): the discipline linter passes, each CVL is present, and — when + a full typechecker is bound — the whole bundle passes the REAL certoraRun CVL typecheck (catches + assign-after-access, mathint-cast, type errors the fast structural check misses). Returns the list of + problems (empty == consistent). CALL THIS before `result`: `result` should only be called when this + says consistent. Reachable invariants show as 'pending proof' (expected — the prover discharges them + later); that is NOT a problem to fix.""" + + @override + async def run(self) -> str: + with self.tool_deps() as d: + problems = d.project.check_consistency() + # H2 'assumed but not proven' is expected during fill (proof happens in the loop, not via a + # tool) — don't treat it as a blocking problem or let it gate the (expensive) full typecheck. + structural = [p for p in problems if "not proven" not in p] + pending = len(problems) - len(structural) + if structural: + return "problems:\n- " + "\n- ".join(structural) + if d.full_typecheck is not None: # the REAL typecheck (writes + certoraRun) + res = d.full_typecheck() + ok, diag = await res if asyncio.iscoroutine(res) else res + if not ok: + return "typecheck failed — fix the CVL at the reported file:line:\n" + diag + note = ("\n(note: reachable invariant(s) pending proof — the prover will discharge them; " + "not a problem to fix)" if pending else "") + return "consistent" + note + + +class Verify(_Tool): + """Run the PROVER on the current model (the real trust anchor, cloud, minutes/call): proves the + shared reachable invariants, then verifies every method's conformance conf. Returns either an + ALL-VERIFIED message or the failing rules with their COUNTEREXAMPLES (concrete inputs) / TIMEOUTs / + dropped invariants. Call it once check_consistency is clean; FIX what it reports (per section 3 for + timeouts) and call verify AGAIN; only call `result` after verify reports ALL methods verified.""" + + @override + async def run(self) -> str: + with self.tool_deps() as d: + if d.verify is None: + return "verify is not available in this run" + return await d.verify() + + +class RenderModel(_Tool): + """Return the current shared model spec as CVL text — inspect the ghosts/readers/CVL bodies. + A re-render with no change since your last one comes back as UNCHANGED (or a diff), to save context; + it is not lost. Set `full=true` to force the complete spec when you need the whole picture again.""" + full: bool = Field(default=False, + description="force the COMPLETE spec instead of a diff/UNCHANGED (e.g. to re-read it in full)") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return _render_dedup(d.project, "model", d.project.render_model(), force_full=self.full) + + +class RenderConformance(_Tool): + """Return a method's current conformance spec as CVL text — inspect its glue + rules. A re-render with + no change since your last one comes back as UNCHANGED (or a diff), to save context; it is not lost. + Set `full=true` to force the complete spec when you need the whole picture again.""" + method: str = Field(description="the method whose conformance spec to render") + full: bool = Field(default=False, + description="force the COMPLETE spec instead of a diff/UNCHANGED (e.g. to re-read it in full)") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + return _render_dedup(d.project, f"conformance:{self.method}", + d.project.render_conformance(self.method), force_full=self.full) + + +class GetFunction(_Tool): + """Read a real Solidity function's exact BODY + the in-tree functions it calls, from the compiled + AST — PRECISE (overloads / inheritance / library calls resolve by node id, not a text guess) and + cheap. PREFER this over grep_files / get_file for reading a method you model or a helper it calls; + `get_function` a listed callee to expand the math one hop at a time. Returns a 'not found' note when + the AST has no such function (a non-function target, or no AST built) — then fall back to get_file.""" + name: str = Field(description="the Solidity function name to read, e.g. a modeled method or a math helper it calls") + + @override + async def run(self) -> str: + with self.tool_deps() as d: + if d.sources_root is None: + return "get_function unavailable here (no scene root) — use grep_files / get_file instead." + from .. import ast_source + try: + out = ast_source.function_source(d.sources_root, self.name) + except Exception as e: + return f"get_function failed ({type(e).__name__}: {e}) — fall back to grep_files / get_file." + return out or (f"no AST definition of '{self.name}' found — it may be a state variable, a name " + f"the AST indexes differently, or absent. Fall back to grep_files / get_file.") + + +# ---------------------------------------------------------------- assembly +_TOOLS: list[tuple[type[_Tool], str]] = [ + (AddModelConstant, "add_model_constant"), + (AddModelFunction, "add_model_function"), + (SetModelMethodBody, "set_model_method_body"), + (AddModelGhostAxiom, "add_model_ghost_axiom"), + (AddRequireInvariant, "add_require_invariant"), + (AddGluePin, "add_glue_pin"), + (AddNondet, "add_nondet"), + (AddHelperLemma, "add_helper_lemma"), + (RemoveHelperLemma, "remove_helper_lemma"), + (RemoveNondet, "remove_nondet"), + (RemoveModelGhostAxiom, "remove_model_ghost_axiom"), + (RemoveModelConstant, "remove_model_constant"), + (RemoveModelFunction, "remove_model_function"), + (CheckConsistency, "check_consistency"), + (RenderModel, "render_model"), + (RenderConformance, "render_conformance"), + (GetFunction, "get_function"), +] + + +def smtool_tools(deps: SmtoolDeps) -> list[BaseTool]: + """The smtool tool set, each bound to `deps` (the Project). Hand these to the agent loop. + The `verify` tool (run the prover) is included only when a verify closure is bound (the refine + runner sets it) — composer-style: the agent drives fill→verify→refine in ONE conversation, so + prover feedback arrives inline as a tool result. CUT source-access tools (grep/read) are supplied + separately as `extra_tools` (composer's fs_tools).""" + tools = [cls.bind(deps).as_tool(name) for cls, name in _TOOLS] + if deps.verify is not None: + tools.append(Verify.bind(deps).as_tool("verify")) + return tools diff --git a/smtool/ast_source.py b/smtool/ast_source.py new file mode 100644 index 00000000..8afcf7e4 --- /dev/null +++ b/smtool/ast_source.py @@ -0,0 +1,142 @@ +"""AST-based source extraction for the optional prefetch (smtool.agent.refine._reference_source). + +Reads the solc `.asts.json` — a byproduct of the `--compilation_steps_only --dump_asts` build (the same +compile that yields `.certora_build.json`; smtool.scene passes the flag) — and returns, for each model +method, its Solidity body plus the TRANSITIVE bodies of the in-tree functions it calls. Precise where the +text slice is heuristic: bodies come from each `FunctionDefinition` node's `src` byte-range, and callees +from `FunctionCall.expression.referencedDeclaration` (an exact node id), so overloads / inheritance / +library `using-for` resolve correctly and the closure is complete, not one-hop-by-name-guess. + +Schema (verified): `.asts.json` = dict[compilationUnit][file][nodeId] = node; a FunctionDefinition has +`nodeType`, `name`, `id`, `src="start:len:fileIdx"` (BYTE offsets), `kind`, `visibility`, `implemented`; +a FunctionCall's callee is `node["expression"]["referencedDeclaration"]` (int id; negative == builtin). + +Memory: the file is many MB (GBs on big projects), so we NEVER json.load it — we stream one compilation +unit at a time (autosetup's `stream_ast_files`, ijson) and keep only a SLIM index (per function: name, +file, src, arity, visibility, implemented, callee-ids). Bodies are sliced from source on demand, not held. +Absent the file (or any hiccup), the caller falls back to the text extractor — nothing here is load-bearing +for correctness (the conformance proof stays the trust anchor); it only spends fewer agent turns. +""" +from pathlib import Path + +from certora_autosetup.utils.file_utils import stream_ast_files + + + +def find_asts(sources_root: str) -> Path | None: + """The newest `.asts.json` under the scene's build dirs, or None (then the caller text-falls-back).""" + cands = sorted(Path(sources_root, ".certora_internal").glob("*/.asts.json"), + key=lambda p: p.stat().st_mtime, reverse=True) + return cands[0] if cands else None + + +def _arity(node: dict) -> int: + return len((node.get("parameters") or {}).get("parameters") or []) + + +def _callee_ids(node: dict) -> list[int]: + """Every `referencedDeclaration` id reached by a FunctionCall in `node`'s subtree (user fns only — + negative ids are Solidity builtins like require/assert). Iterative to avoid deep recursion.""" + out: list[int] = [] + stack = [node] + while stack: + n = stack.pop() + if isinstance(n, dict): + if n.get("nodeType") == "FunctionCall": + rd = (n.get("expression") or {}).get("referencedDeclaration") + if isinstance(rd, int) and rd >= 0: + out.append(rd) + stack.extend(n.values()) + elif isinstance(n, list): + stack.extend(n) + return out + + +def _build_index(asts_path: Path) -> tuple[dict, dict]: + """ONE streaming pass -> (by_id, by_name). by_id[id] = slim record; by_name[name] = [ids]. Peak + memory is a single compilation unit (via stream_ast_files), not the whole file.""" + by_id: dict[int, dict] = {} + by_name: dict[str, list] = {} + for _rel, absmap in stream_ast_files(asts_path): + if not isinstance(absmap, dict): + continue + for file, nodes in absmap.items(): + if not isinstance(nodes, dict): + continue + for node in nodes.values(): + if not (isinstance(node, dict) and node.get("nodeType") == "FunctionDefinition" + and isinstance(node.get("id"), int) and node.get("name")): + continue + nid = node["id"] + if nid in by_id: # same node can recur across compilation units + continue + by_id[nid] = { + "name": node["name"], "file": file, "src": node.get("src"), + "arity": _arity(node), "vis": node.get("visibility"), + "impl": bool(node.get("implemented")), "callees": _callee_ids(node), + } + by_name.setdefault(node["name"], []).append(nid) + return by_id, by_name + + +def _slice(sources_root: str, file: str, src: str | None) -> str | None: + """The source text of a node's `src` byte-range, read from `file` (absolute, or under sources_root).""" + try: + start, length, _ = (int(x) for x in (src or "").split(":")) + except ValueError: + return None + p = Path(file) if Path(file).is_absolute() else Path(sources_root, file) + try: + return p.read_bytes()[start:start + length].decode("utf-8", "replace") + except OSError: + return None + + +_INDEX_CACHE: dict[str, tuple] = {} # asts_path -> (mtime, by_id, by_name); one streaming pass, reused + + +def _cached_index(asts_path: Path) -> tuple[dict, dict]: + """`_build_index` is a full stream of the (many-MB) file — cache it per asts file+mtime so the + on-demand get_function tool is cheap after the first call.""" + key, mt = str(asts_path), asts_path.stat().st_mtime + hit = _INDEX_CACHE.get(key) + if hit and hit[0] == mt: + return hit[1], hit[2] + by_id, by_name = _build_index(asts_path) + _INDEX_CACHE[key] = (mt, by_id, by_name) + return by_id, by_name + + +def function_source(sources_root: str, name: str, *, max_chars: int = 8_000, max_defs: int = 4) -> str | None: + """The Solidity body(ies) of `name` + the in-tree functions each calls, sliced exactly from the AST. + On-demand version of the prefetch: precise (overloads/inheritance resolve via node ids, not regex) and + lazy (the agent pulls one function at a time). Returns None if there is no AST or no such function — + the caller then falls back to grep_files/get_file. Prefers IMPLEMENTED defs; caps count + size.""" + asts_path = find_asts(sources_root) + if asts_path is None: + return None + by_id, by_name = _cached_index(asts_path) + ids = by_name.get(name) + if not ids: + return None + recs = [by_id[i] for i in ids] + recs = [r for r in recs if r["impl"]] or recs # prefer real bodies over interface decls + out, total = [], 0 + for rec in recs[:max_defs]: + body = _slice(sources_root, rec["file"], rec["src"]) + if not body: + continue + callees = sorted({f"{by_id[c]['name']} ({by_id[c]['file']})" for c in rec["callees"] if c in by_id}) + sec = f"### {name} ({rec['file']})\n{body}\n" + if callees: + sec += f"// calls (in-tree): {', '.join(callees[:20])} [get_function any of these to expand]\n" + out.append(sec) + total += len(sec) + if total > max_chars: + out.append(f"### … more definitions of {name} omitted (budget) — grep_files to see them.\n") + break + if len(recs) > max_defs: + out.append(f"### note: {len(recs)} definitions of `{name}` exist; showing {max_defs}.\n") + return "\n".join(out) if out else None + + diff --git a/smtool/cex.py b/smtool/cex.py new file mode 100644 index 00000000..183cc42b --- /dev/null +++ b/smtool/cex.py @@ -0,0 +1,60 @@ +"""Fetch a prover job's counterexamples WITHOUT downloading the whole zipOutput. + +The merger the refine loop needs = POU's targeted fetch + composer's formatter: +- FETCH via ProverOutputUtility: `get_all_checks` enumerates the violated leaves (each carries its + `output_files`), and `get_calltrace_for_violation` pulls just that leaf's callTrace JSON over the + tree-view HTTP endpoint — no tarball, no lossy whole-report parse. +- FORMAT via composer: `calltrace_to_xml(CallTraceModel.model_validate(trace["callTrace"]))` renders the + `` with CONCRETE argument values and noise nodes stripped (e.g. + `glue(id='10001', amount='3948', to='0x2715')`, `mReader(id) ↪ '0x109c39...'`). + +This is what turns "returns must agree" into an actionable CEX the fill agent can fix. Composer's own +path (`composer/prover/cloud.cloud_results`) downloads + extracts the full output tarball; we avoid that, +keeping smtool's zero-tarball footprint (verify.py already reads results treeView-only). Best-effort: any +failure (missing POU / auth / network / schema drift) returns {}, and the loop still refines on the +assert messages. +""" +from __future__ import annotations + +_MAX_CEX_CHARS = 6000 # a call trace is small, but cap so one huge CEX can't blow up the refine prompt + + +def fetch_cex(job_url: str, rules: set[str] | None = None) -> dict[str, str]: + """Return {label: counterexample_xml} for the VIOLATED leaves of `job_url` (all, or only those whose + rule name is in `rules`). `label` is ": ". Fetches only the needed callTrace + files via POU (no zipOutput) and formats via composer. Returns {} on any error.""" + if not job_url: + return {} + try: + from prover_output_utility import ProverOutputAPI + from composer.prover.results import calltrace_to_xml, CallTraceModel + except Exception: + return {} + out: dict[str, str] = {} + try: + api = ProverOutputAPI(use_local=False) + seen: set[tuple] = set() + for c in api.get_all_checks(job_url): + # only violated LEAVES carry a counterexample (output_files); parent nodes have none + if not (c.is_violated and c.output_files): + continue + if rules is not None and c.rule_name not in rules: + continue + key = (c.rule_name, c.output_files[0]) + if key in seen: + continue + seen.add(key) + try: + trace = api.get_calltrace_for_violation(job_url, c).trace_data + node = trace.get("callTrace") if isinstance(trace, dict) else None + if node is None: + continue + xml = "" + calltrace_to_xml(CallTraceModel.model_validate(node)) \ + + "" + except Exception: + continue + label = f"{c.rule_name}: {c.assert_message}" if c.assert_message else c.rule_name + out[label] = xml if len(xml) <= _MAX_CEX_CHARS else xml[:_MAX_CEX_CHARS] + "…(truncated)" + except Exception: + return out + return out diff --git a/smtool/classify.py b/smtool/classify.py new file mode 100644 index 00000000..b4589086 --- /dev/null +++ b/smtool/classify.py @@ -0,0 +1,77 @@ +"""Step 0: classify the function list into MODEL / OBS, and derive default bindings. + +MODEL = state-changing methods -> each gets an CVL + a conformance pair. +OBS = view/pure getters -> each defines a pi element (ghost + glue equality). +HARNESS = requested getters for quantities a MODEL method needs but OBS doesn't cover + (the sufficiency requirement); surfaced, not auto-created. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from .ir import Binding, FunctionSpec + + +@dataclass +class ModelLayout: + """How an input function list is organized for modeling (the output of `classify`): + `model` = the state-changing methods to model, `getters` = all view/pure getters (declared by the + template), `bindings` = the observable→ghost correspondences derived for the observable getters.""" + model: list[FunctionSpec] + getters: list[FunctionSpec] + bindings: list[Binding] + + +def default_ghost_name(getter_name: str) -> str: + """Uniform: keep the Solidity name + `CVL`. + getFoo -> getFooCVL ; balanceOf -> balanceOfCVL. (cosmetic; overridable)""" + return getter_name + "CVL" + + +def default_reader_name(getter_name: str) -> str: + """The model reader function — the Solidity name + `CVLReader`, distinct from the ghost (`CVL`) + and the real getter. getFoo -> getFooCVLReader ; balanceOf -> balanceOfCVLReader.""" + return getter_name + "CVLReader" + + +def binding_for(getter: FunctionSpec) -> Binding: + """Derive the model Binding for an observable getter: the ghost/reader names (overridable defaults), + the mapping key types (= the getter's param types), and the tracked value type (= the single return, + or the `bind_component`-th return for a multi-return getter). Raises if a multi-return getter didn't + say which component the ghost tracks.""" + if len(getter.returns) == 1: + val_type = getter.returns[0] + base = getter.name + else: + if getter.bind_component is None: + raise ValueError( + f"getter {getter.name} has {len(getter.returns)} returns; set " + f"bind_component to say which one the ghost tracks.") + val_type = getter.returns[getter.bind_component] + # Multi-return: each component gets its OWN ghost + reader, so their default names MUST + # disambiguate by component — else both bindings collapse onto `CVL` and the model + # spec redeclares the ghost / overloads the reader (a typecheck error the agent can't fix). + # Prefer the caller's component_names label; fall back to the component index. + cn = getter.component_names + comp = getter.bind_component + suffix = cn[comp] if (cn and comp < len(cn)) else str(comp) + base = f"{getter.name}_{suffix}" + return Binding( + getter=getter, + ghost_name=getter.ghost_name or default_ghost_name(base), + reader_name=getter.reader_name or default_reader_name(base), + key_types=[p.type for p in getter.params], + val_type=val_type, + ) + + +def classify(functions: list[FunctionSpec]) -> ModelLayout: + """Split the input functions into MODEL (state-changing → each gets an CVL + conformance) and + getters (view/pure), and derive a Binding for each OBSERVABLE getter. All view getters are declared + by the template; only observable ones become model ghosts.""" + model = [f for f in functions if f.is_model_method] + # a view flagged model=True is a return-only MODEL method (a computed view), not a ghost observable + getters = [f for f in functions if f.is_getter and not f.model] + # all (non-model) view getters are DECLARED by the template; only observable ones are modeled as ghosts + bindings = [binding_for(g) for g in getters if g.observable] + return ModelLayout(model=model, getters=getters, bindings=bindings) diff --git a/smtool/cvl_parse.py b/smtool/cvl_parse.py new file mode 100644 index 00000000..2cfee232 --- /dev/null +++ b/smtool/cvl_parse.py @@ -0,0 +1,184 @@ +"""Piece B: parse CVL surface TEXT into `composer.cvl.schema` AST, so the fill agent can emit natural +CVL (small tool schemas) instead of AST-as-JSON (~75k tokens/turn — impractical). + +We don't write a CVL parser: `ASTExtraction.jar syntax-check --raw` (via +`certora_autosetup...summary_resolver.extract_cvl_ast`) already parses CVL text to JSON — but in the +Prover's verbose `spec.cvlast` dialect (FQN discriminators + range/scope/tag metadata), NOT +`composer.cvl.schema`. This module is the DIALECT TRANSLATION: `spec.cvlast`-JSON -> composer schema, +bounded to the node types model bodies/expressions use. `--raw` is syntax-only (tolerates free +identifiers), so we wrap fragments in a stub function and pull the piece back out. + +Public: `parse_expression(text) -> S.Expression`, `parse_commands(text) -> list[S.Command]`. +""" +from __future__ import annotations + +import composer.cvl.schema as S +from . import cvlx as x +from certora_autosetup.setup.summary_resolver import extract_cvl_ast + + +class CVLParseError(ValueError): + pass + + +def _disc(j: dict) -> str: + """Last segment of the node's FQN discriminator (e.g. 'AddExp', 'Definition', 'UIntK').""" + return (j.get("cmd_type") or j.get("type") or "").split(".")[-1] + + +# spec.cvlast binary-op node -> composer operator string (as cvlx/pretty_print use) +_BINOP = { + "AddExp": "add", "SubExp": "sub", "MulExp": "mul", "DivExp": "div", "ModExp": "mod", + "ExponentExp": "exponent", "PowExp": "exponent", + "LandExp": "and", "LorExp": "or", "ImpliesExp": "implies", "IffExp": "iff", + "EqExp": "eq", "NeExp": "ne", "GtExp": "gt", "GeExp": "ge", "LtExp": "lt", "LeExp": "le", + "BwLeftShiftExp": "bw_lshift", "BwRightShiftExp": "bw_rshift", "BwAndExp": "bw_and", + "BwOrExp": "bw_or", "BwXOrExp": "bw_xor", +} + + +def _msg(d) -> str: + """A CVL command's description field -> plain message (the jar keeps the surrounding quotes).""" + if not d: + return "" + d = str(d) + return d[1:-1] if len(d) >= 2 and d[0] == d[-1] == '"' else d + + +def _type(j: dict) -> str: + """spec.cvlast PureCVLType -> CVL type-name string.""" + t = _disc(j) + if t == "UIntK": + return f"uint{j.get('k', 256)}" + if t == "IntK": + return f"int{j.get('k', 256)}" + if t == "BytesK": + return f"bytes{j.get('k')}" + if t == "Bool": + return "bool" + if t == "AccountIdentifier": + return "address" + if t == "Mathint": + return "mathint" + return j.get("name") or t # user-defined / fallback + + +def _expr(j: dict) -> "S.Expression": + t = _disc(j) + if t == "VariableExp": + return x.ident(j["id"]) + if t == "NumberLit": + return x.num(int(str(j["n"]), 16)) # the jar serializes NumberLit.n as unprefixed HEX (10 -> "a") + if t == "BoolLit": + return x.boollit(j["b"]) + if t == "LNotExp": + return x.unop_not(_expr(j["e"])) + if t == "CondExp": + return x.cond(_expr(j["c"]), _expr(j["e1"]), _expr(j["e2"])) + if t == "ArrayDerefExp": + return x.idx(_expr(j["array"]), _expr(j["index"])) + if t == "FieldSelectExp": # struct/env field access: premiumDelta.sharesDelta, e.msg.sender + return x.field(_expr(j["structExp"]), j["fieldName"]) + if t == "CastExpr": # to_mathint(...) / require_uint256(...) / assert_uint256(...) + # castType is TO (safe/widening: to_mathint, to_bytesN) | REQUIRE | ASSERT — one cast-fn family + # each. Mapping TO to `require_` (the old default) corrupts the round-trip (require_bytes31 / + # require_mathint are not CVL functions), so decode the kind explicitly. + ct = str(j.get("castType", "")).upper() + cast = {"TO": "to", "ASSERT": "assert"}.get(ct, "require") + return x.call(f"{cast}_{_type(j['toCastType'])}", [_expr(j["arg"])]) + if t == "UnresolvedApplyExp": # a CVL function call: methodId(args...) + base = j.get("base") + host = base["id"] if isinstance(base, dict) and _disc(base) == "VariableExp" else None + return x.call(j["methodId"], [_expr(a) for a in j.get("args", [])], host=host) + if t in _BINOP: + return x.binop(_BINOP[t], _expr(j["l"]), _expr(j["r"])) + raise CVLParseError(f"unhandled expression node: {j.get('type')}") + + +def _lhs(j: dict): + t = _disc(j) + if t == "Id": + return S.IdLhs(type="id", name=j["id"]) + if t == "Array": + return S.ArrayAccessLhs(type="array_access", base=_lhs(j["innerLhs"]), index=_expr(j["index"])) + raise CVLParseError(f"unhandled LHS node: {j.get('type')}") + + +def _block(j) -> list: + """An if/else BRANCH -> list of decoded commands. The jar emits a braced branch as a + Composite.Block `{block:[...]}`, but a BRACE-LESS single statement (`if (c) revert();`) as the bare + command node itself (no `block` key). Handle both, else the brace-less body is silently dropped + (a no-op guard — e.g. `if (b==0) revert();` becoming `if (b==0) {}`, which caused div-by-zero).""" + if not j: + return [] + if "block" in j: + return [_cmd(c) for c in j["block"] if _disc(c) != "Nop"] + if _disc(j) == "Nop": # the jar's sentinel for an absent else / empty branch + return [] + if j.get("cmd_type") or j.get("type"): # a bare single-statement branch + return [_cmd(j)] + return [] + + +def _cmd(j: dict) -> "S.Command": + c = _disc(j) + if c == "Definition": + exp = _expr(j["exp"]) + if isinstance(j.get("type"), dict) and j["type"] and len(j["idL"]) == 1 and _disc(j["idL"][0]) == "Id": + return x.declare(_type(j["type"]), j["idL"][0]["id"], exp) # declaration with init + return S.AssignmentCmd(type="assignment", # (re)assignment, maybe indexed/multi + left_hand_sides=[_lhs(l) for l in j["idL"]], expression=exp) + if c == "Declaration": + return x.declare(_type(j["cvlType"]), j["id"]) + if c == "Assignment": + return S.AssignmentCmd(type="assignment", + left_hand_sides=[_lhs(l) for l in j["idL"]], expression=_expr(j["exp"])) + if c == "If": + return x.if_(_expr(j["cond"]), _block(j.get("thenCmd")), _block(j.get("elseCmd")) or None) + if c == "Return": + return x.ret([_expr(e) for e in j.get("exps", [])]) + if c == "Revert": + reason = j.get("reason") + return x.revert(reason if isinstance(reason, str) else None) + if c == "Assume": # `require(cond, "msg")` — parsed; the linter judges where it's legal + return x.require(_expr(j["exp"]), _msg(j.get("description"))) + if c == "Assert": # `assert cond, "msg";` + return x.assert_(_expr(j["exp"]), _msg(j.get("description"))) + if c in ("Apply", "ApplyCmd"): # bare call statement (side-effecting helper) + return x.apply(_expr(j["exp"])) + raise CVLParseError(f"unhandled command node: {j.get('cmd_type')}") + + +def _parse(frag: str): + root = extract_cvl_ast(frag) + if root is None: + raise CVLParseError(f"jar rejected fragment (syntax error):\n{frag}") + subs = root["ast"]["subs"] + if not subs: + raise CVLParseError(f"no function parsed from:\n{frag}") + return subs[0]["block"] + + +def _decode(fn): + """Run a decode, converting any decoder/validation error into a CVLParseError (so tools reject + cleanly instead of crashing).""" + try: + return fn() + except CVLParseError: + raise + except Exception as e: # e.g. a pydantic ValidationError on a bad node + raise CVLParseError(f"could not decode parsed CVL ({type(e).__name__}: {e})") + + +def parse_commands(text: str, params: list[tuple[str, str]] = ()) -> list["S.Command"]: + """Parse a CVL statement sequence (a function body) into composer Command AST. `params` declares + the free params the body references so names are in scope (types are for the wrapper only).""" + ps = ", ".join(f"{t} {n}" for t, n in params) + block = _parse(f"function _b({ps}) {{ {text} }}") + return _decode(lambda: [_cmd(c) for c in block]) + + +def parse_expression(text: str) -> "S.Expression": + """Parse a single CVL expression into composer Expression AST (free identifiers OK).""" + block = _parse(f"function _e() returns uint256 {{ return {text}; }}") + return _decode(lambda: _expr(block[-1]["exps"][0])) diff --git a/smtool/cvlx.py b/smtool/cvlx.py new file mode 100644 index 00000000..44a66ef1 --- /dev/null +++ b/smtool/cvlx.py @@ -0,0 +1,353 @@ +"""Thin ergonomic builders over composer.cvl.schema — a code-authoring DSL for CVL. + +WHY this exists: composer.cvl.schema is an interchange/validation target for LLM-emitted CVL (a whole +AST as JSON via put_cvl, or surface text via put_cvl_raw) — nobody there assembles the tree node by +node, so composer never needed ergonomic constructors. smtool is the opposite: it GENERATES CVL in +code (the driver + recon agent-sims build specific AST), and doing that against the raw schema is +painful (verbose `type=`/discriminator boilerplate, many required fields, positional-`Field` gotchas). +These helpers smooth that. (Upstream-worthy: composer could adopt this as its blessed builder layer.) + +Every helper returns a schema object; assemble them into a CVLFile and render with +composer.cvl.pretty_print.pretty_print. Validated against smoke_ast.py. +""" +from __future__ import annotations + +import composer.cvl.schema as S + +# ---------------------------------------------------------------- types +SPECIAL_TYPES = {"mathint", "env", "method", "calldataarg"} + + +def prim(name: str) -> S.PrimitiveType: + return S.PrimitiveType(type="primitive", type_name=name) + + +def special(name: str) -> S.SpecialType: + return S.SpecialType(type="special", type_name=name) + + +def mapping(key: str, val) -> S.MappingType: + kt = prim(key) if isinstance(key, str) else key + vt = prim(val) if isinstance(val, str) else val + return S.MappingType(type="mapping", key_type=kt, value_type=vt) + + +def contract_type(host: str, type_name: str) -> S.ContractType: + return S.ContractType(type="contract_type", host_contract=host, type_name=type_name) + + +def ty(name_or_type): + if not isinstance(name_or_type, str): + return name_or_type + if name_or_type.endswith("[]"): # dynamic array T[] (e.g. uint256[], Token.Id[]) + return S.ArrayType(type="dyn_array", base_type=ty(name_or_type[:-2])) + if name_or_type in SPECIAL_TYPES: + return special(name_or_type) + if "." in name_or_type: # a contract/struct type, e.g. IFoo.Bar + host, tn = name_or_type.split(".", 1) + return contract_type(host, tn) + return prim(name_or_type) + + +def tid(type_name: str, name: str) -> S.TypeAndId: + return S.TypeAndId(decl_type=ty(type_name), id=name) + + +def vmparam(type_name: str, name: str | None = None, location=None) -> S.VMParam: + return S.VMParam(ty=S.VMType(base_type=ty(type_name), location=location), name=name) + + +# ---------------------------------------------------------------- expressions +def ident(name: str) -> S.Identifier: + return S.Identifier(type="identifier", name=name) + + +def num(v) -> S.NumberLiteral: + return S.NumberLiteral(type="number_literal", value=str(v)) + + +def boollit(b: bool) -> S.BoolLiteral: + return S.BoolLiteral(type="bool_literal", value=b) + + +def idx(base, index) -> S.ArrayAccess: + """`base[index]` — an array-access expression over PREBUILT sub-expressions (base, index).""" + return S.ArrayAccess(type="array_access", base=base, index=index) + + +def index(base, i) -> S.ArrayAccess: + """`base[i]` — array access with COERCION: `base`/`i` may be strings (idents), ints (number + literals), or prebuilt expressions. A convenience over `idx` for the common `arr[k]` element case.""" + b = ident(base) if isinstance(base, str) else base + ie = num(i) if isinstance(i, int) else (ident(i) if isinstance(i, str) else i) + return idx(b, ie) + + +def length(base): + """`base.length` — the array length (a field-access).""" + return field(ident(base) if isinstance(base, str) else base, "length") + + +def binop(op: str, l, r) -> S.BinaryOp: + return S.BinaryOp(type="binary_op", operator=op, left=l, right=r) + + +def unop(op: str, operand) -> S.UnaryOp: + return S.UnaryOp(type="unary_op", operator=op, operand=operand) + + +def unop_not(operand) -> S.UnaryOp: + return unop("not", operand) + + +def field(base, field_name: str) -> S.FieldAccess: + """A field-selection expression `base.field_name` (e.g. `e.msg.sender` nests two).""" + return S.FieldAccess(type="field_access", base=base, field_name=field_name) + + +def call(name: str, params=(), host: str | None = None, annotation=None, state=None) -> S.FunctionCall: + return S.FunctionCall( + type="function_call", + application=S.FunctionApplication( + annotation=annotation, name=name, host_contract=host, + params=list(params), state=state, + ), + ) + + +def forall(var_type: str, var: str, body) -> S.QuantifierExp: + return S.QuantifierExp(type="quantifier", is_forall=True, variable=tid(var_type, var), body=body) + + +def cond(c, then_e, else_e) -> S.ConditionalExp: + return S.ConditionalExp(type="conditional", condition=c, then_expr=then_e, else_expr=else_e) + + +def forall(var_type: str, var_name: str, body) -> S.QuantifierExp: + """`forall . `. Nest calls for multiple binders. Used to state ghost + axioms (e.g. monotonicity of a memo ghost over its key components).""" + return S.QuantifierExp(type="quantifier", is_forall=True, + variable=S.TypeAndId(decl_type=ty(var_type), id=var_name), body=body) + + +# ---------------------------------------------------------------- commands +def declare(type_name: str, name: str, init=None) -> S.DeclarationCmd: + return S.DeclarationCmd(type="declaration", variable=tid(type_name, name), initial_value=init) + + +def assign(name: str, expr) -> S.AssignmentCmd: + return S.AssignmentCmd(type="assignment", + left_hand_sides=[S.IdLhs(type="id", name=name)], expression=expr) + + +def assign_multi(names: list[str], expr) -> S.AssignmentCmd: + return S.AssignmentCmd(type="assignment", + left_hand_sides=[S.IdLhs(type="id", name=n) for n in names], expression=expr) + + +def assign_index(name: str, indices: list, expr) -> S.AssignmentCmd: + """`name[i0][i1]... = expr;` (nested map/array store).""" + lhs = S.IdLhs(type="id", name=name) + for ix in indices: + lhs = S.ArrayAccessLhs(type="array_access", base=lhs, index=ix) + return S.AssignmentCmd(type="assignment", left_hand_sides=[lhs], expression=expr) + + +def _msg(m: str | None) -> str | None: + """Sanitize a CVL command message (a human-readable label). pretty_print emits it inside double + quotes with NO escaping, so a `"`/backslash/newline in the message produces an invalid spec that + only the prover rejects (cryptically). Messages carry no semantics, so we neutralize those chars + rather than escape them — this is the one free-text field an agent can supply that bypasses the + cvl_parse validator.""" + if not m: + return m + return m.replace("\\", "/").replace('"', "'").replace("\n", " ").replace("\r", " ") + + +def require(expr, message: str) -> S.AssumeCmd: + return S.AssumeCmd(type="assume", expression=expr, message=_msg(message)) + + +def require_invariant(name: str, args=()) -> S.AssumeInvariantCmd: + return S.AssumeInvariantCmd(type="assume_invariant", invariant_name=name, arguments=list(args)) + + +def assert_(expr, message: str) -> S.AssertCmd: + return S.AssertCmd(type="assert", expression=expr, message=_msg(message)) + + +def ret(values=()) -> S.ReturnCmd: + return S.ReturnCmd(type="return", values=list(values)) + + +def revert(message: str | None = None) -> S.RevertCmd: + return S.RevertCmd(type="revert", message=_msg(message)) + + +def apply(fn_call: S.FunctionCall) -> S.ApplyCmd: + return S.ApplyCmd(type="apply", target=fn_call.application) + + +def if_(cond, then_cmds, else_cmds=None) -> S.IfCmd: + else_block = None + if else_cmds is not None: + else_block = S.Else(type="else", commands=list(else_cmds)) + return S.IfCmd(type="if", condition=cond, then_cmd=list(then_cmds), else_block=else_block) + + +def havoc(targets: list, assumption=None) -> S.HavocCmd: + """`havoc t0, t1, ... [assuming ];` — targets are ghost names (str) or LHS/expr nodes. + The assumption is the frame condition (e.g. forall over untouched keys) using `@new`/`@old`.""" + lhs = [S.IdLhs(type="id", name=t) if isinstance(t, str) else t for t in targets] + return S.HavocCmd(type="havoc", targets=lhs, assumption=assumption) + + +def block(commands) -> S.CodeBlock: + return S.CodeBlock(commands=list(commands)) + + +# ---------------------------------------------------------------- top-level blocks +def ghost_mapping(name: str, key: str, val: str, *, persistent=True, axioms=()) -> S.GhostDef: + return S.GhostDef( + type="ghost_def", ghost_name=name, persistent=persistent, + ghost_type=S.GhostVariable(type="ghost_type", base_type=mapping(key, val)), + axioms=list(axioms), + ) + + +def ghost_scalar(name: str, val_type: str, *, persistent=True, axioms=()) -> S.GhostDef: + return S.GhostDef( + type="ghost_def", ghost_name=name, persistent=persistent, + ghost_type=S.GhostVariable(type="ghost_type", base_type=prim(val_type)), + axioms=list(axioms), + ) + + +def ghost_fn(name: str, param_types, ret: str, *, persistent=True, axioms=()) -> S.GhostDef: + """A ghost FUNCTION `[persistent] ghost name() returns ret;` (optionally with axioms). + Keyed on value types (like the model's ghosts — `driver._nested_ghost`; key = param types, nothing to + resolve). Used by the deterministic-memo summary (detsummary): a read-only ghost function IS the memo + — same key, same value — and persistent so a havoc/revert can't re-havoc it mid-proof.""" + return S.GhostDef( + type="ghost_def", ghost_name=name, persistent=persistent, + ghost_type=S.GhostFunction(type="ghost_fun", params=[ty(t) for t in param_types], + result_type=ty(ret)), + axioms=list(axioms), + ) + + +def axiom(exp, *, initial=False) -> S.GhostAxiom: + return S.GhostAxiom(initial=initial, exp=exp) + + +def func(name: str, params, returns, commands) -> S.FunctionDef: + """params: list[(type,name)]; returns: list[type-name]; commands: list[Command]""" + return S.FunctionDef( + type="func_def", name=name, + params=[tid(t, n) for t, n in params], + return_value=[ty(t) for t in returns], + block=block(commands), + ) + + +def rule(name: str, params, commands, filtered=None) -> S.RuleBlock: + return S.RuleBlock( + type="rule", rule_name=name, + rule_params=[tid(t, n) for t, n in params], + filtered_block=filtered, block=block(commands), + ) + + +def invariant(name: str, params, expr, *, filter=None, proofs=()) -> S.Invariant: + return S.Invariant( + type="invariant", name=name, + invariant_params=[tid(t, n) for t, n in params], + invariant_expression=expr, filter=filter, proofs=list(proofs), + ) + + +def preserved(commands, *, with_env: str = "e1") -> S.ProofBlock: + """A generic `preserved with (env ) { }` proof block for an invariant — the + idiom that RELATES the transition's env to the invariant's env `e` (e.g. + `require .block.timestamp <= e.block.timestamp`), which is what makes a time-dependent + invariant provable (see the FV corpus). `commands` reference both `e` and ``.""" + return S.ProofBlock( + target=S.GenericTarget(type="generic"), + block=S.CodeBlock(commands=list(commands)), + with_binding=S.WithBlock(id=with_env), + ) + + +def methods_block(entries) -> S.MethodsBlock: + return S.MethodsBlock(type="methods_block", method_entries=list(entries)) + + +def use_invariant(name: str) -> S.UseDirective: + """`use invariant ;` — include an imported invariant in this spec's verification.""" + return S.UseDirective(type="use_directive", use_kind="invariant", name=name) + + +def m_envfree(contract: str | None, name: str, param_types, return_types) -> S.ImportedFunction: + return S.ImportedFunction( + type="imported_function", + signature=S.MethodSignature( + method_ref=S.MethodReference(contract=contract, method_name=name), + parameters=[vmparam(t) for t in param_types], + return_types=[vmparam(t) for t in return_types], + visibility="external", post_flags=["envfree"], + ), + summary=None, with_env=None, + ) + + +def expect_types(type_names) -> S.ExpectType: + return S.ExpectType(type="type", + expected_types=[S.VMType(base_type=ty(t), location=None) for t in type_names]) + + +def m_expr_summary(contract: str | None, name: str, named_params, return_expect, + summary_call: S.FunctionCall, with_env: str | None = "e", + visibility="external") -> S.ImportedFunction: + """A methods{} expression-summary entry: + `function C.name() external returns () [with (env e)] => summary_call;` + Returns go in the SIGNATURE (not an `expect` clause) so the entry can MERGE with the target's + real scene body (installable over a PRESENT function, e.g. an over-approx of a CUT method). + named_params: list[(type, name)]; return_expect: list[type names].""" + # DETERMINISTIC from the signature arity: a single (or zero) return goes in the SIGNATURE (like + # m_nondet) so the entry MERGES with the target's present real body (installable over an over-approx + # of a CUT/library method); a multi-return uses the `expect (tuple)` form (a methods{} summary body + # cannot declare a tuple return in the signature). + single = len(return_expect) <= 1 + return S.ImportedFunction( + type="imported_function", + signature=S.MethodSignature( + method_ref=S.MethodReference(contract=contract, method_name=name), + parameters=[vmparam(t, n) for t, n in named_params], + return_types=[vmparam(t) for t in return_expect] if single else [], + visibility=visibility, post_flags=[]), + summary=S.ExpressionSummary(type="expression", expression=summary_call, + expect_clause=None if single else expect_types(return_expect)), + with_env=with_env, + ) + + +def m_nondet(contract: str | None, name: str, param_types, return_types, visibility="external") -> S.ImportedFunction: + return S.ImportedFunction( + type="imported_function", + signature=S.MethodSignature( + method_ref=S.MethodReference(contract=contract, method_name=name), + parameters=[vmparam(t) for t in param_types], + return_types=[vmparam(t) for t in return_types], + visibility=visibility, post_flags=[], + ), + summary=S.HavocingSummary(type="havocing", havoc_keyword="nondet"), + with_env=None, + ) + + +def spec_file(*, imports=(), contracts=(), blocks=()) -> S.CVLFile: + return S.CVLFile( + import_specs=[S.ImportSpec(spec_file=i) for i in imports], + import_contract=[S.ContractImport(contract_name=c, as_name=a) for c, a in contracts], + blocks=list(blocks), + ) diff --git a/smtool/demo/run_overapprox.py b/smtool/demo/run_overapprox.py new file mode 100644 index 00000000..e0e6c516 --- /dev/null +++ b/smtool/demo/run_overapprox.py @@ -0,0 +1,134 @@ +"""Generic over-approximation runner — author a SOUND over-approximating summary of a heavy Solidity +function, with an autogenerated conformance PROOF, for ANY contract-under-test. + +ONE file to edit and run. You provide: + + 1. The TARGETS block below — WHICH function(s) to summarize + the GOAL each summary must preserve + (edit `CUT` + `TARGETS`). The goal is the crux: it's what stops the agent settling on the trivial + `Phi = true`. Optionally point a target at a helper spec (`model_spec_import`) defining predicates + Phi uses (e.g. a `definition wellFormed(...)`). + 2. --setup-conf — a trusted setup .conf naming the CUT and its scene (files/solc/packages/links + + `verify: CUT:SetupSpec.spec`). sources_root + cut are read from it. + +The agent then writes Phi (grounding in the CUT source), and the loop proves each `overApprox_` rule +on the prover, WEAKENING Phi on a counterexample / simplifying on a timeout, until it holds as strong as +the goal allows. On success the installable `Summary.spec` is written into the sources tree. + +Run (from smtool/demo/): + PYTHONPATH=:. /.venv/bin/python run_overapprox.py \ + --setup-conf /path/to/MySetup.conf [--sources /path/to/.certora_sources] \ + [--targets my_targets] [--model sonnet] [--fill-steps 120] [--local] [--out result.txt] +""" +import argparse, asyncio, sys +from pathlib import Path + +from smtool.ir import Signature, Param as P +from smtool.overapprox import OverApproxTarget + +# ============================================================================================ +# EDIT THIS BLOCK for your contract-under-test. (Example: an integer sqrt library function.) +# ============================================================================================ +CUT = "MyMathLib" # the contract NAME == currentContract == the setup conf's `verify:` target + + +def TARGETS() -> list[OverApproxTarget]: + return [ + # DEFAULT (auto) mode: no goal — the agent produces the CLOSEST sound over-approximation CVL can + # express + the prover can discharge (reconstruct f as faithfully as possible, relax at the wall). + # This is the autosetup use: summarize a prover-hostile fn (assembly / deps / gas-opt) with no + # property in hand. + OverApproxTarget( + cut=CUT, # stamped from the setup conf by OverApproxProject.of anyway + sig=Signature(name="sqrt", params=[P("uint256", "x")], returns=["uint256"], + mutability="view", visibility="external"), + ), + # GOAL-TAILORED (override) mode: set `goal=...` to preserve a SPECIFIC property (only when known). + # A tag/flag example (bytesN UDVT result), pointing at a helper spec that defines the predicate: + # OverApproxTarget( + # cut=CUT, + # sig=Signature(name="computeId", params=[P("Item[]", "items")], + # returns=["Id"], mutability="view"), + # goal="the top byte (tag) of the returned Id equals the expected module constant.", + # model_spec_import="Phi_id.spec"), + ] +# ============================================================================================ + + +async def main(): + sys.stdout.reconfigure(line_buffering=True) + from langchain_anthropic import ChatAnthropic + from composer.spec.source.source_env import build_basic_source_tools + from smtool.setup import consume_setup + from smtool.overapprox_project import OverApproxProject + from smtool.agent.overapprox_refine import run_overapprox_loop, OverApproxRefineConfig + from run_agent import MODELS, StreamHandler, build_cvl_tools + + ap = argparse.ArgumentParser(description="Author + prove over-approx summaries for the TARGETS above.") + ap.add_argument("--setup-conf", required=True, help="trusted setup .conf naming the CUT + scene") + ap.add_argument("--targets", default=None, + help="OPTIONAL module exposing TARGETS()->list[OverApproxTarget] (and optional CUT), " + "instead of the in-file block") + ap.add_argument("--sources", default=None, help="CUT sources root (default: the setup's sources_root)") + ap.add_argument("--model", default="sonnet", choices=list(MODELS)) + ap.add_argument("--fill-steps", type=int, default=120, help="cap on the agent's tool-call recursion") + ap.add_argument("--max-verify-calls", type=int, default=6, + help="cap on PROVER runs (minutes each) — bounds tightening so it can't loop forever") + ap.add_argument("--local", action="store_true", help="LocalProverRunner instead of cloud") + ap.add_argument("--out", default="", help="dump the final Phi/summary/verdict here") + args = ap.parse_args() + + setup = consume_setup(args.setup_conf) + sources = args.sources or str(setup.sources_root) + out_dir = str(Path(setup.sources_root) / "certora") # write specs into the sources tree + + _targets, _cut = TARGETS, CUT + if args.targets: + import importlib + mod = importlib.import_module(args.targets) + _targets = mod.TARGETS + _cut = getattr(mod, "CUT", setup.cut) + + project = OverApproxProject.of(setup.cut, _targets(), setup.setup_spec_import) + cfg = OverApproxRefineConfig(out_dir=out_dir, setup_conf=setup.conf, + sources_root=str(setup.sources_root), local=args.local) + + llm = ChatAnthropic(model=MODELS[args.model], max_tokens=8000, max_retries=2, timeout=180) + src_tools = build_basic_source_tools(sources, forbidden_read=r".*\.certora_internal.*") \ + .base_source_tools if sources else () + cvl_tools = await build_cvl_tools() + + def _target_line(fn, t): + return (f"- {fn}: GOAL — {t.goal}" if t.goal else + f"- {fn}: no specific property — produce the CLOSEST sound over-approximation " + "(reconstruct as faithfully as CVL allows, relax only at the wall).") + lines = "\n".join(_target_line(fn, t) for fn, t in project.targets.items()) + task = ("Author a sound over-approximating summary for each target below by writing its predicate " + "Phi, then prove each overApprox_ conformance rule. Reconstruct-then-relax; finalize " + f"within the prover-run budget ({args.max_verify_calls}).\n\nTARGETS:\n" + lines) + print(f"=== overapprox: cut={setup.cut} targets={list(project.targets)} model={args.model} " + f"prover={'local' if args.local else 'cloud'} verify_budget={args.max_verify_calls} ===") + transcript = (args.out + ".transcript") if args.out else "overapprox.transcript" + res = await run_overapprox_loop(project, task, cfg, llm=llm, extra_tools=[*src_tools, *cvl_tools], + thread_id=setup.cut, fill_steps=args.fill_steps, + max_verify_calls=args.max_verify_calls, + callbacks=[StreamHandler()], transcript_path=transcript) + + report = [f"=== cut={setup.cut} targets={list(project.targets)} model={args.model} ===", + f"=== ALL VERIFIED === {res.success} verified targets: {res.verified}", + "=== conformance ===", + *(f" [{'PASS' if r.success else 'FAIL'}] {Path(c).name}" + + ("" if r.success else " " + ", ".join(f"{v.rule}:{v.status}" for v in r.failures())) + for c, r in res.results.items())] + for fn in project.targets: + report += [f"\n=== Phi[{fn}] ===\n{project.render_phi(fn)}", + f"=== summary[{fn}] {'(INSTALLABLE — verified)' if fn in project.verified else '(NOT verified)'} ===\n" + + project.render_summary(fn)] + text = "\n".join(report) + print("\n" + text) + if args.out: + Path(args.out).write_text(text) + print(f"\n=== dumped to {args.out} ===") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/smtool/detsummary.py b/smtool/detsummary.py new file mode 100644 index 00000000..109f2676 --- /dev/null +++ b/smtool/detsummary.py @@ -0,0 +1,215 @@ +"""Deterministic-ghost ("memo") summary builder — a SEPARATE piece from overapprox.py. + +overapprox.py proves a per-output predicate Phi and emits the havoc summary `{T res; require Phi; return +res;}` (sound-by-construction, but non-deterministic). This module turns that into a summary that behaves +as a FUNCTION of its input, which consumer proofs usually need: + + persistent ghost Ghost() returns ; // the memo — same key, same value + function CVL() returns { + res = Ghost(); // deterministic load (no store/flag needed: + [ v; require to_bytesN(v) == res; ] // a ghost FUNCTION *is* the memo) + [ require ; ] // re-impose the conformance-proven property + return res; + } + methods { function .(...) internal returns () => CVL(...); [+ also_bind] } + +Determinism is correct-by-construction (a ghost function is deterministic; `persistent` keeps it stable +across havoc/revert). Injectivity is optional (`MemoTarget.injective`, gated on +relational.build_injectivity_rule discharging) — add it only when a consumer needs distinct outputs. + +Keying reuses the model's principle (`driver._nested_ghost`: key on the param types, nothing to choose): +- SCALAR params -> key the ghost directly on their CVL types (UDVT included; ghosts accept value types). +- one ARRAY param -> key on a bounded PREFIX `(uint256 length, x key_len)` (default 3), since + CVL ghosts can't key on an array. Sound over inputs whose length <= key_len (holds via the consumer's + loop bound). The out-of-bounds default needs the element as a scalar, so array elements are cast to + `elem_key_type` (default uint256 via assert_uint256) — resolve the element's underlying type from the + scene with `scene.canonical_arg_types` (reuses autosetup's parse_type_descriptor CANONICAL), or pass it. +""" +from dataclasses import dataclass, field +from typing import Callable + +import composer.cvl.schema as S +from composer.cvl.pretty_print import pretty_print + +from . import cvlx as x + + +def ghost_name(fn: str) -> str: + return fn + "Ghost" + + +def summary_fn_name(fn: str) -> str: + return fn + "CVL" + + +def _is_array(cvl_type: str) -> bool: + return cvl_type.rstrip().endswith("[]") + + +@dataclass +class MemoTarget: + """What to memo-summarize. `params` are (cvl_type, name); `ret` is the single return type. + For an array target (exactly one array param) the ghost keys on (length, first `key_len` elements).""" + cut: str + fn: str + params: list # list[(cvl_type, name)] + ret: str + key_len: int = 3 # array-prefix length (parametric; default 3) + elem_key_type: str = "uint256" # ghost key type for array elements (their canonical/underlying base) + elem_cast: str = "assert_uint256" # cast an element to elem_key_type; the out-of-bounds slot is `0` + also_bind: tuple = () # extra internal method names sharing this memo (same params/ret) + # optional re-imposition of the conformance-proven output property Phi: + phi_of: Callable[[str], S.Expression] | None = None # given a var name, the Phi boolean expression + ret_pin: tuple | None = None # (uintType, to_bytesFn) to pin a bytesN result to `v` before phi_of("v") + # PROVED relational properties encoded as ghost axioms (each gated on its relational.build_*_rule + # discharging). monotone: list of (key_arg_index, increasing) — the memo ghost is monotone in that key + # component. Scalar-keyed + numeric-return only (v1); each axiom is sound iff the real f has it. + monotone: tuple = () + # injective: the memo ghost is injective on its key (distinct keys => distinct result). Sound iff the + # real f is injective on the SAME key (relational.build_injectivity_rule); works for scalar-tuple and + # array-prefix keys. + injective: bool = False + + @property + def array_param(self): + arrs = [(t, n) for t, n in self.params if _is_array(t)] + return arrs[0] if (len(self.params) == 1 and arrs) else None + + +def _key_and_body_array(t: MemoTarget): + """Array target: key = (uint256 len, elem x key_len); body extracts the bounded prefix.""" + atype, aname = t.array_param + key_types = ["uint256"] + [t.elem_key_type] * t.key_len + cmds = [x.declare("uint256", "n", x.field(x.ident(aname), "length"))] + key_args = [x.ident("n")] + for i in range(t.key_len): + # a_i = n > i ? (legs[i]) : 0 (the out-of-bounds slots default to 0) + elem = x.call(t.elem_cast, [x.idx(x.ident(aname), x.num(i))]) + cmds.append(x.declare(t.elem_key_type, f"a{i}", + x.cond(x.binop("gt", x.ident("n"), x.num(i)), elem, x.num(0)))) + key_args.append(x.ident(f"a{i}")) + return key_types, cmds, key_args + + +def _key_and_body_scalar(t: MemoTarget): + """Scalar target: key the ghost directly on the param types (like the model's ghosts).""" + key_types = [ct for ct, _ in t.params] + key_args = [x.ident(n) for _, n in t.params] + return key_types, [], key_args + + +def _monotone_axiom(ghost: str, key_types: list, arg: int, increasing: bool) -> S.GhostAxiom: + """`fGhost` is monotone in key component `arg`: raising it (others fixed) does not lower (increasing) + / not raise (decreasing) the result. As a CLOSED ghost axiom (CVL requires it closed): + forall k0..kn. forall khi. k <= khi => fGhost(..k..) fGhost(..khi..) + The `forall`s are just the free `i,j` bound for axiom syntax; sound iff the real f has the property + (proved by relational.build_monotonicity_rule). Numeric ret only (`<=`/`>=` on the ghost result).""" + kv = [f"k{i}" for i in range(len(key_types))] + khi = f"k{arg}hi" + lo = [x.ident(v) for v in kv] + hi = [x.ident(v) if i != arg else x.ident(khi) for i, v in enumerate(kv)] + body = x.binop("implies", + x.binop("le", x.ident(kv[arg]), x.ident(khi)), + x.binop("le" if increasing else "ge", x.call(ghost, lo), x.call(ghost, hi))) + expr = x.forall(key_types[arg], khi, body) # bind the raised value ... + for i in reversed(range(len(key_types))): # ... then every key component (all universal) + expr = x.forall(key_types[i], kv[i], expr) + return x.axiom(expr) + + +def _injective_axiom(ghost: str, key_types: list) -> S.GhostAxiom: + """`fGhost` is injective on its key: distinct keys give distinct results. As a CLOSED ghost axiom, in + contrapositive form (avoids an OR over the key components): + forall p0..pn. forall q0..qn. fGhost(p..) == fGhost(q..) => (p0==q0 && .. && pn==qn) + Sound iff the real f has this property on the SAME key (proved by relational.build_injectivity_rule): + for a single-array target that key is the bounded prefix, so the rule proves distinct-prefix inputs + give distinct outputs. Works for scalar-tuple and array-prefix keys alike.""" + n = len(key_types) + p = [f"p{i}" for i in range(n)] + q = [f"q{i}" for i in range(n)] + same_key = x.binop("eq", x.ident(p[0]), x.ident(q[0])) + for i in range(1, n): + same_key = x.binop("and", same_key, x.binop("eq", x.ident(p[i]), x.ident(q[i]))) + body = x.binop("implies", + x.binop("eq", x.call(ghost, [x.ident(v) for v in p]), + x.call(ghost, [x.ident(v) for v in q])), + same_key) + expr = body + for i in reversed(range(n)): # bind every q, then every p (all universal) + expr = x.forall(key_types[i], q[i], expr) + for i in reversed(range(n)): + expr = x.forall(key_types[i], p[i], expr) + return x.axiom(expr) + + +def _ghost_axioms(ghost: str, key_types: list, t: MemoTarget) -> list: + """Ghost axioms for the PROVED relational properties on this memo. Monotonicity is scalar-keyed only + (an array-prefix key has no natural per-component order); injectivity applies to either key shape.""" + axioms: list = [] + if t.array_param is None: + axioms += [_monotone_axiom(ghost, key_types, arg, inc) for arg, inc in t.monotone] + if t.injective: + axioms.append(_injective_axiom(ghost, key_types)) + return axioms + + +def build_memo_summary(t: MemoTarget) -> S.CVLFile: + """The deterministic-memo summary as a CVL AST file: the persistent ghost function (with any PROVED + relational axioms, e.g. monotonicity) + `CVL` + the internal methods bindings.""" + g = ghost_name(t.fn) + if t.array_param is not None: + key_types, body, key_args = _key_and_body_array(t) + else: + key_types, body, key_args = _key_and_body_scalar(t) + + body = list(body) + body.append(x.declare(t.ret, "res", x.call(g, key_args))) # deterministic load from the memo + # re-impose Phi (the conformance-proven output property), if any + if t.ret_pin is not None: # bytesN result: pin to an int `v` + uint_ty, to_bytes = t.ret_pin + body.append(x.declare(uint_ty, "v")) + body.append(x.require(x.binop("eq", x.call(to_bytes, [x.ident("v")]), x.ident("res")), + "pin the bytes result to its integer value")) + if t.phi_of is not None: + body.append(x.require(t.phi_of("v"), "over-approx: result satisfies Phi")) + elif t.phi_of is not None: + body.append(x.require(t.phi_of("res"), "over-approx: result satisfies Phi")) + body.append(x.ret([x.ident("res")])) + + summary_fn = x.func(summary_fn_name(t.fn), t.params, [t.ret], body) + ghost = x.ghost_fn(g, key_types, t.ret, axioms=_ghost_axioms(g, key_types, t)) + bindings = [_internal_binding(t, m) for m in [t.fn, *t.also_bind]] + return x.spec_file(blocks=[ghost, summary_fn, x.methods_block(bindings)]) + + +def _internal_binding(t: MemoTarget, method: str) -> S.ImportedFunction: + """`function .() internal returns () => CVL(args);` + Bind the INTERNAL call site (that is where the prover-hostile cost lives), sharing the one summary.""" + params, args = [], [] + for ct, n in t.params: + bn = "_" + n + params.append(x.vmparam(ct, bn, location=("memory" if _is_array(ct) else None))) + args.append(x.ident(bn)) + return S.ImportedFunction( + type="imported_function", + signature=S.MethodSignature( + method_ref=S.MethodReference(contract=t.cut, method_name=method), + # an INTERNAL summary must declare the real method's return arity on the signature + # (`returns ()`); the `expect` clause is for external expression summaries. + parameters=params, return_types=[x.vmparam(t.ret)], visibility="internal", post_flags=[]), + summary=S.ExpressionSummary(type="expression", + expression=x.call(summary_fn_name(t.fn), args), + expect_clause=None), + with_env=None, + ) + + +def render(t: MemoTarget) -> str: + return pretty_print(build_memo_summary(t)) + + +# ---- Phi helpers (build a conformance-proven output property as a cvlx expression) ------------------- +def tag_high_byte(const_pow_240: int, value: int) -> Callable[[str], S.Expression]: + """Phi: the top byte of the (uint) result == `value`, expressed as `v / 2^240 == value` (division, + not a shift — matches the conformance-proven predicate). `const_pow_240` = 2**240.""" + return lambda v: x.binop("eq", x.binop("div", x.ident(v), x.num(const_pow_240)), x.num(value)) diff --git a/smtool/difficulty.py b/smtool/difficulty.py new file mode 100644 index 00000000..f8ab2221 --- /dev/null +++ b/smtool/difficulty.py @@ -0,0 +1,146 @@ +"""Fetch a TIMED-OUT job's difficulty signal WITHOUT the whole zipOutput or statsdata.json. + +A conformance TIMEOUT is a PERFORMANCE problem (the model is likely correct) — and the prover has +already localized the cost. Instead of feeding the agent a bare "it timed out", we fetch the prover's +own ranked NONLINEARITY HOTSPOTS from the failing run (via POU — treeView only, no tarball): + + rule_live_statistics_*.json -> "nonlinearity hotspots" node -> functions ranked by % contribution to + nonlinear ops, each carrying a source file:line. + +A function shows up here ONLY IF its body was INLINED into the SMT problem — a summarized or havoc'd +call contributes no nonlinear ops. So the hotspot list IS the "real bytecode that's in the problem and +expensive" set: exactly the summarization candidates (the agent then judges which are OFF-PATH for the +checked output and safe to NONDET, vs on-path math to mirror by congruence). + +Note on call resolutions: we do NOT use `get_call_resolutions` to find inlined calls. That table is +built only from applied-summary annotations (EVMVerifier CallResolutionTable.kt:161 -> +TACProgram.topoSortedSummaryStart), so an inlined call has NO row; POU additionally filters it to +unresolved (`[?]`) callees (prover_output_utility tree_parser.py:136). It therefore reports the OPPOSITE +of inlined — already-havoc'd unresolved externals — and cannot surface a summarization candidate. + +We deliberately AVOID `statsdata.json` (can be huge, and it is the same nonlinear-op scores WITHOUT +source locations — the locations are joined from the call graph only in the rendered treeView). +Best-effort: any failure (missing POU / auth / network / schema drift) returns an empty report and the +loop still refines on the assert messages + the section-3 playbook. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field + +_MAX_HOTSPOTS = 8 # a ranked pointer, not a dump +# Schema mirrors EVMVerifier's producers (Kotlin, cited so drift is traceable): +# report/LiveCheckInfoNode.kt -> node fields {label, value, children, jumpToDefinition, +# severity, hSev}; jumpToDefinition = TreeViewLocation +# {file, start:{line,col}, end:{line,col}}. +# statistics/data/SingleDifficultyStats.kt:219-259 -> the "nonlinearity hotspots" node: +# parent label = "nonlinearity hotspots" +# child label = "function: $procId" +# value = "contrib. to nonlinear ops: $x %" [ \n "contrib. to max polyn. degree: $y %"] +# jumpToDefinition = callGraphInfo.procIdToSourceLocation[procId] +# (the prover already keeps only procs with nlOps>10 || polydeg>5, sorted by nlOps+polydeg). +_HOTSPOTS_NODE_LABEL = "nonlinearity hotspots" +_HOTSPOT_FN_RE = re.compile(r"function:\s*(?P.+)") # $procId (may contain spaces/quotes) +_HOTSPOT_PCT_RE = re.compile(r"nonlinear ops:\s*(?P\d+)\s*%") + + +@dataclass +class Hotspot: + function: str # e.g. "SomeLib.someNonlinearFn" (procId) + pct: int # % contribution to the rule's nonlinear ops + location: str # "file:line" or "" + + +@dataclass +class DifficultyReport: + hotspots: list[Hotspot] = field(default_factory=list) + + def is_empty(self) -> bool: + return not self.hotspots + + def format(self) -> str: + """A compact, source-located pointer for the refine message: the ranked nonlinearity hotspots. + Every listed function has its real body INLINED in the SMT problem (that is why it contributes + nonlinear ops); those are the summarization candidates.""" + if self.is_empty(): + return "" + out = [" nonlinearity hotspots (prover difficulty report — % of the rule's nonlinear ops; each " + "function's real body is INLINED in the problem):"] + for h in self.hotspots: + at = f" @{h.location}" if h.location else "" + out.append(f" {h.pct:3d}% {h.function}{at}") + out.append( + " -> NONDET the OFF-PATH hotspots (result not read by the checked output) to delete their " + "nonlinear subproblem; if a hotspot stays inlined despite a `_.fn` wildcard NONDET, it is a " + "LINKED target — summarize the CONCRETE contract: `function .(...) external " + "returns (...) => NONDET;` (add_nondet with contract=). For ON-PATH math (the CUT " + "method itself, or a getter the glue observes), mirror the scene's existing summary form so " + "equality is congruence-trivial — do NOT NONDET it.") + return "\n".join(out) + + +def _parse_hotspots(node, out: dict[str, Hotspot]) -> None: + """Walk a rule_live_statistics tree; collect the children of every 'nonlinearity hotspots' node.""" + if isinstance(node, dict): + if node.get("label") == _HOTSPOTS_NODE_LABEL: + for c in node.get("children", []) or []: + mfn = _HOTSPOT_FN_RE.search(str(c.get("label", ""))) + mpct = _HOTSPOT_PCT_RE.search(str(c.get("value", ""))) + if not (mfn and mpct): + continue + fn, pct = mfn.group("fn").strip(), int(mpct.group("pct")) + jd = c.get("jumpToDefinition") or {} + loc = "" + if isinstance(jd, dict) and jd.get("file"): + loc = f"{jd['file']}:{jd.get('start', {}).get('line')}" + prev = out.get(fn) + if prev is None or pct > prev.pct: # dedupe across split rules, keep the worst + out[fn] = Hotspot(function=fn, pct=pct, location=loc) + for c in node.get("children", []) or []: + _parse_hotspots(c, out) + elif isinstance(node, list): + for c in node: + _parse_hotspots(c, out) + + +_LIVE_STATS_RE = re.compile(r"rule_live_statistics_(\d+)\.json") +_PROBE_MAX = 64 # fallback if the status doesn't reference the live-stats files by name + + +def _live_stats_indices(api, job_url: str) -> list[int]: + """The rule_live_statistics_*.json indices for this job. POU's `fetch_job_treeview` bulk-download + does NOT include these files (only rule_output_* + treeViewStatus), but they ARE served per-file by + `fetch_treeview_output_by_filename`. Enumerate them from the treeViewStatus (which references the + per-rule output files); fall back to a bounded probe if none are named.""" + try: + idx = {int(n) for n in _LIVE_STATS_RE.findall(json.dumps(api.get_treeview_status(job_url)))} + if idx: + return sorted(idx) + except Exception: + pass + return list(range(_PROBE_MAX)) + + +def fetch_difficulty(job_url: str) -> DifficultyReport: + """Best-effort difficulty report for a TIMED-OUT job: the prover's ranked nonlinearity hotspots + (function, % of nonlinear ops, source file:line). Returns an empty report on any error. Uses ONLY + the treeView `rule_live_statistics_*.json` files (fetched per-file via POU's public API) — never + statsdata.json or the output tarball.""" + report = DifficultyReport() + if not job_url: + return report + try: + from prover_output_utility import ProverOutputAPI + api = ProverOutputAPI(use_local=False) + except Exception: + return report + + hs: dict[str, Hotspot] = {} + for n in _live_stats_indices(api, job_url): + try: + _parse_hotspots(api.fetch_treeview_output_by_filename(job_url, f"rule_live_statistics_{n}.json"), hs) + except Exception: + continue # index not present (expected when probing) / transient fetch error + report.hotspots = sorted(hs.values(), key=lambda h: h.pct, reverse=True)[:_MAX_HOTSPOTS] + return report diff --git a/smtool/driver.py b/smtool/driver.py new file mode 100644 index 00000000..53d03069 --- /dev/null +++ b/smtool/driver.py @@ -0,0 +1,804 @@ +"""The deterministic driver (component 1): inputs -> skeleton CVL AST + conf. + +Everything here is correct-by-construction. The parts the AI fills later (component 2, via the +mutation tools) are left as explicit HOLES: + HOLE-K constants Named numeric constants the model math needs (e.g. RAY==10^27), emitted as a + `persistent ghost T c { axiom c == v; }`. Added by `add_model_constant`. + HOLE-A ghost axioms Definitional facts about NON-glued, model-internal ghosts used in the + transition/return computation (asserted, so a wrong one is caught). A + restriction on a GLUED ghost is NOT allowed here — it would restrict the CUT + via the glue; state it as a reachable invariant instead (see §4 / lint_glued_ghost_freedom). + HOLE-M model helpers CVL helper functions the CVL bodies call — typically math mirrors (a + library fn in its exact structural form so the return rule closes by + congruence), but any internal helper. CVL-only (no real-contract calls; may + read/write ghosts). Added by `add_model_function`. + HOLE-F CVL body The model method body: a permissive revert-guard, the π state effect (ghost + writes), and the return via the HOLE-M mirrors. Skeleton emits an unconstrained + return (obvious stub); filled by `set_model_method_body`. + HOLE-N per-method NONDET view/pure functions to over-approximate as NONDET in this method's + conformance (a property-directed speedup). Skeleton emits none; added by `add_nondet`. + HOLE-P accrue-idempotence An intermediate `assert` (+ view-only captures) decomposing an + accrue-sensitive return proof (e.g. index stable across the call). Added by `add_helper_lemma`. +The deterministic parts: the observable ghosts+readers (shape derived from the getter signature — +key types = params, value type = return, nothing to choose), CVL signatures, glue (model==real) +equalities, `assumeReachable` calls, the two rule shapes, the methods-block envfree scaffold, and the +.conf rewrite. (WHICH getters are observable is an input/π choice, upstream of the driver, not a hole.) +""" +from __future__ import annotations + +import copy + +import composer.cvl.schema as S +from . import cvlx as x +from .ir import ToolInput, FunctionSpec, Binding, CUT_ARG, CALLER_ARG, FREE_PREFIX +from .classify import ModelLayout, classify + + +# ---------------------------------------------------------------- model spec +def _nested_ghost(b: Binding) -> S.GhostDef: + """The persistent ghost that shadows a binding's real storage. Shape is derived from the getter + signature: a scalar for a no-arg getter, else a nested `mapping(k0 => (k1 => ... => val))` keyed by + the getter's param types (`key_types`) with the tracked return as the value (`val_type`). Emitted + axiom-free — its value is fixed by the glue; see lint_glued_ghost_freedom.""" + if not b.key_types: + return x.ghost_scalar(b.ghost_name, b.val_type) + # build mapping(k0 => (k1 => ... => val)) right-to-left + inner = x.prim(b.val_type) + for kt in reversed(b.key_types): + inner = x.mapping(kt, inner) + return S.GhostDef( + type="ghost_def", ghost_name=b.ghost_name, persistent=True, + ghost_type=S.GhostVariable(type="ghost_type", base_type=inner), axioms=[], + ) + + +def _reader(b: Binding) -> S.FunctionDef: + """The reader function for a binding's ghost — `(k0, k1, ...) { return ghost[k0][k1]; }`. + A pure accessor so the model side of the glue reads like a getter call (`m_G(key)`) rather than a + raw ghost index. This is the glue's model-side function (see lint_glued_ghost_freedom's seed).""" + key_params = [(kt, f"k{i}") for i, kt in enumerate(b.key_types)] + access = x.ident(b.ghost_name) + for i in range(len(b.key_types)): + access = x.idx(access, x.ident(f"k{i}")) + return x.func(b.reader_name, key_params, [b.val_type], [x.ret([access])]) + + +def _multi_getter_groups(cls: ModelLayout) -> dict: + """FULLY-tracked multi-return CUT observable getters, grouped by getter name (components ordered). + + A multi-return getter (e.g. `getPair() -> (a, b)`) has one Binding per tracked + component. A methods{} summary body must be a SINGLE call (no inline tuple), so exposing such a + getter needs one COMBINED reader returning all components — which is only sound when EVERY component + is tracked by a ghost. A partially-tracked multi-return getter (e.g. underlying+decimals, only the + underlying tracked) is left to the real getter (immutable config), the pre-existing behavior.""" + groups: dict = {} + for b in cls.bindings: + g = b.getter + if g.getter_host != "cut" or not g.declare_in_methods or not b.is_multi_return: + continue + groups.setdefault(g.name, []).append(b) + full = {n: sorted(bs, key=lambda b: b.component_index) + for n, bs in groups.items() if len(bs) == len(bs[0].getter.returns)} + return full + + +def _combined_reader(getter_name: str, bs: list) -> S.FunctionDef: + """`CVL() returns (, , ...) { return (ghost0[keys], ghost1[keys], ...); }` — + the single reader the multi-return summary binds to, projecting each component ghost in order.""" + g = bs[0].getter + key_params = [(p.type, p.name) for p in g.params] + accesses = [] + for b in bs: + acc = x.ident(b.ghost_name) + for p in g.params: + acc = x.idx(acc, x.ident(p.name)) + accesses.append(acc) + return x.func(model_fn_name(getter_name), key_params, [b.val_type for b in bs], [x.ret(accesses)]) + + +def _fcvl_stub(m: FunctionSpec) -> S.FunctionDef: + """The skeleton `CVL(address self, , env e)` for a MODEL method — signature fixed, body a + HOLE-F stub that just declares unconstrained result(s) and returns them (typechecks, obviously not + filled). `set_model_method_body` replaces the body. The leading `self` param is the address the + model acts as — the CUT address (currentContract) in conformance — passed at the call site.""" + params = [("address", "self")] + [(p.type, p.name) for p in m.params] + [("env", "e")] + if not m.returns: + return x.func(model_fn_name(m.name), params, [], [x.ret([])]) + # HOLE-F: declare unconstrained result(s) and return them -> typechecks, obviously a stub + cmds, rvals = [], [] + for i, rt in enumerate(m.returns): + rn = "result" if len(m.returns) == 1 else f"result{i}" + cmds.append(x.declare(rt, rn)) + rvals.append(x.ident(rn)) + cmds.append(x.ret(rvals)) + return x.func(model_fn_name(m.name), params, list(m.returns), cmds) + + +def build_model_spec(inp: ToolInput, cls: ModelLayout) -> S.CVLFile: + """The shared model spec (`SymbolicModel.spec`): self-contained — no imports, no methods{}, + persistent ghosts + their readers + one CVL stub per MODEL method.""" + blocks: list = [] + blocks += [_nested_ghost(b) for b in cls.bindings] + blocks += [_reader(b) for b in cls.bindings] + blocks += [_combined_reader(n, bs) for n, bs in _multi_getter_groups(cls).items()] + blocks += [_fcvl_stub(m) for m in cls.model] + return x.spec_file(imports=(), contracts=(), blocks=blocks) + + +# ---------------------------------------------------------------- conformance spec +def _cap(name: str) -> str: + """Capitalize the first letter only (`draw` -> `Draw`), for `Conformance` filenames.""" + return name[:1].upper() + name[1:] + + +# The CUT is the `verify` target, i.e. `currentContract`; an unqualified CVL call resolves to it. +# So CUT calls need NO alias/host, and the CUT-as-address value is `currentContract`. +CURRENT = "currentContract" + + +def _cut_addr(inp) -> str: + """CVL value for the CUT-as-address (model `self`, `CUT_ARG`): the using-ALIAS when the modeled + contract is a dependency reached via alias (the consumer stays the verify target), else + `currentContract` (the modeled contract IS the verify target).""" + return inp.alias or CURRENT + + +def _cut_host(inp, getter=None): + """Host for a CUT method/getter call: the alias when set, else None (unqualified -> currentContract). + A setup-hosted getter is a CVL function -> always None regardless of alias.""" + if getter is not None and getter.getter_host != "cut": + return None + return inp.alias + +# The single SHARED reachability function every conformance rule calls (after the glue) to assume the +# CUT's proven invariants. Lives in the dedicated reachable spec (build_reachable_spec), NOT in the +# per-method conformance spec — so `glue` stays the sole FunctionDef there. add_requireInvariant fills +# its body (idempotent across methods). +ASSUME = "assumeReachable" + +# The correspondence function every conformance rule applies (model==real pinning). It's the SOLE +# FunctionDef in a conformance spec, so Project.find_glue identifies it structurally — this name is +# only the emit label. Kept as a constant so the emit site and the call site can't drift. +GLUE = "glue" + + +def model_fn_name(method: str) -> str: + """The model function that stands in for CUT method `` — `CVL`. One place so the + convention (and any future smt_ prefixing) stays consistent across driver/mutations/project.""" + return method + "CVL" + + +def build_summary_spec(inp: ToolInput, cls: ModelLayout) -> S.CVLFile: + """The CONSUMER summary-application spec (`SymbolicSummary.spec`): imports the model and, in a + methods{} block, SUMMARIZES each real CUT function with its model counterpart so a downstream proof + runs against the (trusted, conformance-verified) symbolic model instead of the heavy real CUT. + + - each MODEL method f -> `function CUT.f() external with (env e) => fCVL(currentContract, + , e) expect ();` + - each OBSERVABLE single-return CUT getter -> `function CUT.g() external [with (env e)] => + () expect ;` (so the consumer reads MODEL state, kept consistent with the + model's writes). + Multi-return getters (config observables the model doesn't write, e.g. underlying+decimals) and + setup-hosted getters are left to the real getter — sound because they're immutable config the model + never mutates; only the single-return observables the methods WRITE need model readers. Apply by + adding `import "SymbolicSummary.spec";` to the consumer's spec (only AFTER conformance passes).""" + entries = [] + for m in cls.model: + call = x.call(model_fn_name(m.name), + [x.ident(_cut_addr(inp)), *[x.ident(p.name) for p in m.params], x.ident("e")]) + entries.append(x.m_expr_summary(inp.cut, m.name, [(p.type, p.name) for p in m.params], + list(m.returns), call, with_env="e")) + for b in cls.bindings: + g = b.getter + if g.getter_host != "cut" or not g.declare_in_methods or len(g.returns) != 1: + continue # setup getters (modeled elsewhere) / multi-return config getters -> real getter + call = x.call(b.reader_name, [x.ident(p.name) for p in g.params]) + entries.append(x.m_expr_summary(inp.cut, g.name, [(p.type, p.name) for p in g.params], + [b.val_type], call, with_env=None if g.effective_envfree else "e")) + # multi-return observable getters (fully tracked): bind to the combined reader (single-call body) + for name, bs in _multi_getter_groups(cls).items(): + g = bs[0].getter + call = x.call(model_fn_name(name), [x.ident(p.name) for p in g.params]) + entries.append(x.m_expr_summary(inp.cut, name, [(p.type, p.name) for p in g.params], + list(g.returns), call, with_env=None if g.effective_envfree else "e")) + return x.spec_file(imports=[inp.model_spec], contracts=(), blocks=[x.methods_block(entries)]) + + +def _reachable_keys(cls: ModelLayout) -> list[tuple[str, str]]: + """The ORDERED, DISTINCT key slots `assumeReachable` exposes — one per key TYPE the model's reachable + invariants may range over, so an invariant keyed by ANY of them (not only the address) can be + requireInvariant'd. Slot 1 (when present) is the state-effect ADDRESS frame var: a reachable + invariant is universal over accounts, so assuming it for the ARBITRARY compared account covers a + multi-account method (e.g. `transferFrom` credits `to`). The remaining slots are the DISTINCT + non-address observable key types (e.g. `uint256 id`), named after the getter's key param so the + agent can pass them by name. Each conformance rule fills the address slot from its framed/fresh + account and the other slots from the METHOD's matching param — so a per-key invariant is assumed + for the key actually under test (a per-key bound the model needs to discharge cast-safety, which + a SINGLE address key could not express). Falls back to the leading model-method param when there are + no observables (a return-only method with no getters).""" + slots: list[tuple[str, str]] = [] + seen: set[str] = set() + for b in cls.bindings: # address slot: the state-effect frame var (old single key) + if not b.state_effect: + continue + for fa in b.frame_arg_names: + if fa.startswith(FREE_PREFIX): + _, ty, var = fa.split(":", 2) + if ty == "address": + slots.append((ty, var)); seen.add(ty); break + if seen: + break + for b in cls.bindings: # one slot per remaining DISTINCT observable key type + for i, kt in enumerate(b.key_types): + if kt not in seen: + seen.add(kt) + nm = b.getter.params[i].name if i < len(b.getter.params) else kt + slots.append((kt, nm)) + if not slots: + p = cls.model[0].params[0] + slots.append((p.type, p.name)) + return slots + + +def _reachable_call_cmds(cls: ModelLayout, m: FunctionSpec, keys: list[tuple[str, str]], + framed_names: set[str]) -> tuple[list, object]: + """`([decls], assumeReachable(args))` for a conformance rule. Each key slot is filled by: a framed + var of that name when the rule already declares one (state-effect framing); else — for a NON-address + key — the METHOD's param of that type (same-name preferred, e.g. `id`), so a per-key invariant + is assumed for the key under test; else a FRESHLY declared var (the address slot in a rule with no + framing — universal over accounts). A method's own address param (e.g. a transfer `to`) is never + used for the address slot; that slot stays the framed/fresh universal account.""" + decls: list = [] + args: list = [] + for ty, name in keys: + if name in framed_names: + args.append(x.ident(name)); continue + chosen = None + if ty != "address": + chosen = next((p.name for p in m.params if p.name == name and p.type == ty), None) \ + or next((p.name for p in m.params if p.type == ty), None) + if chosen is not None: + args.append(x.ident(chosen)) + else: + decls.append(x.declare(ty, name)); args.append(x.ident(name)) + # the rule's env `e` (always in scope) leads the call — matches assumeReachable's `env e` slot, and + # supplies the env an env-taking invariant's requireInvariant needs. + return decls, x.apply(x.call(ASSUME, [x.ident("e"), *args])) + + +def build_reachable_spec(inp: ToolInput, cls: ModelLayout) -> S.CVLFile: + """The dedicated SHARED spec: envfree decls for the invariant-support (non-observable) getters + + an initially-empty `assumeReachable()` function. The CUT invariants + their `requireInvariant`s + are added by add_requireInvariant (best-effort; only prover-VERIFIED ones are kept — that + proof/prune runs against Reachable.conf, separate from the conformance runs). + TODO(reuse): populate/refine these invariants via composer's generate->prove->cex pass + (see composer/spec/source/struct_invariant.py) instead of hand-supplied ones.""" + # Declare EVERY effective-envfree getter here (observable AND non-observable support), deduped by + # (name, arity). Invariants live in this shared spec and reference REAL getters — including + # observable ones (e.g. a per-key balance bound) — so those getters must be declared HERE, or the + # standalone reachable PROOF conf resolves them against the scene's real (non-envfree) signature and + # fails ("missing environment parameter"). Conformance specs import this spec and therefore do NOT + # re-declare these getters (build_conformance_spec skips them), so there is no duplicate. + seen: set = set() + decl_getters = [] + for g in cls.getters: + if not (g.effective_envfree and g.declare_in_methods): + continue + key = (g.name, len(g.params)) + if key not in seen: + seen.add(key); decl_getters.append(g) + blocks: list = [] + if decl_getters: + blocks.append(x.methods_block([x.m_envfree(inp.cut, g.name, [p.type for p in g.params], g.returns) + for g in decl_getters])) + # assumeReachable carries an `env e` FIRST — so an env-taking invariant (needed for a time-dependent + # bound; ~1/5 of real invariants) can be `requireInvariant inv(e, ...)`'d. Envfree invariants ignore it. + blocks.append(x.func(ASSUME, [("env", "e"), *_reachable_keys(cls)], [], [])) # body filled by add_requireInvariant + return x.spec_file(imports=(), blocks=blocks) + + +def _resolve_arg(inp: ToolInput, name: str): + """A glue/frame arg name -> Expression. `CUT_ARG` is the CUT address (`currentContract`); + `CALLER_ARG` is the calling account (`e.msg.sender`, requires `env e` in scope — true in the glue + fn and the stateEffect rule); else a plain identifier (a method param or a glue-local like `u`).""" + if name == CUT_ARG: + return x.ident(_cut_addr(inp)) + if name == CALLER_ARG: + return x.field(x.field(x.ident("e"), "msg"), "sender") + return x.ident(name) + + +def _getter_call(inp: ToolInput, b: Binding, arg_names: list[str]): + """The real-getter call for a binding's glue equality — `getter([e,] args...)`, prepending `env e` + if the getter is env-taking (`envful`). Called UNQUALIFIED (host=None): a CUT getter resolves to + currentContract; a setup-sourced getter is a CVL function. (A CUT getter is never NONDET-summarized — + add_nondet refuses CUT functions — so its concrete `envfree` decl always governs these spec reads.)""" + args = ([x.ident("e")] if b.envful else []) + [_resolve_arg(inp, a) for a in arg_names] + return x.call(b.getter.name, args, host=_cut_host(inp, b.getter)) + + +def _group_by_getter(bindings: list, args_of) -> list: + """Group bindings that read the SAME real getter with the SAME args (key = `(name, arg-names)`), so a + multi-return getter backing several observables (e.g. `getPair() -> (a, b)`, one observable per + component) is LOADED ONCE and each component pinned/asserted separately — instead of a full load + per component (redundant external calls + extra prover work). Preserves first-seen order. + `args_of(b)` = the binding's arg-name list (glue_arg_names for the glue, frame_arg_names for the rule). + The group's component locals come from `group[0].component_names` (bindings in a group agree on the + getter, so component_index selects the right one).""" + order: list = [] + groups: dict = {} + for b in bindings: + key = (b.getter.name, tuple(args_of(b))) + if key not in groups: + groups[key] = [] + order.append(key) + groups[key].append(b) + return [groups[k] for k in order] + + +def build_glue(inp: ToolInput, cls: ModelLayout, m: FunctionSpec) -> S.FunctionDef: + """Glue = (ii) model==real equalities, correct-by-construction from the bindings. Handles + setup-sourced getters, multi-return getters (loaded ONCE, each component pinned — see + _group_by_getter), derived keys (glue_args referencing a local like `u`), and an optional return of + a designated component local.""" + params = [(p.type, p.name) for p in m.params] + array_params = {p.name for p in m.params if p.type.endswith("[]")} + cmds: list = [] + ret_local, ret_type = None, [] + for group in _group_by_getter(cls.bindings, lambda b: b.glue_arg_names): + b0 = group[0] + if any(a in array_params for a in b0.glue_arg_names): + continue # array-keyed observable: pinned per-element by _field_pins + args = [_resolve_arg(inp, a) for a in b0.glue_arg_names] + call = x.call(b0.getter.name, ([x.ident("e")] if b0.envful else []) + args, host=_cut_host(inp, b0.getter)) + if b0.is_multi_return: + names = b0.component_names + for cn, ct in zip(names, b0.getter.returns): + cmds.append(x.declare(ct, cn)) + cmds.append(x.assign_multi(names, call)) # ONE load; shared across the group + value = lambda b, names=names: x.ident(names[b.component_index]) + else: + value = lambda b, call=call: call + for b in group: + reader = x.call(b.reader_name, args) + cmds.append(x.require(x.binop("eq", reader, value(b)), f"glue: model == real for {b.getter.name}")) + if b.glue_return and b0.is_multi_return: + ret_local, ret_type = names[b.component_index], [b.val_type] + if ret_local: + cmds.append(x.ret([x.ident(ret_local)])) + return x.func(GLUE, params + [("env", "e")], ret_type, cmds) + + +def _glue_returns(cls: ModelLayout): + """The type the glue function returns — the val_type of the binding flagged `glue_return` (the + derived local, e.g. the underlying `u`, that the rule reuses), or None if the glue is void.""" + for b in cls.bindings: + if b.glue_return: + return b.val_type + return None + + +def _call_real(inp: ToolInput, m: FunctionSpec, withrevert=True): + """Call the REAL CUT method — `f(e, args...)`, unqualified (resolves to currentContract), + `@withrevert` so the rule can inspect `lastReverted` for revert-conformance.""" + args = [x.ident("e")] + [x.ident(p.name) for p in m.params] + return x.call(m.name, args, host=_cut_host(inp), annotation="withrevert" if withrevert else None) + + +def _call_model(inp: ToolInput, m: FunctionSpec, withrevert=True): + """Call the MODEL method — `fCVL(currentContract, args..., e)`, `@withrevert`. The leading + arg is the CUT address for `CVL`'s `self` param; the `env e` goes last (model convention).""" + args = [x.ident(_cut_addr(inp))] + [x.ident(p.name) for p in m.params] + [x.ident("e")] + return x.call(model_fn_name(m.name), args, annotation="withrevert" if withrevert else None) + + +def _glue_apply(inp: ToolInput, cls: ModelLayout, m: FunctionSpec, bind_to: str | None = None): + """Emit the glue call at the top of a rule — `glue(args..., e);`, or ` bind_to = glue(...);` + when the glue returns a local the rule needs (e.g. `u`).""" + args = [x.ident(p.name) for p in m.params] + [x.ident("e")] + call = x.call(GLUE, args) + if bind_to is not None: + return x.declare(_glue_returns(cls), bind_to, call) + return x.apply(call) + + +def _revert_conf(inp: ToolInput): + """The revert-conformance assert. DEFAULT (`precise_reverts=False`) = OVER-APPROXIMATION: real + success => model success — the model may be MORE permissive (revert LESS), a sound coarsening that + lets a model soundly ignore e.g. access-control reverts unreachable in the consumer. `precise_reverts` + switches to EXACT: `realRev == modelRev`, forbidding any over-approximation (the model must revert + exactly like real).""" + if inp.precise_reverts: + return x.assert_(x.binop("eq", x.ident("realRev"), x.ident("modelRev")), + "model must revert exactly when real reverts (precise)") + return x.assert_(x.binop("implies", x.unop_not(x.ident("realRev")), x.unop_not(x.ident("modelRev"))), + "real success must imply model success (over-approximation)") + + +def build_return_rule(inp: ToolInput, cls: ModelLayout, m: FunctionSpec, + reachable_keys: list[tuple[str, str]] | None = None) -> S.RuleBlock | None: + """`conformance__return`: glue + assumeReachable, then call real and model `@withrevert` and + assert return agreement WHEN BOTH SUCCEED. The revert conformance (`realRev == modelRev`) is asserted + in the state-effect rule for a state changer (de-dup), or HERE for a return-only method. Single vs + multi-return handled separately; multi compares only the `return_compare`-flagged components. + Returns None for a VOID method (no return to compare — `() = call` is not valid CVL): its + revert-conformance is asserted by the state-effect rule (which every state-changing method gets).""" + if not m.returns: + return None + params = [(p.type, p.name) for p in m.params] + cmds: list = [x.declare("env", "e"), _glue_apply(inp, cls, m)] + cmds += _field_pins(inp, cls, m, []) # conservative typed model==real pins (no frame vars here) + # assumeReachable over the SHARED key slots (`reachable_keys`, from the whole-model layout). The + # return rule has no framing, so the address slot is declared FRESH; the per-key slot reuses the + # method's own `id` param (in scope as a rule param). The keys MUST be the shared ones — the + # per-method `_reachable_keys(cls)` can differ (e.g. a preview with no address observable), which + # would mismatch the shared `assumeReachable(...)` declaration — a typecheck conflict the agent + # cannot fix (it reads as "overload assumeReachable / cast address<->uint256"). + keys = reachable_keys or _reachable_keys(cls) + rdecls, rassume = _reachable_call_cmds(cls, m, keys, framed_names=set()) + cmds += [*rdecls, rassume] + # HOLE-P: previewBefore capture + A assertion go here when the return is accrue-sensitive. + single = len(m.returns) == 1 + # Revert conformance (realRev == modelRev) is asserted by the STATE-EFFECT rule for a state changer; + # a return-only method (no state-effect rule) carries it here. Return agreement is checked only when + # BOTH sides succeed (a revert mismatch is the state-effect / here-only revert assert's job). + both_ok = x.binop("and", x.unop_not(x.ident("realRev")), x.unop_not(x.ident("modelRev"))) + revert_assert = [] if m.is_state_changing else [_revert_conf(inp)] + if single: + cmds += [ + x.declare(m.returns[0], "retSol", _call_real(inp, m)), + x.declare("bool", "realRev", x.ident("lastReverted")), + x.declare(m.returns[0], "retModel", _call_model(inp, m)), + x.declare("bool", "modelRev", x.ident("lastReverted")), + *revert_assert, + x.assert_(x.binop("implies", both_ok, x.binop("eq", x.ident("retSol"), x.ident("retModel"))), + "returns must agree when both succeed"), + ] + else: + # multi-return: bind both tuples, compare the flagged components (others over-approximated). + real_ns = [f"retSol{i}" for i in range(len(m.returns))] + model_ns = [f"retModel{i}" for i in range(len(m.returns))] + for rt, n in zip(m.returns, real_ns): + cmds.append(x.declare(rt, n)) + cmds.append(x.assign_multi(real_ns, _call_real(inp, m))) + cmds.append(x.declare("bool", "realRev", x.ident("lastReverted"))) + for rt, n in zip(m.returns, model_ns): + cmds.append(x.declare(rt, n)) + cmds.append(x.assign_multi(model_ns, _call_model(inp, m))) + cmds.append(x.declare("bool", "modelRev", x.ident("lastReverted"))) + cmds += revert_assert + compare = m.return_compare or [True] * len(m.returns) + for i, (rn, mn) in enumerate(zip(real_ns, model_ns)): + if compare[i]: + cmds.append(x.assert_(x.binop("implies", both_ok, + x.binop("eq", x.ident(rn), x.ident(mn))), + f"return component {i} must agree when both succeed")) + return x.rule(f"conformance_{m.name}_return", params, cmds) + + +def _frame_resolve(inp: ToolInput, name: str): + """A frame arg -> (decl_or_None, Expression). A `FREE_PREFIX` arg ("FREE::") declares a + fresh free var (framing over all such values); otherwise resolves like a glue arg.""" + if name.startswith(FREE_PREFIX): + _, ty, var = name.split(":", 2) + return x.declare(ty, var), x.ident(var) + return None, _resolve_arg(inp, name) + + +def _group_load(inp, group: list, args: list, suffix: str) -> tuple[list, "callable"]: + """Load a getter shared by `group` (same getter+args) ONCE at the current point; return + (decls, value) where value(b) is the tracked scalar for binding b. A multi-return getter binds its + components to fresh `_` locals (suffix disambiguates the pre vs post read); a + single-return getter needs no decls and value is the call itself. Coalesces the per-component + double-load (see _group_by_getter).""" + b0 = group[0] + call = x.call(b0.getter.name, ([x.ident("e")] if b0.envful else []) + args, host=_cut_host(inp, b0.getter)) + if not b0.is_multi_return: + return [], (lambda b, call=call: call) + names = [f"{cn}_{suffix}" for cn in b0.component_names] + decls = [x.declare(ct, n) for n, ct in zip(names, b0.getter.returns)] + decls.append(x.assign_multi(names, call)) + return decls, (lambda b, names=names: x.ident(names[b.component_index])) + + +def _numeric_ty(ty: str) -> bool: + return ty == "mathint" or ty.startswith("uint") or ty.startswith("int") + + +def _is_udvt(ty: str) -> bool: + """A user value type (e.g. Token.Id) — not a CVL primitive. It wraps a number, and the + readers coerce it into a numeric key slot (exactly as the model body already does).""" + if ty in ("address", "bool", "string", "bytes", "mathint") or _numeric_ty(ty) or ty.startswith("bytes"): + return False + return True + + +def _key_matches(var_ty: str, key_ty: str, coercible: frozenset[str] = frozenset()) -> bool: + """May an in-scope var of `var_ty` fill a mapping key of `key_ty`? Exact match, or — for a numeric + key — a numeric var, or a var of a KNOWN coercible (UDVT) type. `coercible` is the model's own + non-primitive KEY types: a UDVT `type Id is uint256` legitimately coerces into a numeric key, but a + non-primitive is only trusted to coerce when the model actually uses it AS a key. A struct method + param (e.g. a `Lib.Info`) is never a mapping key, so it is NOT coercible — without this it was pinned + as a `uint256` key (`readerCVL(info)`), an uncatchable-by-agent typecheck error.""" + if var_ty == key_ty: + return True + if _numeric_ty(key_ty): + return _numeric_ty(var_ty) or var_ty in coercible + return False + + +# TODO(perf): this pins the WHOLE allowed set unconditionally (safe default, zero agent burden, but it +# over-pins — every pin is a live getter call, a keccak slot read on assembly tokens). Over-pinning is +# only a PERFORMANCE cost, never a soundness one (more `model==real` pre-pins can't hide a divergence). +# Optimization to consider IF timing shows the pins cost: expose this set as a deterministic ALLOWED +# MENU and let the agent add pins from it SELECTIVELY (in response to an unpinned-read counterexample), +# instead of emitting all upfront. Sound because the menu is sound-by-construction (the agent can only +# pick a valid pin) and a MISSING needed pin surfaces as a VIOLATION, not a silent pass (self-correcting; +# relies on the require-cast lint having removed the vacuity path). Needs a constrained `add_pin` tool + +# a precise "real succeeded but model read unpinned cell X" message (the agent once mis-read this exact +# gap as an auth problem). Alternative with no agent rounds: deterministic BODY-SCAN (pin exactly the +# reader-calls in the filled body) — same precision, but requires rebuilding the glue on body change. +def _field_pins(inp: ToolInput, cls: ModelLayout, m: FunctionSpec, frame_vars: list) -> list: + """Conservative model==real PRE-pins for every single-return observable FIELD, at the TYPED cross + product of the in-scope vars for each key position (method params + e.msg.sender + `frame_vars`). + Body-free and over-pinning: pinning `ghost[k] == getter(k)` at more keys only strengthens the + 'model starts equal to real' premise, and it guarantees every key the model READS is pinned — so + the model can't diverge on an unpinned (havoc'd) cell (e.g. a transfer's credit-side balance). + Scalars pin once. A key position with no matching in-scope var yields no combo for that field; the + per-frame pin (kept alongside) remains its fallback.""" + import itertools + # scope: (key_type, key_EXPR). Scalars contribute their identifier; an ARRAY param T[] contributes + # its bounded elements arr[0..loop_iter) as element-typed keys (CVL has no loops, so arrays are + # addressed by fixed indices up to the run's loop_iter). NOT the frame free vars: those are pinned + # by the per-frame pre-pin (kept alongside). + _ = frame_vars + scope: list = [] + for p in m.params: + if p.type.endswith("[]"): + elem = p.type[:-2] + for k in range(inp.loop_iter): + scope.append((elem, x.index(p.name, k))) + else: + scope.append((p.type, x.ident(p.name))) + scope.append(("address", _resolve_arg(inp, CALLER_ARG))) + # DERIVED-ADDRESS keys: an observable getter that RETURNS an address (e.g. a fee receiver) yields a + # key the method may read/write ANOTHER observable at — the credit target. params + caller miss it, + # so that ghost cell stays unpinned (an unconstrained uint256 -> cast-safety CEX). Capture each such + # getter's value (when its own keys are in-scope, non-free) and add it as an address key, so the + # cross-product pins the address-keyed observables there too. Sound (model==real) and cheap. + addr_decls: list = [] + param_names = {p.name for p in m.params} + for i, b in enumerate(cls.bindings): + if b.is_multi_return or b.val_type != "address" or not b.getter.declare_in_methods: + continue + # capture only when every key of the address getter is concretely in THIS method's scope (a param, + # the caller, or the CUT) — skip free vars / glue-locals we cannot reference here. + if not all(a in (CUT_ARG, CALLER_ARG) or a in param_names for a in b.glue_arg_names): + continue + nm = f"_derivedAddr{i}" + kargs = [_resolve_arg(inp, a) for a in b.glue_arg_names] + call = x.call(b.getter.name, ([x.ident("e")] if b.envful else []) + kargs, host=_cut_host(inp, b.getter)) + addr_decls.append(x.declare("address", nm, call)) + scope.append(("address", x.ident(nm))) + # A non-primitive type is trusted to coerce into a numeric key only when the model uses it as a + # scalar INDEX: either a declared observable KEY type, or an ARRAY-ELEMENT type (arr[i] indexing an + # observable). Both are UDVTs (`type Id is uint256`) by construction. A bare non-primitive SCALAR + # method param (e.g. a struct `Lib.Info`) is NOT an index — excluding it stops it + # being pinned as a `uint256` key, an uncatchable-by-agent typecheck error. (Residual: an array of + # STRUCTS would still be trusted; not a shape the models use — the model keys are always scalars.) + coercible = frozenset( + [kt for b in cls.bindings for kt in b.key_types if _is_udvt(kt)] + + [p.type[:-2] for p in m.params if p.type.endswith("[]") and _is_udvt(p.type[:-2])]) + def vars_of(kt: str) -> list: + return [e for (ty, e) in scope if _key_matches(ty, kt, coercible)] + cmds: list = list(addr_decls) # capture the derived addresses before pinning at them + seen: set = set() + for b in cls.bindings: + if b.is_multi_return: + continue # multi-return: covered by the frame pin (TODO) + kts = b.key_types + combos = [()] if not kts else itertools.product(*[vars_of(kt) for kt in kts]) + for combo in combos: + key = (b.reader_name, tuple(str(e.model_dump()) for e in combo)) + if key in seen: + continue + seen.add(key) + argexprs = list(combo) + reader = x.call(b.reader_name, argexprs) + getter = x.call(b.getter.name, ([x.ident("e")] if b.envful else []) + argexprs, + host=_cut_host(inp, b.getter)) + cmds.append(x.require(x.binop("eq", reader, getter), f"pin: model == real for {b.getter.name}")) + return cmds + + +def _frame_free_vars(cls: ModelLayout) -> list: + """The distinct FREE frame vars (type, name) declared across the bindings' frame args.""" + out, seen = [], set() + for b in cls.bindings: + for fa in b.frame_arg_names: + if fa.startswith(FREE_PREFIX): + _, ty, var = fa.split(":", 2) + if var not in seen: + seen.add(var); out.append((ty, var)) + return out + + +def build_state_effect_rule(inp: ToolInput, cls: ModelLayout, m: FunctionSpec, + reachable_keys: list[tuple[str, str]] | None = None) -> S.RuleBlock: + """After the call, EVERY observable's post-state must agree model==real — the WHOLE pi, by default + (safety-by-default: an effect the model gets wrong is caught). Each observable is framed (free vars + for the keys it ranges over), pinned pre (model == real) and asserted post. Observables opt OUT via + state_effect=False (e.g. ones not yet provable whole). + TODO(perf): narrow the checked set to the observables a method actually WRITES (from a per-method + write-set analysis) instead of all of pi — a future optimization; checking everything is safe but + each extra observable is prover work (and may need its own lemma, e.g. accrue-idempotence).""" + params = [(p.type, p.name) for p in m.params] + se = [b for b in cls.bindings if b.state_effect] + ret = _glue_returns(cls) + cmds: list = [x.declare("env", "e")] + cmds.append(_glue_apply(inp, cls, m, bind_to="u" if ret else None)) + # free-var framing decls + pre-pins, GROUPED so a getter shared by several observables loads once + framed: list = [] + for group in _group_by_getter(se, lambda b: b.frame_arg_names): + b0 = group[0] + args, decls = [], [] + for a in b0.frame_arg_names: + d, expr = _frame_resolve(inp, a) + if d is not None: + decls.append(d) + args.append(expr) + cmds += decls + gdecls, value = _group_load(inp, group, args, "pre") + cmds += gdecls + for b in group: + reader = x.call(b.reader_name, args) + cmds.append(x.require(x.binop("eq", reader, value(b)), f"pin pre: model == real for {b.getter.name}")) + framed.append((group, args)) + # conservative typed pinning: model==real for every field at the cross product of in-scope vars + # per key type (subsumes the single frame pre-pin; pins the keys the model reads, e.g. a credit `to`). + cmds += _field_pins(inp, cls, m, _frame_free_vars(cls)) + # assume the CUT reachable invariants over the SHARED key slots (matches the `assumeReachable(...)` + # declaration). The address slot reuses the FRAMED account (covers the arbitrary compared account, + # incl. a multi-account method's credit target); the per-key slot uses the method's own `id` + # (so a per-key bound is assumed for the key under test). Any slot not already framed is declared + # fresh (sound: an invariant is universal, so assuming it for an arbitrary extra key is harmless). + keys = reachable_keys or _reachable_keys(cls) + framed_names = {fa.split(":", 2)[2] for b in cls.bindings if b.state_effect + for fa in b.frame_arg_names if fa.startswith(FREE_PREFIX)} + rdecls, rassume = _reachable_call_cmds(cls, m, keys, framed_names) + cmds += [*rdecls, rassume] + cmds += [x.apply(_call_real(inp, m)), x.declare("bool", "realRev", x.ident("lastReverted")), + x.apply(_call_model(inp, m)), x.declare("bool", "modelRev", x.ident("lastReverted")), + _revert_conf(inp)] + for group, args in framed: + gdecls, value = _group_load(inp, group, args, "post") + cmds += gdecls + for b in group: + reader = x.call(b.reader_name, args) + cmds.append(x.assert_(x.binop("implies", x.unop_not(x.ident("realRev")), x.binop("eq", value(b), reader)), + f"observable {b.getter.name} effect must agree")) + return x.rule(f"conformance_{m.name}_stateEffect", params, cmds) + + +def build_conformance_spec(inp: ToolInput, cls: ModelLayout, m: FunctionSpec, + setup_spec_import: str | None = None, declared=None, + reachable_spec_import: str | None = None, + reachable_keys: list[tuple[str, str]] | None = None) -> S.CVLFile: + """Assemble one method's conformance spec: imports [setup, reachable, model] + a methods{} block + (envfree decls for the observable getters, minus what the setup already summarizes) + the glue + + the return rule + the state-effect rule.""" + # Import the ONE setup spec (its whole closure — imports + methods{} — is resolved by CVL), the + # shared reachable spec (assumeReachable + CUT invariants), and the shared model. + # `setup_spec_import` comes from the setup .conf (smtool.setup.consume_setup); None only for + # standalone/offline builds that don't wire the setup. + imports = ([setup_spec_import] if setup_spec_import else []) \ + + ([reachable_spec_import] if reachable_spec_import else []) + [inp.model_spec] + # methods{}: envfree decls for OBSERVABLE getters that want declaring (invariant-support / + # non-observable getters are declared in the reachable spec instead; setup CVL getters like + # tokenBalanceOf already exist, so declare_in_methods=False). NONDET: HOLE-N. + # RECONCILE: skip an entry the setup's resolved closure already summarizes (`declared` = + # smtool.resolved_ast.summarized_methods; keyed by (name, arity)). Plain-decl clashes the resolved + # AST can't show are left to the reactive typecheck fallback (TODO). + declared = set(declared or ()) + # When the reachable spec is imported it already declares every effective-envfree getter (so its + # invariants resolve) — skip them here to avoid a duplicate declaration across the import. + if reachable_spec_import: + declared |= {(g.name, len(g.params)) for g in cls.getters + if g.effective_envfree and g.declare_in_methods} + entries = [] + for g in cls.getters: + if not (g.observable and g.effective_envfree and g.declare_in_methods): + continue + key = (g.name, len(g.params)) + if key in declared: # skip setup-summarized AND repeated (multi-component) getters + continue + declared.add(key) + entries.append(x.m_envfree(inp.cut, g.name, [p.type for p in g.params], g.returns)) + blocks: list = [] + if entries: + blocks.append(x.methods_block(entries)) + blocks.append(build_glue(inp, cls, m)) + return_rule = build_return_rule(inp, cls, m, reachable_keys) # None for a void method (see build_return_rule) + if return_rule is not None: + blocks.append(return_rule) + # A computed VIEW model method (model=True on a view) has no state effect — only a return rule. + # Its inputs' storage is kept real by the state-effect rules of the methods that WRITE them. + if m.is_state_changing: + blocks.append(build_state_effect_rule(inp, cls, m, reachable_keys)) + return x.spec_file(imports=imports, contracts=(), blocks=blocks) + + +# ---------------------------------------------------------------- reachable proof (prove-incrementally) +def build_reachable_proof_spec(inp: ToolInput, setup_spec_import: str | None, + invariant_names: list[str]) -> S.CVLFile: + """The verify target that PROVES the shared reachable invariants against the CUT — imports the + setup (scene), the reachable spec (invariant decls + support getters), and the model (shared + constants like RAY), and `use invariant`s each one so it's checked. Run its conf ONCE; the + conformance runs then only assume the VERIFIED invariants. Prove-incrementally: adding a new + invariant (add_requireInvariant) + re-running this conf extends the proven set; drop any that + don't verify (best-effort — TODO: automate the prune via smtool.verify).""" + imports = ([setup_spec_import] if setup_spec_import else []) + [inp.reachable_spec, inp.model_spec] + return x.spec_file(imports=imports, blocks=[x.use_invariant(n) for n in invariant_names]) + + +def rewrite_reachable_conf(setup_conf: dict, inp: ToolInput, invariant_names: list[str]) -> dict: + """The conf that PROVES the reachable invariants: the setup conf (scene) with verify pointed at + ReachableProof.spec and `rule` = the invariant names. Run once; feeds verify.prune_reachable.""" + conf = copy.deepcopy(setup_conf) + # ALIAS path: the modeled contract is a dependency; the CONSUMER stays the verify target (so the + # imported setup spec's unqualified consumer methods resolve, and the invariant — stated over the + # alias — is proven in the same scene as the conformance). Non-alias: the modeled contract IS the CUT. + verify_target = setup_conf["verify"].split(":", 1)[0] if inp.alias else inp.cut + conf["verify"] = f"{verify_target}:{inp.specs_dir}/{inp.cut}ReachableProof.spec" + conf["msg"] = f"{inp.cut} reachable invariants" + conf["rule"] = list(invariant_names) + conf["multi_assert_check"] = True + return conf + + +# ---------------------------------------------------------------- conf rewrite +def invariant_names(spec: S.CVLFile) -> list[str]: + """Names of the invariants declared in `spec`.""" + return [b.name for b in spec.blocks if isinstance(b, S.Invariant)] + + +def verifiable_names(spec: S.CVLFile) -> list[str]: + """The rules + invariants we want VERIFIED in a spec's own run. For conformance specs this is just + the conformance rules (the reachable invariants live in the reachable spec and are proven by + Reachable.conf, not here); the invariant term stays for specs that DO declare invariants.""" + return ([b.rule_name for b in spec.blocks if isinstance(b, S.RuleBlock)] + + invariant_names(spec)) + + +def rewrite_conf(setup_conf: dict, inp: ToolInput, m: FunctionSpec, + conformance_spec: S.CVLFile | None = None) -> dict: + """One method's conformance conf: the setup conf (scene inherited untouched) with verify -> this + method's conformance spec, a CUT-derived msg, multi_assert_check on, and `rule` = just this spec's + conformance rules (via verifiable_names on the post-mutation spec).""" + conf = copy.deepcopy(setup_conf) + spec = f"{inp.specs_dir}/{inp.conformance_prefix}{_cap(m.name)}Conformance.spec" + # ALIAS path: the modeled contract is a dependency (reached via alias); the CONSUMER stays the verify + # target (from the setup conf), so the conformance runs in exactly the consumer scene. Non-alias: + # the modeled contract IS the verify target (inp.cut). + verify_target = setup_conf["verify"].split(":", 1)[0] if inp.alias else inp.cut + conf["verify"] = f"{verify_target}:{spec}" + conf["msg"] = f"{inp.conformance_prefix} {m.name} conformance" + conf["multi_assert_check"] = True + # rule-filter (complete-by-construction): run exactly our rules, so the setup's imported `sanity` + # rule is defined-but-not-run. The shared reachable invariants are NOT listed here — they're + # proven separately by Reachable.conf (prove-once) and only ASSUMED via requireInvariant. + if conformance_spec is not None: + conf["rule"] = verifiable_names(conformance_spec) + # The conformance spec imports the setup spec directly (build_conformance_spec's + # `setup_spec_import`), so the setup's whole closure — imports + methods{} — is inherited and the + # sanity rule is simply not in this conf's `rule` list. Our own methods{} entries are reconciled + # against the setup's RESOLVED summarized set (smtool.resolved_ast, via -printAst) so we don't + # re-add a method the setup already summarizes. + # TODO(reconcile): plain envfree-DECL clashes aren't in the resolved AST's summary lists — add the + # reactive fallback (typecheck -> drop on "duplicate declaration", reusing + # certora_autosetup/typechecker_loop.py's error parsing) for those. + # TODO(names): prefix everything smtool introduces with `smt_` (+ optional deterministic nonce + # for recursive modeling), EXCEPT the CUT-method signatures in methods{} (must match the ABI). + # TODO(perf): one conformance spec per method is inefficient — a model change forces re-verifying + # all previously-passing rules. Group methods that share the same NONDET set into one spec/conf. + return conf diff --git a/smtool/ir.py b/smtool/ir.py new file mode 100644 index 00000000..09ba30d6 --- /dev/null +++ b/smtool/ir.py @@ -0,0 +1,249 @@ +"""Input model + internal IR for the symbolic-model tool. + +Two inputs (see INPUT.md): a setup .conf (assumed correct) naming the CUT, and a +flat list of CUT functions. classify.py splits the list into MODEL / OBS / HARNESS. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +Mutability = Literal["pure", "view", "nonpayable", "payable"] + +# glue_args / frame_args sentinels (the driver's _resolve_arg / _frame_resolve interpret these): +CUT_ARG = "CUT" # this arg is the CUT address (resolves to currentContract) +CALLER_ARG = "CALLER" # this arg is the CALLING account (resolves to e.msg.sender) — used to key a + # per-caller observable (e.g. a getter `g(id, account)`) at the caller, so + # glue/stateEffect pin the model's slot for THIS caller (single-key scope). +FREE_PREFIX = "FREE:" # a frame arg "FREE::" declares a fresh free var (frames over all) + + +def free_var(ty: str, name: str) -> str: + """A frame_args entry for a fresh free var of type `ty` named `name` (frames over all its values).""" + return f"{FREE_PREFIX}{ty}:{name}" + + +@dataclass +class Param: + type: str # CVL type name (e.g. "uint256", "address", or a dotted "IFoo.Bar" struct type) + name: str + + +@dataclass +class Signature: + """The resolved signature of a CUT function — name, params, returns, mutability, visibility — in + the CVL type-string form smtool emits (what `cvlx.ty` decodes). This is NOT hand-authored + knowledge: in the real flow it is sourced from the compiled scene via `from_scene` (see + smtool/scene.py), reusing AutoProver's `MethodParser` (loader) + + `TypeAnalyzer._resolve_type_from_string().cvl_name` (the Solidity->CVL string mapper autosetup + itself uses). We stay string-native and anchored to the composer/EVMVerifier CVL AST (via cvlx), + rather than coupling to autosetup's parallel TypeInfo.""" + name: str + params: list[Param] = field(default_factory=list) + returns: list[str] = field(default_factory=list) # CVL return type names + mutability: Mutability = "nonpayable" + visibility: str = "external" + + @classmethod + def from_scene(cls, m: dict, resolve) -> "Signature": + """Build FACTS from an `all_methods.json` entry `m` (as loaded by AutoProver's `MethodParser`). + `resolve` maps a Solidity type string -> its CVL spelling (wire it to + `TypeAnalyzer._resolve_type_from_string(s).cvl_name`; see smtool/scene.py). No hand-typing.""" + types = m.get("fullSignature", []) + names = list(m.get("paramNames") or []) + if len(names) < len(types): # unnamed / partially-named params -> synthesize + names += [f"p{i}" for i in range(len(names), len(types))] + params = [Param(resolve(t), n) for t, n in zip(types, names)] + returns = [resolve(t) for t in m.get("returns", [])] + return cls(name=m["name"], params=params, returns=returns, + mutability=m.get("stateMutability", "nonpayable"), + visibility=m.get("visibility", "external")) + + +@dataclass +class FunctionSpec: + """One CUT function from input (2): its SIGNATURE (from the scene) plus smtool's MODELING + directives. Mutability (part of the signature) drives classification. Construct offline via + `FunctionSpec.of(...)` or from a scene via `FunctionSpec.from_scene(mdict, resolve, **modeling)`.""" + signature: Signature + + # ---- MODELING directives (smtool-specific; the AI agent / user chooses these) ---- + envfree: bool | None = None # getter only: declare/call envfree? None => default = is_getter + model: bool = False # force a view/pure fn to be MODELED as a return-only method (agent- + # filled body + return conformance, NO stored ghost / NO state-effect) + # — for COMPUTED views (preview/quote/convert) whose value is derived + # from other model state, not stored. State-changing fns are always + # modeled; this is only for views. + observable: bool = True # getter only: modeled as a ghost (True) vs declared-only support + # getter used solely to state a reachable invariant (False). + # Declaration is template either way. + + # ---- getter-as-observable knobs (generalize where the getter comes from & how it's keyed) ---- + getter_host: str = "cut" # "cut" => a CUT getter g(..) (unqualified => currentContract); + # "setup" => a setup CVL getter backed by a setup ghost + declare_in_methods: bool = True # emit an envfree methods{} decl? (setup CVL getters already exist) + ghost_name: str | None = None # override the default model-ghost name (cosmetic; shape is derived) + reader_name: str | None = None # override the default model-reader name + bind_component: int | None = None # multi-return getter: which return index the ghost tracks + component_names: list[str] | None = None # multi-return: local names for the tuple, e.g. ["u","d"] + glue_args: list[str] | None = None # arg NAMES for the getter/reader in the glue; CUT_ARG => the CUT. + # None => the getter's own param names. Lets a binding be keyed + # by a derived local (e.g. the underlying `u`) + the CUT address. + glue_return: bool = False # the bound local of this (multi-return) getter is the glue's return + return_compare: list[bool] | None = None # MODEL method w/ tuple return: which components to + # compare in the return rule (others over-approximated + # / NONDET). None => compare all. + state_effect: bool = True # compare this observable's POST value in the stateEffect rule. + # Safety-by-default: ALL observables are compared (whole pi), so an + # effect the model gets wrong is caught. Set False to opt an + # observable OUT (e.g. one not yet provable whole) — a narrowing + # that is also the future perf optimization (see build_state_effect_rule). + frame_args: list[str] | None = None # stateEffect arg names; free_var(type,name) => fresh free var + # (framing over all e.g. accounts), else resolves like glue_args + + # ---- constructors ---- + @classmethod + def of(cls, name: str, params=(), returns=(), mutability: Mutability = "nonpayable", + visibility: str = "external", **modeling) -> "FunctionSpec": + """Terse offline constructor (recon agent-sims): build the SIGNATURE inline + MODELING kwargs. + In the real flow prefer `from_scene`, which sources the signature from the compiled scene.""" + return cls(Signature(name, list(params), list(returns), mutability, visibility), **modeling) + + @classmethod + def from_scene(cls, m: dict, resolve, **modeling) -> "FunctionSpec": + """SIGNATURE from an `all_methods.json` entry (via `Signature.from_scene`) + MODELING kwargs.""" + return cls(Signature.from_scene(m, resolve), **modeling) + + # ---- signature proxies (keep classify/driver/project/Binding reading .name/.params/... unchanged) ---- + @property + def name(self) -> str: + return self.signature.name + + @property + def params(self) -> list[Param]: + return self.signature.params + + @property + def returns(self) -> list[str]: + return self.signature.returns + + @property + def mutability(self) -> Mutability: + return self.signature.mutability + + @property + def visibility(self) -> str: + return self.signature.visibility + + @property + def is_getter(self) -> bool: + return self.mutability in ("view", "pure") + + @property + def is_state_changing(self) -> bool: + return self.mutability in ("nonpayable", "payable") + + @property + def is_model_method(self) -> bool: + """Modeled as an CVL method (return conformance + agent-filled body): every state-changing + fn, plus any view/pure fn flagged `model=True` (a computed view).""" + return self.is_state_changing or self.model + + @property + def effective_envfree(self) -> bool: + return self.is_getter if self.envfree is None else self.envfree + + +@dataclass +class Binding: + """Correspondence between a model ghost and a real OBS getter (an element of pi). + + The ghost's SHAPE is deterministic from the getter signature (key = params, + value = the tracked return); the ghost/reader NAMES are defaults the AI may rename. + """ + getter: FunctionSpec + ghost_name: str + reader_name: str + key_types: list[str] # getter param types -> mapping key nesting + val_type: str # tracked return type -> mapping value / scalar type + + @property + def getter_host(self) -> str: + return self.getter.getter_host + + @property + def envful(self) -> bool: + return not self.getter.effective_envfree + + @property + def is_multi_return(self) -> bool: + return len(self.getter.returns) > 1 + + @property + def component_index(self) -> int: + return self.getter.bind_component if self.getter.bind_component is not None else 0 + + @property + def component_names(self) -> list[str]: + return self.getter.component_names or [f"c{i}" for i in range(len(self.getter.returns))] + + @property + def glue_arg_names(self) -> list[str]: + return (self.getter.glue_args if self.getter.glue_args is not None + else [p.name for p in self.getter.params]) + + @property + def glue_return(self) -> bool: + return self.getter.glue_return + + @property + def state_effect(self) -> bool: + return self.getter.state_effect + + @property + def frame_arg_names(self) -> list[str]: + return self.getter.frame_args if self.getter.frame_args is not None else self.glue_arg_names + + +@dataclass +class ToolInput: + cut: str # contract NAME == currentContract (verify target); qualifies methods{} + functions: list[FunctionSpec] + alias: str | None = None # using-alias for the modeled contract when it is a DEPENDENCY + # reached via alias (consumer stays the verify target); None => + # the modeled contract IS the verify target (unqualified/currentContract) + model_spec_name: str | None = None # override; default derived from the CUT (see `model_spec`) + conformance_prefix_name: str | None = None # override; default derived from the CUT (see below) + specs_dir: str = "certora/specs" # where verify points, for the conf rewrite + precise_reverts: bool = False # revert conformance. False (default) = OVER-APPROXIMATION + # (`real success => model success`; the model may revert LESS, a + # sound coarsening). True = EXACT (`realRev == modelRev`; the model + # must revert exactly like real — no silent over-approximation). + loop_iter: int = 3 # the run's loop_iter — arrays are bounded to this length. The + # conformance pins an array-keyed observable at elements [0..loop_iter) + # and the model UNROLLS by length (CVL has no loops); share with agent. + + @property + def model_spec(self) -> str: + """The model spec filename — used both as the import in each conformance spec and as the + written file. Defaults to `SymbolicModel.spec`; override via `model_spec_name`.""" + return self.model_spec_name or f"Symbolic{self.cut}Model.spec" + + @property + def conformance_prefix(self) -> str: + """Prefix for the per-method conformance spec files, `Conformance.spec`. + Defaults to the CUT name (generic — not protocol-specific); override via + `conformance_prefix_name`.""" + return self.conformance_prefix_name or self.cut + + @property + def reachable_spec(self) -> str: + """The dedicated shared reachability spec filename (assumeReachable + CUT invariants), + imported by every conformance spec. Derived from the CUT, like `model_spec`.""" + return f"{self.cut}Reachable.spec" + + @property + def summary_spec(self) -> str: + """The CONSUMER summary-application spec filename (imports the model, summarizes each CUT fn -> + model). A downstream proof imports THIS to run against the model instead of the real CUT.""" + return f"Symbolic{self.cut}Summary.spec" diff --git a/smtool/linter.py b/smtool/linter.py new file mode 100644 index 00000000..9abaddf0 --- /dev/null +++ b/smtool/linter.py @@ -0,0 +1,272 @@ +"""The discipline, as checkable invariants over a Project. + +These back the *validation* enforcement layer: after any mutation, the project must +still satisfy every rule below, or the mutation is rejected. (The *constructive* layer +in mutations.py prevents most violations from ever being representable; the linter is +the backstop that also guards raw/body-level mutations.) +""" +from __future__ import annotations + +import composer.cvl.schema as S +from . import walk + + +PURE_BUILTINS = { + "require_uint256", "assert_uint256", "require_int256", "assert_int256", + "to_mathint", "require_uint8", "assert_uint8", +} + + +def _is_reader_call(fa: S.FunctionApplication, readers: set[str]) -> bool: + """True if `fa` is an unqualified call to a model reader (the model side of a glue equality).""" + return fa.host_contract is None and fa.name in readers + + +def _has_reader(expr, readers: set[str]) -> bool: + """True if `expr` contains a model-reader call anywhere (i.e. it references the model's side).""" + return any(_is_reader_call(fa, readers) for fa in walk.calls(expr)) + + +def _touches_state(expr, readers: set[str]) -> bool: + """expr reads model or real state: a model reader, a CUT getter call (host set), or a + setup-sourced getter (a CVL call that is neither a model reader nor a pure builtin).""" + for fa in walk.calls(expr): + if fa.host_contract is not None: + return True + if fa.name not in readers and fa.name not in PURE_BUILTINS: + return True # setup CVL getter (e.g. tokenBalanceOf) + if fa.name in readers: + return True # model reader + return False + + +def _is_gluing_equality(expr, readers: set[str]) -> bool: + """A model==real equality: an `eq` with a model reader on EXACTLY ONE side (the other side + is the real/getter/derived-local side). Catches disguised behavioral pins like + `real == CONST` (no model reader) which have no model side.""" + if not (isinstance(expr, S.BinaryOp) and expr.operator == "eq"): + return False + return _has_reader(expr.left, readers) != _has_reader(expr.right, readers) + + +def _ghost_idents(node, ghost_names: set[str]) -> set[str]: + """Ghost names appearing in `node` (quantifier vars / params filtered out by intersecting with the + real ghost-name set).""" + return {i.name for i in walk.iter_instances(node, S.Identifier)} & ghost_names + + +def lint_glued_ghost_freedom(project) -> list[str]: + """SOUNDNESS (the deepest gate). The glue is a `require ghost == realGetter` — an ASSUMPTION that + pins real state at a point where it is still free. It excludes real states unless the glued ghost + can represent EVERY reachable real value, i.e. unless it is FREE at glue-time. So no axiom may + constrain anything the glue's model side depends on — directly OR transitively (e.g. `F1 == a*RAY` + + `RAY == 10^27` forces F1, hence the real getter, to a multiple of 10^27 → conformance passes + VACUOUSLY). Such facts about real storage belong in a proved REACHABLE INVARIANT (which propagates + to the ghost through the glue), never a model axiom. + + Crucially this bites only on the REQUIRE side (the glue's model-side readers). The model's + transition/return computation (`CVL` bodies, mirrors) is checked by ASSERTs, so it MAY use + axiomatised ghosts freely — a bad axiom there makes the model diverge/revert and the conformance + rule catches it. Hence we seed the pinned set ONLY from the glue-side readers, not the bodies. + + pinned P = (ghosts the glue-side readers transitively read, via the model-function call graph) + closed under axiom co-occurrence (an axiom touching a pinned ghost pins every ghost it + names). Any axiom referencing P is rejected.""" + spec = project.model_spec + ghost_names = {b.ghost_name for b in spec.blocks if isinstance(b, S.GhostDef)} + fns = {b.name: b for b in spec.blocks if isinstance(b, S.FunctionDef)} + reads = {n: _ghost_idents(f.block, ghost_names) for n, f in fns.items()} + calls = {n: {fa.name for fa in walk.calls(f.block) if fa.host_contract is None} & set(fns) + for n, f in fns.items()} + + def transitive_reads(fn: str, seen: set[str]) -> set[str]: + if fn in seen or fn not in fns: + return set() + seen.add(fn) + out = set(reads[fn]) + for c in calls[fn]: + out |= transitive_reads(c, seen) + return out + + readers = {b.reader_name for b in project.cls.bindings} # the glue's model-side functions + pinned: set[str] = set() + for r in readers: + pinned |= transitive_reads(r, set()) + axioms = [(b.ghost_name, _ghost_idents(ax.exp, ghost_names) | {b.ghost_name}) + for b in spec.blocks if isinstance(b, S.GhostDef) for ax in b.axioms] + changed = True # close over axiom co-occurrence + while changed: + changed = False + for _, refs in axioms: + if (refs & pinned) and not (refs <= pinned): + pinned |= refs + changed = True + v: list[str] = [] + for owner, refs in axioms: + hit = refs & pinned + if hit: + v.append(f"model ghost {owner} has an axiom constraining glued/pinned-to-real ghost(s) " + f"{sorted(hit)} — the glue fixes those to real storage, so this restricts the CUT " + f"and makes conformance vacuous; state it as a proved reachable invariant " + f"(add_requireInvariant), not a model axiom") + return v + + +def lint_model_spec(project) -> list[str]: + """The model spec's structural discipline: no methods{} block (it's self-contained), every ghost + `persistent` (else a real-contract call havocs it), no model function calls a real contract + (CVL-only — ghost reads/writes fine), and no setup imports (disjoint namespace).""" + v: list[str] = [] + spec = project.model_spec + for b in spec.blocks: + if isinstance(b, S.MethodsBlock): + v.append("model spec must have NO methods{} block (it is self-contained)") + if isinstance(b, S.GhostDef) and not b.persistent: + v.append(f"model ghost {b.ghost_name} must be `persistent` " + f"(else the real contract call havocs it)") + # model functions are CVL-only: they may read/write ghosts, but must NOT call a real contract + for b in spec.blocks: + if isinstance(b, S.FunctionDef): + for fa in walk.contract_calls(b): + v.append(f"model function {b.name} calls contract {fa.host_contract}.{fa.name}; " + f"the model is CVL-only (no real-contract calls; ghost reads/writes are fine)") + # SOUNDNESS: a model function is a reverting transition — it must revert via `if (cond) revert();`, + # NOT `require`/`assert`. A `require` is an ASSUMPTION (prunes paths, doesn't revert) and would + # break revert-conformance; an `assert` is an obligation that belongs in a conformance rule. + for _ in walk.iter_instances(b, S.AssumeCmd): + v.append(f"model function {b.name} uses `require` — model bodies must revert via " + f"`if (cond) revert();`, not require (require is an unsound assumption here)") + break + for _ in walk.iter_instances(b, S.AssertCmd): + v.append(f"model function {b.name} uses `assert` — assertions belong in a conformance " + f"rule (add_helper_lemma), not in the model body") + break + # SOUNDNESS: a `require_uintN`/`require_intN` CAST assumes the value fits, silently PRUNING + # out-of-range (overflow) inputs — the conformance then passes VACUOUSLY on exactly those + # inputs (unsound; a require_* has no revert, so !realRev=>!modelRev never fires). Use the + # `assert_*` cast so the prover CHECKS the cast is total: a provably-in-range one passes, an + # overflowing one is CAUGHT. Model a genuine wrap explicitly (e.g. assert_uint256(x % 2^256)). + for fa in walk.calls(b): + nm = fa.name + if nm.startswith("require_uint") or nm.startswith("require_int"): + v.append(f"model function {b.name} uses `{nm}` (a require_* cast) — it ASSUMES the " + f"value is in range, silently pruning out-of-range/overflow inputs and making " + f"the conformance vacuous there. Use `assert_{nm.split('_', 1)[1]}` instead " + f"(the prover checks the cast is total); model a real wrap explicitly if the " + f"value can exceed the type.") + break + if spec.import_specs: + v.append("model spec must not import setup specs (disjoint namespace)") + return v + + +def lint_glue(project, method: str) -> list[str]: + """The glue is DETERMINISTIC TEMPLATE (model==real correspondence, one equality per observable) — it + is not agent-editable. This is the backstop: every `require` in it must be a model==real equality. + ANY other require is rejected — a bare input/well-formedness pin unsoundly narrows the domain the + model is proven against (a real revert precondition belongs in the model body's `if (cond) revert();`, + covered by revert-conformance; a state fact belongs in a proved reachable invariant), and a + requireInvariant belongs in assumeReachable, not the glue.""" + v: list[str] = [] + glue = project.find_glue(method) + if glue is None: + return [f"conformance[{method}] has no glue function"] + readers = project.reader_names() | project.model_function_names() + for cmd in glue.block.commands: + if isinstance(cmd, S.AssumeCmd): + if _is_gluing_equality(cmd.expression, readers): + continue + v.append(f'glue[{method}] has a non-correspondence require: "{cmd.message}". The glue is ' + f"template (model==real equalities only). A real revert precondition goes in the " + f"model body's `if (cond) revert();` (revert-conformance covers it); a state fact " + f"goes in a proved reachable invariant — never a bare require in the glue.") + elif isinstance(cmd, S.AssumeInvariantCmd): + v.append(f"glue[{method}] contains a requireInvariant ({cmd.invariant_name}); " + f"reachability assumptions belong in assumeReachable (the reachable spec), " + f"not in the correspondence glue") + # declarations / assignments (binding locals from getters) / returns are fine + return v + + +def _lhs_root(lhs) -> str | None: + """Root identifier of an assignment LHS (`g[i][j] = …` -> `g`).""" + while isinstance(lhs, S.ArrayAccessLhs): + lhs = lhs.base + return lhs.name if isinstance(lhs, S.IdLhs) else None + + +def lint_model_state_coverage(project) -> list[str]: + """SOUNDNESS: every OBSERVABLE ghost that a model `CVL` WRITES — directly OR through a helper it + calls — must be compared post-call by a state_effect assertion. Otherwise the model can set that + ghost to anything, the conformance proof has no obligation on it, and a downstream proof reading the + corresponding getter off the model reads a fabricated value — unsound replacement. Writes are traced + through the model-function call graph because helpers (add_model_function) may also write ghosts.""" + v: list[str] = [] + cls = project.cls + observable = {b.ghost_name for b in cls.bindings} + covered = {b.ghost_name for b in cls.bindings if b.state_effect} + fns = {b.name: b for b in project.model_spec.blocks if isinstance(b, S.FunctionDef)} + direct_writes = {n: {_lhs_root(lhs) for asg in walk.iter_instances(f, S.AssignmentCmd) + for lhs in asg.left_hand_sides} & observable for n, f in fns.items()} + calls = {n: {fa.name for fa in walk.calls(f) if fa.host_contract is None} & set(fns) + for n, f in fns.items()} + + def transitive_writes(fn: str, seen: set[str]) -> set[str]: + if fn in seen or fn not in fns: + return set() + seen.add(fn) + out = set(direct_writes[fn]) + for c in calls[fn]: + out |= transitive_writes(c, seen) + return out + + for m in cls.model: + fn = m.name + "CVL" + if fn not in fns: + continue + for g in sorted(transitive_writes(fn, set()) - covered): + v.append(f"model {fn} writes observable ghost {g} (directly or via a helper) but no " + f"state_effect assertion compares its post-value — unsound (the model could " + f"fabricate {g}); set state_effect=True on its getter so the rule pins it post-call") + return v + + +def lint_reachable(project) -> list[str]: + """The shared reachable spec: (a) every `requireInvariant` in `assumeReachable` must name an + invariant declared in that same spec (no dangling / unproven assumption); (b) SOUNDNESS/proveability + — every invariant body must be stated over the REAL CUT alone (real getters), NOT a model reader or + model function. A reachable invariant is discharged against the real contract by the reachable proof; + if it references model state (`mDrawnIndex == getAssetDrawnIndex`) the real CUT does not constrain + that ghost, so it can NEVER verify — prune_reachable drops it and the assumption silently vanishes. + Such a model↔real fact is the GLUE (a correspondence require), not a reachable invariant.""" + v: list[str] = [] + reach = getattr(project, "reachable", None) + if reach is None: + return v + inv_names = {b.name for b in reach.blocks if isinstance(b, S.Invariant)} + for aic in walk.iter_instances(reach, S.AssumeInvariantCmd): + if aic.invariant_name not in inv_names: + v.append(f"reachable: requireInvariant {aic.invariant_name} has no matching invariant " + f"declared in the reachable spec (dangling / unproven)") + model_names = project.reader_names() | project.model_function_names() + for inv in reach.blocks: + if isinstance(inv, S.Invariant): + hit = {fa.name for fa in walk.calls(inv.invariant_expression) if fa.name in model_names} + if hit: + v.append(f"reachable invariant {inv.name} references model function(s) {sorted(hit)} — a " + f"reachable invariant is proven of the REAL CUT alone, so it must be stated over " + f"REAL getters only (the real contract cannot constrain a model ghost, so this " + f"can never verify). A model==real fact belongs in the glue, not here.") + return v + + +def lint(project) -> list[str]: + """Run the whole discipline over a Project and return all violations ([] == clean). Called by + _commit after every mutation, so a project is only ever accepted in a discipline-compliant state.""" + v = list(lint_model_spec(project)) + v += lint_glued_ghost_freedom(project) + v += lint_model_state_coverage(project) + v += lint_reachable(project) + for m in project.conformance: + v += lint_glue(project, m) + return v diff --git a/smtool/mutations.py b/smtool/mutations.py new file mode 100644 index 00000000..a1cccaba --- /dev/null +++ b/smtool/mutations.py @@ -0,0 +1,483 @@ +"""Constrained mutation tools (component 2). + +Each mutation: + * takes TYPED inputs that cannot express a discipline violation (constructive layer), and + * applies on a snapshot, then validates (structural + linter) and commits only if clean, + otherwise rejects without touching the project (validation layer). + +The discipline-critical one is add_requireInvariant (a requireInvariant is ALWAYS paired with a +real-CUT invariant the prover must discharge — never a bare assumption). The model==real glue +equalities are NOT a mutation: they're emitted correct-by-construction by the driver. +""" +import hashlib + +import composer.cvl.schema as S +from composer.cvl.pretty_print import pretty_print + +from . import cvlx as x +from . import walk +from .project import Project, Result +from .linter import lint +from . import driver + + +def _view_only_violations(project: Project, nodes: list) -> list[str]: + """Every CONTRACT call inside `nodes` must be to a declared view/pure function. A CVL + call (host None: model reader / builtin / pure mirror) is fine. Used to keep helper-lemma + inputs side-effect-free — a capture like `x = readAndIncrement()` must be refused.""" + v: list[str] = [] + for n in nodes: + for fa in walk.contract_calls(n): + mut = project.function_mutability(fa.name) + if mut is None: + v.append(f"lemma input calls {fa.host_contract}.{fa.name} of unknown mutability; " + f"only declared view/pure getters may appear in a helper lemma") + elif mut not in ("view", "pure"): + v.append(f"lemma input calls state-changing {fa.host_contract}.{fa.name}; " + f"helper-lemma inputs must be view/pure only") + return v + + +# ---------------------------------------------------------------- transaction +def _commit(project: Project, work: Project) -> Result: + """The validation layer: given a mutated snapshot `work`, accept it into `project` ONLY if every + spec (model + conformance + reachable) structurally re-validates + pretty-prints and the discipline + linter is clean; otherwise reject and leave `project` untouched. This is what makes every mutation + all-or-nothing and keeps the project always discipline-compliant.""" + # structural: pydantic re-validation + printable + try: + specs = [work.model_spec, *work.conformance.values()] + ([work.reachable] if work.reachable else []) + for spec in specs: + S.CVLFile.model_validate(spec.model_dump()) + pretty_print(spec) + except Exception as e: + return Result(False, f"structural validation failed: {type(e).__name__}: {e}") + viol = lint(work) + if viol: + return Result(False, "rejected: discipline violation(s)", viol) + project.model_spec = work.model_spec + project.conformance = work.conformance + project.confs = work.confs + project.cls = work.cls + project.reachable = work.reachable + return Result(True, "applied") + + +def _insert_before_return(cmds: list, new_cmds: list) -> None: + """Splice `new_cmds` in just before the first `return` (or append if none) — used to add + requireInvariant / input pins into the glue body ahead of its return.""" + for i, c in enumerate(cmds): + if isinstance(c, S.ReturnCmd): + cmds[i:i] = new_cmds + return + cmds.extend(new_cmds) + + +def _idx_last_assert(cmds: list) -> int: + """Index of the LAST assert in a rule (or end if none) — where a helper lemma's intermediate + assert is inserted, so it precedes the rule's final MAIN assertion.""" + last = len(cmds) + for i, c in enumerate(cmds): + if isinstance(c, S.AssertCmd): + last = i + return last + + +def _idx_after_glue(cmds: list, glue_name: str) -> int: + """Index just after the glue call in a rule (fallback: after the `env e;` decl) — where pre-SUT + helper-lemma captures go. Exact match on the glue's actual name (resolved via Project.find_glue) — + no prefix guessing, and not confused by the assumeReachable apply that now follows the glue.""" + for i, c in enumerate(cmds): + # glue may be applied (void) or bound to a local (declaration whose init calls glue) + if isinstance(c, S.ApplyCmd) and c.target.name == glue_name: + return i + 1 + if isinstance(c, S.DeclarationCmd) and c.initial_value is not None: + fa = getattr(c.initial_value, "application", None) + if fa is not None and fa.name == glue_name: + return i + 1 + return 1 # after the `env e;` declaration + + +def _idx_after_var(cmds: list, var: str) -> int: + """Index just after the declaration of `var` (e.g. realRev), for post-SUT captures.""" + for i, c in enumerate(cmds): + if isinstance(c, S.DeclarationCmd) and c.variable.id == var: + return i + 1 + return _idx_last_assert(cmds) + + +# The glue's (ii) model==real equalities are NOT a mutation: they are fully determined by the +# bindings (reader, getter, args, env-ness) and emitted correct-by-construction by the driver +# (driver.build_glue). The only AI-driven glue content is (i) requireInvariant, below. + + +# ---------------------------------------------------------------- reachability (i): requireInvariant +def add_requireInvariant(project: Project, *, inv_name: str, + inv_params: list[tuple[str, str]], inv_expr, + require_args: list[str], env: bool = False, preserved=None) -> Result: + """Create a real-CUT invariant AND requireInvariant it in the SHARED `assumeReachable`, atomically. + + The invariant is CUT-global (independent of which method we prove), so it lives once in the + dedicated reachable spec and every conformance rule assumes it via `assumeReachable`. Idempotent: + the same invariant requested by several methods lands once. A requireInvariant is ONLY introduced + together with the invariant it names; that invariant is a proof obligation, so a model assumption + can never enter as a bare, unproven require. + + ENV invariants: set `env=True` for a fact that must call a NON-envfree getter (a time-dependent + quantity — one that accrues to block.timestamp). The invariant then takes a leading `env e`, and its + requireInvariant passes `assumeReachable`'s env. Almost always pair it with `preserved` — a list of + CVL commands for a `preserved with (env e1) { ... }` block that RELATES the transition env to the + invariant env (e.g. `require e1.block.timestamp <= e.block.timestamp;`), the idiom that makes a + time-dependent invariant provable. An envfree fact (a raw fixed-width storage field) needs NO env. + + NB soundness: the invariant is a CANDIDATE here — it must be DISCHARGED by the reachable conf + (verify.prune_reachable keeps only prover-VERIFIED ones; check_consistency flags any still-assumed + unproven invariant). TODO: source/refine candidates via composer's generate->prove->cex pass.""" + work = project.snapshot() + reach = work.reachable + if reach is None: + return Result(False, "no reachable spec to hold the invariant") + if env: # env-taking invariant: leading `env e`, pass it + a preserved block + inv_params = [("env", "e"), *inv_params] + require_args = ["e", *require_args] + proofs = [x.preserved(preserved)] if preserved else () + new_inv = x.invariant(inv_name, inv_params, inv_expr, proofs=proofs) + existing = next((b for b in reach.blocks + if isinstance(b, S.Invariant) and b.name == inv_name), None) + if existing is not None: + reach.blocks[reach.blocks.index(existing)] = new_inv # re-add REPLACES (fix a bad invariant) + else: + reach.blocks.append(new_inv) + fn = work.find_func(reach, driver.ASSUME) + if fn is None: + return Result(False, f"no {driver.ASSUME} function in the reachable spec") + # require_args go into the `requireInvariant inv(...)` call inside assumeReachable, so each must name + # one of assumeReachable's own key params (the reachable key SLOTS). An arg that isn't a slot (e.g. + # `e`, or a key type the model has no slot for) would be an undeclared identifier at typecheck — a + # failure the agent can't localize. Reject it here with the available slots instead. + slots = [p.id for p in fn.params] + bad = [a for a in require_args if a not in slots] + if bad: + return Result(False, f"requireInvariant args {bad} are not reachable-key slots. assumeReachable " + f"exposes only {slots} — the invariant must be keyed by those. A fact over " + f"another key needs a matching key slot.") + ri = x.require_invariant(inv_name, [x.ident(a) for a in require_args]) + if not any(c.model_dump() == ri.model_dump() for c in fn.block.commands): # idempotent + fn.block.commands.append(ri) + return _commit(project, work) + + +def add_glue_pin(project: Project, *, method: str, observable: str, key_exprs: list) -> Result: + """Add a model==real GLUE PIN for `observable` at `key_exprs`, into `method`'s glue. + + SOUND BY CONSTRUCTION: it emits ONLY `CVLReader(keys) == (keys)` — the ghost + pinned to its OWN real getter at those keys. A model==real pin constrains only the MODEL (never the + real contract), is always satisfiable (the ghost is free), and only STRENGTHENS 'model starts equal + to real' — so it can neither hide a divergence nor make the rule vacuous, at ANY key. The tool + hard-codes this shape, so the agent supplies only the KEYS and cannot introduce an unsound `require`. + + MULTI-RETURN getters (e.g. `getAssetUnderlyingAndDecimals -> (address, uint8)`) cannot be compared + as a tuple in CVL. Pass the GETTER name: the tool loads it ONCE into fresh component locals and pins + each modeled component's reader to its component — mirroring the driver's `build_glue` destructure. + + Use it for a key the deterministic pins miss — typically a DERIVED address (a credit target obtained + from another getter, e.g. a fee receiver) that the method reads/writes: without a pin that ghost cell + is an unconstrained uint256 (-> cast-safety CEX); pinned, it inherits the real getter's field width.""" + work = project.snapshot() + spec = work.conformance.get(method) + if spec is None: + return Result(False, f"no conformance spec for method {method!r}") + bindings = [bd for bd in work.cls.bindings if bd.getter.name == observable] + if not bindings: + return Result(False, f"{observable!r} is not a modeled observable getter — a glue pin binds an " + f"observable's ghost to its real getter") + glue = work.find_func(spec, driver.GLUE) + if glue is None: + return Result(False, f"no {driver.GLUE} function in {method}'s conformance spec") + keys = list(key_exprs) + b0 = bindings[0] + getter = x.call(b0.getter.name, ([x.ident("e")] if b0.envful else []) + keys, + host=driver._cut_host(work.inp, b0.getter)) + msg = f"glue pin (agent): model == real for {observable}" + if b0.is_multi_return: + # load the tuple ONCE, pin each modeled component reader to its component local (never a tuple==). + # locals keyed by the key-signature so a repeat at the SAME keys reuses the names (idempotent) but + # a pin at DIFFERENT keys gets fresh names (no redeclaration collision). + sig = hashlib.md5(repr([k.model_dump() for k in keys]).encode()).hexdigest()[:6] + names = [f"_gp_{observable}_{sig}_{i}" for i in range(len(b0.getter.returns))] + setup = [x.declare(ct, cn) for cn, ct in zip(names, b0.getter.returns)] + setup.append(x.assign_multi(names, getter)) + pins = [x.require(x.binop("eq", x.call(b.reader_name, keys), x.ident(names[b.component_index])), msg) + for b in bindings] + else: + setup = [] + pins = [x.require(x.binop("eq", x.call(b0.reader_name, keys), getter), msg)] + present = [c.model_dump() for c in glue.block.commands] + if all(p.model_dump() in present for p in pins): + return Result(True, f"glue pin for {observable} at those keys already present") + idx = next((i for i, c in enumerate(glue.block.commands) if isinstance(c, S.ReturnCmd)), + len(glue.block.commands)) # before a trailing return, else append + for j, cmd in enumerate([*setup, *pins]): + glue.block.commands.insert(idx + j, cmd) + return _commit(project, work) + + +def add_model_ghost_axiom(project: Project, *, ghost_name: str, axiom_expr, initial: bool = False) -> Result: + """Add an `axiom` to a NON-glued model ghost (HOLE-A) — a definitional fact about model-internal + state used only in the transition/return computation (asserted, so a wrong one is caught by + conformance). The linter (lint_glued_ghost_freedom) REJECTS an axiom that constrains a glued / + pinned-to-real ghost: that would restrict the CUT via the glue require and make conformance + vacuous — state such facts as a proved reachable invariant (add_requireInvariant) instead.""" + work = project.snapshot() + g = work.find_ghost(ghost_name) + if g is None: + return Result(False, f"no model ghost {ghost_name}") + new_ax = x.axiom(axiom_expr, initial=initial) + # idempotent: the SAME axiom (e.g. index>=RAY) is added once even if several methods request it + if any(a.model_dump() == new_ax.model_dump() for a in g.axioms): + return Result(True, f"axiom already present on {ghost_name}") + g.axioms.append(new_ax) + return _commit(project, work) + + +# ---------------------------------------------------------------- model bodies / helpers (HOLE-F/M) +def set_model_method_body(project: Project, *, method: str, commands: list) -> Result: + """Fill CVL's body. The body is CVL — it MAY write model ghosts (that's the state effect) + and call model helpers; it may NOT call real-contract (Solidity) functions (linter enforces).""" + work = project.snapshot() + fn = work.find_func(work.model_spec, driver.model_fn_name(method)) + if fn is None: + return Result(False, f"no model function {method}CVL") + fn.block = S.CodeBlock(commands=list(commands)) + return _commit(project, work) + + +def add_model_function(project: Project, *, name: str, params: list[tuple[str, str]], + returns: list[str], commands: list) -> Result: + """Add — or REPLACE — a model helper CVL function (HOLE-M): a math mirror or any internal helper the + CVL bodies call. CVL only: it MAY read/write model ghosts, but may NOT call real-contract functions + (linter enforces). If it is reachable from the GLUE-side readers it additionally must not touch a + glued/pinned ghost (lint_glued_ghost_freedom); called only from bodies (assert-side), it is free. + + Re-adding an EXISTING name: identical definition -> idempotent no-op (a shared mirror added once and + reused across methods); DIFFERENT definition -> REPLACE it. The replace path is essential for the + refine loop — it is how the agent FIXES a helper's body (e.g. a ceiling division that violated CVL's + single-assignment rule); without it, a re-add would silently keep the old buggy body.""" + work = project.snapshot() + new_fn = x.func(name, params, returns, commands) + existing = work.find_func(work.model_spec, name) + if existing is not None: + if existing.model_dump() == new_fn.model_dump(): + return Result(True, f"model function {name} already present") # idempotent shared re-add + work.model_spec.blocks[work.model_spec.blocks.index(existing)] = new_fn # replace = fix the body + else: + work.model_spec.blocks.append(new_fn) + return _commit(project, work) + + +# ---------------------------------------------------------------- model constant (HOLE-K) +def add_model_constant(project: Project, *, name: str, ctype: str, value_expr) -> Result: + """Add — or REPLACE — a `persistent ghost { axiom == value; }` in the model. + (Getter/methods declarations are NOT here — those are deterministic template output.) + Re-adding the name: identical -> idempotent no-op (shared across methods); DIFFERENT type/value -> + REPLACE it, so the agent can FIX a wrong constant (same rationale as add_model_function).""" + work = project.snapshot() + g = x.ghost_scalar(name, ctype, axioms=[x.axiom(x.binop("eq", x.ident(name), value_expr))]) + existing = work.find_ghost(name) + if existing is not None: + if existing.model_dump() == g.model_dump(): + return Result(True, f"model constant {name} already present") # idempotent shared re-add + work.model_spec.blocks[work.model_spec.blocks.index(existing)] = g # replace = fix the value + else: + work.model_spec.blocks.insert(0, g) + return _commit(project, work) + + +# ---------------------------------------------------------------- NONDET (HOLE-N) +def add_nondet(project: Project, *, method: str, contract: str | None, name: str, + param_types: list[str], return_types: list[str], mutability: str, + visibility: str = "external") -> Result: + """Add a NONDET summary entry to the conformance methods{} block. The summary is fixed + to NONDET — no other summary kind is expressible through this tool. + + SOUNDNESS: NONDET is sound ONLY for view/pure functions (it drops side effects, so + NONDET-ing a state-changing function is unsound and the prover will NOT catch it). The tool + refuses anything not view/pure, and cross-checks the claim against the compiled scene + (`Project.scene_mutability`): with a scene wired it FAILS CLOSED (refuses unless the scene + confirms view/pure); without one it trusts the caller's claim (the remaining gap). It also + REFUSES any function of the CUT itself — the only valid NONDET targets are the method's off-path + calls OUT to OTHER in-scene contracts (see below).""" + # The CUT's own external functions are NEVER valid NONDET targets: in a conformance proof the only + # calls to the CUT are the method under test and the glue / state-effect observable getters, which + # MUST return the real value (that equality IS the model<->real correspondence). NONDET-ing one + # breaks the glue (and, for an envfree getter, the typecheck). Refuse a CUT target whether named + # concretely (contract == CUT) or reached by a `_.f` wildcard whose name also exists on the CUT. + if contract == project.inp.cut or (contract in (None, "_") + and name in {f.name for f in project.inp.functions}): + return Result(False, + f"refused: {name} is a function of the contract-under-test ({project.inp.cut}). Its calls in a " + f"conformance proof are the method under test or the glue/state observable getters, which must " + f"return the REAL value (that equality IS the model<->real correspondence) — NONDET would break " + f"the glue. NONDET only OFF-PATH calls the method makes to OTHER in-scene contracts (oracle / " + f"rate strategy). If {name} shows up in the difficulty report it is because glue/state " + f"legitimately reads it; reduce that cost with a return-pivot lemma or congruence (instructions " + f"section 4 b/c), not NONDET.") + if mutability not in ("view", "pure"): + return Result(False, f"refused: NONDET of {name} is sound only for view/pure " + f"functions, not '{mutability}' (it would drop side effects)") + known = project.function_mutability(name) + if known is not None and known not in ("view", "pure"): + return Result(False, f"refused: {name} is state-changing ('{known}' per the inputs/scene); " + f"NONDET would be unsound") + if known is None and project.scene_mutability is not None: + return Result(False, f"refused: cannot confirm {name} is view/pure — it is not in the modeled " + f"inputs and not found in the scene; NONDET needs a confirmed view/pure target") + work = project.snapshot() + spec = work.conformance[method] + mb = next((b for b in spec.blocks if isinstance(b, S.MethodsBlock)), None) + if mb is None: + mb = x.methods_block([]) + spec.blocks.insert(0, mb) + # A CUT getter can never reach here (refused above), so there is no envfree-vs-summary idiom to + # reconcile. A legitimate target is a call OUT to another in-scene contract: a concrete + # `C.f(...) => NONDET` (a LINKED callee — the wildcard would not override its resolution), or a + # `_.f => NONDET` wildcard for an unlinked callee. + # a wildcard external entry (`_.f external => NONDET`) may NOT specify return types. + rts = [] if (contract == "_" and visibility == "external") else return_types + _same = lambda e: (isinstance(e.summary, S.HavocingSummary) # a prior NONDET for the + and e.signature.method_ref.method_name == name # same (contract, name) + and e.signature.method_ref.contract == contract) # -> replace (idempotent); + mb.method_entries = [e for e in mb.method_entries if not _same(e)] # keeps the envfree decl + mb.method_entries.append(x.m_nondet(contract, name, param_types, rts, visibility)) + return _commit(project, work) + + +# ---------------------------------------------------------------- helper lemma (HOLE-P) +def add_helper_lemma(project: Project, *, method: str, rule_name: str, + captures: list | None = None, post_captures: list | None = None, + assert_expr=None, message: str = "helper lemma") -> Result: + """Insert an intermediate ASSERT (a proof-decomposition lemma) into a rule, plus optional + capture declarations: `captures` go right after the glue (pre-SUT); `post_captures` go right + after the real call (post-SUT, e.g. idxAfter for accrue-idempotence). The tool builds an + AssertCmd — never a require — so a helper lemma can only ADD a checked obligation, never an + assumption; and every call in the captures/assert must be view/pure (see _view_only).""" + work = project.snapshot() + r = work.find_rule(method, rule_name) + if r is None: + return Result(False, f"no rule {rule_name} in conformance[{method}]") + # soundness: the lemma's inputs (captures + assert) may only read state, never mutate it. + check_nodes = list(captures or []) + list(post_captures or []) + ([assert_expr] if assert_expr is not None else []) + vo = _view_only_violations(work, check_nodes) + if vo: + return Result(False, "refused: helper-lemma input is not view-only", vo) + cmds = r.block.commands + if post_captures: + j = _idx_after_var(cmds, "realRev") + cmds[j:j] = list(post_captures) + if captures: + glue = work.find_glue(method) + i = _idx_after_glue(cmds, glue.name if glue else "") + cmds[i:i] = list(captures) + if assert_expr is not None: + k = _idx_last_assert(cmds) + cmds.insert(k, x.assert_(assert_expr, message)) + return _commit(project, work) + + +# ---------------------------------------------------------------- removals (retract a wrong guess) +# The property-directed moves (helper lemma, NONDET, input pin) are GUESSES that the prover may reject +# (a lemma whose assert doesn't hold VIOLATES; a NONDET the output actually depends on VIOLATES). The +# refine loop needs to RETRACT a wrong guess, so each such add has a matching remove. (Model bodies are +# already adjustable via set_model_method_body; reachable invariants via verify.prune_reachable.) +def remove_nondet(project: Project, method: str, *, name: str, contract: str | None = None) -> Result: + """Remove the NONDET methods{} entry for function `name` (optionally scoped to `contract`) from a + method's conformance. Use to retract a NONDET that turned out unsound — the checked output DID + depend on the summarized function, so the conformance rule VIOLATED.""" + work = project.snapshot() + spec = work.conformance[method] + mb = next((b for b in spec.blocks if isinstance(b, S.MethodsBlock)), None) + if mb is None: + return Result(False, f"no methods block in conformance[{method}]") + + def _match(e) -> bool: + ref = getattr(getattr(e, "signature", None), "method_ref", None) + return (ref is not None and ref.method_name == name + and (contract is None or ref.contract == contract)) + + kept = [e for e in mb.method_entries if not _match(e)] + if len(kept) == len(mb.method_entries): + return Result(False, f"no NONDET entry for {name} in conformance[{method}]") + mb.method_entries = kept + return _commit(project, work) + + +def remove_model_ghost_axiom(project: Project, *, ghost_name: str, axiom_expr) -> Result: + """Remove a definitional axiom (matched by its boolean expression, regardless of the init_state flag) + from a NON-glued model ghost — the inverse of add_model_ghost_axiom. Use to retract a wrong axiom + (a ghost holds a LIST of axioms, so this removes by expression rather than replacing).""" + work = project.snapshot() + g = work.find_ghost(ghost_name) + if g is None: + return Result(False, f"no model ghost {ghost_name}") + kept = [a for a in g.axioms if a.exp.model_dump() != axiom_expr.model_dump()] + if len(kept) == len(g.axioms): + return Result(False, f"no axiom matching that expression on {ghost_name}") + g.axioms = kept + return _commit(project, work) + + +def remove_model_constant(project: Project, *, name: str) -> Result: + """Retract a model constant/ghost the AGENT added via add_model_constant — the inverse: removes the + WHOLE `persistent ghost { ... }` declaration (not just an axiom, which would orphan a + bare colliding ghost). REFUSES the template's observable/binding ghosts (their value is fixed by the + glue) — the agent may only remove what it itself added.""" + if name in {b.ghost_name for b in project.cls.bindings}: + return Result(False, f"{name} is a template observable ghost (glue-pinned), not a removable constant") + work = project.snapshot() + g = work.find_ghost(name) + if g is None: + return Result(False, f"no model ghost/constant {name}") + work.model_spec.blocks.remove(g) + return _commit(project, work) + + +def remove_model_function(project: Project, *, name: str) -> Result: + """Retract a model helper the AGENT added via add_model_function — the inverse: removes the whole + FunctionDef. REFUSES the template functions — the per-binding readers and the `CVL` method + bodies — which are structural (not agent-added). The agent may only remove its own helpers/mirrors.""" + protected = project.reader_names() | {driver.model_fn_name(m.name) for m in project.cls.model} + if name in protected: + return Result(False, f"{name} is a template function (a reader or a CVL body), not removable") + work = project.snapshot() + fn = work.find_func(work.model_spec, name) + if fn is None: + return Result(False, f"no model function {name}") + work.model_spec.blocks.remove(fn) + return _commit(project, work) + + +def remove_helper_lemma(project: Project, method: str, rule_name: str, *, message: str) -> Result: + """Remove a helper lemma's ASSERT (matched by `message`) from a rule, and prune any capture + declaration it leaves unreferenced. Use to retract a lemma whose assertion does not actually hold + (it VIOLATED) — e.g. a preview-getter pivot that isn't exactly the method's return.""" + work = project.snapshot() + r = work.find_rule(method, rule_name) + if r is None: + return Result(False, f"no rule {rule_name} in conformance[{method}]") + msg = x._msg(message) + kept = [c for c in r.block.commands if not (isinstance(c, S.AssertCmd) and c.message == msg)] + if len(kept) == len(r.block.commands): + return Result(False, f"no helper lemma with message {message!r} in rule {rule_name}") + # prune capture declarations the lemma introduced that are now unreferenced (dead locals), fixpoint + # so a chain of captures collapses. Only DeclarationCmds go — never the driver's rule bindings, which + # stay referenced by the surviving asserts/glue. + changed = True + while changed: + changed = False + used = {i.name for c in kept for i in walk.iter_instances(c, S.Identifier)} + pruned = [c for c in kept + if not (isinstance(c, S.DeclarationCmd) and c.variable.id not in used)] + if len(pruned) != len(kept): + kept, changed = pruned, True + r.block.commands = kept + return _commit(project, work) diff --git a/smtool/overapprox.py b/smtool/overapprox.py new file mode 100644 index 00000000..6d69b6c1 --- /dev/null +++ b/smtool/overapprox.py @@ -0,0 +1,334 @@ +"""Per-function OVER-APPROXIMATION summary generator (an extension of the whole-CUT model in driver.py). + +Given a scene function `f_sol` and a predicate `Phi(params, res)`, this emits three artifacts that +share the SAME `Phi` (authored once): + + 1. the Phi spec — `function Phi(, res) returns bool { }` + 2. the over-approx SUMMARY — `function CVL(, env e) returns + { res; require Phi(, res); return res; }` + + a methods{} binding `C.f(...) => CVL(..., e)` + 3. the conformance PROOF — `rule overApprox_() + { env e; retSol = f(e, ...); assert !revert => Phi(..., retSol); }` + +Soundness is by construction: the summary returns ANY value satisfying `Phi`, and the conformance rule +proves the REAL output satisfies `Phi` (so the summary over-approximates `f_sol`). Install the summary +IFF the rule discharges. A passing rule always implies over-approximation (`forall a => exists a`), so +the discipline never trades soundness — the only requirement, enforced by keeping `Phi` a boolean +predicate over `(params, res)` with functionally-pinned internals, is completeness (that the honest +`assert Phi(f_sol(x))` is not accidentally stronger than the `exists`-execution soundness statement). + +This mirrors driver.build_return_rule, minus the model/glue: the exact-equality conformance smtool +already emits is the special case `Phi(x,y) = (y == f_cvl_exact(x))`. Reuses cvlx AST builders + ir. +v1: single-return f_sol (void => no rule; multi-return is a later generalization). +""" + +from dataclasses import dataclass + +import composer.cvl.schema as S + +from . import cvlx as x +from .ir import Signature + + +def phi_name(fn: str) -> str: + """The predicate function name for `fn` — `Phi`. One place so summary and proof agree.""" + return fn + "Phi" + + +def summary_fn_name(fn: str) -> str: + """The over-approximating summary function name — `CVL`.""" + return fn + "CVL" + + +def reverts_name(fn: str) -> str: + """The REVERT-predicate (Ψ) function name for `fn` — `Reverts`. `Ψ(params)` is true exactly where + the summary must revert; it is the dual of `Phi` (which constrains the RETURNED value on success).""" + return fn + "Reverts" + + +def revert_rule_name(fn: str) -> str: + """The revert-conformance rule name — `revertConform_` (dual of `overApprox_`).""" + return "revertConform_" + fn + + +@dataclass +class OverApproxTarget: + """What to summarize: the contract `cut` holding `f_sol` (signature `sig`), and `Phi`'s body. + + `phi_body` is the command list of the `Phi(params, res)` predicate (correct-by-construction HOLE — + the agent/user fills it, exactly like driver's HOLE-F). None => the obvious stub `return true`. + `phi_body` MUST be a boolean predicate over the params + `res` (functionally-pinned internals only); + that restriction is what makes the emitted conformance rule sound AND complete (see module doc).""" + + cut: str + sig: Signature + phi_body: list | None = None + psi_body: list | None = None # the REVERT predicate Ψ(params) body — a boolean formula over + # the params, true exactly where the summary must revert. None => + # the summary never reverts: still SOUND (summary-reverts ⊆ + # real-reverts = ∅), but COARSER — it hands back a value where the + # real `f` would revert, so a consumer proof can explore an + # impossible non-revert path (spurious CEX). A Ψ proven by + # `revertConform_` (Ψ(x) => f reverts on x) makes the summary + # revert where `f` does — a faithful, still-sound summary. + result_name: str = "res" + phi_spec_name: str | None = None # file the Phi spec is written as / imported (default Phi.spec) + psi_spec_name: str | None = None # file the Ψ spec is written as / imported (default Reverts.spec) + model_spec_import: str | None = None # optional model spec Phi/summary read ghosts from + setup_spec_import: str | None = None # the scene's setup spec the CONFORMANCE spec imports (scene + # aliases/summaries + the CUT declaration); None => self-contained + goal: str = "" # NL description of what Phi should preserve (agent-facing; drives + # the goal-directed fill — with no goal the agent settles on `true`) + envfree: bool | None = None # override env-freeness. None => derive from mutability + # (pure/view). But view != envfree: a `view` fn that reads + # block.timestamp / a getter is env-DEPENDENT and must be + # env-threaded (set False), else the envfree static check fails. + + @property + def phi_import(self) -> str: + return self.phi_spec_name or (phi_name(self.sig.name) + ".spec") + + @property + def psi_import(self) -> str: + return self.psi_spec_name or (reverts_name(self.sig.name) + ".spec") + + +def _names(base: str, n: int) -> list: + """Result-variable names: `[base]` for a single return, `[base0, base1, ...]` for multi-return. + Single-return keeps the bare `base` (back-compat with the emitted single-return specs).""" + return [base] if n == 1 else [f"{base}{i}" for i in range(n)] + + +def _phi_params(t: OverApproxTarget) -> list: + """Phi's parameter list: the function's params followed by ITS RETURNS — `(, res)` for a + single return, `(, T0 res0, T1 res1, ...)` for multi-return (Phi over the whole tuple).""" + resn = _names(t.result_name, len(t.sig.returns)) + return [(p.type, p.name) for p in t.sig.params] + list(zip(t.sig.returns, resn)) + + +def build_phi(t: OverApproxTarget) -> S.FunctionDef: + """`function Phi(, res) returns bool { }`. Body defaults to a `return true` + stub (the HOLE) so the skeleton typechecks before Phi is filled.""" + body = t.phi_body if t.phi_body is not None else [x.ret([x.boollit(True)])] + return x.func(phi_name(t.sig.name), _phi_params(t), ["bool"], body) + + +def build_phi_spec(t: OverApproxTarget) -> S.CVLFile: + """The shared Phi spec — just the predicate (optionally importing a model spec it reads).""" + imports = [t.model_spec_import] if t.model_spec_import else () + return x.spec_file(imports=imports, blocks=[build_phi(t)]) + + +def _psi_params(t: OverApproxTarget) -> list: + """Ψ's parameter list — just `f`'s params (a revert predicate over the inputs). Kept env-free like + `Phi` (§ _phi_params): the predicate is a pure boolean formula over the params, so it typechecks + standalone and reads the same in the summary guard and the conformance rule.""" + return [(p.type, p.name) for p in t.sig.params] + + +def build_psi(t: OverApproxTarget) -> S.FunctionDef: + """`function Reverts() returns bool { }`. Body defaults to `return false` (the + never-revert stub — the current summary behavior) so the skeleton typechecks before Ψ is filled.""" + body = t.psi_body if t.psi_body is not None else [x.ret([x.boollit(False)])] + return x.func(reverts_name(t.sig.name), _psi_params(t), ["bool"], body) + + +def build_psi_spec(t: OverApproxTarget) -> S.CVLFile: + """The shared Ψ spec — just the revert predicate (optionally importing a model spec it reads).""" + imports = [t.model_spec_import] if t.model_spec_import else () + return x.spec_file(imports=imports, blocks=[build_psi(t)]) + + +def _idents(node) -> set: + """Every identifier NAME appearing in a rendered (model_dump'd) expression/command tree.""" + out: set = set() + if isinstance(node, dict): + if node.get("type") == "identifier": + out.add(node.get("name")) + for v in node.values(): + out |= _idents(v) + elif isinstance(node, list): + for v in node: + out |= _idents(v) + return out + + +def lint_phi(t: OverApproxTarget) -> list: + """SOUNDNESS guardrail: flag DOMAIN-RESTRICTING `require`s in Phi's body. A `require` whose condition + touches NO fresh (havoc'd, no-initializer) local constrains only the params/result — a domain + restriction. That is UNSOUND: in the conformance `assert !reverted => Phi(x, f(x))` the require makes + the assert pass VACUOUSLY on the excluded inputs, and the installed `require Phi` then silently DROPS + those real inputs, so the summary is no longer an over-approximation. The ONLY sanctioned `require` + introduces/constrains a fresh WITNESS local (e.g. `uint248 v; require to_bytes31(v) == res;`), which + references that local and is total over the domain. Syntactic heuristic (a require referencing a fresh + local passes); the rigorous check is Phi-totality (a prover obligation — TODO). A genuine domain fact + belongs in a proved reachable invariant, not a require in Phi.""" + if not t.phi_body: + return [] + fresh = {c.model_dump()["variable"]["id"] for c in t.phi_body + if c.model_dump().get("type") == "declaration" and c.model_dump().get("initial_value") is None} + problems = [] + for c in t.phi_body: + d = c.model_dump() + if d.get("type") == "assume": # a `require` statement + refs = _idents(d.get("expression")) + if not (refs & fresh): + problems.append( + f"Phi[{t.sig.name}]: a `require` constrains only params/result " + f"(uses {sorted(r for r in refs if r)}, no fresh witness local) — a DOMAIN RESTRICTION, " + "which is UNSOUND (it silently narrows the summary; the conformance would pass " + "vacuously). Fix by one of: (1) if it restates the callee's REVERT condition " + "(div-by-zero, insufficient balance), move it to the REVERT PREDICATE via set_psi " + "(`return c == 0;`) — the summary then reverts there exactly like `f` (faithful); " + "OMIT it only if that condition is inexpressible (never-revert is sound but coarser); " + "(2) if it is a result property, put it in Phi's `return` (which the conformance " + "ASSERTS), not a `require`; (3) if it is a genuine reachable fact, relocate it to a " + "proved requireInvariant. Only a fresh WITNESS-local require may stay (e.g. " + "`uint248 v; require to_bytes31(v) == res;`).") + return problems + + +def lint_psi(t: OverApproxTarget) -> list: + """SOUNDNESS guardrail for Ψ: the revert predicate is a PURE boolean formula over the params — the + revert condition belongs in the `return` (e.g. `return c == 0;`), never in a `require`. A `require` + inside Ψ would PRUNE inputs when Ψ is evaluated, defeating the point (and it has no witness-local + idiom to justify it, unlike Phi). Flags any `require` in Ψ's body.""" + if not t.psi_body: + return [] + problems = [] + for c in t.psi_body: + if c.model_dump().get("type") == "assume": # a `require` statement + problems.append( + f"Psi[{t.sig.name}]: the revert predicate must be a PURE boolean over the params — state " + "the revert condition in the `return` (e.g. `return c == 0 || bal < amt;`), never a " + "`require` (a require in Ψ prunes inputs rather than modeling a revert).") + return problems + + +def _envfree(t: OverApproxTarget) -> bool: + """Whether to summarize `f_sol` ENVFREE (no `env` threaded through summary, binding, or proof). + Honors an explicit `t.envfree` override; otherwise DERIVES from mutability (pure/view). NB view != + envfree — a `view` that reads `block.timestamp` or storage is env-DEPENDENT; declaring it envfree + makes the prover's envfree static check fail, so pass `envfree=False` for those. (Deriving this + automatically from the body is the proper fix; the override is the escape hatch until then.)""" + if t.envfree is not None: + return t.envfree + return t.sig.mutability in ("pure", "view") + + +def build_summary(t: OverApproxTarget) -> S.FunctionDef: + """The over-approximating summary body. When Ψ is present: `if (Reverts(params)) revert();` FIRST + — the summary reverts exactly where `f` does (proven by `revertConform_`). Then havoc each result, + `require Phi(params, res...)`, return the (tuple of) result(s). Takes a trailing `env e` param only for + a non-envfree `f_sol`.""" + rets = list(t.sig.returns) + resn = _names(t.result_name, len(rets)) + phi_args = [x.ident(p.name) for p in t.sig.params] + [x.ident(n) for n in resn] + cmds: list = [] + if t.psi_body is not None: # revert where the real f reverts + psi_args = [x.ident(p.name) for p in t.sig.params] + cmds.append(x.if_(x.call(reverts_name(t.sig.name), psi_args), + [x.revert("over-approx: f reverts on these inputs")])) + cmds += [x.declare(rt, n) for rt, n in zip(rets, resn)] # havoc each result + cmds.append(x.require(x.call(phi_name(t.sig.name), phi_args), "over-approx: result satisfies Phi")) + cmds.append(x.ret([x.ident(n) for n in resn])) # return res / (res0, res1, ...) + params = [(p.type, p.name) for p in t.sig.params] + if not _envfree(t): + params = params + [("env", "e")] + return x.func(summary_fn_name(t.sig.name), params, rets, cmds) + + +def build_summary_spec(t: OverApproxTarget) -> S.CVLFile: + """The summary spec: imports Phi (+ Ψ when present), defines `CVL`, binds `C.f => CVL(...)`.""" + envfree = _envfree(t) + call_args = [x.ident(p.name) for p in t.sig.params] + ([] if envfree else [x.ident("e")]) + call = x.call(summary_fn_name(t.sig.name), call_args) + binding = x.m_expr_summary(t.cut, t.sig.name, [(p.type, p.name) for p in t.sig.params], + list(t.sig.returns), call, with_env=None if envfree else "e") + imports = [t.phi_import] + ([t.psi_import] if t.psi_body is not None else []) + return x.spec_file(imports=imports, contracts=(), + blocks=[build_summary(t), x.methods_block([binding])]) + + +def _call_real(t: OverApproxTarget) -> S.FunctionCall: + """Call the REAL `f_sol` — `f([e,] args...)`, unqualified (resolves to currentContract), `@withrevert` + so the rule can gate the assertion on real success (mirrors driver._call_real). No leading `e` when + envfree.""" + lead = [] if _envfree(t) else [x.ident("e")] + return x.call(t.sig.name, lead + [x.ident(p.name) for p in t.sig.params], + host=None, annotation="withrevert") + + +def build_conformance_rule(t: OverApproxTarget) -> S.RuleBlock | None: + """`overApprox_`: call the real function and assert its output satisfies Phi on real success — + `assert !realReverted => Phi(params, retSol...)`. Proves `f_cvl` over-approximates `f_sol`. Returns + None for a VOID function (no result to constrain). Handles multi-return: the tuple is bound via a + multi-assignment `(retSol0, retSol1, ...) = f@withrevert(...)` and Phi ranges over all components.""" + rets = list(t.sig.returns) + if not rets: + return None # void: nothing to constrain + retn = _names("retSol", len(rets)) + params = [(p.type, p.name) for p in t.sig.params] + phi_args = [x.ident(p.name) for p in t.sig.params] + [x.ident(n) for n in retn] + cmds: list = [] + if not _envfree(t): + cmds.append(x.declare("env", "e")) + if len(rets) == 1: + cmds.append(x.declare(rets[0], retn[0], _call_real(t))) # single: declare + init + else: + cmds += [x.declare(rt, n) for rt, n in zip(rets, retn)] # multi: declare each ... + cmds.append(x.assign_multi(retn, _call_real(t))) # ... then (r0, r1, ...) = f@withrevert(...) + cmds += [ + x.declare("bool", "realRev", x.ident("lastReverted")), + x.assert_( + x.binop("implies", x.unop_not(x.ident("realRev")), + x.call(phi_name(t.sig.name), phi_args)), + "real output must satisfy Phi (summary over-approximates the real function)", + ), + ] + return x.rule("overApprox_" + t.sig.name, params, cmds) + + +def build_revert_rule(t: OverApproxTarget) -> S.RuleBlock | None: + """`revertConform_` (dual of `overApprox_`): call the real `f` and assert the summary reverts + ONLY where `f` reverts — `assert Psi(params) => realReverted`. This is the SOUND direction (summary + reverts ⊆ real reverts): a proven Ψ makes the summary revert where `f` does without ever dropping a + real non-revert behavior. Returns None when Ψ is unset (the summary then never reverts — sound but + coarser). Independent of the return arity (bare `f@withrevert` call, return value ignored), so it + applies to multi-return and even void `f`.""" + if t.psi_body is None: + return None + params = [(p.type, p.name) for p in t.sig.params] + psi_args = [x.ident(p.name) for p in t.sig.params] + cmds: list = [] + if not _envfree(t): + cmds.append(x.declare("env", "e")) + cmds.append(x.apply(_call_real(t))) # f@withrevert([e,] args...) + cmds += [ + x.declare("bool", "realRev", x.ident("lastReverted")), + x.assert_( + x.binop("implies", x.call(reverts_name(t.sig.name), psi_args), x.ident("realRev")), + "summary reverts only where the real function reverts (Psi => realReverted)", + ), + ] + return x.rule(revert_rule_name(t.sig.name), params, cmds) + + +def build_conformance_spec(t: OverApproxTarget) -> S.CVLFile: + """The conformance spec: imports the scene setup spec (if any, for the scene's aliases/summaries + + the CUT declaration) + Phi (+ Ψ when present) + (for an envfree target) an envfree decl of `f_sol` + + the over-approx value rule and, when Ψ is set, the revert-conformance rule (real `f_sol`, no summary + imported).""" + rule = build_conformance_rule(t) + rrule = build_revert_rule(t) + blocks: list = [] + if _envfree(t): + blocks.append(x.methods_block([x.m_envfree(t.cut, t.sig.name, + [p.type for p in t.sig.params], list(t.sig.returns))])) + if rule is not None: + blocks.append(rule) + if rrule is not None: + blocks.append(rrule) + imports = (([t.setup_spec_import] if t.setup_spec_import else []) + [t.phi_import] + + ([t.psi_import] if t.psi_body is not None else [])) + return x.spec_file(imports=imports, contracts=(), blocks=blocks) diff --git a/smtool/overapprox_project.py b/smtool/overapprox_project.py new file mode 100644 index 00000000..177b6d50 --- /dev/null +++ b/smtool/overapprox_project.py @@ -0,0 +1,234 @@ +"""OverApproxProject: the mutable state the over-approximation agent's ONE mutation (`set_phi`) drives. + +This is the over-approx counterpart of `project.Project`, but far simpler: the whole model is a single +authored hole — the predicate `Phi(params, res)` per target function. Everything else (the summary, the +conformance rule) is DETERMINISTIC given Phi (see `overapprox.py`). So the agent's only lever is Phi, and +the loop's job is to make Phi as STRONG as the conformance proof allows (weaken on a counterexample). + +Holds one `OverApproxTarget` per target function (keyed by name). `set_phi` parses CVL surface text into +the target's `phi_body` on a snapshot, validates structurally, and commits — mirroring the +snapshot→validate→commit discipline of the model mutations. `write` emits, per target, the three +artifacts (`Phi.spec`, `Summary.spec`, `Conformance.spec`) + the conformance `.conf`; the +runner then proves `overApprox_`. Reuses `overapprox.py` builders, `cvl_parse`, `project._set_perf` +(same perf conf settings as the model loop), and `pretty_print` — it changes no existing smtool file. +""" +import copy +import json +from dataclasses import dataclass, field +from pathlib import Path + +from composer.cvl.pretty_print import pretty_print + +from . import overapprox as oa +from .overapprox import OverApproxTarget +from .cvl_parse import parse_commands, CVLParseError +from .project import Result, _set_perf + + +def conformance_rule_name(fn: str) -> str: + """The value-conformance rule the runner proves for target `fn` (matches build_conformance_rule).""" + return "overApprox_" + fn + + +def conformance_rule_names(t: OverApproxTarget) -> list[str]: + """All conformance rules to run for target `t`: the value rule `overApprox_` (single-return) plus, + when Ψ is set, the revert rule `revertConform_` — the two the emitted conformance spec carries.""" + names: list[str] = [] + if oa.build_conformance_rule(t) is not None: + names.append(conformance_rule_name(t.sig.name)) + if oa.build_revert_rule(t) is not None: + names.append(oa.revert_rule_name(t.sig.name)) + return names + + +@dataclass +class OverApproxProject: + """Targets keyed by function name; the shared CUT + scene setup import + specs dir. Each target's + `phi_body` is the hole `set_phi` fills. `verified` records the targets whose conformance rule the + prover discharged (so the summary may be installed).""" + cut: str + targets: dict[str, OverApproxTarget] + setup_spec_import: str | None = None + specs_dir: str = "certora/specs" + verified: set = field(default_factory=set) + last_verified: dict = field(default_factory=dict) # fn -> the phi_body that last PROVED (the sound + # fallback: on budget-exhaustion we ship this, + # never a tighter-but-failing Phi) + + # -------------------------------------------------- construction + @classmethod + def of(cls, cut: str, targets: list[OverApproxTarget], setup_spec_import: str | None = None, + specs_dir: str = "certora/specs") -> "OverApproxProject": + """Build from a list of targets. Stamps each target's `cut`/`setup_spec_import` from the project + so the emitted conformance spec imports the scene setup and calls the right CUT.""" + by_name: dict[str, OverApproxTarget] = {} + for t in targets: + t.cut = cut + if t.setup_spec_import is None: + t.setup_spec_import = setup_spec_import + by_name[t.sig.name] = t + return cls(cut=cut, targets=by_name, setup_spec_import=setup_spec_import, specs_dir=specs_dir) + + # -------------------------------------------------- the ONE mutation + def set_phi(self, fn: str, cvl_text: str) -> Result: + """Fill / replace target `fn`'s predicate body from CVL surface text. The body is a sequence of + CVL statements ending in `return ` (it may declare locals and `require` — the sqrt/tag + idioms). Parsed with the Phi param scope ([params..., res]); a parse error is a REJECTED result + the agent fixes. Applies on a snapshot and commits only if it re-renders (structurally valid).""" + if fn not in self.targets: + return Result(False, f"unknown target '{fn}' (targets: {sorted(self.targets)})") + t = self.targets[fn] + params = oa._phi_params(t) # [(type, name)...] including the trailing result + try: + cmds = parse_commands(cvl_text, params) + except CVLParseError as e: + return Result(False, f"CVL parse error in Phi body: {e}") + snap = copy.deepcopy(t) + snap.phi_body = cmds + try: + pretty_print(oa.build_phi_spec(snap)) # structural: the Phi spec must render + except Exception as e: # pragma: no cover - defensive + return Result(False, f"Phi does not render: {type(e).__name__}: {e}") + bad = oa.lint_phi(snap) # SOUNDNESS guardrail: no domain-restricting require + if bad: + return Result(False, "Phi has a domain-restricting require (would be unsound)", violations=bad) + self.targets[fn] = snap + self.verified.discard(fn) # Phi changed → any prior verdict is stale + return Result(True, f"set Phi for '{fn}' ({len(cmds)} statement(s))") + + def set_psi(self, fn: str, cvl_text: str) -> Result: + """Fill / replace target `fn`'s REVERT predicate Ψ(params) from CVL surface text — a boolean + formula over the params, true where the summary must revert (e.g. `return c == 0;`). Installs + `if (Ψ(params)) revert();` at the top of the summary and adds the `revertConform_` rule + (`Ψ => realReverted`). Ψ over the params only (no result, no witness locals), so a `require` is + never legitimate here — the lint rejects it. Snapshot→validate→commit, like set_phi.""" + if fn not in self.targets: + return Result(False, f"unknown target '{fn}' (targets: {sorted(self.targets)})") + t = self.targets[fn] + try: + cmds = parse_commands(cvl_text, oa._psi_params(t)) + except CVLParseError as e: + return Result(False, f"CVL parse error in Psi body: {e}") + snap = copy.deepcopy(t) + snap.psi_body = cmds + try: + pretty_print(oa.build_psi_spec(snap)) # structural: the Ψ spec must render + except Exception as e: # pragma: no cover - defensive + return Result(False, f"Psi does not render: {type(e).__name__}: {e}") + bad = oa.lint_psi(snap) # SOUNDNESS guardrail: no require in Ψ + if bad: + return Result(False, "Psi must be a pure boolean revert predicate (no require)", violations=bad) + self.targets[fn] = snap + self.verified.discard(fn) # Ψ changed → any prior verdict is stale + return Result(True, f"set Psi (revert predicate) for '{fn}' ({len(cmds)} statement(s))") + + # -------------------------------------------------- verified-fallback bookkeeping + def mark_verified(self, fn: str) -> None: + """Record that `fn`'s current Phi PROVED — add it to `verified` and snapshot the proven body as + the fallback (so a later tighter-but-failing Phi never overwrites what we can ship).""" + self.verified.add(fn) + self.last_verified[fn] = copy.deepcopy(self.targets[fn].phi_body) + + def restore_best_verified(self) -> list[str]: + """Reset each target whose current Phi is NOT its last-proven one back to that proven Phi. Called + at loop end so the emitted summary is always a conformance-VERIFIED Phi, even if the agent left a + tighter-but-failing one when the budget ran out. Returns the fns restored.""" + restored = [] + for fn, body in self.last_verified.items(): + if fn in self.targets and self.targets[fn].phi_body is not body: + self.targets[fn].phi_body = copy.deepcopy(body) + restored.append(fn) + return restored + + # -------------------------------------------------- rendering + def render_phi(self, fn: str) -> str: + return pretty_print(oa.build_phi_spec(self.targets[fn])) + + def render_psi(self, fn: str) -> str: + return pretty_print(oa.build_psi_spec(self.targets[fn])) + + def render_summary(self, fn: str) -> str: + # the sound-by-construction HAVOC summary (require Phi; havoc'd result). The DETERMINISTIC-ghost + # (memo) form — for consumer proofs that need f to behave as a function — is built separately by + # smtool.detsummary from the same conformance-proven Phi. + return pretty_print(oa.build_summary_spec(self.targets[fn])) + + def render_conformance(self, fn: str) -> str: + return pretty_print(oa.build_conformance_spec(self.targets[fn])) + + # -------------------------------------------------- conf + output + def provable_targets(self) -> list[str]: + """Targets that yield at least one conformance rule: the value rule (single-return `f_sol`) or, + when Ψ is set, the revert rule (a void `f_sol` has no value rule but can still have a Ψ).""" + return [fn for fn, t in self.targets.items() if conformance_rule_names(t)] + + def _conf(self, fn: str, setup_conf: dict) -> dict: + """The conformance .conf for `fn`: the setup conf (scene inherited untouched) with verify -> the + conformance spec, rule = the value rule `overApprox_` plus `revertConform_` when Ψ is set + (the setup's imported `sanity` rule stays defined-but-not-run), multi_assert_check on, and + smtool's perf settings.""" + conf = copy.deepcopy(setup_conf) + conf["verify"] = f"{self.cut}:{self.specs_dir}/{fn}Conformance.spec" + conf["msg"] = f"overapprox {fn} conformance" + conf["multi_assert_check"] = True + conf["rule"] = conformance_rule_names(self.targets[fn]) + return _set_perf(conf) + + def conf_paths(self, out_dir: str) -> list[str]: + """The conformance .conf path emitted per provable target (what the runner verifies).""" + return [f"{out_dir}/conf/{fn}Conformance.conf" for fn in self.provable_targets()] + + def write(self, out_dir: str, setup_conf: dict) -> list[str]: + """Write, per target: `Phi.spec`, `Summary.spec` (the installable deliverable), and — for + a provable (single-return) target — `Conformance.spec` + `.conf`. Returns the paths written. + Specs go in the subdir named by `specs_dir`'s last component (so it agrees with the conf's + `verify` path, which is `specs_dir`-relative — e.g. `certora/spec` vs `certora/specs`).""" + out = Path(out_dir) + spec_dir = out / Path(self.specs_dir).name + spec_dir.mkdir(parents=True, exist_ok=True) + (out / "conf").mkdir(parents=True, exist_ok=True) + written: list[str] = [] + for fn, t in self.targets.items(): + pp = spec_dir / t.phi_import + pp.write_text(self.render_phi(fn)) + sp = spec_dir / f"{fn}Summary.spec" + sp.write_text(self.render_summary(fn)) + written += [str(pp), str(sp)] + if t.psi_body is not None: # the revert predicate Ψ (dual of Phi) + rp = spec_dir / t.psi_import + rp.write_text(self.render_psi(fn)) + written.append(str(rp)) + if not conformance_rule_names(t): # void f_sol with no Ψ: no conformance to run + continue + cs = spec_dir / f"{fn}Conformance.spec" + cs.write_text(self.render_conformance(fn)) + cc = out / "conf" / f"{fn}Conformance.conf" + cc.write_text(json.dumps(self._conf(fn, setup_conf), indent=4)) + written += [str(cs), str(cc)] + return written + + # -------------------------------------------------- consistency (no prover) + def check_consistency(self) -> list[str]: + """Structural/typecheck coherence (NOT the SMT proof — that's the loop's verify). Per target: + the Phi spec type-checks standalone, Phi returns bool, and the conformance spec references it. + Returns a list of problems ([] == consistent).""" + from .typecheck import typecheck_spec + problems: list[str] = [] + for fn, t in self.targets.items(): + if t.phi_body is None: + problems.append(f"[{fn}] Phi is unfilled (still the `return true` stub) — call set_phi") + if list(t.sig.returns) and len(t.sig.returns) != 1: + problems.append(f"[{fn}] f_sol has {len(t.sig.returns)} returns; v1 over-approx is " + f"single-return only (multi-return Phi is a later generalization)") + ok, tail = typecheck_spec(self.render_phi(fn)) + if not ok: + last = tail.strip().splitlines()[-1] if tail.strip() else "(no output)" + problems.append(f"[{fn}] Phi spec does not typecheck: {last}") + problems += oa.lint_phi(t) # SOUNDNESS: no domain-restricting require in Phi + if t.psi_body is not None: + ok, tail = typecheck_spec(self.render_psi(fn)) + if not ok: + last = tail.strip().splitlines()[-1] if tail.strip() else "(no output)" + problems.append(f"[{fn}] Psi spec does not typecheck: {last}") + problems += oa.lint_psi(t) # SOUNDNESS: no require in the revert predicate Ψ + return problems diff --git a/smtool/partial_model.py b/smtool/partial_model.py new file mode 100644 index 00000000..dd7f418f --- /dev/null +++ b/smtool/partial_model.py @@ -0,0 +1,90 @@ +"""Orchestrator for a PARTIAL model: given a consumer `.conf` + pi + rho, produce a conformance-proven, +separation-checked, installed symbolic model of a dependency contract — WITHOUT changing the consumer's +verify target. + +The consumer (e.g. a router) STAYS the verify target in every derived run. The modeled contract C +(e.g. a position/token manager) is a dependency reached via its `using` alias (e.g. `dep`). This is the safe +composition: the conformance + separation are proven in exactly the scene the model is installed into +(same parametric context, same dispatch/summaries), so they directly license the install. It relies on +the driver's alias support (`ToolInput.alias`, threaded through the call sites). + +DETERMINISTIC pipeline (code, not a meta-agent). Reuses: + * driver.py — model spec (fCVL stubs) + conformance rules (alias-aware) + install summary. + * the fill agent — fills the fCVL bodies (the ONE AI step; run_smtool's graphcore loop). Templated + token semantics are a future shortcut; for now the agent proposes bodies and the + conformance disposes them. + * separation.py — the partial-model soundness gate (pi/rho storage-write-disjointness, getter-frame). + * ProverRunner — the cloud runs; each derived .conf reuses the consumer conf (files/links/solc/ + packages), only swapping `verify` to the generated spec (tokens stay REAL for the + conformance/separation runs; the model is installed only in the consumer run). + +FLOW (each step gates the next): + 1. layout — build ToolInput(cut=C, alias) + ModelLayout from pi (functions + observables). + 2. model — driver.build_model_spec (stubs) -> fill fCVL bodies (agent) -> SymbolicModel.spec. + 3. conform — driver conformance rules (alias-aware) over the consumer scene; RUN; require all VERIFIED + (with the token SOLVENCY invariant when C is a token — removes only unreachable states). + 4. separate — separation.build_separation_rules(pi_fns, rho_fns, pi_obs, rho_obs); RUN; require VERIFIED. + 5. install — driver.build_summary_spec -> add `import "SymbolicSummary.spec";` to the consumer spec. + Installed ONLY after 3 AND 4 pass. Optionally re-run the consumer conf for the perf delta. + +This module provides the deterministic assembly (`emit`) that turns (conf, pi, rho) into the runnable +artifacts + the confs; the run/gate/install driving loop reuses smtool's ProverRunner + verify.py. +""" +from dataclasses import dataclass, field + +from .ir import ToolInput, FunctionSpec, Signature +from .separation import Observable + + +@dataclass +class PartialModelSpec: + """The whole input: a consumer `.conf` (stays the verify target) + the pi/rho projection of a + dependency contract C. rho is REACHABILITY-DERIVED (Router-reached C functions minus pi — from the + surviving-call-graph); unreached C functions are omitted (never execute in the proof).""" + consumer_conf: str # path to the consumer run .conf + setup_spec: str # the consumer's setup spec (imported so the effective scene matches) + modeled_contract: str # C — the dependency contract to model + alias: str # C's `using` alias in the consumer scene (e.g. "dep") + + pi_functions: list = field(default_factory=list) # FunctionSpec — modeled (state-changers + views) + pi_observables: list = field(default_factory=list) # Observable — pi getters (balanceOf, ...) + rho_functions: list = field(default_factory=list) # Signature — retained (reached, not modeled) + rho_observables: list = field(default_factory=list) # Observable — rho getters (moduleById, ...) + + solvency_invariant: str | None = None # e.g. "requireSolventCollateral" — assumed in the conformance + # for a token C (removes unreachable overflow states; sound) + + def tool_input(self) -> ToolInput: + """The smtool ToolInput: CUT is the MODELED contract C (names methods{} + envfree decls), but the + call-time host/self use `alias` so the consumer stays the verify target.""" + return ToolInput(cut=self.modeled_contract, functions=self.pi_functions, alias=self.alias) + + +def separation_spec(spec: PartialModelSpec): + """The separation soundness gate for `spec`: pi/rho storage-write-disjointness (W1: rho-fns preserve + pi observables; W2: pi-fns preserve rho observables), over the consumer scene (tokens real, no model). + Returns a CVLFile. Read non-interference (R) + the hook fast-path are separation.py follow-ups.""" + from .separation import build_separation_rules, build_separation_spec + pi_sigs = [f.signature for f in spec.pi_functions] + rules = build_separation_rules(pi_sigs, spec.rho_functions, spec.pi_observables, + spec.rho_observables, spec.modeled_contract, alias=spec.alias) + return build_separation_spec(spec.setup_spec, rules) + + +def derived_conf(spec: PartialModelSpec, verify_spec: str, *, install: bool = False) -> dict: + """A run .conf derived from the consumer conf: reuse everything (files/links/solc/packages/flags), + swap `verify` to `:` — the consumer STAYS the verify target. `install` + is a marker for the consumer run that imports the model summary (vs the conformance/separation runs + that keep the tokens real). Conformance needs full formula checking (drop -skipFormulaChecking).""" + import json + conf = json.loads(open(spec.consumer_conf).read()) + conf["verify"] = f"{_consumer_cut(conf)}:{verify_spec}" + conf["prover_args"] = [a for a in conf.get("prover_args", []) + if a and a.lstrip("-").split()[0] != "skipFormulaChecking"] + conf.pop("rule", None) + return conf + + +def _consumer_cut(conf: dict) -> str: + """The consumer's verify target (e.g. `Router`) — kept as the CUT in every derived run.""" + return conf["verify"].split(":", 1)[0] diff --git a/smtool/project.py b/smtool/project.py new file mode 100644 index 00000000..c7be0b93 --- /dev/null +++ b/smtool/project.py @@ -0,0 +1,307 @@ +"""Project: the mutable set of artifacts the mutation tools operate on. + +Holds the model spec + per-method conformance specs (as CVL AST) + confs. Mutations +apply on a deep copy, then the project validates (structural + linter); a mutation is +committed only if validation is clean, otherwise rejected — so the project is always +discipline-compliant by construction of its mutators. +""" +from __future__ import annotations + +import copy +import json +from dataclasses import dataclass, field +from pathlib import Path + +import composer.cvl.schema as S +from composer.cvl.pretty_print import pretty_print + +from .ir import ToolInput +from .classify import classify, ModelLayout +from . import driver + +# Perf settings applied to every generated conf, to speed up the heavy CUT conformance runs: +# - prover_args (as {name: value}, empty value == a bare flag): `-calltraceFreeOpt twostage` (defers +# call-trace construction), `-split false` (disables splitting), `-backendStrategy singlerace` (single +# solver race) — these almost always help here. +# - top-level `smt_timeout`: a MODEST budget (default 600s) so hard instances TIME OUT FAST and the +# refine loop reaches the timeout-resolution step sooner, rather than burning ~25min on a doomed +# proof. Override via env `SMTOOL_SMT_TIMEOUT` (e.g. 300 for quick debugging). +import os as _os +PERF_PROVER_ARGS = {"calltraceFreeOpt": "twostage", "split": "false", "backendStrategy": "singlerace"} +PERF_PROPERTIES = {"smt_timeout": int(_os.environ.get("SMTOOL_SMT_TIMEOUT", "600"))} + + +def _set_perf(conf: dict) -> dict: + """Set smtool's perf settings on a conf dict AT CREATION (alongside the verify/msg/rule rewrite in + driver.rewrite_conf): PERF_PROPERTIES (smt_timeout) as top-level keys, and PERF_PROVER_ARGS as + `- ` prover_args entries (deduped against any inherited args). Doing it here — the one + place a conf is created from the base — means it's set once per conf and needs no separate + re-application pass on every write (which re-ran + re-logged for all confs each check/verify).""" + conf.update(PERF_PROPERTIES) + args = list(conf.get("prover_args", [])) + # a conformance proof must run FULL formula checking — drop the scene's -skipFormulaChecking if inherited + args = [a for a in args if a and a.lstrip("-").split()[0] != "skipFormulaChecking"] + have = {a.lstrip("-").split()[0] for a in args if a} + for k, v in PERF_PROVER_ARGS.items(): + if k not in have: + args.append(f"-{k} {v}") + conf["prover_args"] = args + return conf + + +@dataclass +class Project: + inp: ToolInput + cls: ModelLayout + model_spec: S.CVLFile + conformance: dict[str, S.CVLFile] # method name -> conformance spec + confs: dict[str, dict] # method name -> conf dict + reachable: S.CVLFile | None = None # shared reachable spec (assumeReachable + CUT invariants) + setup_spec_import: str | None = None # the setup spec the conformance/proof specs import + scene_mutability: object = None # optional resolve(name)->stateMutability from the compiled + # scene (scene.mutability_resolver), for the add_nondet + # cross-check; None => the check is caller-trusted (see below) + verified_invariants: set = field(default_factory=set) # reachable invariants the prover DISCHARGED + # (populated by verify.prune_reachable). An assumed invariant + # not in here is an unproven assumption — check_consistency flags it. + + # -------------------------------------------------- construction + @classmethod + def from_input(cls, inp: ToolInput, setup_conf: dict | None = None, + setup_spec_import: str | None = None, declared=None) -> "Project": + """Build a Project from ONE ToolInput (all methods share that input's function list). The + multi-method production path is `from_method_specs`; this single-input path is used offline + (e.g. the recons build one method at a time).""" + c = classify(inp.functions) + model = driver.build_model_spec(inp, c) + reachable = driver.build_reachable_spec(inp, c) + conf_specs, confs = {}, {} + for m in c.model: + conf_specs[m.name] = driver.build_conformance_spec( + inp, c, m, setup_spec_import, declared, reachable_spec_import=inp.reachable_spec) + if setup_conf is not None: + confs[m.name] = driver.rewrite_conf(setup_conf, inp, m) + return cls(inp=inp, cls=c, model_spec=model, conformance=conf_specs, confs=confs, + reachable=reachable, setup_spec_import=setup_spec_import) + + @classmethod + def from_method_specs(cls, method_specs: list[ToolInput], + setup_spec_import: str | None = None, declared=None, + precise_reverts: bool = False, loop_iter: int = 3) -> "Project": + """Build ONE Project with a SINGLE shared model + per-method conformance. The model is the + union of the per-method observables (deduped by getter name; the observable occurrence wins, + so it defines the ghost) plus one CVL stub per method. Each method's conformance is built + from ITS OWN spec (per-method getter roles), importing the one shared model. No merge. + `precise_reverts` (the single smtool switch) sets EXACT revert conformance everywhere; default + False = the sound OVER-APPROXIMATION.""" + for spec in method_specs: + spec.precise_reverts = precise_reverts + spec.loop_iter = loop_iter + base = method_specs[0] + methods: dict[str, object] = {} + getters: dict[str, object] = {} + for spec in method_specs: + for f in spec.functions: + if f.is_state_changing: + methods.setdefault(f.name, f) + else: + # key by (name, bound component): two components of ONE multi-return getter + # (e.g. `getPair() -> (a, b)`) get distinct ghosts; the same observable declared + # across methods still merges to one. + key = (f.name, f.bind_component) + cur = getters.get(key) + if cur is None or (f.observable and not cur.observable): + getters[key] = f + union = list(methods.values()) + list(getters.values()) + model_input = ToolInput( + cut=base.cut, functions=union, alias=base.alias, model_spec_name=base.model_spec_name, + conformance_prefix_name=base.conformance_prefix_name, specs_dir=base.specs_dir, + precise_reverts=precise_reverts, loop_iter=loop_iter) + model_cls = classify(union) + model = driver.build_model_spec(model_input, model_cls) + reachable = driver.build_reachable_spec(model_input, model_cls) # ONE shared reachable spec + # the reachable key SLOTS are a property of the WHOLE model (their declaration lives in the shared + # reachable spec) — thread them so every per-method conformance rule calls assumeReachable with + # the declared slots, even for methods whose own layout would pick different keys. + reachable_keys = driver._reachable_keys(model_cls) + conformance: dict[str, S.CVLFile] = {} + for spec in method_specs: + c = classify(spec.functions) # per-method classification (per-method getter roles) + m = c.model[0] + conformance[m.name] = driver.build_conformance_spec( + spec, c, m, setup_spec_import, declared, reachable_spec_import=model_input.reachable_spec, + reachable_keys=reachable_keys) + return cls(inp=model_input, cls=model_cls, model_spec=model, conformance=conformance, + confs={}, reachable=reachable, setup_spec_import=setup_spec_import) + + # -------------------------------------------------- lookups + def model_function_names(self) -> set[str]: + """Names of all functions in the model spec (readers, mirrors/helpers, and CVL bodies).""" + return {b.name for b in self.model_spec.blocks if isinstance(b, S.FunctionDef)} + + def function_mutability(self, name: str) -> str | None: + """Mutability of a function: from the modeled inputs first, else the compiled scene + (`scene_mutability`, if wired), else None (unknown). Used by add_nondet to refuse NONDET of a + state-changing function.""" + for f in self.inp.functions: + if f.name == name: + return f.mutability + return self.scene_mutability(name) if self.scene_mutability else None + + def reader_names(self) -> set[str]: + """The model reader names (the glue's model-side accessor functions), one per binding.""" + return {b.reader_name for b in self.cls.bindings} + + def find_func(self, spec: S.CVLFile, name: str) -> S.FunctionDef | None: + """The FunctionDef named `name` in the given spec (model / conformance / reachable), or None.""" + for b in spec.blocks: + if isinstance(b, S.FunctionDef) and b.name == name: + return b + return None + + def find_glue(self, method: str) -> S.FunctionDef | None: + """The correspondence function — identified STRUCTURALLY as the sole FunctionDef in a + conformance spec (model fns live in the model spec; assumeReachable in the reachable spec), + not by a name prefix.""" + fns = [b for b in self.conformance[method].blocks if isinstance(b, S.FunctionDef)] + return fns[0] if fns else None + + def reachable_invariant_names(self) -> list[str]: + """Names of the invariants DECLARED in the reachable spec (the candidate set to prove).""" + return driver.invariant_names(self.reachable) if self.reachable else [] + + def assumed_invariant_names(self) -> list[str]: + """Invariants ASSUMED (requireInvariant in assumeReachable) — each must be prover-DISCHARGED + (in verified_invariants) before the conformance results may be trusted.""" + fn = self.find_func(self.reachable, driver.ASSUME) if self.reachable else None + return [c.invariant_name for c in fn.block.commands + if isinstance(c, S.AssumeInvariantCmd)] if fn else [] + + def drop_invariants(self, names) -> None: + """Best-effort prune: remove each named invariant (its declaration in the reachable spec AND + its `requireInvariant` in assumeReachable) — used for the ones that did NOT verify. Re-write + the project afterwards to regenerate the reachable/proof/conformance artifacts without them. + See smtool.verify.prune_reachable.""" + names = set(names) + if self.reachable is None or not names: + return + self.reachable.blocks = [b for b in self.reachable.blocks + if not (isinstance(b, S.Invariant) and b.name in names)] + fn = self.find_func(self.reachable, driver.ASSUME) + if fn is not None: + fn.block.commands = [c for c in fn.block.commands + if not (isinstance(c, S.AssumeInvariantCmd) and c.invariant_name in names)] + + def find_rule(self, method: str, rule_name: str) -> S.RuleBlock | None: + """The rule named `rule_name` in `method`'s conformance spec, or None.""" + for b in self.conformance[method].blocks: + if isinstance(b, S.RuleBlock) and b.rule_name == rule_name: + return b + return None + + def find_ghost(self, name: str) -> S.GhostDef | None: + """The ghost named `name` in the model spec, or None.""" + for b in self.model_spec.blocks: + if isinstance(b, S.GhostDef) and b.ghost_name == name: + return b + return None + + # -------------------------------------------------- rendering + def render_model(self) -> str: + """The model spec as CVL text.""" + return pretty_print(self.model_spec) + + def render_conformance(self, method: str) -> str: + """A method's conformance spec as CVL text.""" + return pretty_print(self.conformance[method]) + + def render_summary(self) -> str: + """The CONSUMER summary-application spec: imports the model and summarizes each real CUT function + with its model counterpart, so a downstream proof runs against the (conformance-verified) symbolic + model instead of the heavy real CUT. Apply only AFTER conformance passes.""" + return pretty_print(driver.build_summary_spec(self.inp, self.cls)) + + def snapshot(self) -> "Project": + """A deep copy — a mutation applies to the snapshot, then _commit accepts or discards it.""" + return copy.deepcopy(self) + + def build_conf(self, method: str, setup_conf: dict) -> dict: + """Final .conf for a method, built AFTER mutations so its `rule` list is complete — + it reads the post-mutation conformance spec (which includes discharge invariants added + by add_requireInvariant). Do NOT derive the rule list from the skeleton.""" + m = next(f for f in self.cls.model if f.name == method) + # The shared reachable invariants are NOT proven here — they're proven ONCE against the + # (unchanging) CUT by the dedicated reachable conf, and each conformance run only ASSUMES + # them via requireInvariant. (Listing an imported invariant in this run's `rule` filter is + # also a CVL error unless `use invariant`'d.) See TODO(prove-once) in driver / add_requireInvariant. + return _set_perf(driver.rewrite_conf(setup_conf, self.inp, m, conformance_spec=self.conformance[method])) + + # -------------------------------------------------- output + consistency + def write(self, out_dir: str, setup_conf: dict) -> list[str]: + """Write the ONE shared model + each method's conformance spec and conf. The confs are + built post-mutation (complete `rule` list). Returns the paths written.""" + out = Path(out_dir) + (out / "specs").mkdir(parents=True, exist_ok=True) + (out / "conf").mkdir(parents=True, exist_ok=True) + written: list[str] = [] + mp = out / "specs" / self.inp.model_spec + mp.write_text(self.render_model()) + written.append(str(mp)) + # the consumer summary-application spec (imports the model, summarizes each CUT fn -> model); + # a downstream proof imports THIS to run against the trusted model instead of the real CUT. + smp = out / "specs" / self.inp.summary_spec + smp.write_text(self.render_summary()) + written.append(str(smp)) + inv_names = self.reachable_invariant_names() + if self.reachable is not None: + rp = out / "specs" / self.inp.reachable_spec + rp.write_text(pretty_print(self.reachable)) + written.append(str(rp)) + if inv_names: # prove-incrementally: one proof spec + conf that discharges the shared invariants + psp = out / "specs" / f"{self.inp.cut}ReachableProof.spec" + psp.write_text(pretty_print( + driver.build_reachable_proof_spec(self.inp, self.setup_spec_import, inv_names))) + pcf = out / "conf" / f"{self.inp.cut}Reachable.conf" + pcf.write_text(json.dumps(_set_perf(driver.rewrite_reachable_conf(setup_conf, self.inp, inv_names)), indent=4)) + written += [str(psp), str(pcf)] + for method in self.conformance: + base = f"{self.inp.conformance_prefix}{driver._cap(method)}Conformance" + sp = out / "specs" / f"{base}.spec" + sp.write_text(self.render_conformance(method)) + cp = out / "conf" / f"{base}.conf" + cp.write_text(json.dumps(self.build_conf(method, setup_conf), indent=4)) + written += [str(sp), str(cp)] + return written + + def check_consistency(self) -> list[str]: + """Are the final specs consistent with the final shared model? (structural/typecheck, NOT + the SMT proof — that's `verify`). Returns a list of problems ([] == consistent): + - the shared model type-checks standalone; + - each method's CVL exists in the model (the conformance references it); + - the discipline linter passes (model purity, glue shape, no dangling requireInvariant).""" + from .linter import lint + from .typecheck import typecheck_spec + problems: list[str] = [] + ok, out = typecheck_spec(self.render_model()) + if not ok: + tail = out.strip().splitlines()[-1] if out.strip() else "(no output)" + problems.append(f"model spec does not typecheck: {tail}") + for method in self.conformance: + if self.find_func(self.model_spec, driver.model_fn_name(method)) is None: + problems.append(f"conformance[{method}] references {method}CVL, missing from the model") + # H2 gate: every assumed reachable invariant must be prover-DISCHARGED (verify.prune_reachable + # records the survivors). An unproven assumption makes the conformance results conditional. + for inv in self.assumed_invariant_names(): + if inv not in self.verified_invariants: + problems.append(f"invariant {inv} is ASSUMED (requireInvariant in assumeReachable) but " + f"not proven — run the reachable conf via verify.prune_reachable before " + f"trusting these conformance results") + problems += lint(self) + return problems + + +@dataclass +class Result: + ok: bool + message: str + violations: list[str] = field(default_factory=list) diff --git a/smtool/relational.py b/smtool/relational.py new file mode 100644 index 00000000..5d78ec2e --- /dev/null +++ b/smtool/relational.py @@ -0,0 +1,193 @@ +"""Relational (k-call) conformance-rule templates — properties of `f_sol` that need MORE THAN ONE call +to state (monotonicity, injectivity, ...), unlike overapprox's per-output Phi (one call). + +`build_conformance_rule` (overapprox) is the 1-call, per-output template: `y = f(x); assert Phi(x,y)`. +This module adds k-call templates. A discharged relational rule LICENSES adding the property as a ghost +AXIOM on the deterministic-memo summary (detsummary) — the same sound-by-construction gate as the +per-output Phi, but the property rides on the ghost (`fGhost` is monotone/injective), not `require Phi` +(one call can't state a cross-call property). Generic/templated: parameterized by the target signature + +which arg/output/relation. + +v1: MONOTONICITY of a SCALAR argument (multi-return aware) and INJECTIVITY (scalar-tuple key or a single +array param's bounded prefix key). Struct-field ("equal-except") relations are follow-ups. +""" +from dataclasses import dataclass + +import composer.cvl.schema as S + +from . import cvlx as x +from .overapprox import OverApproxTarget, _envfree, _names + + +@dataclass +class MonotoneSpec: + """`f` is monotone in scalar param `arg` w.r.t. output component `out`: raising `arg` (others fixed) + does not lower (increasing) / not raise (decreasing) that output. `guard` is an optional CVL bool + AST over the params (e.g. the raised value < a cap) restricting the domain where it holds.""" + arg: int # index of the (scalar) param that varies + out: int = 0 # output component compared (0 for single-return) + increasing: bool = True # non-decreasing (True) vs non-increasing (False) + guard: object = None # optional S.Expression bool guard over the params; None = unguarded + + +def monotone_rule_name(fn: str, arg: int) -> str: + return f"monotone_{fn}_arg{arg}" + + +def build_monotonicity_rule(t: OverApproxTarget, spec: MonotoneSpec) -> S.RuleBlock | None: + """`monotone__arg`: two REAL calls that agree on every arg except `arg` (call B raises it), + then assert the chosen output component is ordered. Calls are plain (no @withrevert) — a reverting + input prunes the path, so the property is asserted only where both calls succeed (matches the hand + proofs). Returns None for a void/out-of-range target.""" + sig = t.sig + rets = list(sig.returns) + if not rets or spec.arg >= len(sig.params) or spec.out >= len(rets): + return None + envfree = _envfree(t) + vname = sig.params[spec.arg].name + vtype = sig.params[spec.arg].type + v_hi = vname + "_hi" # call B's raised value for the varied arg + rule_params = [(p.type, p.name) for p in sig.params] + [(vtype, v_hi)] + lead = [] if envfree else [x.ident("e")] # a shared env across both calls + args_lo = [x.ident(p.name) for p in sig.params] + args_hi = [x.ident(p.name) if i != spec.arg else x.ident(v_hi) for i, p in enumerate(sig.params)] + lo, hi = _names("rLo", len(rets)), _names("rHi", len(rets)) + + cmds: list = [] + if not envfree: + cmds.append(x.declare("env", "e")) + cmds.append(x.require(x.binop("lt", x.ident(vname), x.ident(v_hi)), "the varied argument strictly increases")) + if spec.guard is not None: + cmds.append(x.require(spec.guard, "monotonicity domain guard")) + call_lo = x.call(sig.name, lead + args_lo) # plain call: non-reverting inputs only + call_hi = x.call(sig.name, lead + args_hi) + if len(rets) == 1: + cmds += [x.declare(rets[0], lo[0], call_lo), x.declare(rets[0], hi[0], call_hi)] + else: + cmds += [x.declare(rt, n) for rt, n in zip(rets, lo)] + cmds.append(x.assign_multi(lo, call_lo)) + cmds += [x.declare(rt, n) for rt, n in zip(rets, hi)] + cmds.append(x.assign_multi(hi, call_hi)) + op = "le" if spec.increasing else "ge" + cmds.append(x.assert_( + x.binop(op, x.ident(lo[spec.out]), x.ident(hi[spec.out])), + f"monotone: raising arg {spec.arg} does not " + f"{'lower' if spec.increasing else 'raise'} output {spec.out}")) + return x.rule(monotone_rule_name(sig.name, spec.arg), rule_params, cmds) + + +def build_monotonicity_spec(t: OverApproxTarget, spec: MonotoneSpec) -> S.CVLFile | None: + """The monotonicity rule wrapped in a runnable spec: the scene setup import (if any) + an envfree + decl of `f_sol` (for an envfree target, so the two calls need no env) + the rule. Mirrors + overapprox.build_conformance_spec's envfree/setup wiring.""" + rule = build_monotonicity_rule(t, spec) + if rule is None: + return None + return _wrap_relational_spec(t, rule) + + +def _wrap_relational_spec(t: OverApproxTarget, rule: S.RuleBlock) -> S.CVLFile: + """A relational rule wrapped in a runnable spec: the scene setup import (if any) + an envfree decl of + `f_sol` (for an envfree target, so the calls need no env) + the rule.""" + blocks: list = [] + if _envfree(t): + blocks.append(x.methods_block([x.m_envfree(t.cut, t.sig.name, + [p.type for p in t.sig.params], list(t.sig.returns))])) + blocks.append(rule) + imports = [t.setup_spec_import] if t.setup_spec_import else () + return x.spec_file(imports=imports, blocks=blocks) + + +@dataclass +class InjectiveSpec: + """`f` is injective: distinct inputs produce distinct outputs. The input KEY that must differ matches + detsummary's ghost keying, so a discharged rule licenses injectivity on the memo: + - SCALAR params -> the whole param tuple (distinct = any param differs). + - one ARRAY param -> the bounded PREFIX `(length, first `key_len` elements)`, so the rule proves + `distinct-prefix => distinct-output` (sound for the prefix-keyed ghost; the real hash conflates + only same-prefix inputs, which the ghost is allowed to collapse). `out` selects the output + component compared (single-return: 0). `guard` optionally restricts the domain.""" + out: int = 0 # output component asserted distinct (0 for single-return) + key_len: int = 3 # array-prefix length (must match MemoTarget.key_len) + elem_cast: str = "assert_uint256" # cast an array element to a comparable scalar (matches detsummary) + guard: object = None # optional S.Expression bool guard over the A-params; None = unguarded + call_host: str | None = None # call the real fn through this alias (`.f(...)`) when the + # target is a dependency reached by a `using` alias in a larger CUT; + # None = bare call `f(...)` (target IS the verified contract) + + +def injective_rule_name(fn: str) -> str: + return f"injective_{fn}" + + +def _prefix_key_exprs(aname: str, spec: InjectiveSpec) -> list: + """The ghost-key components of array param `aname` as expressions: `[length, e0, e1, ...]` where + `ei = length > i ? (aname[i]) : 0` — identical to detsummary._key_and_body_array's key.""" + length = x.field(x.ident(aname), "length") + keys = [length] + for i in range(spec.key_len): + elem = x.call(spec.elem_cast, [x.idx(x.ident(aname), x.num(i))]) + keys.append(x.cond(x.binop("gt", length, x.num(i)), elem, x.num(0))) + return keys + + +def _or(exprs: list): + """Left-fold a non-empty list of booleans with `||`.""" + acc = exprs[0] + for e in exprs[1:]: + acc = x.binop("or", acc, e) + return acc + + +def build_injectivity_rule(t: OverApproxTarget, spec: InjectiveSpec = InjectiveSpec()) -> S.RuleBlock | None: + """`injective_`: two REAL calls on independent inputs A and B; require the input KEYS differ, then + assert the chosen output component differs. Plain calls (a reverting input prunes the path), matching + build_monotonicity_rule. Returns None for a void / out-of-range target.""" + sig = t.sig + rets = list(sig.returns) + if not rets or spec.out >= len(rets): + return None + envfree = _envfree(t) + a = {p.name: p.name + "_a" for p in sig.params} + b = {p.name: p.name + "_b" for p in sig.params} + rule_params = ([(p.type, a[p.name]) for p in sig.params] + + [(p.type, b[p.name]) for p in sig.params]) + lead = [] if envfree else [x.ident("e")] + args_a = [x.ident(a[p.name]) for p in sig.params] + args_b = [x.ident(b[p.name]) for p in sig.params] + lo, hi = _names("rA", len(rets)), _names("rB", len(rets)) + + arr = [p for p in sig.params if p.type.rstrip().endswith("[]")] + single_array = len(sig.params) == 1 and len(arr) == 1 + if single_array: # distinct = the bounded prefix keys differ + ka = _prefix_key_exprs(a[arr[0].name], spec) + kb = _prefix_key_exprs(b[arr[0].name], spec) + distinct = _or([x.binop("ne", ca, cb) for ca, cb in zip(ka, kb)]) + else: # distinct = any scalar param differs + distinct = _or([x.binop("ne", x.ident(a[p.name]), x.ident(b[p.name])) for p in sig.params]) + + cmds: list = [] + if not envfree: + cmds.append(x.declare("env", "e")) + cmds.append(x.require(distinct, "the two inputs differ on the summary key")) + if spec.guard is not None: + cmds.append(x.require(spec.guard, "injectivity domain guard")) + call_a = x.call(sig.name, lead + args_a, host=spec.call_host) # plain call: non-reverting inputs only + call_b = x.call(sig.name, lead + args_b, host=spec.call_host) + if len(rets) == 1: + cmds += [x.declare(rets[0], lo[0], call_a), x.declare(rets[0], hi[0], call_b)] + else: + cmds += [x.declare(rt, n) for rt, n in zip(rets, lo)] + cmds.append(x.assign_multi(lo, call_a)) + cmds += [x.declare(rt, n) for rt, n in zip(rets, hi)] + cmds.append(x.assign_multi(hi, call_b)) + cmds.append(x.assert_( + x.binop("ne", x.ident(lo[spec.out]), x.ident(hi[spec.out])), + f"injective: distinct inputs give a distinct output {spec.out}")) + return x.rule(injective_rule_name(sig.name), rule_params, cmds) + + +def build_injectivity_spec(t: OverApproxTarget, spec: InjectiveSpec = InjectiveSpec()) -> S.CVLFile | None: + """The injectivity rule wrapped in a runnable spec (setup import + envfree decl + rule).""" + rule = build_injectivity_rule(t, spec) + return _wrap_relational_spec(t, rule) if rule is not None else None diff --git a/smtool/resolved_ast.py b/smtool/resolved_ast.py new file mode 100644 index 00000000..34c8ea6b --- /dev/null +++ b/smtool/resolved_ast.py @@ -0,0 +1,65 @@ +"""The TRUSTED, import+scene-resolved view of the setup spec. + +The effective `methods{}` set of a setup spec cannot be read from the top file's text: a `methods` +entry may be declared in a NESTED import. The only trusted source is the resolved CVL AST produced by +`Typechecker.jar -printAst` (which runs the real `CVLAstBuilder`, resolving imports + scene). +`ASTExtraction.jar --raw` does NOT resolve imports (it blanks them), so it must not be used here. + +We obtain the dump via `certoraRun --compilation_steps_only --dump_cvl_ast `: +`--compilation_steps_only` runs the local build + typecheck but skips the cloud/SMT submit, and the +same build step also writes `all_methods.json` (the facts for smtool/scene.py). So ONE local pass — +no cloud, cacheable per setup — yields both the facts and this resolved AST. +""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + + +def dump_resolved_ast(setup_conf_path: str | Path, out_path: str | Path, + cwd: str | Path | None = None) -> Path: + """Run a LOCAL build+typecheck of `setup_conf_path` (no cloud) that writes the resolved CVL AST + JSON to `out_path`. Reuses certoraRun. `cwd` should be the sources root the conf's paths resolve + against (default: the conf's parents[2], matching smtool.setup.consume_setup).""" + setup_conf_path = Path(setup_conf_path) + cwd = Path(cwd) if cwd else setup_conf_path.resolve().parents[2] + subprocess.run( + ["certoraRun", str(setup_conf_path), "--compilation_steps_only", + "--dump_cvl_ast", str(Path(out_path).resolve())], + cwd=str(cwd), check=True, + ) + return Path(out_path) + + +def _method_key(sig_holder: dict) -> tuple[str, int] | None: + """(functionName, arity) from a summary signature holder (`internal/externalSummaries[i].first`). + Returns None if the shape is unexpected (never guess — an imperfect key must not cause a + false skip).""" + sig = sig_holder.get("signature") + if not isinstance(sig, dict): + return None + name = sig.get("functionName") + if not isinstance(name, str): + return None + params = sig.get("params") + arity = len(params) if isinstance(params, list) else 0 + return (name, arity) + + +def summarized_methods(ast: str | Path | dict) -> set[tuple[str, int]]: + """The set of `(functionName, arity)` the setup's RESOLVED closure summarizes + (internal + external + unresolved), flattened across nested imports. This is the trusted set to + reconcile our own `methods{}` entries against. Plain `envfree` DECLARATIONS (no summary) are NOT + in the resolved AST's summary lists — those clashes need the reactive typecheck fallback.""" + if not isinstance(ast, dict): + ast = json.loads(Path(ast).read_text()) + keys: set[tuple[str, int]] = set() + for grp in ("internalSummaries", "externalSummaries", "unresolvedSummaries"): + for pair in ast.get(grp, []): + holder = pair.get("first") if isinstance(pair, dict) else None + if isinstance(holder, dict): + k = _method_key(holder) + if k is not None: + keys.add(k) + return keys diff --git a/smtool/scene.py b/smtool/scene.py new file mode 100644 index 00000000..7c362cf9 --- /dev/null +++ b/smtool/scene.py @@ -0,0 +1,162 @@ +"""Bridge to the compiled scene: load CUT method FACTS + the Solidity->CVL type mapper by REUSING +AutoProver's own parsers (no duplicated parsing logic). Feeds `Signature.from_scene` / +`FunctionSpec.from_scene` in ir.py. + +Requires a compiled scene: a `.certora_internal` dir holding `all_methods.json` + +`all_user_defined_types.json` (a Certora build artifact). Offline recon agent-sims do NOT need this — +they hand-author facts via `FunctionSpec.of(...)`. + +Reused, not reinvented: + - `MethodParser` -> loads `all_methods.json` (name, fullSignature, paramNames, returns, + stateMutability, visibility per method). + - `TypeAnalyzer._resolve_type_from_string(s).cvl_name` -> the canonical Solidity-string -> CVL + spelling (qualifies dotted struct types like `IFoo.Bar`, handles arrays). Same mapper + autosetup uses to emit its summaries. Its output strings flow through + `cvlx.ty` into composer.cvl.schema (the EVMVerifier CVL-AST mirror). +""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from certora_autosetup.parsers.method_parser import MethodParser +from certora_autosetup.parsers.type_analyzer import TypeAnalyzer + + +def load_methods(certora_internal_path: str = ".certora_internal") -> MethodParser: + """A `MethodParser` over the scene's `all_methods.json`.""" + return MethodParser(str(Path(certora_internal_path) / "all_methods.json")) + + +def type_resolver(certora_internal_path: str = ".certora_internal"): + """Return `resolve(solidity_type_str) -> cvl_type_str`, backed by AutoProver's `TypeAnalyzer`.""" + ta = TypeAnalyzer(certora_internal_path) + ta.parse_all() # loads user-defined types + methods into the registry (required before resolving) + return lambda s: ta._resolve_type_from_string(s).cvl_name + + +def method_dict(parser: MethodParser, contract: str, name: str) -> dict | None: + """The `all_methods.json` entry for `contract.name` (first match), or None.""" + for m in parser.get_methods_by_contract(contract): + if m["name"] == name: + return m + return None + + +def methods_from_build(build_json_path) -> list[dict]: + """Extract method FACTS in the `all_methods.json` shape directly from certoraRun's + `.certora_build.json`, REUSING certora_autosetup's `parse_type_descriptor` for the typeDesc -> CVL + type-string flattening (primitives AND structs/arrays — no reinvention) — WITHOUT running autosetup. + `certoraRun --compilation_steps_only` emits `.certora_build.json` (raw typeDescs); autosetup's + `generate_all_methods_json` normally post-processes it, but that is a stateful method — here we do + just the fields `Signature.from_scene` reads (name/contractName/fullSignature/paramNames/returns/ + stateMutability/visibility), deduped like autosetup's `_process_method_info`.""" + from certora_autosetup.utils.types import parse_type_descriptor, TypeParseMode + Q = TypeParseMode.QUALIFIED + data = json.loads(Path(build_json_path).read_text()) + out: list[dict] = [] + seen: set = set() + for obj in data.values(): + if not (isinstance(obj, dict) and "contracts" in obj): + continue + for c in obj.get("contracts", []) or []: + cname = c.get("name", "") + for m in c.get("allMethods", []) or []: + mc = m.get("contractName", cname) + sig = [parse_type_descriptor(a.get("typeDesc", {}), Q, mc) for a in m.get("fullArgs", [])] + rets = [parse_type_descriptor(r.get("typeDesc", {}), Q, mc) for r in m.get("returns", [])] + key = (mc, m.get("name"), tuple(sig)) + if key in seen: + continue + seen.add(key) + out.append({"name": m.get("name", ""), "contractName": mc, "fullSignature": sig, + "paramNames": list(m.get("paramNames", [])), "returns": rets, + "stateMutability": m.get("stateMutability", "nonpayable"), + "visibility": m.get("visibility", "external")}) + return out + + +def canonical_arg_types(build_json_path, contract: str, fn: str) -> list[str] | None: + """The CANONICAL (underlying) arg types of `contract.fn` from `.certora_build.json`, REUSING + autosetup's `parse_type_descriptor` in CANONICAL mode — which recurses a UDVT into its `underlying` + (so a uint256-backed UDVT array -> `uint256[]`, a bytes31-backed UDVT -> `bytes31`). Lets the + deterministic-memo summary (detsummary) resolve an array element's base type + cast WITHOUT grepping + source. None if not found.""" + from certora_autosetup.utils.types import parse_type_descriptor, TypeParseMode + C = TypeParseMode.CANONICAL + data = json.loads(Path(build_json_path).read_text()) + for obj in data.values(): + if not (isinstance(obj, dict) and "contracts" in obj): + continue + for c in obj.get("contracts", []) or []: + for m in c.get("allMethods", []) or []: + mc = m.get("contractName", c.get("name", "")) + if m.get("name") == fn and mc == contract: + return [parse_type_descriptor(a.get("typeDesc", {}), C, mc) for a in m.get("fullArgs", [])] + return None + + +def _newest_build_json(sources_root) -> Path | None: + cands = sorted(Path(sources_root, ".certora_internal").glob("*/.certora_build.json"), + key=lambda p: p.stat().st_mtime) + return cands[-1] if cands else None + + +def ensure_all_methods_json(sources_root, setup_conf, scene_path=None, + certora_run_path: str = "certoraRun") -> str: + """Return a `.certora_internal` dir containing `all_methods.json` for a scene-sourced input. + + Preference: an EXISTING all_methods.json (in the integrated flow autosetup already produced it) -> + else DERIVE one from certoraRun's `.certora_build.json` via `methods_from_build` (reuse, not + autosetup), compiling once with `--compilation_steps_only` if no build is present yet. The derived + fullSignature/returns are already CVL type strings, so `Signature.from_scene` reads them with an + identity resolver (SceneInput falls back to identity when all_user_defined_types.json is absent). + Fails loud if it cannot produce the file.""" + scene_dir = Path(scene_path) if scene_path else Path(sources_root, ".certora_internal") + if (scene_dir / "all_methods.json").exists(): + return str(scene_dir) + build = _newest_build_json(sources_root) + if build is None: + # --dump_asts co-produces `.asts.json` (the solc AST) in the build dir at no extra cost — the + # same compile that yields `.certora_build.json`. The AST-based source prefetch consumes it + # (smtool.ast_source); absent, prefetch falls back to a text slice. (autosetup passes both flags.) + subprocess.run([certora_run_path, str(setup_conf), "--compilation_steps_only", "--dump_asts"], + cwd=str(sources_root), check=True) + build = _newest_build_json(sources_root) + if build is None: + raise RuntimeError( + "cannot derive all_methods.json: no .certora_build.json after --compilation_steps_only. " + "Pass --scene pointing at an autosetup-produced .certora_internal (with all_methods.json).") + out = Path(sources_root, ".certora_internal", "all_methods.json") + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(methods_from_build(build), indent=2)) + return str(out.parent) + + +def mutability_resolver(certora_internal_path: str = ".certora_internal"): + """Return `resolve(name) -> stateMutability | None` from the scene, for cross-checking NONDET + targets (mutations.add_nondet). CONSERVATIVE: if any contract's method of that name is + state-changing, report state-changing (fail toward unsound-if-NONDET'd). None if the name is + absent from the scene.""" + parser = load_methods(certora_internal_path) + + def resolve(name: str): + muts = [m.get("stateMutability") for m in parser.get_methods_by_name(name)] + if not muts: + return None + for strong in ("payable", "nonpayable"): + if strong in muts: + return strong + return muts[0] + + return resolve + + +def signature_from_scene(certora_internal_path, contract, name): + """Native `Signature` for `contract.name` from the compiled scene's `all_methods.json`, or None if + absent. Reuses AutoProver's `MethodParser` + `TypeAnalyzer` via `Signature.from_scene` — no string + parsing. (Fallback to `methods_from_build` when only the raw build json is present is a TODO.)""" + from .ir import Signature + m = method_dict(load_methods(certora_internal_path), contract, name) + return Signature.from_scene(m, type_resolver(certora_internal_path)) if m else None diff --git a/smtool/scene_input.py b/smtool/scene_input.py new file mode 100644 index 00000000..c0ace8ba --- /dev/null +++ b/smtool/scene_input.py @@ -0,0 +1,160 @@ +"""Scene-sourced input front-end: build smtool `ToolInput`s from an IDENTIFIER-LIST config + the +compiled scene, instead of hand-typing signatures. + +The per-CUT config is pure data — for each method to model, its name (+ `model=True` for a computed +view) and the observables it corresponds on, each given by NAME plus the RESIDUAL knobs that are NOT +scene facts: keying (`key`), state-effect (`se`), env-freeness (`envfree`), and the multi-return +component. Everything else — params, returns, mutability, visibility, ghost/reader names — is pulled +from `all_methods.json` via `FunctionSpec.from_scene` (see scene.py) and the driver's default names. + +An input module using this exposes `CUT` + `MODEL` (the data) + `build(scene_path)`; `run_smtool --scene` +calls `build` with the compiled scene under the setup's sources tree. See demo/spoke_hub_input_scene.py. + +Residual knobs are what a future static front-end would DERIVE (env-read pass for `envfree`; storage +write-set / access-path analysis for `se` + `key`). Until then they're explicit, with conservative +intent (frame mapping observables universally; check every observable the consumer reads). + +Setup getters (a CVL getter backed by the setup, e.g. `tokenBalanceOf`) are NOT in `all_methods.json`, +so an observable may carry an explicit `params`/`returns` + `host="setup"` to hand-source that one +signature; CUT getters need none of that. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from .ir import ToolInput, FunctionSpec, Param, CUT_ARG, CALLER_ARG, free_var +from . import scene as _scene + + +# ---- config records (pure data; no scene needed to construct) ---- + +@dataclass +class Method: + """A CUT method (or a `model=True` computed view) to model. Signature comes from the scene.""" + name: str + model: bool = False + + +@dataclass +class Obs: + """An observable getter the method corresponds on. Facts from the scene; only residual knobs here. + + key: {param_name: 'caller'|'cut'|'free'|''} — how to key the getter. 'caller' => e.msg.sender, + 'cut' => the CUT address, 'free' => a fresh universally-quantified var in the state rule (the + glue, which can't take a free var, falls back to 'caller'), any other string => a derived local + (e.g. 'u'). Absent params keep their own name. None => the getter's own params (identity keying). + se: compare this observable's POST value in the state-effect rule (the write-set residual). + envfree: None => default (a getter is envfree); set False for a getter that reads block/msg (accrual). + component: multi-return getter — which return the ghost tracks. + host: 'cut' (default) or 'setup' (a setup CVL getter -> not declared, sig from `params`/`returns`). + glue_return: this (multi-return) getter's tracked component is the glue's return (a derived key like `u`). + component_names: local names for a multi-return tuple (e.g. ['u','d']); default c0,c1,... + params/returns: ONLY for a setup getter absent from the scene (hand-sourced signature). + """ + name: str + key: dict | None = None # keying for BOTH glue and frame (unless overridden below) + glue_key: dict | None = None # glue-only keying override (falls back to `key`) + frame_key: dict | None = None # frame-only keying override (falls back to `key`) + se: bool = True + envfree: bool | None = None + component: int | None = None + host: str = "cut" + glue_return: bool = False + component_names: list[str] | None = None + params: list[tuple] | None = None # setup getter: [(type,name),...] + returns: list[str] | None = None # setup getter: [type,...] + + +# ---- helpers so a config module reads terse ---- + +def method(name: str, *, model: bool = False) -> Method: + return Method(name, model=model) + +def obs(name: str, **knobs) -> Obs: + return Obs(name, **knobs) + + +class SceneInput: + """Bound to a compiled scene; turns `Method`/`Obs` records into `FunctionSpec`s with facts sourced + from `all_methods.json` (CUT methods/getters) + auto-derived names.""" + + def __init__(self, cut: str, certora_internal_path: str): + self.cut = cut + self.parser = _scene.load_methods(certora_internal_path) + try: + self._resolve = _scene.type_resolver(certora_internal_path) + except Exception: + self._resolve = lambda s: s # fallback: identity (fine for primitive types) + + def _scene_dict(self, name: str) -> dict: + d = _scene.method_dict(self.parser, self.cut, name) + if d is None: + raise ValueError(f"method {self.cut}.{name} not in the scene's all_methods.json " + f"(a setup getter needs explicit params/returns; a harness getter needs " + f"the harness compiled into the scene)") + return d + + # ---- keying ---- + def _keys(self, params: list[Param], override: dict | None, *, frame: bool) -> list[str]: + out = [] + for p in params: + o = (override or {}).get(p.name) + if o == "caller": out.append(CALLER_ARG) + elif o == "cut": out.append(CUT_ARG) + elif o == "free": out.append(free_var(p.type, p.name) if frame else CALLER_ARG) + elif o is None: out.append(p.name) + else: out.append(o) # a derived local (e.g. 'u') + return out + + # ---- builders ---- + def method_spec(self, m: Method) -> FunctionSpec: + return FunctionSpec.from_scene(self._scene_dict(m.name), self._resolve, model=m.model) + + def obs_spec(self, o: Obs) -> FunctionSpec: + if o.host == "setup": + if o.params is None or o.returns is None: + raise ValueError(f"setup getter {o.name} needs explicit params/returns (not in the scene)") + params = [Param(self._resolve(t), n) for t, n in o.params] + sig_params = params + sig = FunctionSpec.of(o.name, params, [self._resolve(t) for t in o.returns], "view", + observable=True, getter_host="setup", declare_in_methods=False) + else: + d = self._scene_dict(o.name) + sig = FunctionSpec.from_scene(d, self._resolve, observable=True) + sig_params = sig.params + # residual knobs + if o.envfree is not None: + sig.envfree = o.envfree + sig.state_effect = o.se + if o.component is not None: + sig.bind_component = o.component + if o.component_names is not None: + sig.component_names = o.component_names + sig.glue_return = o.glue_return + gk = o.glue_key if o.glue_key is not None else o.key + fk = o.frame_key if o.frame_key is not None else o.key + if gk is not None: + sig.glue_args = self._keys(sig_params, gk, frame=False) + if fk is not None: + sig.frame_args = self._keys(sig_params, fk, frame=True) + return sig + + def tool_input(self, m: Method, observables: list[Obs]) -> ToolInput: + return ToolInput(cut=self.cut, + functions=[self.method_spec(m)] + [self.obs_spec(o) for o in observables]) + + +def build_specs(cut: str, certora_internal_path: str, model: dict): + """`model` = {method_name: (Method, [Obs, ...])} OR {method_name: [Obs, ...]} (method defaults). + Returns (specs_fn, all_methods) matching run_smtool's --input contract: specs(methods)->[ToolInput], + ALL_METHODS list.""" + si = SceneInput(cut, certora_internal_path) + def entry(name, v): + if isinstance(v, tuple): + m, obss = v + else: + m, obss = method(name), v + return si.tool_input(m, obss) + inputs = {name: entry(name, v) for name, v in model.items()} + all_methods = list(model) + return (lambda methods=None: [inputs[x] for x in (methods or all_methods)]), all_methods diff --git a/smtool/separation.py b/smtool/separation.py new file mode 100644 index 00000000..23e95d98 --- /dev/null +++ b/smtool/separation.py @@ -0,0 +1,135 @@ +"""Separation prover — the soundness gate for a PARTIAL model. + +smtool models a SUBSET of a contract C: pi = the Router-reached functions+storage we replace with a +symbolic model (ghost storage), rho = the Router-reached functions+storage we KEEP REAL. Unreached +functions are out of scope (Router never calls them, so they never execute in the proof). So +pi ∪ rho ⊆ C, and rho is reachability-derived (from the surviving-call-graph): the C functions +reachable from Router, minus pi. + +smtool's own conformance proves the modeled functions match real on the pi observables. That is NOT +enough for a partial model — installing it means, in the consumer proof, pi-functions run as the model +over GHOST pi-storage (real pi-storage is never written) while rho-functions run REAL. The hybrid +diverges from the all-real execution in exactly three ways, so there are three obligations: + + (W1) a rho-function WRITES pi-storage -> the ghost goes stale (model reads ghost; reality saw the write) + (W2) a pi-function WRITES rho-storage -> the model drops a write to real storage rho still observes + (R) a rho-function READS pi-storage -> rho reads the initial real pi-storage; reality reads the live value + (only via a RAW read that bypasses the modeled getter; a read THROUGH the getter hits the ghost, + so it is already consistent) + +KEY SIMPLIFICATION (EVM encapsulation): a contract's storage is private — external code touches it only +through C's functions. So separation is a WITHIN-CONTRACT property: we range over C's own functions, not +the whole scene. + +This module implements the GETTER-BASED checks (robust to assembly/opaque storage — they reason over +getter VALUES, never raw slots, which is exactly why the conformance already worked for Solady tokens): + + * W1/W2 — build_frame_rule: run the REAL function, assert every getter on the OTHER side is unchanged + on real success. Load-bearing and cheap (one call each). + +TODO / follow-ups (documented, not built here): + * R (read separation) — getter-based NON-INTERFERENCE: run f from two states that AGREE on the rho + getters but leave the pi getters free, assert f's return + rho-effect are equal (catches even a raw + read, without hooking it). The CVL 2-copy mechanic is the fiddly part; and for DISJOINT-CONCERN + contracts (registry vs balances, as in our tokens) rho never reads pi-storage, so R is trivially + satisfied. Track it as the second getter-based pass. + * HOOK OPTIMIZATION — a faster uniform check when the storage is CVL-referenceable: `hook Sload/Sstore` + on pi-storage set a ghost flag, run each rho-function, assert the flag stayed false (catches reads + AND writes in one run). Gated by a CANARY (run a known accessor — the getter itself for a read, a + witnessed getter value-change for a write — and assert the hook FIRED; if not, hooks are dead on this + (assembly) storage and we use the getter path). Not built: getter path suffices for most cases. +""" +from dataclasses import dataclass + +import composer.cvl.schema as S + +from . import cvlx as x +from .ir import Signature + + +@dataclass +class Observable: + """A getter whose backing storage a frame rule shows is untouched. `getter` is the scene Signature + (an envfree view). A frame rule declares one fresh var per getter argument, framing over ALL keys.""" + contract: str + getter: Signature + + @property + def arg_types(self) -> list: + return [p.type for p in self.getter.params] + + @property + def numeric(self) -> bool: + rets = list(self.getter.returns) + if len(rets) != 1: + return False + t = rets[0] + return t.startswith("uint") or t.startswith("int") or t == "mathint" + + +def _read(obs: Observable, arg_names: list): + call = x.call(obs.getter.name, [x.ident(a) for a in arg_names], host=obs.contract) + return x.call("to_mathint", [call]) if obs.numeric else call + + +def _compare_type(obs: Observable) -> str: + return "mathint" if obs.numeric else list(obs.getter.returns)[0] + + +def build_frame_rule(fn: Signature, fn_contract: str, preserved: list, *, + alias: str | None = None) -> S.RuleBlock: + """`separation_`: run the REAL `fn` and assert every observable in `preserved` is unchanged — + i.e. `fn` does not WRITE the storage those observables read. Each observable is framed with fresh + free vars over its keys. A PLAIN call (no `@withrevert`) prunes reverting paths — an implicit + success assumption — so the frame is asserted only where `fn` succeeds; on a revert all storage + rolls back, so the frame holds trivially and needs no check. + + direction W1: `fn` in rho, `preserved` = the pi observables. + direction W2: `fn` in pi, `preserved` = the rho observables. + """ + host = alias or fn_contract + rule_params = [(p.type, p.name) for p in fn.params] + frame_names: list = [] + for oi, obs in enumerate(preserved): + names = [f"o{oi}_k{ai}" for ai in range(len(obs.arg_types))] + frame_names.append(names) + rule_params += list(zip(obs.arg_types, names)) + + cmds: list = [] + pure = fn.mutability == "pure" + if not pure: + cmds.append(x.declare("env", "e")) + # snapshot each preserved observable PRE + pre = [] + for oi, obs in enumerate(preserved): + pn = f"pre_o{oi}" + pre.append(pn) + cmds.append(x.declare(_compare_type(obs), pn, _read(obs, frame_names[oi]))) + # call the REAL fn PLAIN — a revert prunes the path (implicit success assumption) + lead = [] if pure else [x.ident("e")] + cmds.append(x.apply(x.call(fn.name, lead + [x.ident(p.name) for p in fn.params], host=host))) + # assert each preserved observable UNCHANGED + for oi, obs in enumerate(preserved): + cmds.append(x.assert_( + x.binop("eq", x.ident(pre[oi]), _read(obs, frame_names[oi])), + f"separation: {fn.name} must not write {obs.contract}.{obs.getter.name} storage")) + return x.rule(f"separation_{fn.name}", rule_params, cmds) + + +def build_separation_rules(pi_fns: list, rho_fns: list, pi_obs: list, rho_obs: list, + contract: str, *, alias: str | None = None) -> list: + """The two-direction write separation for a partial model of `contract`: + W1: every rho-function preserves the pi observables, + W2: every pi-function preserves the rho observables. + `pi_fns`/`rho_fns` are Signatures; `pi_obs`/`rho_obs` are Observables. rho is reachability-derived + (Router-reached, not modeled); unreached functions are omitted (never execute in the proof).""" + rules = [build_frame_rule(f, contract, pi_obs, alias=alias) for f in rho_fns] # W1 + rules += [build_frame_rule(f, contract, rho_obs, alias=alias) for f in pi_fns] # W2 + return rules + + +def build_separation_spec(setup_import: str | None, rules: list) -> S.CVLFile: + """Wrap separation rules in a runnable spec. Imports the conformance-capable setup so the effective + scene (links, existing summaries) matches the real run; tokens stay REAL — NO model installed here.""" + imports = [setup_import] if setup_import else () + return x.spec_file(imports=imports, blocks=list(rules)) diff --git a/smtool/setup.py b/smtool/setup.py new file mode 100644 index 00000000..7b62442c --- /dev/null +++ b/smtool/setup.py @@ -0,0 +1,41 @@ +"""Setup consumption — the minimal extraction from a setup `.conf`. + +Our conformance spec `import`s the setup spec (inheriting its `using`, `methods{}`, imports, links) +and drops only the sanity rule via the conf's `rule` filter. And CUT calls are UNQUALIFIED — an +unqualified CVL call resolves to `currentContract`, which IS the CUT (the `verify` target). So we +need NO alias and NO spec parsing here; just three things from the conf: + - cut : CUT contract name (conf.verify, before the ':') + - setup_spec_import : the setup spec to import (conf.verify, after the ':'; imported by basename) + - conf : the raw setup conf dict (files/solc/... copied into the conformance conf) +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class SetupInfo: + conf_path: Path + sources_root: Path + conf: dict # raw setup .conf (files / solc / packages / … to copy) + cut: str # CUT contract name (== currentContract in the conformance spec) + setup_spec: Path # the setup spec (conf.verify target) — the spec we import + setup_spec_import: str # what the conformance spec writes in `import "..."` (co-located, basename) + loop_iter: int = 1 # the run's loop_iter (arrays bounded to this length); default 1 if unset + + +def consume_setup(conf_path: str | Path, sources_root: str | Path | None = None) -> SetupInfo: + conf_path = Path(conf_path).resolve() + conf = json.loads(conf_path.read_text()) + # conf.verify is "CUT:relpath/to/spec", relative to the sources root (where certoraRun runs). + # Default heuristic: conf sits at /certora/conf/x.conf, so root = conf_path.parents[2]. + root = Path(sources_root).resolve() if sources_root else conf_path.parents[2] + cut, spec_rel = conf["verify"].split(":", 1) + setup_spec = (root / spec_rel).resolve() + return SetupInfo( + conf_path=conf_path, sources_root=root, conf=conf, cut=cut, + setup_spec=setup_spec, setup_spec_import=setup_spec.name, + loop_iter=int(conf.get("loop_iter", 1)), + ) diff --git a/smtool/tests/__init__.py b/smtool/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/smtool/tests/conftest.py b/smtool/tests/conftest.py new file mode 100644 index 00000000..50f5c118 --- /dev/null +++ b/smtool/tests/conftest.py @@ -0,0 +1,22 @@ +"""Shared fixtures for smtool unit tests — fast, deterministic, GENERIC (a fictional CUT `C`; +no LLM, no prover, no compiled scene, no customer contract).""" +import pytest + +from smtool.ir import ToolInput, FunctionSpec, Param as P +from smtool.project import Project + + +@pytest.fixture +def spec(): + """FunctionSpec.of with terse (type, name) param tuples: spec('f', [('uint256','x')], ['uint256']).""" + def _spec(name, params=(), returns=(), mutability="nonpayable", **modeling): + return FunctionSpec.of(name, [P(t, n) for t, n in params], list(returns), mutability, **modeling) + return _spec + + +@pytest.fixture +def project(): + """Build a Project over the fictional CUT `C` from a set of FunctionSpecs (skeleton; no fills).""" + def _project(*funcs, cut="C"): + return Project.from_input(ToolInput(cut=cut, functions=list(funcs))) + return _project diff --git a/smtool/tests/test_array_support.py b/smtool/tests/test_array_support.py new file mode 100644 index 00000000..5d2a821f --- /dev/null +++ b/smtool/tests/test_array_support.py @@ -0,0 +1,81 @@ +"""smtool array support: array-param (T[]) methods are modeled by UNROLLING to the run's loop_iter. + +CVL has no loops/recursion, so a batch method is unrolled over its bounded length: the conformance pins +the array-keyed observable at the fixed elements arr[0..loop_iter-1] (via _field_pins), the malformed +scalar glue pin for an array key is SKIPPED, and the agent is told the loop_iter so it can unroll the +body. cvlx's array-access / .length builders must also produce exactly the CVL parser's AST. +""" +from smtool.ir import ToolInput, FunctionSpec, Param as P, free_var, CALLER_ARG +from smtool.project import Project +from smtool.agent.refine import _array_guidance +from smtool.cvl_parse import parse_expression +from smtool import cvlx as x +from composer.cvl.pretty_print import pretty_print + +PID = "Vault.TokenId" # a generic user-defined value type (UDVT) + + +def _bal(glue): + return FunctionSpec.of("balanceOf", [P("address", "owner"), P("uint256", "id")], ["uint256"], "view", + envfree=True, ghost_name="balCVL", reader_name="mBal", + glue_args=glue, frame_args=[free_var("address", "a"), free_var("uint256", "i")]) + + +def _batch_project(loop_iter=3): + spec = ToolInput(cut="C", alias="c", functions=[ + FunctionSpec.of("batchOp", [P(PID + "[]", "_ids"), P("uint256[]", "_amounts")], [], + "nonpayable"), + _bal([CALLER_ARG, "_ids"]), + ]) + return Project.from_method_specs([spec], None, None, loop_iter=loop_iter) + + +# ---- cvlx builders match the real parser ------------------------------------ +def test_cvlx_index_matches_parser(): + assert x.index("_ids", 2).model_dump() == parse_expression("_ids[2]").model_dump() + + +def test_cvlx_length_matches_parser(): + assert x.length("_ids").model_dump() == parse_expression("_ids.length").model_dump() + + +def test_array_type_parses(): + assert x.ty("uint256[]").type == "dyn_array" + assert x.ty("Vault.TokenId[]").base_type.type == "contract_type" + + +# ---- conformance: element pins + skipped glue ------------------------------- +def test_element_pins_at_loop_iter(): + txt = pretty_print(_batch_project(loop_iter=3).conformance["batchOp"]) + # the array-keyed observable is pinned at each element 0..loop_iter-1 + for k in range(3): + assert f"_ids[{k}]" in txt + assert "_ids[3]" not in txt # loop_iter is exclusive upper bound + + +def test_glue_skips_array_key(): + # glue() body is empty — a scalar pin over an array key is malformed, so it is skipped (elements + # are pinned by _field_pins instead). + txt = pretty_print(_batch_project().conformance["batchOp"]) + glue = txt.split("function glue(")[1].split("}")[0] + assert "mBal" not in glue + + +def test_loop_iter_threads(): + assert _batch_project(loop_iter=2).inp.loop_iter == 2 + txt = pretty_print(_batch_project(loop_iter=2).conformance["batchOp"]) + assert "_ids[1]" in txt and "_ids[2]" not in txt + + +# ---- agent guidance ---------------------------------------------------------- +def test_array_guidance_fires_for_batch(): + g = _array_guidance(_batch_project(loop_iter=3)) + assert "loop_iter is 3" in g and "batchOp" in g and "arr[0..2]" in g + + +def test_array_guidance_empty_for_scalar(): + spec = ToolInput(cut="C", alias="c", functions=[ + FunctionSpec.of("op", [P(PID, "_id"), P("uint256", "_amount")], [], "nonpayable"), + _bal([CALLER_ARG, "_id"]), + ]) + assert _array_guidance(Project.from_method_specs([spec], None, None)) == "" diff --git a/smtool/tests/test_ast_source.py b/smtool/tests/test_ast_source.py new file mode 100644 index 00000000..92bc2db5 --- /dev/null +++ b/smtool/tests/test_ast_source.py @@ -0,0 +1,69 @@ +"""AST-backed source oracle (smtool.ast_source.function_source, exposed as the get_function tool): exact +bodies via `src` byte-ranges + callees via `referencedDeclaration`, streamed + cached. Tested on a +synthetic `.asts.json` mirroring the real schema dict[unit][file][id]=node — no real source involved.""" +import json +from smtool import ast_source + + +def test_callee_ids_and_arity_helpers(): + node = {"nodeType": "FunctionDefinition", "parameters": {"parameters": [{}, {}]}, + "body": {"statements": [ + {"nodeType": "FunctionCall", "expression": {"referencedDeclaration": 7}}, + {"nodeType": "FunctionCall", "expression": {"referencedDeclaration": -18}}, # builtin -> dropped + {"x": [{"nodeType": "FunctionCall", "expression": {"referencedDeclaration": 9}}]}]}} + assert ast_source._arity(node) == 2 + assert sorted(ast_source._callee_ids(node)) == [7, 9] # nested found, negative builtin excluded + + +def _write_scene(tmp_path, sol, asts): + (tmp_path / "C.sol").write_text(sol) + bd = tmp_path / ".certora_internal" / "BUILD" + bd.mkdir(parents=True) + (bd / ".asts.json").write_text(json.dumps(asts)) + ast_source._INDEX_CACHE.clear() # fresh index for this scene + + +def test_function_source_returns_body_and_callees(tmp_path): + sol = "function act(uint256 x) external { helper(x); }\nfunction helper(uint256 x) internal { x; }\n" + a0, a1 = sol.index("function act"), sol.index("}") + 1 + h0, h1 = sol.index("function helper"), sol.rindex("}") + 1 + asts = {"unit": {"C.sol": { + "1": {"nodeType": "FunctionDefinition", "id": 1, "name": "act", "src": f"{a0}:{a1 - a0}:0", + "visibility": "external", "implemented": True, "parameters": {"parameters": [{}]}, + "body": {"statements": [{"nodeType": "FunctionCall", + "expression": {"referencedDeclaration": 2}}]}}, + "2": {"nodeType": "FunctionDefinition", "id": 2, "name": "helper", "src": f"{h0}:{h1 - h0}:0", + "visibility": "internal", "implemented": True, "parameters": {"parameters": [{}]}, + "body": {"statements": []}}}}} + _write_scene(tmp_path, sol, asts) + out = ast_source.function_source(str(tmp_path), "act") + assert "### act" in out and "function act(uint256 x)" in out # exact body, sliced by src + assert "helper (C.sol)" in out and "get_function" in out # callee listed, expand hint + # helper is fetched on demand, one hop at a time (NOT inlined here) + assert "function helper(uint256 x)" not in out + assert ast_source.function_source(str(tmp_path), "helper") is not None + + +def test_function_source_prefers_implemented_over_interface(tmp_path): + sol = "function act(uint256 x) external { x; }\n" + asts = {"u": {"C.sol": { + "1": {"nodeType": "FunctionDefinition", "id": 1, "name": "act", "src": f"0:{len(sol)-1}:0", + "visibility": "external", "implemented": True, "parameters": {"parameters": [{}]}, "body": {}}, + "2": {"nodeType": "FunctionDefinition", "id": 2, "name": "act", "src": "0:5:0", + "visibility": "external", "implemented": False, "parameters": {"parameters": [{}]}, "body": {}}}}} + _write_scene(tmp_path, sol, asts) + out = ast_source.function_source(str(tmp_path), "act") + assert out and "function act" in out # the implemented def, not the decl + + +def test_function_source_missing_returns_none(tmp_path): + _write_scene(tmp_path, "function act() external {}\n", + {"u": {"C.sol": {"1": {"nodeType": "FunctionDefinition", "id": 1, "name": "act", + "src": "0:26:0", "implemented": True, + "parameters": {"parameters": []}, "body": {}}}}}) + assert ast_source.function_source(str(tmp_path), "nope") is None # -> caller falls back to get_file + + +def test_function_source_no_asts_returns_none(tmp_path): + ast_source._INDEX_CACHE.clear() + assert ast_source.function_source(str(tmp_path), "act") is None # no .asts.json -> None diff --git a/smtool/tests/test_classify.py b/smtool/tests/test_classify.py new file mode 100644 index 00000000..bf763a32 --- /dev/null +++ b/smtool/tests/test_classify.py @@ -0,0 +1,28 @@ +"""Classification (MODEL vs OBSERVABLE) + default ghost/reader names.""" +from smtool import classify + + +def test_default_names(): + assert classify.default_ghost_name("getX") == "getXCVL" + assert classify.default_reader_name("getX") == "getXCVLReader" + + +def test_state_changing_and_computed_view_are_model_methods(project, spec): + pr = project( + spec("f", [("uint256", "x")], ["uint256"], "nonpayable"), # state-changing -> model + spec("getX", [("uint256", "x")], ["uint256"], "view", envfree=True), # view -> observable + spec("preview", [("uint256", "x")], ["uint256"], "view", model=True), # computed view -> model + ) + model_names = {m.name for m in pr.cls.model} + getter_names = {g.name for g in pr.cls.getters} + assert "f" in model_names + assert "preview" in model_names # model=True view is a model method (return-only) + assert "getX" in getter_names + assert "getX" not in model_names + + +def test_observable_getter_gets_a_binding(project, spec): + pr = project(spec("f", [("uint256", "x")], ["uint256"], "nonpayable"), + spec("getX", [("uint256", "x")], ["uint256"], "view", envfree=True)) + binding_getters = {b.getter.name for b in pr.cls.bindings} + assert "getX" in binding_getters diff --git a/smtool/tests/test_corpus_shapes.py b/smtool/tests/test_corpus_shapes.py new file mode 100644 index 00000000..6a667b83 --- /dev/null +++ b/smtool/tests/test_corpus_shapes.py @@ -0,0 +1,75 @@ +"""Generators exercised on the pure-math over-approx SHAPES that recur in real FV projects — without an +LLM/prover/scene, and without naming or reproducing any specific project's code. These are generic +patterns (AMM constant-product bounds, integer sqrt, mulDiv) used to check the over-approx / memo +builders, and to PIN the current v1 boundaries (multi-return) as known+tested, not silent. +""" +from smtool.ir import Signature, Param as P +from smtool.overapprox import OverApproxTarget +from smtool.overapprox_project import OverApproxProject +from smtool.detsummary import MemoTarget, render + + +def _proj(*targets, cut="C"): + return OverApproxProject.of(cut, list(targets), setup_spec_import="Setup.spec") + + +# ---------------------------------------------------------------- two-sided algebraic bound Phi +def test_bound_phi_conformance_shape(): + """A per-output over-approx of a pure math fn: `y = f(x); assert bound(x, y)`. Here a constant-product + AMM output with a two-sided algebraic bound as Phi — the common shape where exact equality is + intractable (nonlinear) so a proved bound is the right form.""" + pr = _proj(OverApproxTarget(cut="C", sig=Signature( + name="amountOut", params=[P("uint256", "deducted"), P("uint256", "reserveOut"), + P("uint256", "reserveIn")], returns=["uint256"], mutability="view"))) + pr.set_phi("amountOut", + "mathint lhs = res * (reserveIn + deducted);" + "return lhs <= deducted * reserveOut" + " && lhs >= deducted * (reserveOut - 1) - reserveIn;") + conf = pr.render_conformance("amountOut") + assert "rule overApprox_amountOut(uint256 deducted, uint256 reserveOut, uint256 reserveIn)" in conf + assert "amountOut@withrevert(deducted, reserveOut, reserveIn)" in conf # calls the REAL fn + assert "! realRev => amountOutPhi(deducted, reserveOut, reserveIn, retSol)" in conf # assert Phi on it + assert "res * (reserveIn + deducted)" in pr.render_phi("amountOut") # the algebraic bound (in Phi spec) + + +# ---------------------------------------------------------------- integer sqrt bracket Phi +def test_sqrt_bracket_phi(): + pr = _proj(OverApproxTarget(cut="C", sig=Signature(name="sqrt", params=[P("uint256", "x")], + returns=["uint256"], mutability="pure"))) + pr.set_phi("sqrt", "mathint r = res; return r * r <= to_mathint(x) && to_mathint(x) < (r + 1) * (r + 1);") + conf = pr.render_conformance("sqrt") + assert "rule overApprox_sqrt(uint256 x)" in conf and "sqrt@withrevert(x)" in conf + assert "r * r <= to_mathint(x)" in pr.render_phi("sqrt") # cast round-trips (TO fix) + + +# ---------------------------------------------------------------- mulDiv: memo keyed on scalars +def test_muldiv_memo_scalar_keys(): + """A pure multi-scalar math fn -> the deterministic memo keys directly on the three uint256 params + (no array prefix, no cast) — the model's ghost-keying principle applied to a scalar target.""" + t = MemoTarget(cut="C", fn="mulDiv", + params=[("uint256", "a"), ("uint256", "b"), ("uint256", "d")], ret="uint256") + txt = render(t) + assert "persistent ghost mulDivGhost(uint256, uint256, uint256) returns uint256;" in txt + assert "uint256 res = mulDivGhost(a, b, d);" in txt + assert "?" not in txt and "length" not in txt # no array machinery + assert "function C.mulDiv(uint256 _a, uint256 _b, uint256 _d) internal returns (uint256) => mulDivCVL(_a, _b, _d);" in txt + + +# ---------------------------------------------------------------- multi-return: Phi over the tuple +def test_multireturn_conformance_over_tuple(): + """A multi-return fn (e.g. a swap quote -> (amount, fee, feeAmount)): Phi ranges over the whole tuple, + the rule binds it via a multi-assignment `(retSol0, retSol1, retSol2) = f@withrevert(...)`, and the + summary returns the tuple with `expect (...)`.""" + pr = _proj(OverApproxTarget(cut="C", sig=Signature( + name="quote", params=[P("bool", "flag"), P("uint256", "amountIn")], + returns=["uint256", "uint24", "uint256"], mutability="view"))) + assert pr.provable_targets() == ["quote"] # multi-return IS provable now + pr.set_phi("quote", "return res1 < 1000000 && (res0 > 0 => res2 <= amountIn);") + phi = pr.render_phi("quote") + assert "function quotePhi(bool flag, uint256 amountIn, uint256 res0, uint24 res1, uint256 res2)" in phi + conf = pr.render_conformance("quote") + assert "(retSol0, retSol1, retSol2) = quote@withrevert(flag, amountIn);" in conf # tuple assignment + assert "quotePhi(flag, amountIn, retSol0, retSol1, retSol2)" in conf # Phi over the tuple + summ = pr.render_summary("quote") + assert "return (res0, res1, res2);" in summ + assert "=> quoteCVL(flag, amountIn) expect (uint256, uint24, uint256);" in summ # tuple binding diff --git a/smtool/tests/test_cvlx.py b/smtool/tests/test_cvlx.py new file mode 100644 index 00000000..72f2c120 --- /dev/null +++ b/smtool/tests/test_cvlx.py @@ -0,0 +1,44 @@ +"""cvlx AST builders + the SpecialType CVL-IR extension (env/mathint/method/calldataarg). + +These guard the composer.cvl.schema.SpecialType node smtool depends on — the exact thing that was +missing when smtool was checkpointed off master.""" +from composer.cvl.pretty_print import pretty_print + +from smtool import cvlx as x + + +def _render(*blocks): + return pretty_print(x.spec_file(blocks=list(blocks))) + + +def test_ty_dispatches_special_vs_primitive(): + assert x.ty("env").type_name == "env" # SpecialType + assert x.ty("mathint").type_name == "mathint" # SpecialType + assert x.ty("uint256").type_name == "uint256" # PrimitiveType (not special) + + +def test_special_types_render_in_a_function(): + # a CVL function with env + mathint params and a mathint return — pure SpecialType exercise + f = x.func("g", [("env", "e"), ("mathint", "m")], ["mathint"], [x.ret([x.ident("m")])]) + txt = _render(f) + assert "env e" in txt + assert "mathint m" in txt + + +def test_ghost_and_rule_render(): + g = x.ghost_mapping("gh", "uint256", "uint256") + r = x.rule("r", [("uint256", "k")], + [x.assert_(x.binop("eq", x.ident("k"), x.ident("k")), "trivial")]) + txt = _render(g, r) + assert "persistent ghost" in txt + assert "rule r" in txt + + +def test_if_revert_renders_with_body(): + # regression: brace-less `if (c) revert();` must keep its then-branch (the milestone-7 _block bug) + f = x.func("h", [("uint256", "b")], ["uint256"], + [x.if_(x.binop("eq", x.ident("b"), x.num(0)), [x.revert()]), + x.ret([x.ident("b")])]) + txt = _render(f) + assert "revert()" in txt + assert "if" in txt diff --git a/smtool/tests/test_detsummary.py b/smtool/tests/test_detsummary.py new file mode 100644 index 00000000..24c9bc75 --- /dev/null +++ b/smtool/tests/test_detsummary.py @@ -0,0 +1,69 @@ +"""detsummary — the deterministic-memo ghost-summary builder (AST). Fast, no LLM/prover/scene. + +Guards: the persistent ghost FUNCTION (the memo), array-prefix keying (length + first K elems, K +parametric), scalar keying directly on param types (reusing the model's principle), the internal +`returns`-carrying bindings (+ also_bind), and the absence of any injectivity axiom. Generic +identifiers only (a `bytes31`-backed id computed from an element array — no specific project's code).""" +from smtool.detsummary import MemoTarget, render, tag_high_byte + + +def test_array_target_prefix_keyed_memo(): + t = MemoTarget(cut="M", fn="digest", params=[("M.Item[]", "xs")], ret="M.Id", + key_len=4, also_bind=("_alt",), + ret_pin=("uint248", "to_bytes31"), phi_of=tag_high_byte(2**240, 3)) + txt = render(t) + # the memo: a PERSISTENT ghost FUNCTION keyed on (uint256 len, 4x uint256) — no store, no flag + assert "persistent ghost digestGhost(uint256, uint256, uint256, uint256, uint256) returns M.Id;" in txt + # bounded-prefix key extraction with the element cast + out-of-bounds default 0 + assert "uint256 n = xs.length;" in txt + assert "uint256 a0 = n > 0 ? assert_uint256(xs[0]) : 0;" in txt + assert "uint256 a3 = n > 3 ? assert_uint256(xs[3]) : 0;" in txt + # deterministic load, then Phi re-imposed via the byte-pin (top byte == 3, as division by 2^240) + assert "M.Id res = digestGhost(n, a0, a1, a2, a3);" in txt + assert "require(to_bytes31(v) == res" in txt + assert f"require(v / {2**240} == 3" in txt + # INTERNAL bindings (where the cost is) for the primary + sibling, carrying `returns` + assert "function M.digest(M.Item[] memory _xs) internal returns (M.Id) => digestCVL(_xs);" in txt + assert "function M._alt(M.Item[] memory _xs) internal returns (M.Id) => digestCVL(_xs);" in txt + # determinism ONLY — no injectivity axiom + assert "forall" not in txt and "Inv" not in txt + + +def test_key_len_defaults_to_3(): + t = MemoTarget(cut="M", fn="digest", params=[("M.Item[]", "xs")], ret="M.Id") + txt = render(t) + assert "persistent ghost digestGhost(uint256, uint256, uint256, uint256) returns M.Id;" in txt # len + 3 + assert "uint256 a2 = n > 2 ? assert_uint256(xs[2]) : 0;" in txt and "a3" not in txt + + +def test_monotone_ghost_axiom(): + """A PROVED relational property (monotonicity) rides on the memo ghost as a CLOSED axiom — the + `forall`s are the free i,j bound for axiom syntax (a rule leaves them free; a ghost axiom must + bind them). Scalar-keyed, numeric return.""" + t = MemoTarget(cut="C", fn="feeOut", params=[("uint24", "fee"), ("uint256", "amount")], ret="uint256", + monotone=((0, True),)) + txt = render(t) + assert "persistent ghost feeOutGhost(uint24, uint256) returns uint256 {" in txt # axiom block, not bare decl + assert "forall uint24 k0." in txt and "forall uint256 k1." in txt and "forall uint24 k0hi." in txt + assert "k0 <= k0hi => feeOutGhost(k0, k1) <= feeOutGhost(k0hi, k1)" in txt # monotone in fee + + +def test_monotone_ignored_for_array_key(): + """v1: monotonicity axioms apply to scalar-keyed memos only (an array-prefix key has no natural + per-component monotonicity) — silently not emitted, so a bare ghost decl (no axiom).""" + t = MemoTarget(cut="C", fn="digest", params=[("M.Item[]", "xs")], ret="uint256", key_len=2, + monotone=((0, True),)) + txt = render(t) + assert "persistent ghost digestGhost(uint256, uint256, uint256) returns uint256;" in txt # bare decl + assert "forall" not in txt + + +def test_scalar_target_keys_on_param_types_directly(): + """Scalar inputs: key the ghost on the param CVL types directly (like driver._nested_ghost) — no + prefix, no cast.""" + t = MemoTarget(cut="M", fn="price", params=[("uint256", "id"), ("address", "who")], ret="uint256") + txt = render(t) + assert "persistent ghost priceGhost(uint256, address) returns uint256;" in txt + assert "uint256 res = priceGhost(id, who);" in txt # keyed directly on the params + assert "xs.length" not in txt and "?" not in txt # no array-prefix machinery + assert "function M.price(uint256 _id, address _who) internal returns (uint256) => priceCVL(_id, _who);" in txt diff --git a/smtool/tests/test_driver.py b/smtool/tests/test_driver.py new file mode 100644 index 00000000..169a908d --- /dev/null +++ b/smtool/tests/test_driver.py @@ -0,0 +1,58 @@ +"""Driver rendering: the deterministic skeleton (glue + return rule + state-effect rule), plus +regression tests for the void-return fix and the multi-return-getter load coalescing.""" + + +def test_render_model_and_conformance(project, spec): + pr = project(spec("f", [("uint256", "x")], ["uint256"], "nonpayable"), + spec("getX", [("uint256", "x")], ["uint256"], "view", envfree=True)) + m = pr.render_model() + c = pr.render_conformance("f") + assert "fCVL" in m # model-method body stub for f + assert "rule conformance_f_return" in c + assert "rule conformance_f_stateEffect" in c # state-changing -> gets a state-effect rule + assert "env e" in c # SpecialType env in the rule/glue + assert "function glue" in c + + +def test_view_getter_declared_envfree(project, spec): + from composer.cvl.pretty_print import pretty_print + pr = project(spec("f", [("uint256", "x")], ["uint256"], "nonpayable"), + spec("getX", [("uint256", "x")], ["uint256"], "view", envfree=True)) + # the envfree getter is declared ONCE, in the SHARED reachable spec (so invariants over it resolve in + # the standalone reachable proof conf); conformance imports it and does NOT re-declare (no duplicate). + r = pretty_print(pr.reachable) + c = pr.render_conformance("f") + assert "function C.getX(uint256) external returns (uint256) envfree" in r + assert "function C.getX(" not in c + + +def test_void_method_has_no_return_rule(project, spec): + # returns [] -> no `conformance__return` (nothing to compare) and never `() = call` + pr = project(spec("act", [("uint256", "x")], [], "nonpayable"), + spec("getX", [("uint256", "x")], ["uint256"], "view", envfree=True)) + c = pr.render_conformance("act") + assert "conformance_act_return" not in c + assert "() =" not in c + assert "rule conformance_act_stateEffect" in c # revert-conformance still covered here + + +def test_multireturn_getter_is_coalesced(project, spec): + # ONE getter (getPair) backing TWO observables (component 0 and 1) must LOAD ONCE per program point + # and be DECLARED once in methods{} — not once per component. + pr = project( + spec("f", [("uint256", "x")], ["uint256"], "nonpayable"), + spec("getPair", [("uint256", "x")], ["uint256", "uint256"], "view", + envfree=True, bind_component=0, ghost_name="gA", reader_name="rA"), + spec("getPair", [("uint256", "x")], ["uint256", "uint256"], "view", + envfree=True, bind_component=1, ghost_name="gB", reader_name="rB"), + ) + from composer.cvl.pretty_print import pretty_print + c = pr.render_conformance("f") + # glue body + state-rule pre + state-rule post = 3 loads (would be 6 without coalescing) + assert c.count("= getPair(") == 3 + # single methods{} declaration (would be 2 without the dedup) — now in the shared reachable spec, + # not re-declared in conformance (which imports it) + assert pretty_print(pr.reachable).count("function C.getPair(") == 1 + assert c.count("function C.getPair(") == 0 + # both components still pinned/asserted (rA against c0, rB against c1) + assert "rA(" in c and "rB(" in c diff --git a/smtool/tests/test_glue_pin.py b/smtool/tests/test_glue_pin.py new file mode 100644 index 00000000..8a3b0217 --- /dev/null +++ b/smtool/tests/test_glue_pin.py @@ -0,0 +1,79 @@ +"""add_glue_pin: a sound-by-construction model==real glue pin at an agent-chosen key — for a DERIVED +address key (a credit target from another getter) that the deterministic field-pins miss, whose ghost +cell is otherwise an unconstrained uint256 (-> cast-safety CEX).""" +from smtool.ir import ToolInput, FunctionSpec, Param as P, free_var +from smtool.project import Project +from smtool import mutations as mut +from smtool.cvl_parse import parse_expression +from composer.cvl.pretty_print import pretty_print + + +def _proj(): + # credit-transfer shape: a state-changer + an address-keyed observable + a fee-receiver getter + m = FunctionSpec.of("act", [P("uint256", "id"), P("uint256", "shares")], [], "nonpayable") + bal = FunctionSpec.of("getBalanceOf", [P("uint256", "id"), P("address", "holder")], + ["uint256"], "view", envfree=True, frame_args=[free_var("address", "a")]) + fr = FunctionSpec.of("getReceiver", [P("uint256", "id")], ["address"], "view", envfree=True) + return Project.from_method_specs([ToolInput(cut="C", functions=[m, bal, fr])], None, None) + + +def test_glue_pin_at_derived_key_is_model_equals_real(): + pr = _proj() + r = mut.add_glue_pin(pr, method="act", observable="getBalanceOf", + key_exprs=[parse_expression("id"), parse_expression("getReceiver(id)")]) + assert r.ok, r.message + glue = pretty_print(pr.conformance["act"]) + # the pin is EXACTLY reader(keys) == getter(keys) at the derived key — model==real, sound + assert ("getBalanceOfCVLReader(id, getReceiver(id)) == " + "getBalanceOf(id, getReceiver(id))") in glue.replace("\n", " ") + + +def test_glue_pin_idempotent(): + pr = _proj() + keys = [parse_expression("id"), parse_expression("getReceiver(id)")] + assert mut.add_glue_pin(pr, method="act", observable="getBalanceOf", key_exprs=keys).ok + n1 = pretty_print(pr.conformance["act"]).count("glue pin (agent)") + mut.add_glue_pin(pr, method="act", observable="getBalanceOf", key_exprs=list(keys)) + n2 = pretty_print(pr.conformance["act"]).count("glue pin (agent)") + assert n1 == n2 == 1, "re-adding the same pin must be idempotent" + + +def test_glue_pin_rejects_non_observable(): + pr = _proj() + r = mut.add_glue_pin(pr, method="act", observable="notAnObservable", + key_exprs=[parse_expression("id")]) + assert not r.ok and "not a modeled observable" in r.message + + +def _proj_multi(): + # a MULTI-RETURN getter (address, uint8) with one observable per component (the u/d shape) + m = FunctionSpec.of("act", [P("uint256", "id"), P("uint256", "shares")], [], "nonpayable") + ud = [FunctionSpec.of("getUnderlyingAndDecimals", [P("uint256", "id")], ["address", "uint8"], "view", + envfree=True, observable=True, component_names=["u", "d"], bind_component=i) + for i in (0, 1)] + return Project.from_method_specs([ToolInput(cut="C", functions=[m, *ud])], None, None) + + +def test_glue_pin_multireturn_destructures_instead_of_tuple_compare(): + pr = _proj_multi() + r = mut.add_glue_pin(pr, method="act", observable="getUnderlyingAndDecimals", + key_exprs=[parse_expression("id")]) + assert r.ok, r.message + glue = pretty_print(pr.conformance["act"]).replace("\n", " ") + # the BUG was comparing a scalar reader to the whole tuple (untypeable); the fix loads once + pins + # each component to its own local — so the buggy `reader == getter(tuple)` shape must NOT appear + assert "CVLReader(id) == getUnderlyingAndDecimals(id)" not in glue + # one agent pin per modeled component, each against a fresh destructure local + assert glue.count("glue pin (agent): model == real for getUnderlyingAndDecimals") == 2 + assert "getUnderlyingAndDecimals_uCVLReader(id) == _gp_getUnderlyingAndDecimals_" in glue + assert "getUnderlyingAndDecimals_dCVLReader(id) == _gp_getUnderlyingAndDecimals_" in glue + + +def test_glue_pin_multireturn_idempotent(): + pr = _proj_multi() + keys = [parse_expression("id")] + assert mut.add_glue_pin(pr, method="act", observable="getUnderlyingAndDecimals", key_exprs=keys).ok + n1 = pretty_print(pr.conformance["act"]).count("glue pin (agent)") + mut.add_glue_pin(pr, method="act", observable="getUnderlyingAndDecimals", key_exprs=list(keys)) + n2 = pretty_print(pr.conformance["act"]).count("glue pin (agent)") + assert n1 == n2 == 2, "re-adding the same multi-return pin must not re-declare/duplicate" diff --git a/smtool/tests/test_linter.py b/smtool/tests/test_linter.py new file mode 100644 index 00000000..fac6409b --- /dev/null +++ b/smtool/tests/test_linter.py @@ -0,0 +1,23 @@ +"""Discipline linter: a freshly-generated skeleton is clean, and the glue (template model==real +equalities) has no discipline violations.""" +from smtool import linter + + +def _proj(project, spec): + return project(spec("f", [("uint256", "x")], ["uint256"], "nonpayable"), + spec("getX", [("uint256", "x")], ["uint256"], "view", envfree=True)) + + +def test_lint_returns_a_list(project, spec): + problems = linter.lint(_proj(project, spec)) + assert isinstance(problems, list) + + +def test_template_glue_is_discipline_clean(project, spec): + # the glue is deterministic model==real equalities -> lint_glue must find nothing + assert linter.lint_glue(_proj(project, spec), "f") == [] + + +def test_skeleton_has_no_model_spec_violations(project, spec): + # empty CVL stubs + persistent ghosts + no setup imports -> lint_model_spec clean + assert linter.lint_model_spec(_proj(project, spec)) == [] diff --git a/smtool/tests/test_multireturn_getter.py b/smtool/tests/test_multireturn_getter.py new file mode 100644 index 00000000..65541dc0 --- /dev/null +++ b/smtool/tests/test_multireturn_getter.py @@ -0,0 +1,183 @@ +"""A multi-return observable getter must generate DISTINCT per-component ghosts + readers. + +Regression for a `getPair() -> (uint256, int256)` bug: both components defaulted to the same +`CVL` name, so the model spec redeclared the ghost / overloaded the reader — an uncatchable +typecheck error (the agent has no tool to edit a glue-pinned template ghost). Each component must get +its own ghost/reader; the combined tuple reader projects both. +""" +from smtool.ir import ToolInput, FunctionSpec, Param as P +from smtool.project import Project +from composer.cvl.pretty_print import pretty_print + + +def _model_text(*fns): + pr = Project.from_method_specs([ToolInput(cut="C", functions=list(fns))], None, None) + return pretty_print(pr.model_spec) + + +def _pair_getter(component): + # getPair(uint256 id) -> (uint256, int256); one observable per tracked component + return FunctionSpec.of("getPair", [P("uint256", "id")], ["uint256", "int256"], "view", + envfree=True, bind_component=component) + + +def test_multireturn_components_get_distinct_ghosts(): + # a state-changer so the getters are observables of something (model needs >=1 model method) + m = FunctionSpec.of("poke", [P("uint256", "id")], [], "nonpayable") + txt = _model_text(m, _pair_getter(0), _pair_getter(1)) + # distinct component ghosts, no collision + assert "getPair_0CVL" in txt and "getPair_1CVL" in txt + assert "mapping(uint256 => uint256) getPair_0CVL" in txt.replace("\n", " ") or "getPair_0CVL;" in txt + # the combined tuple reader reads BOTH distinct ghosts + ghost_decls = [l for l in txt.splitlines() if l.strip().startswith("persistent ghost") and "getPairCVL;" in l] + assert not ghost_decls, "no ghost may be named exactly getPairCVL (that's the combined reader)" + + +def test_no_duplicate_declarations(): + m = FunctionSpec.of("poke", [P("uint256", "id")], [], "nonpayable") + txt = _model_text(m, _pair_getter(0), _pair_getter(1)) + # collect every declared ghost + function name; none may repeat + names = [] + for l in txt.splitlines(): + s = l.strip() + if s.startswith("persistent ghost"): + names.append(s.rstrip("{;").split()[-1].rstrip(";")) + elif s.startswith("function "): + names.append(s.split("(")[0].split()[-1]) + dups = {n for n in names if names.count(n) > 1} + assert not dups, f"duplicate declarations (redeclaration typecheck error): {dups}" + + +# ---- reachable-key type consistency (second driver bug) -------------------------------------------- +def test_assumeReachable_call_matches_declared_key_type(): + """The return rule's `assumeReachable(...)` must pass a var of the DECLARED key type. When the + reachable key is an address (a state-effect frame var) but the method's first param is NOT an + address (e.g. a uint256 id), passing `m.params[0]` blindly is a type mismatch the agent can't fix.""" + from smtool.ir import ToolInput, FunctionSpec, Param as P, free_var + from smtool.project import Project + from composer.cvl.pretty_print import pretty_print + # a method whose FIRST param is uint256 (id), + an address-keyed observable -> address key + m = FunctionSpec.of("act", [P("uint256", "id"), P("uint256", "amt"), P("address", "to")], + ["uint256"], "nonpayable") + bal = FunctionSpec.of("balByAccount", [P("address", "acct")], ["uint256"], "view", envfree=True, + ghost_name="balCVL", reader_name="mBal", + frame_args=[free_var("address", "a")]) + pr = Project.from_method_specs([ToolInput(cut="C", functions=[m, bal])], None, None) + reach = pretty_print(pr.reachable) + decl = [l for l in reach.splitlines() if "function assumeReachable" in l][0] + # the return rule must NOT call assumeReachable(id) (uint256); it declares+passes the key var + ret = pretty_print(pr.conformance["act"]) + assert "assumeReachable(id)" not in ret, "return rule passes the wrong (uint256) key" + # the reachable key is the address frame var (here named `a`); the return rule declares + passes it, + # led by the rule's env `e` (assumeReachable's first slot, for env-taking invariants) + assert "address a" in decl, "reachable key should be the address frame var" + assert "address a;" in ret and "assumeReachable(e, a" in ret, \ + "return rule must declare + pass an address var, led by the env" + + +# ---- MULTI-KEY reachable slots across the WHOLE-model union (third + fifth driver bugs) ------------- +def _union_project(): + from smtool.ir import ToolInput, FunctionSpec, Param as P, free_var + from smtool.project import Project + act = FunctionSpec.of("act", [P("uint256", "id"), P("uint256", "amt"), P("address", "to")], + ["uint256"], "nonpayable") + bal = FunctionSpec.of("balByAccount", [P("address", "acct")], ["uint256"], "view", envfree=True, + ghost_name="balCVL", reader_name="mBal", frame_args=[free_var("address", "a")]) + # preview: computed view, uint256 first param, observables keyed uint256 only (no address) + prev = FunctionSpec.of("preview", [P("uint256", "id"), P("uint256", "amt")], + ["uint256"], "view", model=True) + sh = FunctionSpec.of("getBal", [P("uint256", "id")], ["uint256"], "view", envfree=True) + # state-changer with NO address observable + touch = FunctionSpec.of("touch", [P("uint256", "id"), P("uint256", "amt")], [], "nonpayable") + return Project.from_method_specs([ + ToolInput(cut="C", functions=[act, bal]), + ToolInput(cut="C", functions=[prev, sh]), + ToolInput(cut="C", functions=[touch, sh]), + ], None, None) + + +def test_union_reachable_exposes_a_slot_per_key_type(): + """`assumeReachable` must expose ONE slot per DISTINCT key type the model's invariants can range over + — the address frame var AND the `uint256 id` observable key — so a PER-KEY invariant can be + requireInvariant'd (a single address slot could not host it). Every conformance rule fills the + address slot with `a` (framed/fresh) and the id slot with the method's own `id`, both matching the + declared types.""" + from composer.cvl.pretty_print import pretty_print + pr = _union_project() + decl = [l for l in pretty_print(pr.reachable).splitlines() if "function assumeReachable" in l][0] + # multi-key declaration: env slot first, then address, then the uint256 id slot + assert "assumeReachable(env e, address a, uint256 id)" in decl, decl + for name, spec in pr.conformance.items(): + t = pretty_print(spec) + for s in (l.strip() for l in t.splitlines()): + if "assumeReachable(" in s and not s.startswith("function"): + # led by the rule's env, then the id slot (method's own id) + the address slot + assert s == "assumeReachable(e, a, id);", f"{name}: bad reachable call {s!r}" + + +def test_perkey_invariant_can_be_added_via_requireInvariant(): + """The fix's PURPOSE: a per-key (`id`-keyed) reachable invariant — e.g. to bound real storage so a + model's mathint->uint256 casts match the real fixed-width fields — must be expressible. Regression + for the case where the agent got stuck: `requireInvariant balBound(id)` inside `assumeReachable` was + an undeclared-identifier typecheck error because the single address slot had no `id`.""" + from smtool import mutations, cvlx as cx + from composer.cvl.pretty_print import pretty_print + pr = _union_project() + # add a per-key invariant keyed by the id slot + r = mutations.add_requireInvariant( + pr, inv_name="balBound", inv_params=[("uint256", "id")], + inv_expr=cx.binop("le", cx.call("getBal", [cx.ident("id")]), cx.num(2**120 - 1)), + require_args=["id"]) + assert r.ok, r.message + reach = pretty_print(pr.reachable) + assert "requireInvariant balBound(id)" in reach, reach + # a NON-slot arg is rejected with an actionable message (not a silent typecheck failure) + bad = mutations.add_requireInvariant( + pr, inv_name="bogus", inv_params=[("uint256", "id")], + inv_expr=cx.binop("le", cx.call("getBal", [cx.ident("id")]), cx.num(1)), + require_args=["notaslot"]) + assert not bad.ok and "not reachable-key slots" in bad.message, bad.message + + +# ---- struct method-param must not be pinned as a scalar key (fourth driver bug) -------------------- +def test_struct_param_not_pinned_as_scalar_key(): + """`_field_pins` cross-products the method params against each observable's key types. A STRUCT param + (e.g. `act(uint256, Lib.Info)`) is not a valid mapping key, yet the old `_is_udvt` blocklist treated + every non-primitive as coercible -> it emitted `readerCVL(info)` against a `uint256`-keyed reader, an + uncatchable-by-agent typecheck error. Only a type the model actually uses AS a key (a real UDVT) may + coerce into a numeric key.""" + from smtool.ir import ToolInput, FunctionSpec, Param as P + from smtool.project import Project + from composer.cvl.pretty_print import pretty_print + + act = FunctionSpec.of("act", [P("uint256", "id"), P("Lib.Info", "info")], [], "nonpayable") + idx = FunctionSpec.of("getIdx", [P("uint256", "id")], ["uint256"], "view", envfree=True) + pr = Project.from_method_specs([ToolInput(cut="C", functions=[act, idx])], None, None) + t = pretty_print(pr.conformance["act"]) + bad = [l.strip() for l in t.splitlines() if "info" in l and "CVLReader(" in l] + assert not bad, f"struct param pinned as a scalar key (typecheck error): {bad}" + # the legit uint256 id key is still pinned (the fix must not over-prune) + assert "getIdxCVLReader(id)" in t, "valid uint256-key pin was lost" + + +def test_env_invariant_with_preserved_block(): + """An env-taking invariant (a time-dependent bound over a non-envfree getter) gets a leading `env e`, + its requireInvariant passes assumeReachable's env, and a `preserved with (env e1)` block relates the + two envs (the idiom that makes it provable). Mainstream in real FV specs (~1/5 of invariants); the + driver must express it, not just envfree ones.""" + from smtool import mutations + from smtool.cvl_parse import parse_expression, parse_commands + from composer.cvl.pretty_print import pretty_print + pr = _union_project() + r = mutations.add_requireInvariant( + pr, inv_name="idxMonotone", inv_params=[("uint256", "id")], + inv_expr=parse_expression("getBal(id) <= 2^120 - 1"), + require_args=["id"], env=True, + preserved=parse_commands("require e1.block.timestamp <= e.block.timestamp;")) + assert r.ok, r.message + reach = pretty_print(pr.reachable) + assert "invariant idxMonotone(env e, uint256 id)" in reach # leading env param + assert "preserved with (env e1)" in reach # the relating proof block + assert "e1.block.timestamp <= e.block.timestamp" in reach + assert "requireInvariant idxMonotone(e, id)" in reach # passes the env + assert "function assumeReachable(env e," in reach # env slot exists diff --git a/smtool/tests/test_mutations.py b/smtool/tests/test_mutations.py new file mode 100644 index 00000000..b63f38cd --- /dev/null +++ b/smtool/tests/test_mutations.py @@ -0,0 +1,44 @@ +"""add_nondet soundness gates — the invariants the PROVER cannot catch, so they must hold at the tool +boundary: NONDET is refused for state-changing targets and for any function of the CUT (concrete or a +`_.f` wildcard whose name collides with a CUT function); it is allowed for an off-path OTHER-contract +view.""" +from smtool import mutations as M + + +def _proj(project, spec): + return project(spec("f", [("uint256", "x")], ["uint256"], "nonpayable"), + spec("getX", [("uint256", "x")], ["uint256"], "view", envfree=True)) + + +def test_nondet_refuses_cut_function_concrete(project, spec): + pr = _proj(project, spec) + r = M.add_nondet(pr, method="f", contract="C", name="getX", + param_types=["uint256"], return_types=["uint256"], mutability="view") + assert not r.ok + assert "contract-under-test" in r.message + + +def test_nondet_refuses_cut_function_via_wildcard(project, spec): + pr = _proj(project, spec) + # `_.getX` collides with a CUT function name -> must also be refused + r = M.add_nondet(pr, method="f", contract="_", name="getX", + param_types=["uint256"], return_types=["uint256"], mutability="view") + assert not r.ok + + +def test_nondet_refuses_state_changing_target(project, spec): + pr = _proj(project, spec) + r = M.add_nondet(pr, method="f", contract="Other", name="doThing", + param_types=[], return_types=["uint256"], mutability="nonpayable") + assert not r.ok + + +def test_nondet_allows_offpath_other_contract_view(project, spec): + pr = _proj(project, spec) + # a view on ANOTHER in-scene contract, off the checked output -> legitimate NONDET target + r = M.add_nondet(pr, method="f", contract="Oracle", name="latestPrice", + param_types=["uint256"], return_types=["uint256"], mutability="view") + assert r.ok + # ... and it can be retracted + back = M.remove_nondet(pr, "f", name="latestPrice", contract="Oracle") + assert back.ok diff --git a/smtool/tests/test_overapprox.py b/smtool/tests/test_overapprox.py new file mode 100644 index 00000000..03e92111 --- /dev/null +++ b/smtool/tests/test_overapprox.py @@ -0,0 +1,249 @@ +"""Over-approximation generator (overapprox.py + overapprox_project.py) + the CVL cast round-trip. + +Fast, GENERIC (a fictional CUT `C`), no LLM / prover / compiled scene. Guards: (1) the cvl_parse cast +kind decode (TO/REQUIRE/ASSERT) — the `to_bytes31`/`to_mathint` round-trip that a wrong default silently +corrupted to `require_*`; (2) the three emitted artifacts (Phi / summary / conformance) for the envfree +and env paths; (3) set_phi discipline; (4) write() output + conf shape.""" +import json +import tempfile +from pathlib import Path + +from composer.cvl.pretty_print import pretty_print + +from smtool import cvlx as x +from smtool.cvl_parse import parse_commands +from smtool.ir import Signature, Param as P +from smtool.overapprox import OverApproxTarget, build_conformance_rule, build_revert_rule +from smtool.overapprox_project import OverApproxProject, conformance_rule_name + + +def _sig(name, params, returns, mutability="view"): + return Signature(name=name, params=[P(t, n) for t, n in params], returns=list(returns), + mutability=mutability) + + +def _project(*targets, cut="C", setup="Setup.spec"): + return OverApproxProject.of(cut, list(targets), setup_spec_import=setup) + + +# ---------------------------------------------------------------- cast round-trip (regression) +def test_cast_kinds_round_trip(): + """to_/require_/assert_ casts must survive parse->render. A wrong default folded TO into require_, + yielding the non-existent require_bytes31 / require_mathint (broke the byte-extract + mathint idioms).""" + cmds = parse_commands("bytes31 b = to_bytes31(v); mathint m = to_mathint(v); " + "uint256 u = require_uint256(m); uint256 a = assert_uint256(m); return b;", + [("uint248", "v")]) + txt = pretty_print(x.spec_file(blocks=[x.func("t", [("uint248", "v")], ["bytes31"], cmds)])) + assert "to_bytes31(v)" in txt and "to_mathint(v)" in txt + assert "require_uint256(m)" in txt and "assert_uint256(m)" in txt + assert "require_bytes31" not in txt and "require_mathint" not in txt # the corruption is gone + + +# ---------------------------------------------------------------- the three artifacts (envfree) +def test_envfree_conformance_and_summary_shape(): + pr = _project(OverApproxTarget(cut="C", sig=_sig("sqrt", [("uint256", "x")], ["uint256"]))) + pr.set_phi("sqrt", "mathint r = res; return r * r <= to_mathint(x) && to_mathint(x) < (r + 1) * (r + 1);") + + conf = pr.render_conformance("sqrt") + assert 'import "Setup.spec";' in conf and 'import "sqrtPhi.spec";' in conf + assert "function C.sqrt(uint256) external returns (uint256) envfree;" in conf + assert "rule overApprox_sqrt(uint256 x)" in conf + assert "sqrt@withrevert(x)" in conf # calls the REAL function, no env (envfree) + assert "! realRev => sqrtPhi(x, retSol)" in conf # assert real output satisfies Phi + + summ = pr.render_summary("sqrt") + assert "function sqrtCVL(uint256 x) returns uint256" in summ + assert 'require(sqrtPhi(x, res)' in summ # havoc res + require Phi (over-approx) + assert "function C.sqrt(uint256 x) external returns (uint256) => sqrtCVL(x);" in summ + + phi = pr.render_phi("sqrt") + assert "function sqrtPhi(uint256 x, uint256 res) returns bool" in phi + assert "to_mathint(x)" in phi # cast preserved end-to-end + + +# ---------------------------------------------------------------- the env path (state-changing f) +def test_env_path_threads_env(): + pr = _project(OverApproxTarget(cut="C", sig=_sig("act", [("uint256", "amt")], ["uint256"], + mutability="nonpayable"))) + pr.set_phi("act", "return res <= amt;") + conf = pr.render_conformance("act") + assert "env e;" in conf and "act@withrevert(e, amt)" in conf # env declared + threaded to the call + summ = pr.render_summary("act") + assert "function actCVL(uint256 amt, env e) returns uint256" in summ + assert "with (env e) => actCVL(amt, e)" in summ # binding threads env + + +# ---------------------------------------------------------------- revert predicate Ψ (fidelity) +def test_no_psi_summary_never_reverts(): + """Default (no Ψ): the summary has no revert guard and there is no revert-conformance rule — the + sound-but-coarse behavior. The value conformance is the only rule.""" + t = OverApproxTarget(cut="C", sig=_sig("f", [("uint256", "a")], ["uint256"])) + pr = _project(t) + pr.set_phi("f", "return res <= a;") + assert build_revert_rule(pr.targets["f"]) is None + assert "revert()" not in pr.render_summary("f") + assert "revertConform_f" not in pr.render_conformance("f") + + +def test_psi_makes_summary_revert_and_adds_conformance(): + """With Ψ set: the summary reverts on Ψ FIRST (before havoc/require Phi), the conformance spec gains + the dual rule `revertConform_f` asserting `Psi => realReverted` (the sound direction), and both the + Phi and Ψ specs are imported.""" + t = OverApproxTarget(cut="C", sig=_sig("divmul", [("uint256", "a"), ("uint256", "c")], ["uint256"])) + pr = _project(t) + pr.set_phi("divmul", "return to_mathint(res) == to_mathint(a);") + assert pr.set_psi("divmul", "return c == 0;").ok + + summ = pr.render_summary("divmul") + assert "if(divmulReverts(a, c))" in summ and "revert(" in summ # guard reverts where f does + assert summ.index("revert(") < summ.index("divmulPhi(a, c, res)") # revert BEFORE havoc/require Phi + assert 'import "divmulReverts.spec";' in summ and 'import "divmulPhi.spec";' in summ + + psi = pr.render_psi("divmul") + assert "function divmulReverts(uint256 a, uint256 c) returns bool" in psi + assert "c == 0" in psi + + conf = pr.render_conformance("divmul") + assert "rule overApprox_divmul" in conf # value rule kept + assert "rule revertConform_divmul" in conf # + the dual revert rule + assert "divmulReverts(a, c) => realRev" in conf # Psi => realReverted + assert 'import "divmulReverts.spec";' in conf + + +def test_set_psi_rejects_require(): + """Ψ is a pure boolean over params — a `require` (which would prune inputs) is rejected; a plain + boolean `return` is accepted.""" + pr = _project(OverApproxTarget(cut="C", sig=_sig("f", [("uint256", "a")], ["uint256"]))) + pr.set_phi("f", "return res <= a;") + assert pr.set_psi("f", "return a == 0;").ok + r = pr.set_psi("f", "require a > 0; return a == 0;") + assert not r.ok and any("PURE boolean" in v for v in r.violations) + assert pr.targets["f"].psi_body is not None # rejected set_psi didn't clobber + assert not pr.set_psi("nope", "return true;").ok # unknown target + + +def test_write_and_conf_include_revert_rule(): + """write() emits the Ψ spec and the conformance .conf runs BOTH rules when Ψ is set.""" + t = OverApproxTarget(cut="C", sig=_sig("f", [("uint256", "a")], ["uint256"])) + pr = _project(t) + pr.set_phi("f", "return res <= a;") + pr.set_psi("f", "return a == 0;") + with tempfile.TemporaryDirectory() as d: + written = {Path(p).name for p in pr.write(d, {"files": ["C.sol"], "verify": "C:Setup.spec"})} + assert "fReverts.spec" in written + conf = json.loads((Path(d) / "conf" / "fConformance.conf").read_text()) + assert conf["rule"] == ["overApprox_f", "revertConform_f"] + + +# ---------------------------------------------------------------- set_phi discipline +def test_set_phi_rejects_and_preserves(): + pr = _project(OverApproxTarget(cut="C", sig=_sig("f", [("uint256", "a")], ["uint256"]))) + assert pr.set_phi("f", "return a <= 100;").ok + good = pr.targets["f"].phi_body + assert not pr.set_phi("nope", "return true;").ok # unknown target + r = pr.set_phi("f", "return a <<< ;") # parse error + assert not r.ok and "parse error" in r.message + assert pr.targets["f"].phi_body is good # a rejected set_phi does not clobber + + +def test_set_phi_rejects_domain_restricting_require(): + """SOUNDNESS guardrail: a `require` that constrains only params/result (no fresh witness local) is a + domain restriction — rejected. A `require` that pins a fresh witness local (byte-extract idiom) is OK.""" + pr = _project(OverApproxTarget(cut="C", sig=_sig("f", [("uint256", "x")], ["uint256"]))) + good = pr.set_phi("f", "return a <= 100;".replace("a", "x")) # a plain bound in the RETURN is fine + assert good.ok + # a domain-restricting require on a param -> REJECTED + r = pr.set_phi("f", "require x < 100; return x <= 100;") + assert not r.ok and any("DOMAIN RESTRICTION" in v for v in r.violations) + assert pr.targets["f"].phi_body is not None # rejected set_phi didn't clobber + # a require restricting the result -> REJECTED + r2 = pr.set_phi("f", "require res != 0; return res <= x;") + assert not r2.ok and any("DOMAIN RESTRICTION" in v for v in r2.violations) + # a WITNESS pin (require references a fresh local) -> ACCEPTED, even with an inequality bracket + r3 = pr.set_phi("f", "uint256 k; require k * k <= x && (k + 1) * (k + 1) > x; return res <= x;") + assert r3.ok + + +def test_set_phi_invalidates_stale_verdict(): + pr = _project(OverApproxTarget(cut="C", sig=_sig("f", [("uint256", "a")], ["uint256"]))) + pr.set_phi("f", "return a <= 100;") + pr.verified.add("f") + pr.set_phi("f", "return a <= 200;") + assert "f" not in pr.verified # Phi changed => prior verdict is stale + + +# ---------------------------------------------------------------- write() output + conf +def test_write_emits_artifacts_and_conf(): + pr = _project(OverApproxTarget(cut="C", sig=_sig("f", [("uint256", "a")], ["uint256"]))) + pr.set_phi("f", "return a <= 100;") + with tempfile.TemporaryDirectory() as d: + setup_conf = {"files": ["C.sol"], "solc": "solc8.20", "verify": "C:Setup.spec"} + written = {Path(p).name for p in pr.write(d, setup_conf)} + assert {"fPhi.spec", "fSummary.spec", "fConformance.spec", "fConformance.conf"} <= written + conf = json.loads((Path(d) / "conf" / "fConformance.conf").read_text()) + assert conf["verify"] == "C:certora/specs/fConformance.spec" + assert conf["rule"] == [conformance_rule_name("f")] == ["overApprox_f"] + assert conf["files"] == ["C.sol"] and "smt_timeout" in conf # scene inherited + perf applied + + +# ---------------------------------------------------------------- budget + verified fallback +def _passing(rule): + from smtool import verify as V + return V.VerifyResult(conf=f"/x/{rule}.conf", success=True, job_url="u", + rules=[V.RuleVerdict(rule=rule, status="VERIFIED", passed=True)]) + + +def test_verify_budget_refuses_when_spent(monkeypatch): + """The verify tool must refuse a prover run once the budget is spent (bounds cost), and say so.""" + import asyncio + from smtool.agent import overapprox_refine as R + pr = _project(OverApproxTarget(cut="C", sig=_sig("f", [("uint256", "a")], ["uint256"]))) + pr.set_phi("f", "return a <= 100;") + calls = {"n": 0} + + async def fake_prove(project, cfg): + calls["n"] += 1 + return {"/x/overApprox_f.conf": _passing("overApprox_f")} + monkeypatch.setattr(R, "_prove_and_verify", fake_prove) + + budget = [2] + verify = R._make_verify(pr, cfg=None, budget=budget) + assert "VERIFIED" in asyncio.run(verify()) # 1st: runs + assert "VERIFIED" in asyncio.run(verify()) # 2nd: runs + msg = asyncio.run(verify()) # 3rd: refused + assert "BUDGET SPENT" in msg and calls["n"] == 2 # prover was invoked exactly twice + + +def test_restore_best_verified_ships_proven_phi(): + """A tighter-but-unproven Phi left at budget-exhaustion is discarded; the last PROVEN Phi is shipped.""" + pr = _project(OverApproxTarget(cut="C", sig=_sig("f", [("uint256", "a")], ["uint256"]))) + pr.set_phi("f", "return a <= 100;") + pr.mark_verified("f") # this Phi proved -> it's the fallback + proven = pr.render_phi("f") + pr.set_phi("f", "return a <= 50;") # agent tightens; budget cuts off before verify + assert pr.render_phi("f") != proven + pr.restore_best_verified() + assert pr.render_phi("f") == proven # shipped Phi is the proven one + + +# ---------------------------------------------------------------- specs_dir path consistency +def test_write_honors_specs_dir_basename(): + """write() must put specs in the subdir named by specs_dir's last component, so it agrees with the + conf's specs_dir-relative `verify` path — e.g. a scene using `certora/spec` (singular), not `specs`.""" + pr = OverApproxProject.of("C", [OverApproxTarget(cut="C", sig=_sig("f", [("uint256", "a")], ["uint256"]))], + setup_spec_import="Setup.spec", specs_dir="certora/spec") + pr.set_phi("f", "return a <= 100;") + with tempfile.TemporaryDirectory() as d: + pr.write(d, {"files": ["C.sol"], "verify": "C:Setup.spec"}) + assert (Path(d) / "spec" / "fConformance.spec").exists() # subdir = specs_dir basename + assert not (Path(d) / "specs").exists() # not the hardcoded "specs" + conf = json.loads((Path(d) / "conf" / "fConformance.conf").read_text()) + assert conf["verify"] == "C:certora/spec/fConformance.spec" # verify agrees with where it landed + + +# ---------------------------------------------------------------- void f -> no conformance rule +def test_void_target_has_no_rule(): + t = OverApproxTarget(cut="C", sig=_sig("poke", [("uint256", "a")], [], mutability="nonpayable")) + assert build_conformance_rule(t) is None + pr = _project(t) + assert pr.provable_targets() == [] # nothing to prove for a void f diff --git a/smtool/tests/test_relational.py b/smtool/tests/test_relational.py new file mode 100644 index 00000000..3cfe5a95 --- /dev/null +++ b/smtool/tests/test_relational.py @@ -0,0 +1,70 @@ +"""Relational (k-call) conformance templates — monotonicity. Fast, no LLM/prover/scene, generic names. + +The CHECK rule is quantifier-free (the rule's free params are the ∀). The ENCODE side (the monotone ghost +axiom in detsummary) IS quantified, because a CVL ghost axiom must be a closed formula — see +test_detsummary.""" +from composer.cvl.pretty_print import pretty_print + +from smtool import cvlx as x +from smtool.ir import Signature, Param as P +from smtool.overapprox import OverApproxTarget +from smtool.relational import build_monotonicity_rule, build_monotonicity_spec, MonotoneSpec + + +def _t(name, params, returns, mutability="pure", **kw): + return OverApproxTarget(cut="C", sig=Signature(name=name, params=[P(t, n) for t, n in params], + returns=list(returns), mutability=mutability), **kw) + + +def _render(rule): + return pretty_print(x.spec_file(blocks=[rule])) + + +def test_monotone_rule_scalar_is_quantifier_free(): + """Two real calls agreeing except the varied arg; assert the output is ordered. No quantifier — the + rule params ARE the universal quantification.""" + r = build_monotonicity_rule(_t("feeOut", [("uint24", "fee"), ("uint256", "amount")], ["uint256"]), + MonotoneSpec(arg=0, increasing=True)) + txt = _render(r) + assert "rule monotone_feeOut_arg0(uint24 fee, uint256 amount, uint24 fee_hi)" in txt + assert 'require(fee < fee_hi' in txt # varied arg strictly increases + assert "uint256 rLo = feeOut(fee, amount);" in txt # call A (no @withrevert) + assert "uint256 rHi = feeOut(fee_hi, amount);" in txt # call B: only the varied arg changes + assert "assert(rLo <= rHi" in txt # non-decreasing + assert "forall" not in txt and "@withrevert" not in txt # quantifier-free; plain calls + + +def test_monotone_rule_decreasing_multireturn_guard_env(): + g = x.binop("lt", x.ident("fee_hi"), x.num(1000000)) + r = build_monotonicity_rule( + _t("quote", [("uint24", "fee"), ("uint256", "amt")], ["uint256", "uint24", "uint256"], + mutability="view", envfree=False), + MonotoneSpec(arg=0, out=1, increasing=False, guard=g)) + txt = _render(r) + assert "env e;" in txt # shared env across both calls + assert 'require(fee_hi < 1000000' in txt # domain guard + assert "(rLo0, rLo1, rLo2) = quote(e, fee, amt);" in txt # multi-return tuple binds + assert "(rHi0, rHi1, rHi2) = quote(e, fee_hi, amt);" in txt + assert "assert(rLo1 >= rHi1" in txt # non-increasing, output component 1 + + +def test_monotone_spec_wraps_rule_with_envfree_decl(): + """The runnable spec adds the envfree decl (an envfree target's two calls need no env) + the rule — + without it the pure calls fail to typecheck ('missing environment parameter').""" + t = _t("feeOut", [("uint24", "fee"), ("uint256", "amount")], ["uint256"]) # pure => envfree + txt = pretty_print(build_monotonicity_spec(t, MonotoneSpec(arg=0))) + assert "function C.feeOut(uint24, uint256) external returns (uint256) envfree;" in txt + assert "rule monotone_feeOut_arg0(" in txt + # non-envfree target: env threaded instead, no envfree decl + t2 = _t("act", [("uint256", "amt")], ["uint256"], mutability="nonpayable") + txt2 = pretty_print(build_monotonicity_spec(t2, MonotoneSpec(arg=0))) + assert "envfree" not in txt2 and "env e;" in txt2 + + +def test_monotone_rule_none_for_void_or_bad_index(): + assert build_monotonicity_rule(_t("poke", [("uint256", "a")], [], mutability="nonpayable"), + MonotoneSpec(arg=0)) is None # void + assert build_monotonicity_rule(_t("f", [("uint256", "a")], ["uint256"]), + MonotoneSpec(arg=5)) is None # arg out of range + assert build_monotonicity_rule(_t("f", [("uint256", "a")], ["uint256"]), + MonotoneSpec(arg=0, out=3)) is None # out of range diff --git a/smtool/tests/test_render_dedup.py b/smtool/tests/test_render_dedup.py new file mode 100644 index 00000000..ee249359 --- /dev/null +++ b/smtool/tests/test_render_dedup.py @@ -0,0 +1,62 @@ +"""render_model/render_conformance de-duplication: a repeat render returns only what CHANGED (UNCHANGED +or a unified diff) instead of re-dumping the whole spec, with a periodic full re-sync. Observation-only — +the model is untouched. Cuts the ~1/4 of agent turns spent re-rendering + the context they bloat.""" +from types import SimpleNamespace +from smtool.agent.tools import _render_dedup, _RENDER_FULL_EVERY + + +def test_first_render_is_full(): + p = SimpleNamespace() + assert _render_dedup(p, "model", "A\nB\nC") == "A\nB\nC" + + +def test_identical_rerender_is_unchanged_not_full(): + p = SimpleNamespace() + _render_dedup(p, "model", "A\nB\nC") + out = _render_dedup(p, "model", "A\nB\nC") + assert "UNCHANGED" in out and "A\nB\nC" not in out + + +def test_changed_rerender_returns_a_diff(): + # a realistically-sized spec: a one-line change -> the diff is far smaller than the full text + base = "\n".join(f"line {i}" for i in range(40)) + changed = base.replace("line 20", "line 20 CHANGED") + p = SimpleNamespace() + _render_dedup(p, "model", base) + out = _render_dedup(p, "model", changed) + assert "CHANGED" in out and "+line 20 CHANGED" in out and "-line 20" in out + assert len(out) < len(changed) # compressed: a diff, not the whole spec + + +def test_targets_are_independent(): + p = SimpleNamespace() + _render_dedup(p, "model", "M1") + # a different target's first render is still full, unaffected by the model cache + assert _render_dedup(p, "conformance:f", "C1") == "C1" + + +def test_periodic_full_resync(): + p = SimpleNamespace() + text = "A\nB\nC" + outs = [_render_dedup(p, "model", text) for _ in range(_RENDER_FULL_EVERY + 2)] + # outs[0] full; the next _RENDER_FULL_EVERY-1 are compressed; then a full re-sync reappears + assert outs[0] == text + assert any(o == text for o in outs[1:]), "must re-emit the full spec periodically (re-sync)" + assert sum(1 for o in outs if "UNCHANGED" in o) >= 1 + + +def test_force_full_returns_whole_spec_on_demand(): + p = SimpleNamespace() + _render_dedup(p, "model", "A\nB\nC") + # a plain re-render would be UNCHANGED; force_full overrides that + out = _render_dedup(p, "model", "A\nB\nC", force_full=True) + assert out == "A\nB\nC" + # and it re-syncs the counter: the NEXT identical render compresses again + assert "UNCHANGED" in _render_dedup(p, "model", "A\nB\nC") + + +def test_near_total_change_falls_back_to_full(): + p = SimpleNamespace() + _render_dedup(p, "model", "a\nb\nc\nd") + big = "\n".join(f"totally different line {i}" for i in range(20)) + assert _render_dedup(p, "model", big) == big # diff >= full -> return full diff --git a/smtool/tests/test_scene.py b/smtool/tests/test_scene.py new file mode 100644 index 00000000..62f9f0ef --- /dev/null +++ b/smtool/tests/test_scene.py @@ -0,0 +1,54 @@ +"""Scene sourcing: Signature.from_scene (all_methods.json shape) and scene.methods_from_build +(deriving facts from certoraRun's .certora_build.json via the reused parse_type_descriptor).""" +import json + +from smtool import scene +from smtool.ir import Signature + + +def test_signature_from_scene(): + m = {"name": "f", "fullSignature": ["uint256", "address"], "paramNames": ["a", "b"], + "returns": ["uint256"], "stateMutability": "view", "visibility": "external"} + sig = Signature.from_scene(m, lambda s: s) # identity resolver (types already CVL strings) + assert sig.name == "f" + assert [p.type for p in sig.params] == ["uint256", "address"] + assert [p.name for p in sig.params] == ["a", "b"] + assert sig.returns == ["uint256"] + assert sig.mutability == "view" + + +def test_signature_from_scene_synthesizes_missing_param_names(): + m = {"name": "g", "fullSignature": ["uint256", "address"], "paramNames": ["a"], # short + "returns": [], "stateMutability": "nonpayable", "visibility": "external"} + sig = Signature.from_scene(m, lambda s: s) + assert len(sig.params) == 2 # not dropped by zip + assert sig.params[0].name == "a" + + +def test_methods_from_build(tmp_path): + build = {"unit": {"contracts": [{"name": "C", "allMethods": [ + {"name": "f", "contractName": "C", "paramNames": ["a"], + "fullArgs": [{"typeDesc": {"type": "Primitive", "primitiveName": "uint256"}, "location": ""}], + "returns": [{"typeDesc": {"type": "Primitive", "primitiveName": "uint256"}, "location": ""}], + "stateMutability": "nonpayable", "visibility": "external"}, + {"name": "getA", "contractName": "C", "paramNames": ["a"], + "fullArgs": [{"typeDesc": {"type": "Primitive", "primitiveName": "uint256"}, "location": ""}], + "returns": [{"typeDesc": {"type": "Primitive", "primitiveName": "address"}, "location": ""}], + "stateMutability": "view", "visibility": "external"}, + ]}]}} + p = tmp_path / "build.json" + p.write_text(json.dumps(build)) + ms = {m["name"]: m for m in scene.methods_from_build(str(p))} + assert set(ms) == {"f", "getA"} + assert ms["f"]["fullSignature"] == ["uint256"] and ms["f"]["returns"] == ["uint256"] + assert ms["f"]["stateMutability"] == "nonpayable" + assert ms["getA"]["returns"] == ["address"] and ms["getA"]["stateMutability"] == "view" + + +def test_methods_from_build_dedups(tmp_path): + ent = {"name": "f", "contractName": "C", "paramNames": [], "fullArgs": [], "returns": [], + "stateMutability": "view", "visibility": "external"} + build = {"a": {"contracts": [{"name": "C", "allMethods": [ent, dict(ent)]}]}} # same method twice + p = tmp_path / "b.json" + p.write_text(json.dumps(build)) + assert len(scene.methods_from_build(str(p))) == 1 diff --git a/smtool/typecheck.py b/smtool/typecheck.py new file mode 100644 index 00000000..49f15440 --- /dev/null +++ b/smtool/typecheck.py @@ -0,0 +1,91 @@ +"""CVL typecheckers for smtool. + +Two levels: +- `typecheck_spec(spec_text)`: the standalone `EntryPointKt` jar on a single .spec string — FAST but + SYNTAX-ONLY. It does NOT catch semantic CVL rules (e.g. "assigning to a CVL variable after it is + accessed") and cannot resolve scene symbols. A cheap structural gate on the model spec. +- `typecheck_conf(conf)`: the FULL certoraRun typechecker via `--compilation_steps_only` — compiles the + scene + typechecks every imported spec exactly as a real run does, LOCALLY (no cloud job). This is the + one that catches the semantic errors WITH file:line, at a fraction of a cloud round's cost. Use it as + the pre-cloud gate in the refine loop so typecheck failures come back to the agent fast. + + We run the plain `certoraRun --compilation_steps_only` command directly (NOT autosetup's + CompilationWorkaroundManager): that machinery exists to make a *broken* project compile by mutating + its solc/config, but smtool runs on an already-good scene and only ever mutates the CVL spec — so the + only failures are CVL typecheck errors, which the workaround manager neither fixes nor should rewrite + the trusted scene conf over. Failure signalling is verified in EVMVerifier/scripts/certoraRun.py: + `run_typechecker` raises `CertoraUserInputError` on a nonzero typecheck, which propagates uncaught → + the process exits nonzero. So `returncode == 0` iff compile+typecheck passed — no error-marker + grepping. The typechecker prints its `Error in spec file (::): ` block at the + END of the output, so on failure we hand the agent the tail verbatim. +""" +from __future__ import annotations + +import subprocess +import tempfile +from pathlib import Path + +from composer.certora_env import typechecker_jar + + +def typecheck_spec(spec_text: str) -> tuple[bool, str]: + jar = str(typechecker_jar()) + with tempfile.NamedTemporaryFile("w", suffix=".spec", delete=False) as f: + f.write(spec_text) + f.flush() + res = subprocess.run( + ["java", "-classpath", jar, "EntryPointKt", f.name], + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + ok = res.returncode == 0 + return ok, (res.stdout + res.stderr) + + +# Generic compiler PROGRESS/WARNING prefixes that dominate --compilation_steps_only output but say +# nothing about a CVL error. We drop these before returning the diagnostics — NOT an enumeration of +# error types (the exit code is the authoritative pass/fail), just noise removal so the actual +# `Error in spec file (...)` lines aren't buried under ~35 lines of auto-finder / storage-layout warnings. +_NOISE_PREFIXES = ("WARNING:", "Compiling ", "INFO:") + +# certoraRun prints this header immediately before the actionable spec-error report; everything BEFORE +# it is compile/autofinder/warning noise — including the multi-line, non-fatal "Stack too deep" autofinder +# fallback, which the agent can neither fix nor should act on (it misleads toward --via-ir). Slice here. +_ERROR_SECTION_HEADER = "Please check the errors below" + + +def _clean_diagnostics(output: str, tail_lines: int) -> str: + """Extract the typechecker's actionable error report (`Error in spec file (:)` …). + Prefer slicing from certoraRun's error-section header — that drops ALL the compile/autofinder noise + before it (incl. the misleading stack-too-deep block). Fall back to dropping obvious progress/warning + lines + tailing when the header isn't present (e.g. a genuine compile failure, no typecheck reached).""" + lines = output.splitlines() + for i in range(len(lines) - 1, -1, -1): # last occurrence = the final error report + if _ERROR_SECTION_HEADER in lines[i]: + report = [ln.split("ERROR ALWAYS -", 1)[-1].strip() if "ERROR ALWAYS -" in ln else ln.strip() + for ln in lines[i + 1:] if ln.strip()] + if report: + return "\n".join(report[:tail_lines]) + break + kept = [ln for ln in lines if ln.strip() and not ln.lstrip().startswith(_NOISE_PREFIXES)] + return "\n".join(kept[-tail_lines:]) + + +def typecheck_conf(conf_path: str | Path, sources_root: str | Path, + certora_run_path: str = "certoraRun", timeout: int = 900, + tail_lines: int = 25) -> tuple[bool, str]: + """Run the FULL certoraRun typechecker on a conf (`--compilation_steps_only`, no cloud job) from + `sources_root` (certoraRun's cwd, so relative conf paths resolve). Returns (ok, diagnostics): ok is + the exit code (authoritative — see module docstring); diagnostics is the error report with the + compiler warning/progress noise stripped (so the `Error in spec file (:)` lines lead), + empty when ok. Catches the semantic CVL errors the standalone jar misses (assign-after-access, + type mismatch, unresolved symbol).""" + conf_path = Path(conf_path).resolve() + try: + res = subprocess.run([certora_run_path, str(conf_path), "--compilation_steps_only"], + cwd=str(sources_root), text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=timeout) + except (OSError, subprocess.TimeoutExpired) as e: + return False, f"typecheck could not run: {type(e).__name__}: {e}" + if res.returncode == 0: + return True, "" + return False, _clean_diagnostics(res.stdout or "", tail_lines) diff --git a/smtool/verify.py b/smtool/verify.py new file mode 100644 index 00000000..0719b752 --- /dev/null +++ b/smtool/verify.py @@ -0,0 +1,190 @@ +"""Async prover runner + result parser — the mechanism behind smtool's "these rules hold" guarantee. + +Wraps certora_autosetup's `ProverRunner` (Cloud/Local) so smtool can actually run a conformance conf +and read per-rule verdicts. POLICY (when / which confs to run) stays with the caller (the author +agent in wf1, a skill/human in wf2). The runner caches by conf content-hash, so re-running an +unchanged conf is a cache hit — selective re-verification during refinement falls out for free. + +Async so a caller can `await` it and present it to an author agent. +""" +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass, field +from pathlib import Path + +from certora_autosetup.utils.enhanced_config_manager import ConfigManager, ProverJobSpec, FileContent +from certora_autosetup.utils.cloud_runner import CloudProverRunner +from certora_autosetup.utils.local_runner import LocalProverRunner +from certora_autosetup.utils.runner_types import ProverResult, RuleResult, JobStatus + + +@dataclass +class RuleVerdict: + rule: str + status: str # "VERIFIED" / "VIOLATED" / ... + passed: bool + method: str | None = None + assert_message: str | None = None + node_type: str | None = None # POU NodeType; "ROOT" is the per-rule top-level verdict node + + @property + def is_root(self) -> bool: + return self.node_type == "ROOT" + + @classmethod + def of(cls, r: RuleResult) -> "RuleVerdict": + return cls(rule=r.rule_name, status=r.status, passed=r.passed, + method=r.method, assert_message=r.assert_message, node_type=r.node_type) + + +@dataclass +class VerifyResult: + conf: str + success: bool # job succeeded AND every rule passed + job_url: str | None + rules: list[RuleVerdict] + error: str | None = None + cex: dict = field(default_factory=dict) # {label: counterexample_xml}, filled lazily on failure + difficulty: str = "" # ranked nonlinearity hotspots + inlined calls, filled lazily on TIMEOUT + cancelled: bool = False # early-terminated (not run to completion) — re-run next round, NOT a failure + + @classmethod + def of(cls, conf: str, r: ProverResult) -> "VerifyResult": + rules = [RuleVerdict.of(x) for x in r.rule_results] + cancelled = getattr(r.job_handle, "status", None) == JobStatus.CANCELLED + return cls(conf=conf, success=r.success and all(v.passed for v in rules), + job_url=r.job_url, rules=rules, error=r.error_message, cancelled=cancelled) + + def failures(self) -> list[RuleVerdict]: + return [v for v in self.rules if not v.passed] + + +def _project_root(conf_path: Path, sources_root: str | Path | None) -> Path: + # conf.verify/files are relative to the sources root (where certoraRun runs). Default heuristic: + # conf at /certora/conf/x.conf -> root = parents[2]. + return Path(sources_root).resolve() if sources_root else conf_path.resolve().parents[2] + + +def _cut_of(conf_path: Path) -> str: + return json.loads(conf_path.read_text())["verify"].split(":", 1)[0] + + +def _runner(root: Path, cm: ConfigManager, certora_run_path: str, local: bool, disable_cache: bool, + stop_on_first_violation: bool = False): + kw = dict(project_root=root, config_manager=cm, certora_run_path=certora_run_path, + disable_cache=disable_cache) + if local: + return LocalProverRunner(**kw) + return CloudProverRunner(**kw, stop_on_first_violation=stop_on_first_violation) + + +async def verify(conf_path: str | Path, *, sources_root: str | Path | None = None, + certora_run_path: str = "certoraRun", local: bool = False, + disable_cache: bool = False, msg: str | None = None, + stop_on_first_violation: bool = False) -> VerifyResult: + """Run ONE conf; return its per-rule verdicts. Async; content-hash cached. stop_on_first_violation + cancels the job on the first violated rule (partial results are still returned).""" + conf_path = Path(conf_path).resolve() + root = _project_root(conf_path, sources_root) + runner = _runner(root, ConfigManager(root), certora_run_path, local, disable_cache, + stop_on_first_violation) + job = ProverJobSpec(contract_name=_cut_of(conf_path), phase="conformance", + config_file=FileContent.from_file(conf_path), + msg=msg or f"smtool conformance: {conf_path.name}") + return VerifyResult.of(str(conf_path), await runner.check_with_prover(job)) + + +async def prune_reachable(project, reachable_conf_path: str | Path, *, + sources_root: str | Path | None = None, certora_run_path: str = "certoraRun", + local: bool = False, disable_cache: bool = False): + """Run the reachable conf through ProverRunner and DROP every candidate invariant that did NOT + VERIFY (timeouts/violations included), mutating `project` so its reachable/conformance artifacts + keep only the proven set. This is the best-effort gate: only prover-VERIFIED invariants survive + as `requireInvariant`s. Returns (kept, dropped, VerifyResult); RE-WRITE the project afterwards to + emit the pruned artifacts. Prove-incrementally: add more invariants + call this again. + TODO(discovery): source the candidate invariants from composer's generate->prove->cex pass.""" + # Early-stop is sound here: a candidate is kept ONLY if its ROOT node is VERIFIED, so an invariant + # whose job is cancelled on the first violation (ROOT not VERIFIED / absent) is correctly dropped. + res = await verify(reachable_conf_path, sources_root=sources_root, certora_run_path=certora_run_path, + local=local, disable_cache=disable_cache, msg="smtool reachable invariants", + stop_on_first_violation=True) + # An invariant's proof verdict is its TOP-LEVEL (ROOT) node — POU aggregates the base case + every + # preservation sub-check into it. Read the verdict there, NOT from the leaf checks: leaf aggregation + # (e.g. "no leaf failed") is unsound on any partially-finished run (our own cancel, a prover-side + # timeout, a halt) because an absent leaf is not a passing one. An invariant survives ONLY if its + # ROOT node is VERIFIED; RUNNING/PENDING/TIMEOUT/VIOLATED/absent all mean "not proven". The leaf + # verdicts stay in res.rules for the agent to see WHICH method/assert violated. + verified = {v.rule for v in res.rules if v.is_root and v.passed} + candidates = set(project.reachable_invariant_names()) + kept = candidates & verified + project.drop_invariants(candidates - verified) + project.verified_invariants |= kept # record the discharged survivors (H2 gate: check_consistency) + return sorted(kept), sorted(candidates - verified), res + + +class _StopOnFirstFailure: + """EarlyTerminationCallback: terminate the batch the moment a job finishes WITHOUT fully verifying. + NB: `ProverResult.success` means the JOB RAN, not that every rule passed — a VIOLATED or TIMEOUT + rule leaves success=True with a failing rule_result. So we must also check the rule verdicts, or + early-stop would only fire on a job crash and never on an actual conformance failure.""" + def should_terminate(self, completed_result, all_completed_results) -> bool: + r = completed_result + return not (r.success and all(rr.passed for rr in r.rule_results)) + + +async def verify_all_early(conf_paths, *, sources_root: str | Path | None = None, + certora_run_path: str = "certoraRun", local: bool = False, + disable_cache: bool = False, msg: str | None = None) -> dict[str, VerifyResult]: + """Submit every conf CONCURRENTLY on ONE runner; the moment one finishes FAILING (violation or + timeout), CANCEL the rest and return, instead of blocking on the slowest job (verify_all is a + barrier — a 40-min job stalls the whole round even after another already failed). Uses the runner's + built-in early_termination_callback (it cancels the remaining tasks). Returns {conf: VerifyResult}; + confs cancelled mid-flight come back with `cancelled=True` (re-run next round) — NOT real failures. + + SIMPLIFICATION (deliberate, for now): we cancel ALL remaining jobs on the first failure. That's + ideal when the fix changes the SHARED MODEL (which re-runs every rule anyway), but too aggressive + when the failure is fixable per-rule WITHOUT touching the model (e.g. a violated add_helper_lemma + lives in one method's conformance spec) — there the still-running jobs stay valid and cancelling + them is avoidable redo. TODO: cancel only when the refine will touch the shared model.""" + paths = [Path(c).resolve() for c in conf_paths] + if not paths: + return {} + root = _project_root(paths[0], sources_root) + # Conformance only needs the FIRST violation to trigger a refine, so cancel each job as soon as one + # of its rules violates (partial results carry the violation for the agent). + runner = _runner(root, ConfigManager(root), certora_run_path, local, disable_cache, + stop_on_first_violation=True) + specs = [ProverJobSpec(contract_name=_cut_of(c), phase="conformance", + config_file=FileContent.from_file(c), + msg=msg or f"smtool conformance: {c.name}") for c in paths] + results = await runner.submit_and_wait_for_jobs(specs, early_termination_callback=_StopOnFirstFailure()) + by_conf = {str(Path(r.job_spec.config_file.path).resolve()): r for r in results if r is not None} + out: dict[str, VerifyResult] = {} + for c in paths: + r = by_conf.get(str(c)) + out[str(c)] = (VerifyResult.of(str(c), r) if r is not None else + VerifyResult(conf=str(c), success=False, job_url=None, rules=[], + error="no result (cancelled)", cancelled=True)) + return out + + +async def verify_all(conf_paths, *, sources_root: str | Path | None = None, + certora_run_path: str = "certoraRun", local: bool = False, + disable_cache: bool = False, stop_on_fail: bool = False) -> dict[str, VerifyResult]: + """Run several confs. Unchanged confs are cache hits, so this is cheap after a targeted fix. + stop_on_fail: run sequentially and bail on the first failure (else run concurrently).""" + paths = [Path(c).resolve() for c in conf_paths] + common = dict(sources_root=sources_root, certora_run_path=certora_run_path, + local=local, disable_cache=disable_cache) + if not stop_on_fail: + results = await asyncio.gather(*[verify(c, **common) for c in paths]) + return {str(c): r for c, r in zip(paths, results)} + out: dict[str, VerifyResult] = {} + for c in paths: + r = await verify(c, **common) + out[str(c)] = r + if not r.success: + break + return out diff --git a/smtool/walk.py b/smtool/walk.py new file mode 100644 index 00000000..8dd2b1d6 --- /dev/null +++ b/smtool/walk.py @@ -0,0 +1,34 @@ +"""Generic AST traversal over composer.cvl.schema pydantic trees.""" +from __future__ import annotations + +from typing import Iterator, TypeVar + +from pydantic import BaseModel + +import composer.cvl.schema as S + +T = TypeVar("T") + + +def iter_instances(obj, cls: type[T]) -> Iterator[T]: + """Yield every instance of `cls` anywhere inside a pydantic tree / list / tuple.""" + if isinstance(obj, cls): + yield obj + if isinstance(obj, BaseModel): + for name in type(obj).model_fields: + yield from iter_instances(getattr(obj, name), cls) + elif isinstance(obj, (list, tuple)): + for x in obj: + yield from iter_instances(x, cls) + + +def calls(obj) -> Iterator[S.FunctionApplication]: + return iter_instances(obj, S.FunctionApplication) + + +def contract_calls(obj, alias: str | None = None) -> Iterator[S.FunctionApplication]: + """FunctionApplications that are contract calls (host_contract set). If `alias` given, + only that contract.""" + for fa in calls(obj): + if fa.host_contract is not None and (alias is None or fa.host_contract == alias): + yield fa diff --git a/summarization_detector/README.md b/summarization_detector/README.md new file mode 100644 index 00000000..94080f69 --- /dev/null +++ b/summarization_detector/README.md @@ -0,0 +1,43 @@ +# summarization-target detector + +A standalone AutoProver tool that, from **one prover run**, ranks the functions worth summarizing and says +**how** — per-function over-approximation (via `smtool`'s overapprox generator) or a whole-contract +symbolic model (via `smtool`'s driver). It decides *what* to summarize; `smtool` generates the summaries. +Autosetup runs it after a slow/timeout run to summarize the expensive functions before paying for them. + +It is a separate tool from `smtool` and only **reuses** AutoProver code: `smtool.difficulty` (the prover's +nonlinearity report) and `certora_autosetup` (the solc AST reader). + +## Signals + +1. **Nonlinear (SMT phase)** — `smtool.difficulty`: functions whose inlined body contributes nonlinear ops. +2. **Hashing / encoding (build phase)** — a solc-AST walk (`scan_ast`): `keccak256`/`sha256`/`ecrecover` + with `abi.encode*` context; assembly is excluded (Yul isn't a Solidity `FunctionCall`), and the input + length class (dynamic vs fixed-size) is used to rank. Invisible to the difficulty report. +3. **Resolved-expensive external** — a nonlinear hotspot whose owning contract isn't the CUT. + +A **reachability-from-the-main-contract gate** prunes signal-2 candidates the CUT can't reach, using the +solc AST's internal call edges plus the prover's `externalCallGraph.json` (resolved links + dispatch). + +## Usage + +The one input is a prover-run **URL** — it fetches the sources + conf, derives the main contract (the +conf's `verify` field), generates the AST, and pulls the difficulty report: + +``` +python -m summarization_detector --url https://.../output/// +``` + +Optional: `--cut` (override the derived contract), `--solc-dir` (so the conf's `solcN.NN` resolves), +`--work-dir`, `--external-call-graph` (else auto-found in the fetched tree), `--include-dependencies`. + +Offline path (when you already have the artifacts instead of a URL): + +``` +python -m summarization_detector --cut Router --conf run.conf # AST-only hashing signal +python -m summarization_detector --cut Router --ast .asts.json \ + --job-url https://.../ --external-call-graph Reports/externalCallGraph.json +``` + +`externalCallGraph.json` is emitted by the prover (EVMVerifier `ExternalCallSiteCollector`) at scene +setup — after call resolution, before the per-rule optimize pass — and enables the reachability gate. diff --git a/summarization_detector/__init__.py b/summarization_detector/__init__.py new file mode 100644 index 00000000..67050b9b --- /dev/null +++ b/summarization_detector/__init__.py @@ -0,0 +1,35 @@ +"""Summarization-target detector — a standalone AutoProver tool. + +From ONE prover run it ranks the functions worth summarizing and says HOW (per-function over-approx vs +whole-contract symbolic model), so autosetup can summarize the expensive ones before paying for them. It +is a SEPARATE tool from smtool — it decides WHAT to summarize; smtool then generates the summaries — and +only reuses AutoProver code (`smtool.difficulty`, `certora_autosetup`). Invoke via `detect()` or the CLI +(`python -m summarization_detector` / `detect-summaries`). +""" +from .detect import ( + Boundary, + Candidate, + DetectionReport, + HashSignal, + detect, + detect_from, + scan_ast, + reachable_from_main, + cone_weights, +) +from .sources import detect_url, cut_from_conf, find_run_conf + +__all__ = [ + "Boundary", + "Candidate", + "DetectionReport", + "HashSignal", + "detect", + "detect_from", + "detect_url", + "scan_ast", + "reachable_from_main", + "cone_weights", + "cut_from_conf", + "find_run_conf", +] diff --git a/summarization_detector/__main__.py b/summarization_detector/__main__.py new file mode 100644 index 00000000..eb53e2f3 --- /dev/null +++ b/summarization_detector/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/summarization_detector/cli.py b/summarization_detector/cli.py new file mode 100644 index 00000000..81bd37da --- /dev/null +++ b/summarization_detector/cli.py @@ -0,0 +1,55 @@ +"""Standalone CLI for the summarization-target detector — `python -m summarization_detector` or the +installed `detect-summaries` command. + +The one input is `--url` (a prover-run URL): it fetches the sources + conf, derives the main contract, +generates the AST, and pulls the difficulty report — getting as much signal as available. The lower-level +flags (`--ast`/`--conf`/`--cut`/…) are overrides / an offline path when you already have the artifacts.""" +import argparse +import json + +from .detect import detect +from .sources import detect_url + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + prog="detect-summaries", + description="Rank the functions worth summarizing in a scene (and how: over-approx vs symbolic " + "model). Give just --url; everything else is derived.") + p.add_argument("--url", default=None, + help="prover-run URL — the only input needed: fetches sources + conf, derives the main " + "contract, generates the AST, and pulls the difficulty report.") + p.add_argument("--cut", default=None, + help="override the main/parametric contract (else derived from the conf's `verify`).") + p.add_argument("--work-dir", default=None, + help="where to fetch sources + generate the AST (default: a temp dir). --url mode only.") + p.add_argument("--external-call-graph", default=None, + help="the prover's externalCallGraph.json — enables the reachability-from-CUT gate " + "(auto-found in the fetched tree when present).") + p.add_argument("--solc-dir", default=None, + help="directory prepended to PATH so the conf's solcN.NN resolves.") + p.add_argument("--include-dependencies", action="store_true", + help="also report hashing in lib/ dependency code (off by default).") + p.add_argument("--json", action="store_true", + help="emit the report as JSON (for pipeline/tool consumption) instead of text.") + # offline / override path (when you already have the artifacts instead of a URL): + p.add_argument("--ast", default=None, help="path to a solc AST dump (.asts.json).") + p.add_argument("--conf", default=None, help="a .conf to generate the AST from (offline, needs --cut).") + p.add_argument("--job-url", default=None, + help="difficulty-report URL for the offline path (with --ast/--conf).") + a = p.parse_args(argv) + + if a.url: + report = detect_url(a.url, work_dir=a.work_dir, solc_dir=a.solc_dir, cut=a.cut, + external_call_graph=a.external_call_graph, + include_dependencies=a.include_dependencies) + else: + if not (a.ast or a.conf): + p.error("give --url, or the offline path: --ast/--conf (+ --cut).") + if not a.cut: + p.error("--cut is required on the offline path (no --url to derive it from).") + report = detect(a.job_url, ast_path=a.ast, conf=a.conf, cut=a.cut, solc_dir=a.solc_dir, + external_call_graph=a.external_call_graph, + include_dependencies=a.include_dependencies) + print(json.dumps(report.to_dict(), indent=2) if a.json else report.format()) + return 0 diff --git a/summarization_detector/detect.py b/summarization_detector/detect.py new file mode 100644 index 00000000..011770ba --- /dev/null +++ b/summarization_detector/detect.py @@ -0,0 +1,899 @@ +"""Summarization-target DETECTOR — from ONE prover run, rank the functions worth summarizing and say +HOW (per-function over-approx via overapprox.py, or whole-contract symbolic model via driver.py). + +Autosetup calls this on the SANITY run (a `satisfy true` per-method reachability check) to decide what to +summarize BEFORE the real rules exist; the overapprox/model tooling then does it. + +We are therefore a STATIC PREDICTOR of what will be expensive for REAL rules — not a reader of runtime +cost. The sanity run only needs one arbitrary path that exits a method, so it DODGES expensive code +(branches around a hash, picks trivial inputs); its completing fast is NOT evidence the code is cheap. So +a runtime signal (difficulty hotspot / timeout) is POSITIVE-ONLY — present means "so central even sanity +couldn't avoid it" (strong), but ABSENT is never a reason to drop a candidate. Acceptance/ranking comes +from the code's STRUCTURE (signals 2/3 + the reachability & cone-of-influence over the real call graph). + +THREE signals, each catching a different cost class: + + 1. NONLINEAR (SMT phase) — the prover's own difficulty report (`difficulty.fetch_difficulty`): ranked + functions whose INLINED body contributes nonlinear ops (mulDiv, pow, sqrt, div). Catches the + timeouts that reach SMT. + 2. HASHING / ENCODING — a Solidity-AST walk (`scan_ast`): calls to keccak256 / sha256 / ecrecover / + abi.encode* in a function body. These choke the BUILD / points-to phase, so they produce NO + nonlinear hotspot — invisible to signal 1 (the getConditionId case). We read the AST (never regex + the .sol) so comments and `assembly {}` (storage-slot keccaks) are excluded for free — a Yul call is + not a Solidity `FunctionCall` node. + 3. RESOLVED-EXPENSIVE EXTERNAL — a signal-1 hotspot whose owning contract is NOT the CUT: a call that + ALREADY resolved/linked to a real contract/oracle but is expensive once inlined. We do NOT touch + UNRESOLVED (`[?]`) calls — resolving those is call-resolution's job (a separate autosetup tool). + +The WHOLE-vs-PART classifier (`_classify_mode`): a PURE/VIEW output-only function -> per-function +over-approx (a Phi over its result). A stateful external contract the rules lean on (several of its +methods are hotspots) -> whole-contract symbolic model. + +AST acquisition (`ensure_ast`): the raw solc AST (`.asts.json`) is all we need — its `FunctionDefinition` +nodes carry name + `stateMutability` + `visibility`, so we never touch autosetup's `all_methods.json` +(which only autosetup can produce). Pass an existing `ast_path` (autosetup already ran `--dump_asts`), or +pass a `conf` and we run `certoraRun --compilation_steps_only --dump_asts` ourselves (standalone mode). + +Pure cores (`scan_ast`, `detect_from`) take already-fetched inputs so they are unit-testable offline; +`detect` is the thin orchestrator; `cli.main` is the standalone command. A separate tool from smtool (it +decides WHAT to summarize; smtool generates the summaries), it only REUSES AutoProver code — +`smtool.difficulty` for the difficulty signal and `certora_autosetup.utils.file_utils` for the AST. +""" +import json +import re +import subprocess +import sys +from dataclasses import asdict, dataclass, field +from pathlib import Path + +from certora_autosetup.utils.file_utils import stream_ast_files + +from smtool.difficulty import DifficultyReport, fetch_difficulty + +# ---------------------------------------------------------------- signal 2: AST hashing/encoding calls +# The TRIGGER is an actual hash builtin — a global `Identifier` callee. Yul (assembly) calls are +# `YulFunctionCall`, never Solidity `FunctionCall`, so storage-slot keccaks are excluded automatically. +_HASH_IDENT = {"keccak256", "sha256", "ripemd160", "ecrecover"} +# `abi.encode` / `abi.encodePacked` (MemberAccess on the `abi` object) are CONTEXT — what is being hashed +# (they decide the length class), never a trigger on their own. Deliberately EXCLUDES encodeWithSelector / +# encodeCall / encodeWithSignature: those build calldata for an external CALL, not a hash. +_ENCODERS = {"encode", "encodePacked"} + + +@dataclass +class HashSignal: + """Signal-2 record: a function whose body performs semantic hashing/encoding (build/points-to cost + the SMT difficulty report cannot see). Mutability/visibility come from the same FunctionDefinition + node — the classifier's pure/view test needs no separate source.""" + function: str # "Contract.fn" ("fn" for a file-level free function) + contract: str # owning contract/library ("" for a free function) + name: str + mutability: str # "pure" | "view" | "payable" | "nonpayable" | "" + visibility: str # "external" | "public" | "internal" | "private" + patterns: tuple[str, ...] # the hashing/encoding calls found, e.g. ("keccak256", "abi.encodePacked") + file: str # absolute source path + is_dependency: bool # under a lib/ dependency tree (deprioritize vs project src/) + dynamic_input: bool = False # hashes UNBOUNDED-length data (bytes/string/dynamic array) — the costly + # kind (the prover models up to hashing_length_bound bytes). A hash over + # only fixed-size fields (the typical EIP-712 digest) is bounded/cheap. + + +@dataclass +class Boundary: + """A caller of a detected leaf, offered as an alternative place to summarize. The leaf is where the + COST is; a caller is often where the CLEAN semantic boundary is (a summary there subsumes the leaf). + Feasibility is described by three orthogonal facts: `has_return` (a void function has no value to + model — nothing to summarize at); `expressible` (has a return AND all param/return types are CVL-clean + — no opaque dynamic bytes/string, mapping, or function type); `mutating` (writes state, NOT view/pure — + summarizing it as a value would erase side effects the properties may observe). `hops` = call-graph + distance from the candidate. `direction` = "up" (a CALLER to summarize at — the hashing-leaf case) or + "down" (a shared nonlinear PRIMITIVE the candidate inlines — the real arg-based target). `shared` = for + a "down" target, how many sibling candidates also inline it (fan-in). Best: expressible, non-mutating.""" + function: str + hops: int + signature: str + expressible: bool + has_return: bool + mutating: bool + direction: str = "up" + shared: int = 1 + + +@dataclass +class Candidate: + """A function worth summarizing, with WHY (`signals`) and HOW (`mode`).""" + function: str # "Contract.fn" + contract: str + signals: tuple[str, ...] # subset of {"nonlinear", "hashing", "external"} + mode: str # "over_approx" | "symbolic_model" + score: float # rank key (higher = summarize first) + evidence: str # human-readable justification + boundaries: list[Boundary] = field(default_factory=list) # caller boundaries to summarize at instead + + +@dataclass +class DetectionReport: + candidates: list[Candidate] = field(default_factory=list) + + def is_empty(self) -> bool: + return not self.candidates + + def to_dict(self) -> dict: + """Machine-readable form for the autosetup/smtool pipeline: each candidate (the problematic + function, why, and rank) with its caller-boundary shortlist (call chain + summarizability).""" + return asdict(self) + + def format(self) -> str: + if self.is_empty(): + return "no summarization candidates detected" + out = ["summarization candidates (what to summarize first, and how):"] + for c in self.candidates: + out.append(f" [{c.mode:14s}] {c.score:5.1f} {c.function} <{','.join(c.signals)}>") + out.append(f" {c.evidence}") + for b in c.boundaries: + if not b.has_return: + feas = "no return value" # void — nothing to model as a summary + elif not b.expressible: # has a return, so the TYPE is the problem + feas = "opaque sig" + elif b.mutating: + feas = "state-changing — prefer a pure boundary" + else: + feas = "summarizable here" + if b.direction == "down": # a shared nonlinear primitive (descend) + arrow = "↓" + tag = f"shared ×{b.shared}, {feas}" if b.shared > 1 else feas + else: # a caller boundary (walk up) + arrow, tag = "↑", feas + out.append(f" {arrow} +{b.hops} {b.signature} [{tag}]") + return "\n".join(out) + + +# ---------------------------------------------------------------- signal 2 core: the AST walk +def _span(node: dict) -> tuple[int, int] | None: + """The node's `src` = "offset:length:fileId" -> (start, end) byte offsets. All nodes within one + source file share a fileId, so containment by (start, end) alone attributes a call to its function.""" + src = node.get("src") + if not isinstance(src, str): + return None + try: + off, length, _fid = src.split(":") + return int(off), int(off) + int(length) + except ValueError: + return None + + +def _classify_call(call: dict) -> tuple[str, str] | None: + """Classify a FunctionCall as a hash TRIGGER or an ENCODER (context), or None. Returns ("hash", + "keccak256") for a hash builtin, ("encode", "abi.encodePacked") for an abi encoder (base guarded so a + user type's own `.encode()` is not mistaken for `abi.encode`). Only a "hash" makes a function a + candidate; "encode" merely informs the length class.""" + ex = call.get("expression") or {} + nt = ex.get("nodeType") + if nt == "Identifier" and ex.get("name") in _HASH_IDENT: + return ("hash", ex["name"]) + if nt == "MemberAccess" and ex.get("memberName") in _ENCODERS: + base = ex.get("expression") or {} + if base.get("nodeType") == "Identifier" and base.get("name") == "abi": + return ("encode", "abi." + ex["memberName"]) + return None + + +def _arg_types(call: dict) -> tuple[str, ...]: + """The solc typeStrings of a call's arguments (e.g. ('address','bytes32','uint256')).""" + out = [] + for a in call.get("arguments") or []: + if isinstance(a, dict): + out.append((a.get("typeDescriptions") or {}).get("typeString") or "") + return tuple(out) + + +def _is_dynamic_type(ts: str) -> bool: + """True if a solc typeString denotes UNBOUNDED-length data: `bytes`, `string`, or a dynamic array + `T[]`. Fixed-width (`bytes32`, `uint256`, `address`, a fixed array `T[5]`, a struct) is False — + the storage location suffix (` memory`/` calldata`/` storage`) is dropped first.""" + if not ts: + return False + base = ts.split(" ", 1)[0] # "uint256[] memory" -> "uint256[]"; "bytes memory" -> "bytes" + if base.endswith("[]"): + return True + if base in ("bytes", "string"): + return True + return False + + +def _refs(node) -> set: + """All positive `referencedDeclaration` ids in an expression subtree — the declarations it reads.""" + out: set = set() + if isinstance(node, dict): + rd = node.get("referencedDeclaration") + if isinstance(rd, int) and rd > 0: + out.add(rd) + for v in node.values(): + out |= _refs(v) + elif isinstance(node, list): + for v in node: + out |= _refs(v) + return out + + +def _dynamic_input(sites: list, param_ids: set) -> bool: + """Whether a function's hashing consumes unbounded-length USER data. A site counts only when it has a + dynamic-typed operand AND the call references a function PARAMETER (`param_ids`) — so a hash over a + constant/immutable/literal (e.g. `keccak256(bytes(name))` for a fixed EIP-712 name) is NOT dynamic + despite the `bytes` type. `sites` are (pattern, arg_types, refs) per hash/encode call. An `abi.encode*` + with a dynamic arg is the source; a bare `keccak256(x)` counts only when no encoder feeds it (else its + `bytes memory` arg is just the encode result, whose class the encode site already decided).""" + has_encode = any(p.startswith("abi.") for p, _, _ in sites) + for pattern, args, refs in sites: + if not (refs & param_ids): # not user-controlled -> not the expensive case + continue + if pattern.startswith("abi."): + if any(_is_dynamic_type(t) for t in args): + return True + elif not has_encode and any(_is_dynamic_type(t) for t in args): + return True + return False + + +def _tightest(off: int, spans: list) -> tuple | None: + """The smallest span in `spans` (each `((start,end), *payload)`) that contains offset `off` — the + innermost enclosing function/contract.""" + best = None + for entry in spans: + (s, e) = entry[0] + if s <= off < e and (best is None or (e - s) < (best[0][1] - best[0][0])): + best = entry + return best + + +def scan_ast(ast_path: str | Path) -> list[HashSignal]: + """Signal 2: walk the raw solc AST dump (`.asts.json`) and return every function that performs + semantic hashing/encoding. Streams the file (it is often hundreds of MB) and processes each source + file ONCE — a file recurs under every compilation unit that imports it, with re-numbered node ids but + identical content, so we dedup by absolute path. Nodes are pre-flattened (every descendant is a + top-level entry), so a single pass over each file's nodes finds all FunctionCall/FunctionDefinition/ + ContractDefinition nodes; attribution is by src-offset containment.""" + out: dict[str, HashSignal] = {} + sites: dict[str, list] = {} # fnkey -> [(pattern, arg_types, refs)...] for the dynamic/fixed decision + hash_pats: dict[str, set] = {} # fnkey -> the set of HASH-builtin names it calls (for ecrecover-only) + param_ids: dict[str, set] = {} # fnkey -> its parameter declaration ids (for the user-input test) + seen: set[str] = set() + for _rel, pdata in stream_ast_files(Path(ast_path)): + if not isinstance(pdata, dict): + continue + for absp, nodes in pdata.items(): + if absp in seen or not isinstance(nodes, dict): + continue + seen.add(absp) + contracts: list = [] # ((start,end), name) + funcs: list = [] # ((start,end), name, mutability, visibility, param_ids) + hits: list = [] # (offset, kind, pattern, arg_types, refs) + for node in nodes.values(): + if not isinstance(node, dict): + continue + sp = _span(node) + if sp is None: + continue + nt = node.get("nodeType") + if nt == "ContractDefinition": + contracts.append((sp, node.get("name") or "")) + elif nt == "FunctionDefinition": + pids = {p.get("id") for p in ((node.get("parameters") or {}).get("parameters") or []) + if isinstance(p, dict) and isinstance(p.get("id"), int)} + funcs.append((sp, node.get("name") or "", node.get("stateMutability") or "", + node.get("visibility") or "", pids)) + elif nt == "FunctionCall": + kp = _classify_call(node) + if kp: + hits.append((sp[0], kp[0], kp[1], _arg_types(node), _refs(node.get("arguments")))) + # AST source keys are project-relative (e.g. "lib/solady/src/tokens/ERC20.sol"), so test for a + # dependency-root path COMPONENT rather than a "/lib/" substring (which misses a leading "lib/"). + parts = set(Path(absp).parts) + is_dep = bool(parts & {"lib", "node_modules", ".certora_internal"}) + per_fn: dict[str, tuple] = {} # fnkey -> (contract, name, mut, vis) + for off, kind, pat, argtypes, refs in hits: + f = _tightest(off, funcs) + if f is None: # a call at file scope — no owning fn + continue + (_fsp, fname, mut, vis, pids) = f + c = _tightest(f[0][0], contracts) + cname = c[1] if c else "" + key = f"{cname}.{fname}" if cname else fname + per_fn[key] = (cname, fname, mut, vis) + sites.setdefault(key, []).append((pat, argtypes, refs)) + param_ids[key] = pids + if kind == "hash": + hash_pats.setdefault(key, set()).add(pat) + for key, (cname, fname, mut, vis) in per_fn.items(): + if not hash_pats.get(key): # encoder-only (calldata/serialization) — not hashing + continue + pats = tuple(sorted({p for p, _, _ in sites[key]})) + rec = out.get(key) + if rec is None: + out[key] = HashSignal(function=key, contract=cname, name=fname, mutability=mut, + visibility=vis, patterns=pats, file=absp, is_dependency=is_dep) + else: + rec.patterns = tuple(sorted({*rec.patterns, *pats})) + # ecrecover ONLY (signature recovery) is not usefully over-approximable — its result is a recovered + # address with no sound property tighter than havoc. Drop those (a function that also hashes stays). + out = {k: v for k, v in out.items() if not (hash_pats.get(k, set()) <= {"ecrecover"})} + for key, rec in out.items(): + rec.dynamic_input = _dynamic_input(sites.get(key, []), param_ids.get(key, set())) + return sorted(out.values(), key=lambda h: h.function) + + +# ---------------------------------------------------------------- AST acquisition (optional-arg design) +_MISSING_IMPORT_RE = re.compile(r'\d+:\d+:"([^"]+)"') + + +def _touch_missing_imported_specs(*outputs: str) -> list: + """Recreate empty placeholders for imports certoraRun reports as missing. The prover prints + `... import declarations do not import existing .spec files:` then `::""` entries. + The real files were skipped on upload precisely because they are EMPTY, so an empty placeholder is + faithful. Returns the paths created (empty list if the failure is something else).""" + created: list = [] + for out in outputs: + if "do not import existing" not in out: + continue + for m in _MISSING_IMPORT_RE.finditer(out): + p = Path("".join(m.group(1).split())) # certoraRun wraps long paths across lines in captured + if not p.exists(): # (non-tty) output — rejoin before treating as a path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("") + created.append(p) + return created + + +def ensure_ast(ast_path: str | Path | None = None, *, conf: str | Path | None = None, + solc_dir: str | Path | None = None) -> Path: + """Return a path to a solc AST dump. If `ast_path` is given (autosetup already ran `--dump_asts`, or a + prior run), use it. Otherwise run `certoraRun --compilation_steps_only --dump_asts` (standalone + mode) and return the freshest `.asts.json` it writes under `.certora_internal/`. `solc_dir` is + prepended to PATH so the conf's `solcN.NN` resolves. Raises if neither input suffices.""" + if ast_path is not None: + p = Path(ast_path) + if not p.exists(): + raise FileNotFoundError(f"ast_path does not exist: {p}") + return p + if conf is None: + raise ValueError("provide ast_path (existing AST) or conf (to generate one via certoraRun)") + conf = Path(conf) + work = conf.parent + env_path = None + if solc_dir is not None: + import os + env_path = {**os.environ, "PATH": f"{solc_dir}:{os.environ.get('PATH', '')}"} + cmd = ["certoraRun", conf.name, "--compilation_steps_only", "--dump_asts"] + proc = subprocess.run(cmd, cwd=work, env=env_path, capture_output=True, text=True) + # TEMPORARY WORKAROUND: the source-files upload skips EMPTY files, so an empty importable source that + # another file imports is dropped while its importer is kept — the fetched scene then fails to compile + # on the missing import (fixed upstream for NEW runs; existing runs stay broken). The missing file is + # empty by definition, so recreate the missing imported spec(s) as empty placeholders and retry. + for _ in range(5): + if proc.returncode == 0: + break + created = _touch_missing_imported_specs(proc.stdout or "", proc.stderr or "") + if not created: + break + print(f"[detect] created {len(created)} empty placeholder spec(s) for skipped-empty imports; retrying", + file=sys.stderr) + proc = subprocess.run(cmd, cwd=work, env=env_path, capture_output=True, text=True) + if proc.returncode != 0: + tail = "\n".join((proc.stderr or proc.stdout or "").strip().splitlines()[-25:]) + raise RuntimeError( + f"certoraRun AST generation failed (exit {proc.returncode}) in {work} for {conf.name}:\n{tail}\n" + f"(hint: if the error is a missing solc, pass solc_dir / --solc-dir so the conf's solcN.NN " + f"resolves; if it is a missing source/package, the fetched scene may be incomplete.)") + dumps = sorted((work / ".certora_internal").rglob("*.asts.json"), key=lambda p: p.stat().st_mtime) + if not dumps: + raise FileNotFoundError(f"certoraRun produced no .asts.json under {work}/.certora_internal") + return dumps[-1] + + +# ---------------------------------------------------------------- fusion + classifier +def _contract_of(procid: str) -> str: + """Contract/library prefix of a difficulty procId ('SomeLib.fn' -> 'SomeLib'; bare -> '').""" + return procid.split(".", 1)[0] if "." in procid else "" + + +def _classify_mode(contract: str, signals: tuple[str, ...], external_multi: bool) -> str: + """WHOLE (symbolic_model) vs PART (over_approx). Whole-contract when the cost is a stateful external + dependency the rules lean on — heuristic: several methods of the same external contract are hotspots + (`external_multi`). Otherwise a single output-only function -> per-function over-approx.""" + if "external" in signals and external_multi: + return "symbolic_model" + return "over_approx" + + +def _is_cvl_ghost(function: str) -> bool: + """A `CVL/Ghost Function '...'` hotspot is an ALREADY-APPLIED CVL summary / ghost, not a Solidity + function to summarize — so it is never a candidate (it IS the summary). The prover labels these + procIds distinctly; real Solidity hotspots are `Contract.fn` with a source location.""" + return function.lstrip().startswith(("CVL/Ghost", "CVL Function", "Ghost")) + + +def _strip_procid(function: str) -> str: + """Normalize a prover procId to `Contract.fn` for the detector: drop a leading `(internal)` / + `(external)` marker the prover prefixes to inlined-function hotspots. Without this the marker leaks + into the contract parse, so the CUT's OWN internal function (`(internal) Stonks.foo`) is misread as a + different contract and wrongly classified as a resolved-external. (The raw marker stays intact in the + shared DifficultyReport, which smtool's refine message relies on.)""" + return re.sub(r"^\((?:internal|external)\)\s*", "", function.strip()) + + +def detect_from(hash_signals: list[HashSignal], difficulty: DifficultyReport, *, cut: str, + include_dependencies: bool = False, + cone_weight: dict[str, int] | None = None) -> DetectionReport: + """Fuse the three signals into a ranked candidate list. `difficulty` supplies signals 1 (nonlinear) + and 3 (its hotspots whose contract != `cut` are resolved-expensive externals); `hash_signals` supply + signal 2. `cut` is the verified contract (its own methods are NOT "external"). Dependency-tree + functions (lib/) are dropped unless `include_dependencies`. `cone_weight` (per-function + cone-of-influence size) re-weights the build-phase hashing signal by how much code consumes the + result — the only cost proxy available there. CVL/ghost hotspots (already-applied summaries) are + excluded — they are the summary, not a summarization target.""" + hotspots = [h for h in difficulty.hotspots if not _is_cvl_ghost(h.function)] + ext_counts: dict[str, int] = {} + for h in hotspots: + c = _contract_of(_strip_procid(h.function)) + if c and c != cut: + ext_counts[c] = ext_counts.get(c, 0) + 1 + + cand: dict[str, Candidate] = {} + + def _bump(key: str, contract: str, sig: str, score: float, evidence: str): + c = cand.get(key) + if c is None: + cand[key] = Candidate(function=key, contract=contract, signals=(sig,), mode="over_approx", + score=score, evidence=evidence) + else: + if sig not in c.signals: + c.signals = (*c.signals, sig) + c.score += score + c.evidence += " | " + evidence + + # signal 1 (nonlinear) + 3 (external): the difficulty hotspots + for h in hotspots: + fn = _strip_procid(h.function) + contract = _contract_of(fn) + _bump(fn, contract, "nonlinear", float(h.pct), + f"{h.pct}% of nonlinear ops" + (f" @{h.location}" if h.location else "")) + if contract and contract != cut: + _bump(fn, contract, "external", 10.0, + f"resolved external in {contract} (not the CUT)") + + # signal 2 (hashing/encoding): the AST scan. Dynamic-input hashing (unbounded bytes/string/array) is + # the costly kind; a fixed-size digest (typical EIP-712) is bounded — score it far lower so the noise + # of signature digests sinks below the real candidates rather than being dropped outright. + for h in hash_signals: + if h.is_dependency and not include_dependencies: + continue + cls = "dynamic-input" if h.dynamic_input else "fixed-size" + _bump(h.function, h.contract, "hashing", 20.0 if h.dynamic_input else 4.0, + f"{'/'.join(h.patterns)} [{cls}] ({h.mutability or 'n/a'} {h.visibility})") + + # cone-of-influence re-weighting for the hashing signal: build-phase cost has no per-function measure, + # so scale a hashing candidate by how much reachable code consumes its result (normalized within the + # candidate set, factor in [1, 2], so it stays comparable to the measured nonlinear pct). A hash whose + # id threads the protocol rises; a leaf digest (tiny cone) stays low. + if cone_weight: + hashing = [c for c in cand.values() if "hashing" in c.signals] + mx = max((cone_weight.get(c.function, 0) for c in hashing), default=0) or 1 + for c in hashing: + w = cone_weight.get(c.function, 0) + c.score *= 1 + w / mx + c.evidence += f" | cone={w}" + + # classify mode per candidate + for c in cand.values(): + external_multi = c.contract in ext_counts and ext_counts[c.contract] >= 2 + c.mode = _classify_mode(c.contract, c.signals, external_multi) + + ranked = sorted(cand.values(), key=lambda c: c.score, reverse=True) + return DetectionReport(candidates=ranked) + + +def _survives(function: str, surviving_set: set[str], survivors_bare: set[str]) -> bool: + """Whether `function` (a scan_ast candidate) reaches SMT per the prover's surviving set. A host-less + free (file-level) function has no contract host in the AST (e.g. `computeBaseHash`), but the prover + attributes it to an arbitrary host in the surviving set (e.g. `Ownable.computeBaseHash`) — so it's + matched by bare name. A hosted candidate matches on its exact qualified name, so a genuinely- + unreachable `CTHelpers.getConditionId` is NOT resurrected by a same-named method on another contract + (`survivors_bare` is `{name after last '.'}` over the surviving set).""" + return function in surviving_set or ("." not in function and function in survivors_bare) + + +def detect(job_url: str | None = None, *, ast_path: str | Path | None = None, + conf: str | Path | None = None, cut: str, solc_dir: str | Path | None = None, + include_dependencies: bool = False, + external_call_graph: str | Path | None = None, + surviving_set: set[str] | None = None) -> DetectionReport: + """Orchestrate the detector. `job_url` (optional) supplies the difficulty report (signals 1+3); with + none, only the static signal-2 (hashing) runs. The AST is resolved by `ensure_ast` (`ast_path` if + given, else generated from `conf`). `cut` is the verified contract name. + + Reachability gate for signal-2: `surviving_set` (the prover's postOptimize surviving call graphs — + the functions that actually reach SMT) is authoritative and used when present. Otherwise we fall back + to AST + `external_call_graph` reachability from the CUT (see `reachable_from_main`).""" + ast = ensure_ast(ast_path, conf=conf, solc_dir=solc_dir) + hash_signals = scan_ast(ast) + cone: dict[str, int] = {} + reachable: set[str] = set() + edges: dict[str, set[str]] = {} + sigs: dict[str, tuple[str, bool, bool, bool]] = {} + merged = _unit_declaring(ast, cut) # the CUT's compilation unit, built once + if merged is not None: + edges, roots, by_name, sizes = _ast_call_graph(merged, cut) + if external_call_graph is not None: + _add_external_edges(edges, by_name, external_call_graph) + reachable = _bfs(edges, roots) # for cone (rank) + the ecg fallback gate + cone = _cone_weights(edges, reachable, sizes) + sigs = _signatures(merged) # for caller-boundary expressibility + if surviving_set: # authoritative: exactly what reached SMT + survivors_bare = {n.rpartition(".")[2] for n in surviving_set} + hash_signals = [h for h in hash_signals if _survives(h.function, surviving_set, survivors_bare)] + elif merged is not None and external_call_graph is not None: # fallback: complete cross-contract edges + hash_signals = [h for h in hash_signals if h.function in reachable] + difficulty = fetch_difficulty(job_url) if job_url else DifficultyReport() + report = detect_from(hash_signals, difficulty, cut=cut, cone_weight=cone, + include_dependencies=include_dependencies) + # For each hashing candidate (the cost LEAF), offer caller boundaries — a summary at a clean-signature + # caller subsumes the leaf and is often more feasible to model (see Boundary). + bounds_reach = surviving_set if surviving_set else (reachable or None) + if edges: + # nonlinear candidates: descend to the shared nonlinear PRIMITIVE they inline, and count how many + # candidates share each (fan-in) — a primitive shared across many methods is the real target. + # Filter by AST reachability, NOT the surviving set: library `using`-for primitives (e.g. + # MathLib.mulDivDown) get no INTERNAL_FUNC_START annotation, so they are absent from the surviving + # set even though they are genuinely inlined into a surviving method. + nl = [c.function for c in report.candidates if "nonlinear" in c.signals and "hashing" not in c.signals] + prim_reach = {m: _descend_to_prims(edges, m, reachable or None) for m in nl} + fanin: dict[str, int] = {} + for prims in prim_reach.values(): + for p in prims: + fanin[p] = fanin.get(p, 0) + 1 + for c in report.candidates: + if "hashing" in c.signals: # walk UP to a clean caller boundary + c.boundaries = _caller_boundaries(edges, sigs, c.function, bounds_reach) + elif "nonlinear" in c.signals: # descend DOWN to the shared nonlinear primitive + targets = sorted(prim_reach.get(c.function, {}).items(), + key=lambda kv: (-fanin.get(kv[0], 0), kv[1], kv[0]))[:4] + c.boundaries = [Boundary(p, d, *sigs.get(p, (p, False, False, True)), + direction="down", shared=fanin.get(p, 1)) + for p, d in targets] + return report + + +# ---------------------------------------------------------------- reachability-from-main (signal-2 gate) +# Signal 2 sweeps EVERY function in the scene; most are noise (an EIP-712 digest in a module the CUT never +# reaches). We prune to functions reachable from the CUT's external/public entry points over the real call +# graph. Edges come from two authoritative sources, never guessed: DIRECT internal/library calls from the +# solc AST (`referencedDeclaration`), and CROSS-CONTRACT calls from the prover's `externalCallGraph.json` +# (linking + dispatch — a static AST can't resolve which impl an interface call hits). Reachability +# OVER-approximates (dispatch matched by selector across the scene), which is the safe direction for a +# prune: we keep a maybe-reachable candidate rather than drop a real one. +def _span3(node: dict) -> tuple[int, int, int] | None: + """`src` = "offset:length:fileId" -> (start, end, fileId). fileId distinguishes the source files that + share one compilation unit's node-id space.""" + src = node.get("src") + if not isinstance(src, str): + return None + try: + off, length, fid = src.split(":") + return int(off), int(off) + int(length), int(fid) + except ValueError: + return None + + +def _enclosing(off: int, spans: list) -> str | None: + """The tightest (start, end, name) span containing `off` — the enclosing contract/function name.""" + best = None + for s, e, name in spans: + if s <= off < e and (best is None or (e - s) < (best[1] - best[0])): + best = (s, e, name) + return best[2] if best else None + + +def _unit_declaring(ast_path: str | Path, cut: str) -> dict | None: + """Merge (by node id) all nodes of the compilation unit that DECLARES contract `cut`. Node ids are + unique within a unit's single solc id space, so `referencedDeclaration` resolves inside the merge; the + CUT's unit already contains every file it imports (its full reachable closure).""" + for _rel, pdata in stream_ast_files(Path(ast_path)): + if not isinstance(pdata, dict): + continue + merged: dict[str, dict] = {} + declares = False + for _abs, nodes in pdata.items(): + if not isinstance(nodes, dict): + continue + for nid, node in nodes.items(): + if isinstance(node, dict): + merged[nid] = node + if node.get("nodeType") == "ContractDefinition" and node.get("name") == cut: + declares = True + if declares: + return merged + return None + + +def _ast_call_graph(merged: dict, cut: str): + """From the CUT's merged compilation unit build: internal call edges (caller qual -> callee qual via + FunctionCall `referencedDeclaration`), the CUT's external/public entry points (BFS roots), and a + name->quals index (to resolve dispatch selectors to scene methods). `qual` = "Contract.fn".""" + contracts_by_fid: dict[int, list] = {} + for node in merged.values(): + if node.get("nodeType") == "ContractDefinition": + sp = _span3(node) + if sp: + contracts_by_fid.setdefault(sp[2], []).append((sp[0], sp[1], node.get("name") or "")) + + fndefs_by_fid: dict[int, list] = {} + qual_by_id: dict[str, str] = {} + roots: set[str] = set() + by_name: dict[str, set[str]] = {} + sizes: dict[str, int] = {} # qual -> body size (source bytes), for cone weight + for nid, node in merged.items(): + if node.get("nodeType") != "FunctionDefinition": + continue + sp = _span3(node) + if sp is None: + continue + cname = _enclosing(sp[0], contracts_by_fid.get(sp[2], [])) or "" + fname = node.get("name") or "" + qual = f"{cname}.{fname}" if cname else fname + fndefs_by_fid.setdefault(sp[2], []).append((sp[0], sp[1], qual)) + qual_by_id[nid] = qual + sizes[qual] = max(sizes.get(qual, 0), sp[1] - sp[0]) + if fname: + by_name.setdefault(fname, set()).add(qual) + if cname == cut and node.get("visibility") in ("external", "public"): + roots.add(qual) + + edges: dict[str, set[str]] = {} + for node in merged.values(): + if node.get("nodeType") != "FunctionCall": + continue + ref = (node.get("expression") or {}).get("referencedDeclaration") + callee = qual_by_id.get(str(ref)) if ref is not None else None + if callee is None: + continue + sp = _span3(node) + if sp is None: + continue + caller = _enclosing(sp[0], fndefs_by_fid.get(sp[2], [])) + if caller: + edges.setdefault(caller, set()).add(callee) + return edges, roots, by_name, sizes + + +def _add_external_edges(edges: dict, by_name: dict, external_call_graph: str | Path) -> None: + """Fold the prover's resolved/dispatch cross-contract edges into `edges`. For a RESOLVED target we add + `caller -> targetContract.selectorMethod`; for a dispatch/symbolic target (contract unknown) we add + `caller -> X.selectorMethod` for every scene contract X declaring that method (selector-matched + over-approx). Selectors give the callee method name; unresolved targets with no scene match are skipped + (they leave the scene — the linker's concern, not ours).""" + data = json.loads(Path(external_call_graph).read_text()) + known = {q for qs in by_name.values() for q in qs} + for host, call_edges in data.items(): + for e in call_edges: + caller = f"{host}.{e['caller'].split('(', 1)[0]}" + sel_names = [s["signature"].split("(", 1)[0] for s in e.get("selectors", []) + if s.get("signature")] + for t in e.get("targets", []): + contract = t.get("contract") + for sel in sel_names: + if contract: + callee = f"{contract}.{sel}" + if callee in known: + edges.setdefault(caller, set()).add(callee) + else: # dispatch: any scene method of that name + for callee in by_name.get(sel, ()): + edges.setdefault(caller, set()).add(callee) + + +def _build_graph(ast_path: str | Path, cut: str, external_call_graph: str | Path | None): + """(edges, roots, sizes) for the CUT's compilation unit — AST internal edges (+ prover external/dispatch + edges when given). None if the CUT's unit isn't found. Shared by reachability and the cone weight.""" + merged = _unit_declaring(ast_path, cut) + if merged is None: + return None + edges, roots, by_name, sizes = _ast_call_graph(merged, cut) + if external_call_graph is not None: + _add_external_edges(edges, by_name, external_call_graph) + return edges, roots, sizes + + +def _bfs(edges: dict, roots) -> set[str]: + seen = set(roots) + stack = list(roots) + while stack: + cur = stack.pop() + for nxt in edges.get(cur, ()): + if nxt not in seen: + seen.add(nxt) + stack.append(nxt) + return seen + + +def _cone_weights(edges: dict, reachable: set, sizes: dict) -> dict[str, int]: + """Per reachable function, the total body size of its TRANSITIVE CONSUMERS — the code that (transitively) + calls it, hence reasons about its output. Invert the edges (callee -> callers) and walk from f toward its + callers, staying inside `reachable`.""" + consumers: dict[str, set[str]] = {} + for caller, callees in edges.items(): + for callee in callees: + consumers.setdefault(callee, set()).add(caller) + out: dict[str, int] = {} + for f in reachable: + seen: set[str] = set() + stack = [c for c in consumers.get(f, ()) if c in reachable] + while stack: + c = stack.pop() + if c in seen: + continue + seen.add(c) + stack.extend(x for x in consumers.get(c, ()) if x in reachable and x not in seen) + out[f] = sum(sizes.get(g, 0) for g in seen) + return out + + +_NON_EXPRESSIBLE_ELEM = {"bytes", "string"} # dynamic byte blobs; fixed `bytesN` / value types are fine + + +def _param_infos(node: dict, key: str) -> list[tuple[str, object]]: + """(typeString for display, typeName node for the structural check) per `parameters`/`returnParameters`.""" + ps = ((node.get(key) or {}).get("parameters")) or [] + return [(((p.get("typeDescriptions") or {}).get("typeString") or ""), p.get("typeName")) for p in ps] + + +def _expressible_typename(tn: object, merged: dict, seen: frozenset = frozenset()) -> bool: + """Whether a solc `typeName` is modelable as a typed CVL summary value — walked RECURSIVELY, so a blob + nested inside an array or struct is caught (unlike a flat typeString check). Expressible: value types, + UDVTs, enums, contracts; arrays (fixed or dynamic) of an expressible element; structs whose fields are + all expressible. NOT expressible, wherever nested: dynamic `bytes`/`string` (abi-encoded blobs — the + point of an opaque boundary), mappings, and function types.""" + if not isinstance(tn, dict): + return False + nt = tn.get("nodeType") + if nt == "ElementaryTypeName": + return (tn.get("name") or "") not in _NON_EXPRESSIBLE_ELEM + if nt == "ArrayTypeName": + return _expressible_typename(tn.get("baseType"), merged, seen) + if nt == "UserDefinedTypeName": + ref = tn.get("referencedDeclaration") + decl = merged.get(str(ref)) if ref is not None else None + if not isinstance(decl, dict): + return True # external/interface type not in unit -> value-like + if decl.get("nodeType") == "StructDefinition": + sid = str(ref) + if sid in seen: # recursive struct: no new blob on this cycle + return True + return all(_expressible_typename((m or {}).get("typeName"), merged, seen | {sid}) + for m in (decl.get("members") or [])) + return True # enum / UDVT / contract -> value-like + if nt in ("Mapping", "FunctionTypeName"): + return False + return False # unknown node -> conservative + + +def _signatures(merged: dict) -> dict[str, tuple[str, bool, bool, bool]]: + """qual -> (readable `fn(pt,..) -> rt,..` signature, is-CVL-expressible, has-a-return, is-mutating). + Expressible = has a return AND every param/return type is CVL-expressible (see `_expressible_typename`). + has_return is kept SEPARATELY so a void function (no value to model) is distinguished from one with an + opaque type. Mutating = stateMutability not view/pure (summarizing it as a value erases side effects).""" + contracts_by_fid: dict[int, list] = {} + for node in merged.values(): + if node.get("nodeType") == "ContractDefinition": + sp = _span3(node) + if sp: + contracts_by_fid.setdefault(sp[2], []).append((sp[0], sp[1], node.get("name") or "")) + out: dict[str, tuple[str, bool, bool, bool]] = {} + for node in merged.values(): + if node.get("nodeType") != "FunctionDefinition": + continue + sp = _span3(node) + if sp is None: + continue + cname = _enclosing(sp[0], contracts_by_fid.get(sp[2], [])) or "" + fname = node.get("name") or "" + qual = f"{cname}.{fname}" if cname else fname + params = _param_infos(node, "parameters") + rets = _param_infos(node, "returnParameters") + sig = f"{fname}({', '.join(t for t, _ in params)})" + ( + f" -> {', '.join(t for t, _ in rets)}" if rets else "") + has_return = bool(rets) + types_ok = all(_expressible_typename(tn, merged) for _, tn in params + rets) + mutating = (node.get("stateMutability") or "") not in ("view", "pure") + out[qual] = (sig, has_return and types_ok, has_return, mutating) + return out + + +def _caller_boundaries(edges: dict, sigs: dict, leaf: str, reachable: set[str] | None, + max_hops: int = 4, limit: int = 4) -> list[Boundary]: + """Walk UP the call graph from `leaf` (a detected hasher) and return caller boundaries — places to + summarize instead of the leaf — ranked expressible-first then nearest. Restricted to `reachable` + (host-less free functions matched by bare name, as in the gate) when given, so suggestions are real.""" + consumers: dict[str, set[str]] = {} + for caller, callees in edges.items(): + for callee in callees: + consumers.setdefault(callee, set()).add(caller) + bare = {n.rpartition(".")[2] for n in reachable} if reachable is not None else set() + hops: dict[str, int] = {} + frontier, depth = {leaf}, 0 + while frontier and depth < max_hops: + depth += 1 + nxt: set[str] = set() + for f in frontier: + for c in consumers.get(f, ()): + if c not in hops and c != leaf: + hops[c] = depth + nxt.add(c) + frontier = nxt + out: list[Boundary] = [] + for q, h in hops.items(): + if reachable is not None and not _survives(q, reachable, bare): + continue + sig, expressible, has_return, mutating = sigs.get(q, (q, False, False, True)) + out.append(Boundary(q, h, sig, expressible, has_return, mutating)) + # expressible first, then view/pure over state-changing, then nearest — the cleanest, safest boundary + out.sort(key=lambda b: (not b.expressible, b.mutating, b.hops, b.function)) + return out[:limit] + + +# Nonlinear PRIMITIVES: arg-based math-library fns (Morpho MathLib, Solmate FixedPointMathLib, OZ Math, +# ds-math, ...). A nonlinear hotspot METHOD usually just INLINES one of these; the difficulty report +# attributes the ops to the method, so descending to the shared primitive names the real, arg-based +# over-approx target. Name-based for now — extensible to an AST nonlinear-op body scan. +_NONLINEAR_PRIMS = { + "mulDiv", "mulDivDown", "mulDivUp", "fullMulDiv", "fullMulDivUp", "mulDivRoundingUp", + "mulWad", "mulWadDown", "mulWadUp", "divWad", "divWadDown", "divWadUp", "sMulWad", "sDivWad", + "rpow", "wpow", "pow", "rmul", "rdiv", "wmul", "wdiv", "sqrt", "cbrt", "exp", "expWad", "ln", "lnWad", +} + + +def _is_nonlinear_prim(qual: str) -> bool: + return qual.rpartition(".")[2] in _NONLINEAR_PRIMS + + +def _descend_to_prims(edges: dict, method: str, reachable: set[str] | None, + max_depth: int = 4) -> dict[str, int]: + """BFS DOWN the call graph from `method`; return {nonlinear-primitive qual -> min call depth} for the + primitives it (transitively) inlines. Restricted to `reachable` (free functions bare-name matched, as + in the gate) so dead code is never suggested.""" + bare = {n.rpartition(".")[2] for n in reachable} if reachable is not None else set() + prims: dict[str, int] = {} + seen = {method} + frontier, depth = {method}, 0 + while frontier and depth < max_depth: + depth += 1 + nxt: set[str] = set() + for f in frontier: + for callee in edges.get(f, ()): + if callee in seen: + continue + seen.add(callee) + nxt.add(callee) + if (_is_nonlinear_prim(callee) and callee not in prims + and (reachable is None or _survives(callee, reachable, bare))): + prims[callee] = depth + frontier = nxt + return prims + + +def reachable_from_main(ast_path: str | Path, cut: str, external_call_graph: str | Path | None = None) -> set[str]: + """The set of `Contract.fn` reachable from the CUT's external/public entry points, over the combined + call graph (AST internal edges + prover external/dispatch edges). Empty if the CUT's compilation unit + can't be found (caller treats empty as "don't prune").""" + g = _build_graph(ast_path, cut, external_call_graph) + return _bfs(g[0], g[1]) if g else set() + + +def cone_weights(ast_path: str | Path, cut: str, external_call_graph: str | Path | None = None) -> dict[str, int]: + """Heuristic cone-of-influence weight per reachable function — the size of the code that consumes its + output (`_cone_weights`). The prover exposes no COI, so we approximate it over the call graph; it + over-approximates the true data-flow cone but orders candidates by how widely their result propagates + (a hash whose id threads the protocol outranks a leaf digest).""" + g = _build_graph(ast_path, cut, external_call_graph) + if g is None: + return {} + edges, roots, sizes = g + return _cone_weights(edges, _bfs(edges, roots), sizes) diff --git a/summarization_detector/sources.py b/summarization_detector/sources.py new file mode 100644 index 00000000..ce432d63 --- /dev/null +++ b/summarization_detector/sources.py @@ -0,0 +1,200 @@ +"""Turn a prover-run URL into everything the detector needs, so `--url` is the only input: + + fetch the job SOURCES -> find its `run.conf` -> derive the main contract (the conf's `verify` field) + -> generate the AST (`certoraRun --compilation_steps_only --dump_asts`) -> best-effort the external call + graph -> run `detect` with the difficulty report the URL also provides. + +Live I/O (POU fetch + certoraRun), kept out of `detect.py`'s pure analysis core. Reuses POU +(`ProverOutputAPI.fetch_job_sources`) and `detect.ensure_ast`. vaas-dev URLs need `AISS_ENV=dev` — set +here from the host so the caller needn't. +""" +import json +import os +import re +import tempfile +import time +from pathlib import Path +from typing import Callable, TypeVar + +from .detect import DetectionReport, detect, ensure_ast + +_T = TypeVar("_T") + +_TRANSIENT = ("connection reset", "connection aborted", "timed out", "temporarily unavailable", + "read timed out", "remotedisconnected", "max retries") + + +def _is_transient(e: Exception) -> bool: + return any(s in str(e).lower() for s in _TRANSIENT) + + +def _retry_transient(fn: Callable[[], _T], tries: int = 4) -> _T: + """Call fn, retrying only TRANSIENT network errors (the intermittent vaas-dev/prover connection + resets) with linear backoff; a non-transient error (auth, 404) is raised immediately.""" + for attempt in range(tries): + try: + return fn() + except Exception as e: + if attempt == tries - 1 or not _is_transient(e): + raise + time.sleep(2 * (attempt + 1)) + raise RuntimeError("unreachable: retry loop exhausted without return or raise") + + +def _aiss_env_for(url: str) -> None: + """vaas-dev jobs require AISS_ENV=dev for POU auth; set it from the URL host if unset.""" + if "vaas-dev" in url: + os.environ.setdefault("AISS_ENV", "dev") + + +def fetch_sources(url: str, dest: str | Path) -> Path: + """Fetch a job's `.certora_sources` tree to `dest` (POU), retrying transient resets. Returns the root.""" + _aiss_env_for(url) + from prover_output_utility import ProverOutputAPI + dest = Path(dest) + dest.mkdir(parents=True, exist_ok=True) + api = ProverOutputAPI(use_local=False) + return Path(_retry_transient(lambda: api.fetch_job_sources(url, dest))) + + +def find_run_conf(sources_dir: str | Path) -> Path: + """Locate the job's run conf in a fetched sources tree — the canonical + `inputs/.certora_sources/run.conf`, else the shallowest `run.conf`/`*.conf` outside lib/ and the + `.certora_internal` machinery.""" + root = Path(sources_dir) + canonical = root / "inputs" / ".certora_sources" / "run.conf" + if canonical.exists(): + return canonical + for pattern in ("run.conf", "*.conf"): + cands = [p for p in root.rglob(pattern) + if "/lib/" not in str(p) and "/.certora_internal/" not in str(p)] + if cands: + return min(cands, key=lambda p: len(p.parts)) + raise FileNotFoundError(f"no run.conf found under {root}") + + +def cut_from_conf(conf_path: str | Path) -> str: + """The main (verified) contract — the part before ':' in the conf's `verify` field + (`"Router:certora/specs/x.spec"` -> `"Router"`), falling back to the first `parametric_contracts`.""" + conf = json.loads(Path(conf_path).read_text()) + verify = conf.get("verify") or "" + if isinstance(verify, str) and ":" in verify: + return verify.split(":", 1)[0] + parametric = conf.get("parametric_contracts") or [] + return parametric[0] if parametric else "" + + +def find_external_call_graph(sources_dir: str | Path) -> Path | None: + """The prover's `externalCallGraph.json` if a local tree already carries one (e.g. a local prover run's + `Reports/`). None otherwise. For a job URL use `fetch_external_call_graph` — it's a Reports artifact, + NOT a source file, so it isn't in `fetch_job_sources` output.""" + hits = list(Path(sources_dir).rglob("externalCallGraph.json")) + return hits[0] if hits else None + + +def fetch_external_call_graph(url: str, dest: str | Path) -> Path | None: + """Fetch just `Reports/externalCallGraph.json` — the prover writes it under `Reports/`, not into + `.certora_sources`, so the source-files fetch misses it. Uses POU's single-file endpoint + (`fetch_output_file` -> `/f/`), NOT the whole output tarball. Returns the written path, or + None when the run produced none (a prover build without the collector) or on any fetch error. + NB: needs an up-to-date POU (the ProverCLI repo) — older installs lack `fetch_output_file`.""" + _aiss_env_for(url) + try: + from prover_output_utility import ProverOutputAPI + api = ProverOutputAPI(use_local=False) + # fetch_output_file needs a current POU (ProverCLI); older installs lack it -> AttributeError -> + # caught below -> no ecg (graceful). type: ignore because the installed stub may be stale. + content = _retry_transient( + lambda: api.fetch_output_file(url, "externalCallGraph.json")) # type: ignore[attr-defined] + if not content: + return None + out = Path(dest) / "externalCallGraph.json" + out.write_text(content if isinstance(content, str) else json.dumps(content)) + return out + except Exception: + return None + + +def _reachable_from_reports(reports_dir: str | Path) -> set[str]: + """Union, across every rule's POST-optimize surviving call graph, of the external `procedures` + (`Contract.method`) and the internal functions (name with its `(sig)` stripped -> `Contract.method`). + This is the set of functions that actually reach SMT — the authoritative reachability gate. The + manifest (`survivingCallGraph_map.json`) names the files; absent it, we glob the per-rule files.""" + d = Path(reports_dir) + mp = d / "survivingCallGraph_map.json" + if mp.exists(): + m = json.loads(mp.read_text()) + files = [d / f for fs in m.values() for f in fs if "postOptimize" in f] + else: + files = list(d.glob("SurvivingCallGraph-*postOptimize.json")) + reach: set[str] = set() + for f in files: + if not f.exists(): + continue + g = json.loads(f.read_text()) + for p in g.get("procedures", []): + reach.add(p["procId"]) + for i in g.get("internalFunctions", []): + reach.add(re.sub(r"\(.*\)", "", i["name"])) + return reach + + +def find_surviving_call_graphs(sources_dir: str | Path) -> set[str] | None: + """The postOptimize reachability set from a local prover run's `Reports/` (see `_reachable_from_reports`). + None when the run carries no collector output. For a job URL use `fetch_surviving_call_graphs`.""" + hits = list(Path(sources_dir).rglob("survivingCallGraph_map.json")) + if not hits: + hits = list(Path(sources_dir).rglob("SurvivingCallGraph-*postOptimize.json")) + if not hits: + return None + return _reachable_from_reports(hits[0].parent) or None + + +def fetch_surviving_call_graphs(url: str, dest: str | Path) -> set[str] | None: + """Fetch a run's postOptimize surviving call graphs and return the reachability set. Reads the manifest + `survivingCallGraph_map.json` (POU single-file endpoint), then each rule's postOptimize file — mirroring + POU's `unsat_core_map` -> `read_unsat_cores`. None when the run produced none (a prover build without the + collector) or on any fetch error. NB: needs an up-to-date POU (the ProverCLI repo).""" + _aiss_env_for(url) + try: + from prover_output_utility import ProverOutputAPI + api = ProverOutputAPI(use_local=False) + # fetch_output_file needs a current POU; older installs lack it -> AttributeError -> caught -> None. + raw = _retry_transient( + lambda: api.fetch_output_file(url, "survivingCallGraph_map.json")) # type: ignore[attr-defined] + if not raw: + return None + manifest = json.loads(raw) if isinstance(raw, str) else raw + out = Path(dest) / "surviving_call_graphs" + out.mkdir(parents=True, exist_ok=True) + (out / "survivingCallGraph_map.json").write_text(json.dumps(manifest)) + for files in manifest.values(): + for fn in files: + if "postOptimize" not in fn: + continue + content = _retry_transient( + lambda fn=fn: api.fetch_output_file(url, fn)) # type: ignore[attr-defined] + if content: + (out / fn).write_text(content if isinstance(content, str) else json.dumps(content)) + return _reachable_from_reports(out) or None + except Exception: + return None + + +def detect_url(url: str, *, work_dir: str | Path | None = None, solc_dir: str | Path | None = None, + cut: str | None = None, external_call_graph: str | Path | None = None, + include_dependencies: bool = False) -> DetectionReport: + """The one-input entry: from a prover-run URL, fetch + derive everything and run the detector. `cut` + and `external_call_graph` override what would otherwise be derived/fetched. `work_dir` (default: a + temp dir) holds the fetched sources + generated AST.""" + work = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix="detect-")) + src = fetch_sources(url, work / "sources") + conf = find_run_conf(src) + cut = cut or cut_from_conf(conf) + if not cut: + raise ValueError(f"could not derive the main contract from {conf} — pass cut=") + ast = ensure_ast(conf=conf, solc_dir=solc_dir) + ecg = external_call_graph or fetch_external_call_graph(url, work) # Reports artifact — from the tarball + surviving = fetch_surviving_call_graphs(url, work) # postOptimize reachability gate + return detect(url, ast_path=ast, cut=cut, external_call_graph=ecg, + surviving_set=surviving, include_dependencies=include_dependencies) diff --git a/summarization_detector/tests/__init__.py b/summarization_detector/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/summarization_detector/tests/test_detect.py b/summarization_detector/tests/test_detect.py new file mode 100644 index 00000000..cfea7c47 --- /dev/null +++ b/summarization_detector/tests/test_detect.py @@ -0,0 +1,399 @@ +"""Summarization-target detector (detect.py). Fast, offline, no prover/scene: + - scan_ast: a synthetic solc-AST fixture exercises span-attribution, the `abi.`-base guard (a non-abi + `.encode()` is NOT hashing), source-file dedup, and the lib/ dependency flag. + - detect_from: the three-signal fusion + the whole-vs-part (over_approx vs symbolic_model) classifier. +No real `.asts.json` is needed — scan_ast streams any JSON of the documented shape.""" +import json +import tempfile +from pathlib import Path + +from summarization_detector.detect import ( + scan_ast, detect_from, HashSignal, reachable_from_main, cone_weights, _touch_missing_imported_specs, + _survives, _caller_boundaries, _expressible_typename, +) +from smtool.difficulty import DifficultyReport, Hotspot + + +def _node(nt, off, length, **kw): + return {"nodeType": nt, "src": f"{off}:{length}:1", **kw} + + +def _args(*specs): + """Each spec is a type string, or a (type, ref_id) tuple attaching a `referencedDeclaration` — used to + model an argument that reads a function PARAMETER (the user-input test for dynamic-length hashing).""" + out = [] + for s in specs: + if isinstance(s, tuple): + out.append({"typeDescriptions": {"typeString": s[0]}, "referencedDeclaration": s[1]}) + else: + out.append({"typeDescriptions": {"typeString": s}}) + return out + + +def _fdef(off, length, name, *, vis="internal", mut="pure", param_ids=()): + return _node("FunctionDefinition", off, length, name=name, stateMutability=mut, visibility=vis, + parameters={"parameters": [{"id": i} for i in param_ids]}) + + +def _ident_call(off, name, *type_strings): + return _node("FunctionCall", off, 8, arguments=_args(*type_strings), + expression={"nodeType": "Identifier", "name": name}) + + +def _member_call(off, member, base, *type_strings): + return _node("FunctionCall", off, 8, arguments=_args(*type_strings), + expression={"nodeType": "MemberAccess", "memberName": member, + "expression": {"nodeType": "Identifier", "name": base}}) + + +def _write_ast(tmp, files: dict) -> Path: + """files: {abs_path: {node_id: node}} -> a .asts.json keyed by one compilation unit per file.""" + doc = {abs_path: {abs_path: nodes} for abs_path, nodes in files.items()} + p = Path(tmp) / "x.asts.json" + p.write_text(json.dumps(doc)) + return p + + +def test_scan_ast_attributes_guards_and_flags(): + nodes = { + "1": _node("ContractDefinition", 0, 200, name="Lib", contractKind="library"), + "2": _node("FunctionDefinition", 10, 90, name="hashIt", stateMutability="pure", visibility="internal"), + "3": _ident_call(50, "keccak256"), # in hashIt -> flagged + "4": _member_call(60, "encodePacked", "abi"), # in hashIt -> flagged + "5": _node("FunctionDefinition", 110, 80, name="plain", stateMutability="view", visibility="public"), + "6": _member_call(150, "encode", "myCodec"), # NON-abi .encode in plain -> NOT flagged + "7": _ident_call(195, "keccak256"), # file-scope (outside any fn) -> skipped + } + with tempfile.TemporaryDirectory() as d: + sigs = scan_ast(_write_ast(d, {"src/A.sol": nodes})) + assert len(sigs) == 1 + s = sigs[0] + assert s.function == "Lib.hashIt" and s.contract == "Lib" and s.name == "hashIt" + assert s.mutability == "pure" and s.visibility == "internal" + assert s.patterns == ("abi.encodePacked", "keccak256") # sorted; the non-abi encode is excluded + assert s.is_dependency is False + + +def test_scan_ast_requires_hash_trigger_and_classifies_length(): + """Only a function that calls a HASH builtin is a candidate — an encoder-only function (serialization + or calldata via encodeWithSelector) is NOT. Length class: a hash over dynamic data (bytes/string) is + dynamic-input; over fixed-size fields it is fixed.""" + nodes = { + "1": _node("ContractDefinition", 0, 400, name="H", contractKind="contract"), + # fixed-size digest: keccak(abi.encode(uint256, address)) + "2": _fdef(10, 80, "hashFixed"), + "3": _ident_call(30, "keccak256", "bytes memory"), + "4": _member_call(40, "encode", "abi", "uint256", "address"), + # dynamic digest over a PARAMETER (id 900): keccak(abi.encodePacked(bytes param)) + "5": _fdef(100, 80, "hashDyn", param_ids=(900,)), + "6": _ident_call(120, "keccak256", "bytes memory"), + "7": _member_call(130, "encodePacked", "abi", ("bytes memory", 900)), + # serialization only (abi.encode, no hash) -> dropped + "8": _fdef(200, 80, "serialize"), + "9": _member_call(220, "encode", "abi", "uint256"), + # calldata construction (encodeWithSelector is not even a tracked encoder) -> dropped + "10": _fdef(300, 80, "callData", vis="internal", mut="view"), + "11": _member_call(320, "encodeWithSelector", "abi", "bytes4", "address"), + } + with tempfile.TemporaryDirectory() as d: + sigs = {s.function: s for s in scan_ast(_write_ast(d, {"src/H.sol": nodes}))} + assert set(sigs) == {"H.hashFixed", "H.hashDyn"} # serialize + callData dropped (no hash trigger) + assert sigs["H.hashFixed"].dynamic_input is False + assert sigs["H.hashDyn"].dynamic_input is True # dynamic operand reads a parameter + + +def test_scan_ast_constant_dynamic_typed_input_is_not_dynamic(): + """A hash over a dynamic-TYPED but non-parameter operand (a constant/immutable/local, e.g. + `keccak256(bytes(name))` for a fixed EIP-712 name) is NOT dynamic-input — it is bounded/cheap.""" + nodes = { + "1": _node("ContractDefinition", 0, 200, name="E", contractKind="contract"), + "2": _fdef(10, 90, "nameHash", vis="internal", mut="view"), # no params + "3": _ident_call(40, "keccak256", "bytes memory"), # arg reads a LOCAL, not a param + } + with tempfile.TemporaryDirectory() as d: + sigs = {s.function: s for s in scan_ast(_write_ast(d, {"src/E.sol": nodes}))} + assert set(sigs) == {"E.nameHash"} + assert sigs["E.nameHash"].dynamic_input is False # constant-valued -> not expensive + + +def test_scan_ast_drops_ecrecover_only(): + """`ecrecover`-only functions are dropped (a recovered address has no over-approximable property); a + function that also hashes is kept.""" + nodes = { + "1": _node("ContractDefinition", 0, 300, name="S", contractKind="contract"), + "2": _fdef(10, 90, "recover", param_ids=(900,)), # only ecrecover -> dropped + "3": _ident_call(40, "ecrecover", "bytes32", "uint8", "bytes32", "bytes32"), + "4": _fdef(110, 90, "digestAndRecover", param_ids=(901,)), # keccak + ecrecover -> kept + "5": _ident_call(140, "keccak256", ("bytes memory", 901)), + "6": _ident_call(160, "ecrecover", "bytes32", "uint8", "bytes32", "bytes32"), + } + with tempfile.TemporaryDirectory() as d: + sigs = {s.function: s for s in scan_ast(_write_ast(d, {"src/S.sol": nodes}))} + assert set(sigs) == {"S.digestAndRecover"} # recover (ecrecover-only) dropped + + +def test_scan_ast_dedups_and_flags_dependency(): + """A source file recurs under many compilation units — processed ONCE. A lib/ path is a dependency.""" + lib_nodes = { + "1": _node("ContractDefinition", 0, 100, name="ERC20", contractKind="contract"), + "2": _node("FunctionDefinition", 10, 80, name="permit", stateMutability="nonpayable", visibility="public"), + "3": _ident_call(40, "keccak256"), + } + # same abs path appears under two compilation units (two top-level rel keys) + p = Path(tempfile.mkdtemp()) / "x.asts.json" + p.write_text(json.dumps({ + "unitA": {"lib/solady/src/tokens/ERC20.sol": lib_nodes}, + "unitB": {"lib/solady/src/tokens/ERC20.sol": lib_nodes}, + })) + sigs = scan_ast(p) + assert len(sigs) == 1 and sigs[0].function == "ERC20.permit" # deduped, not counted twice + assert sigs[0].is_dependency is True # under lib/ + + +def test_detect_from_fuses_and_classifies(): + """Three signals fuse per function; an external contract with >=2 hotspots -> symbolic_model, a lone + output function -> over_approx. Dependency hashing is dropped by default.""" + diff = DifficultyReport(hotspots=[ + Hotspot("Oracle.getPrice", 40, "O.sol:10"), # external (not CUT), 2 methods -> whole-contract + Hotspot("Oracle.latestRound", 20, "O.sol:20"), + Hotspot("C.mulThing", 55, "C.sol:5"), # the CUT's own nonlinear math -> over_approx + ]) + hs = [ + HashSignal("C.hashId", "C", "hashId", "pure", "internal", ("keccak256",), "src/C.sol", False), + HashSignal("Dep.enc", "Dep", "enc", "pure", "internal", ("abi.encode",), "lib/x/Dep.sol", True), + ] + rep = detect_from(hs, diff, cut="C") + by = {c.function: c for c in rep.candidates} + + assert by["Oracle.getPrice"].mode == "symbolic_model" # external + >=2 methods -> whole contract + assert "external" in by["Oracle.getPrice"].signals + assert by["C.mulThing"].mode == "over_approx" and by["C.mulThing"].signals == ("nonlinear",) + assert by["C.hashId"].mode == "over_approx" and "hashing" in by["C.hashId"].signals + assert "Dep.enc" not in by # dependency hashing filtered by default + assert rep.candidates == sorted(rep.candidates, key=lambda c: c.score, reverse=True) # ranked + + +def test_detect_from_drops_cvl_ghost_hotspots(): + # a CVL/ghost hotspot is an ALREADY-APPLIED summary — must never be a candidate (it IS the summary) + diff = DifficultyReport(hotspots=[ + Hotspot("CVL/Ghost Function 'mulDivDownSummary(x,y,denominator)'", 86, ""), # already a summary + Hotspot("AmountConverter.getExpectedOut", 10, "contracts/AmountConverter.sol:134"), + ]) + rep = detect_from([], diff, cut="Stonks") + fns = {c.function for c in rep.candidates} + assert "AmountConverter.getExpectedOut" in fns # real math kept + assert not any("Ghost" in f or "CVL" in f for f in fns) # ghost summary dropped + + +def test_detect_from_strips_internal_prefix_so_cut_own_fn_is_not_external(): + # the prover prefixes inlined hotspots with "(internal)"; without stripping, the CUT's own function + # parses to contract "(internal) Stonks" != cut and is wrongly flagged a resolved-external. + diff = DifficultyReport(hotspots=[Hotspot("(internal) Stonks.estimateTradeOutput", 12, "S.sol:387")]) + c = detect_from([], diff, cut="Stonks").candidates[0] + assert c.function == "Stonks.estimateTradeOutput" # (internal) marker stripped + assert c.contract == "Stonks" # parses to the CUT + assert c.signals == ("nonlinear",) and "external" not in c.signals # CUT's own math, NOT external + + +def _fndef(off, length, name, vis="internal"): + return _node("FunctionDefinition", off, length, name=name, stateMutability="pure", visibility=vis) + + +def _call_ref(off, ref): + return _node("FunctionCall", off, 8, expression={"nodeType": "Identifier", "name": "x", "referencedDeclaration": ref}) + + +def _reach_fixture(tmp, orphan_name="orphan"): + """Main.entry (external) internally calls `reached`; `` is defined but uncalled. Both + reached and orphan hash (keccak) so scan_ast flags them; reachability decides which survives.""" + nodes = { + "1": _node("ContractDefinition", 0, 400, name="Main", contractKind="contract"), + "10": _fndef(10, 90, "entry", vis="external"), + "11": _call_ref(50, 20), # entry -> reached (referencedDeclaration=20) + "20": _fndef(110, 90, "reached"), + "21": _ident_call(150, "keccak256", "bytes memory"), + "22": _member_call(155, "encodePacked", "abi", "bytes"), + "30": _fndef(210, 90, orphan_name), + "31": _ident_call(250, "keccak256", "bytes memory"), + "32": _member_call(255, "encodePacked", "abi", "bytes"), + } + return _write_ast(tmp, {"src/Main.sol": nodes}) + + +def test_survives_matches_free_functions_by_bare_name(): + # The prover attributes a free (file-level) function to an arbitrary host in the surviving set. + surviving = {"Ownable.computeBaseHash", "CombinatorialModule.getConditionId", "Router.convert"} + bare = {n.rpartition(".")[2] for n in surviving} + # host-less candidate (a free function) matches by bare name despite the mis-attributed host + assert _survives("computeBaseHash", surviving, bare) + # hosted candidate matches only exactly — a genuinely-unreachable CTHelpers.getConditionId is NOT + # resurrected by the same-named method on another contract + assert not _survives("CTHelpers.getConditionId", surviving, bare) + assert _survives("CombinatorialModule.getConditionId", surviving, bare) + # host-less candidate with no bare-name match stays out + assert not _survives("notReachable", surviving, bare) + + +def _elem(name): + return {"nodeType": "ElementaryTypeName", "name": name} + + +def _arr(base): + return {"nodeType": "ArrayTypeName", "baseType": base} + + +def _udt(ref): + return {"nodeType": "UserDefinedTypeName", "referencedDeclaration": ref} + + +def test_expressible_typename_recurses_arrays_and_structs(): + # value types / fixed bytes expressible; dynamic bytes/string not + assert _expressible_typename(_elem("uint256"), {}) and _expressible_typename(_elem("bytes32"), {}) + assert not _expressible_typename(_elem("bytes"), {}) and not _expressible_typename(_elem("string"), {}) + # NESTING the flat check missed: bytes[] and struct-with-a-bytes-field are NOT expressible + assert _expressible_typename(_arr(_elem("uint256")), {}) + assert not _expressible_typename(_arr(_elem("bytes")), {}) # bytes[] + merged = { + "10": {"nodeType": "UserDefinedValueTypeDefinition"}, # a UDVT (e.g. PositionId) + "20": {"nodeType": "StructDefinition", "members": [{"typeName": _elem("bytes")}]}, + "30": {"nodeType": "StructDefinition", "members": [{"typeName": _elem("uint256")}]}, + "50": {"nodeType": "StructDefinition", # struct WITH a mapping field + "members": [{"typeName": {"nodeType": "Mapping"}}, {"typeName": _elem("uint256")}]}, + } + assert _expressible_typename(_udt(10), merged) # UDVT -> value-like + assert _expressible_typename(_arr(_udt(10)), merged) # PositionId[] -> expressible + assert not _expressible_typename(_udt(20), merged) # struct WITH a bytes field + assert _expressible_typename(_udt(30), merged) # struct of value types + assert not _expressible_typename({"nodeType": "Mapping"}, merged) # mapping + assert not _expressible_typename(_udt(50), merged) # mapping nested in a struct + + +def test_caller_boundaries_prefers_expressible_pure_callers(): + # leaf <- encodeFromData (opaque) <- mutClose (clean but MUTATING, hop2) <- pureFar (clean+pure, hop3) + edges = { + "L.encodeFromData": {"computeBaseHash"}, + "M.mutClose": {"L.encodeFromData"}, + "M.pureFar": {"M.mutClose"}, + } + sigs = { # (signature, expressible, has_return, mutating) + "L.encodeFromData": ("encodeFromData(uint256, bytes) -> C", False, True, False), # opaque bytes arg + "M.mutClose": ("mutClose(PositionId[]) -> C", True, True, True), # clean but mutating + "M.pureFar": ("pureFar(PositionId[]) -> C", True, True, False), # clean + pure + } + reach = {"computeBaseHash", "L.encodeFromData", "M.mutClose", "M.pureFar"} + b = _caller_boundaries(edges, sigs, "computeBaseHash", reach) + # pure+clean wins even though FARTHER; mutating-clean next; opaque last (view-preference beats distance) + assert [x.function for x in b] == ["M.pureFar", "M.mutClose", "L.encodeFromData"] + assert b[0].expressible and not b[0].mutating + assert b[1].expressible and b[1].mutating + assert not b[2].expressible + + +def test_caller_boundaries_filters_to_reachable(): + edges = {"C.caller": {"computeBaseHash"}} + sigs = {"C.caller": ("caller(uint256) -> bytes32", True, True, False)} + assert _caller_boundaries(edges, sigs, "computeBaseHash", reachable={"computeBaseHash"}) == [] # caller unreached + assert _caller_boundaries(edges, sigs, "computeBaseHash", reachable=None) # no filter -> kept + + +def test_boundary_tags_distinguish_no_return_from_opaque_sig(): + from summarization_detector.detect import DetectionReport, Candidate, Boundary + rep = DetectionReport(candidates=[Candidate( + "leaf", "", ("hashing",), "over_approx", 10.0, "ev", + boundaries=[ + # void (all value-type args, no return) — NOT opaque, just nothing to model + Boundary("A.consumePermit", 1, "consumePermit(address, uint256)", expressible=False, + has_return=False, mutating=True), + # has a return but an opaque `bytes` param — genuinely opaque signature + Boundary("A.encodeFromData", 1, "encodeFromData(bytes) -> b32", expressible=False, + has_return=True, mutating=False), + ])]) + lines = rep.format().splitlines() + permit_line = next(ln for ln in lines if "consumePermit" in ln) + enc_line = next(ln for ln in lines if "encodeFromData" in ln) + assert "no return value" in permit_line and "opaque sig" not in permit_line # void -> distinct tag + assert "opaque sig" in enc_line # opaque-type keeps its tag + + +def test_descend_to_prims_finds_shared_nonlinear_primitive(): + from summarization_detector.detect import _descend_to_prims, _is_nonlinear_prim + assert _is_nonlinear_prim("MathLib.mulDivDown") and not _is_nonlinear_prim("V.previewDeposit") + edges = { # both methods reach MathLib.mulDivDown + "V.previewDeposit": {"V.accrueInterestView", "MathLib.mulDivDown"}, + "V.accrueInterestView": {"MathLib.mulDivDown"}, + "V.deposit": {"V.previewDeposit"}, + } + assert _descend_to_prims(edges, "V.previewDeposit", None) == {"MathLib.mulDivDown": 1} + assert _descend_to_prims(edges, "V.deposit", None) == {"MathLib.mulDivDown": 2} # transitive, min depth + # a primitive not in the reachable set is dropped (never suggest dead code) + assert _descend_to_prims(edges, "V.previewDeposit", reachable={"V.previewDeposit"}) == {} + + +def test_boundary_down_direction_renders_shared_count(): + from summarization_detector.detect import DetectionReport, Candidate, Boundary + rep = DetectionReport(candidates=[Candidate( + "V.previewDeposit", "V", ("nonlinear",), "over_approx", 100.0, "ev", + boundaries=[Boundary("MathLib.mulDivDown", 2, "mulDivDown(uint256, uint256, uint256) -> uint256", + expressible=True, has_return=True, mutating=False, direction="down", shared=8)])]) + line = next(ln for ln in rep.format().splitlines() if "mulDivDown" in ln) + assert "↓" in line and "shared ×8" in line and "summarizable here" in line + + +def test_reachable_from_main_internal_edges(): + """BFS from Main's external entry over AST referencedDeclaration edges: `reached` is in, `orphan` out. + Gating scan_ast on the reachable set drops the scene-unreachable hashing candidate.""" + with tempfile.TemporaryDirectory() as d: + ast = _reach_fixture(d) + reach = reachable_from_main(ast, "Main") + assert "Main.reached" in reach and "Main.orphan" not in reach + cands = {h.function for h in scan_ast(ast)} + assert cands == {"Main.reached", "Main.orphan"} # scan flags both + gated = {c for c in cands if c in reach} + assert gated == {"Main.reached"} # reachability drops the orphan + + +def test_cone_weights_sums_consumer_code_size(): + """Cone-of-influence heuristic: a function's weight is the total body size of its transitive consumers + (reachable callers). `Main.reached` is consumed only by `Main.entry` (body size 90); a root has none; + an unreachable function is not weighted.""" + with tempfile.TemporaryDirectory() as d: + ast = _reach_fixture(d) + cone = cone_weights(ast, "Main") + assert cone["Main.reached"] == 90 # consumed by Main.entry (span 10..100 -> 90 bytes) + assert cone["Main.entry"] == 0 # a root — nothing consumes it + assert "Main.orphan" not in cone # unreachable -> not weighted + + +def test_reachable_from_main_external_dispatch_edge(): + """A dispatch call site (no resolved contract, selector `merge`) in externalCallGraph.json makes the + otherwise-unreachable `Main.merge` reachable via selector-name matching.""" + with tempfile.TemporaryDirectory() as d: + ast = _reach_fixture(d, orphan_name="merge") + assert "Main.merge" not in reachable_from_main(ast, "Main") # unreachable without external edges + ecg = Path(d) / "externalCallGraph.json" + ecg.write_text(json.dumps({ + "Main": [{"caller": "entry(uint256)", "selectors": [{"kind": "sighash", "signature": "merge(uint256)"}], + "targets": [{"resolution": "symbolicOutput"}]}] # dispatch: no contract + })) + reach = reachable_from_main(ast, "Main", ecg) + assert "Main.merge" in reach # selector-matched dispatch edge + + +def test_touch_missing_imported_specs_recreates_empty_placeholders(): + """The empty-file-skip workaround: parse certoraRun's missing-import error and recreate the (empty) + imported spec so a re-run resolves it. Only acts on the missing-import error, and never clobbers.""" + with tempfile.TemporaryDirectory() as d: + missing = Path(d) / "certora" / "specs" / "summaries" / "C_call_resolution.spec" + err = (f'In {d}/certora/specs/sanity-C.spec, the following import declarations do not import ' + f'existing .spec files:\n2:2:"{missing}"\n') + created = _touch_missing_imported_specs("", err) + assert created == [missing] + assert missing.exists() and missing.read_text() == "" # empty placeholder, matches the real file + assert _touch_missing_imported_specs("", err) == [] # already exists -> no-op, no clobber + assert _touch_missing_imported_specs("some unrelated compile error") == [] # only the import error + + +def test_detect_from_includes_dependencies_when_asked(): + hs = [HashSignal("Dep.enc", "Dep", "enc", "pure", "internal", ("abi.encode",), "lib/x/Dep.sol", True)] + rep = detect_from(hs, DifficultyReport(), cut="C", include_dependencies=True) + assert any(c.function == "Dep.enc" for c in rep.candidates) diff --git a/summarization_detector/tests/test_sources.py b/summarization_detector/tests/test_sources.py new file mode 100644 index 00000000..9080ec77 --- /dev/null +++ b/summarization_detector/tests/test_sources.py @@ -0,0 +1,63 @@ +"""URL→inputs plumbing (sources.py) — the pure, offline parts: deriving the main contract from a conf's +`verify` field and locating the run conf in a fetched tree. (fetch/certoraRun/POU paths are live I/O, +exercised end-to-end, not here.)""" +import json +import tempfile +from pathlib import Path + +from summarization_detector.sources import ( + cut_from_conf, find_run_conf, find_external_call_graph, find_surviving_call_graphs) + + +def test_cut_from_conf_reads_verify_then_parametric(): + with tempfile.TemporaryDirectory() as d: + c = Path(d) / "run.conf" + c.write_text(json.dumps({"verify": "Router:certora/specs/sanity-Router.spec"})) + assert cut_from_conf(c) == "Router" + c.write_text(json.dumps({"parametric_contracts": ["Vault", "Other"]})) # no verify -> parametric + assert cut_from_conf(c) == "Vault" + c.write_text(json.dumps({"files": ["A.sol"]})) # neither -> "" + assert cut_from_conf(c) == "" + + +def test_find_run_conf_prefers_canonical_and_skips_lib(): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "inputs" / ".certora_sources").mkdir(parents=True) + (root / "inputs" / ".certora_sources" / "run.conf").write_text("{}") + (root / "lib" / "dep").mkdir(parents=True) + (root / "lib" / "dep" / "run.conf").write_text("{}") # must be ignored + found = find_run_conf(root) + assert found == root / "inputs" / ".certora_sources" / "run.conf" + + +def test_find_external_call_graph_optional(): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + assert find_external_call_graph(root) is None # absent -> None (no gate) + rpt = root / "Reports" + rpt.mkdir() + (rpt / "externalCallGraph.json").write_text("{}") + assert find_external_call_graph(root) == rpt / "externalCallGraph.json" + + +def test_find_surviving_call_graphs_unions_procs_and_stripped_internals(): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + assert find_surviving_call_graphs(root) is None # absent -> None (no gate) + rpt = root / "inputs" / ".certora_sources" / "Reports" + rpt.mkdir(parents=True) + post = "SurvivingCallGraph-ruleA-postOptimize.json" + pre = "SurvivingCallGraph-ruleA-preOptimize.json" + (rpt / "survivingCallGraph_map.json").write_text(json.dumps({"ruleA": [pre, post]})) + (rpt / post).write_text(json.dumps({ + "procedures": [{"callId": 0, "procId": "Widget.entry"}], + "internalFunctions": [{"name": "HashLib.digest(bytes32,uint256)", + "summarizable": True}], + })) + (rpt / pre).write_text(json.dumps({ # pre must be IGNORED + "procedures": [{"callId": 9, "procId": "Widget.preOnly"}], + "internalFunctions": [], + })) + s = find_surviving_call_graphs(root) + assert s == {"Widget.entry", "HashLib.digest"} # sig stripped; post-only diff --git a/tests/test_cloudrunner_stop_on_violation.py b/tests/test_cloudrunner_stop_on_violation.py new file mode 100644 index 00000000..f8d55e4f --- /dev/null +++ b/tests/test_cloudrunner_stop_on_violation.py @@ -0,0 +1,101 @@ +"""CloudProverRunner.stop_on_first_violation: cancel a still-running job the moment a rule VIOLATES, +and fetch partial results robustly (a cancelled/gappy job must degrade, never crash). +""" +import asyncio +from types import SimpleNamespace + +from prover_output_utility.models import JobStatus as ProverJobStatus + +from certora_autosetup.utils.cloud_runner import CloudProverRunner +from certora_autosetup.utils.prover_runner import ProverRunner + + +def _check(rule, violated): + # mirrors prover_output_utility.models.CheckResult: the helper reads the `is_violated` property. + return SimpleNamespace(rule_name=rule, is_violated=violated) + + +def _stub(**attrs): + s = SimpleNamespace(**attrs) + s.log = lambda msg, level="INFO": None + return s + + +# ---- _partial_violated_checks: filter + never-raise ------------------------------------------------ + +def test_partial_violated_checks_filters_violated(): + api = SimpleNamespace(get_all_checks=lambda url: [ + _check("a", False), _check("b", True), _check("c", False), _check("b", True), + ]) + stub = _stub() + allc, viol = CloudProverRunner._partial_violated_checks(stub, api, "u") + assert len(allc) == 4 + assert sorted({c.rule_name for c in viol}) == ["b"] + assert len(viol) == 2 + + +def test_partial_violated_checks_never_raises_on_fetch_error(): + def boom(url): + raise RuntimeError("job cancelled, tree missing") + stub = _stub() + allc, viol = CloudProverRunner._partial_violated_checks(stub, SimpleNamespace(get_all_checks=boom), "u") + assert allc == [] and viol == [] + + +# ---- parse robustness on a cancelled/gappy job ----------------------------------------------------- + +def test_parse_rule_results_empty_on_fetch_error(): + def boom(job): + raise RuntimeError("cannot fetch checks for cancelled job") + stub = _stub(prover_api=SimpleNamespace(get_all_checks=boom)) + out = ProverRunner.parse_rule_results_from_job(stub, "cancelled-job-id") + assert out == [] + + +# ---- the poll-loop hook: cancel + return partial checks on first violation -------------------------- + +def test_wait_cancels_and_returns_partial_on_first_violation(): + cancelled = {"called": False} + async def fake_cancel(url): + cancelled["called"] = True + return True + checks = [_check("ok", False), _check("bad", True)] + api = SimpleNamespace( + get_job_info=lambda url: SimpleNamespace(status=ProverJobStatus.RUNNING, start_time=1.0, finish_time=None), + get_all_checks=lambda url: checks, + ) + stub = _stub(stop_on_first_violation=True, _cancel_cloud_job=fake_cancel) + stub._partial_violated_checks = CloudProverRunner._partial_violated_checks.__get__(stub) + success, _s, _f, early = asyncio.run( + CloudProverRunner._wait_for_job_completion_with_api(stub, api, "u", 60) + ) + assert success is False + assert cancelled["called"] is True + assert early is not None and [c.rule_name for c in early] == ["ok", "bad"] + + +def test_wait_ignores_violation_when_flag_off(): + # flag off -> a RUNNING poll with a violated check must NOT cancel; a subsequent SUCCEEDED ends it. + seq = [ProverJobStatus.RUNNING, ProverJobStatus.SUCCEEDED] + def get_job_info(url): + return SimpleNamespace(status=seq.pop(0), start_time=1.0, finish_time=2.0) + cancelled = {"called": False} + async def fake_cancel(url): + cancelled["called"] = True + return True + api = SimpleNamespace(get_job_info=get_job_info, get_all_checks=lambda url: [_check("bad", True)]) + stub = _stub(stop_on_first_violation=False, _cancel_cloud_job=fake_cancel) + stub._partial_violated_checks = CloudProverRunner._partial_violated_checks.__get__(stub) + # poll_interval is 10s; shrink the wait by making the 2nd poll SUCCEED. asyncio.sleep(10) would stall + # the test, so patch asyncio.sleep to a no-op for this call. + import certora_autosetup.utils.cloud_runner as cr + orig_sleep = asyncio.sleep + async def nosleep(_): return None + asyncio.sleep = nosleep + try: + success, _s, _f, early = asyncio.run( + CloudProverRunner._wait_for_job_completion_with_api(stub, api, "u", 60) + ) + finally: + asyncio.sleep = orig_sleep + assert success is True and early is None and cancelled["called"] is False diff --git a/tests/test_model_require_cast.py b/tests/test_model_require_cast.py new file mode 100644 index 00000000..71506155 --- /dev/null +++ b/tests/test_model_require_cast.py @@ -0,0 +1,45 @@ +"""lint_model_spec rejects `require_uintN`/`require_intN` CAST expressions in a model body. + +A require_* cast ASSUMES its argument fits the target type, silently pruning out-of-range (overflow) +inputs. In a conformance rule that makes `!realRev => ...` pass VACUOUSLY on exactly those inputs — a +false positive (confirmed on an ERC20 transferFrom credit-side balance). The sound form is the +`assert_*` cast: the prover then CHECKS the cast is total, so a provably-in-range cast passes and an +overflowing one is caught. +""" +from types import SimpleNamespace + +from smtool import cvlx as x +from smtool.linter import lint_model_spec + + +def _model(*fns): + return SimpleNamespace(model_spec=x.spec_file(blocks=list(fns))) + + +def _credit(cast): + # colBalCVL[to] = (bal + amount) -- the transferFrom credit + body = [x.assign_index("colBalCVL", [x.ident("to")], + x.call(cast, [x.binop("add", x.ident("bal"), x.ident("amount"))]))] + return x.func("transferFromCVL", [("address", "to"), ("uint256", "bal"), ("uint256", "amount")], [], body) + + +def test_require_uint_cast_is_flagged(): + problems = lint_model_spec(_model(_credit("require_uint256"))) + assert any("require_uint256" in p and "require_* cast" in p for p in problems) + + +def test_require_int_cast_is_flagged(): + problems = lint_model_spec(_model(_credit("require_int128"))) + assert any("require_int128" in p for p in problems) + + +def test_assert_uint_cast_is_not_flagged(): + # the sound form — prover-checked totality; no soundness flag + problems = lint_model_spec(_model(_credit("assert_uint256"))) + assert not any("cast" in p for p in problems) + + +def test_flag_message_names_assert_replacement(): + problems = lint_model_spec(_model(_credit("require_uint256"))) + msg = next(p for p in problems if "require_uint256" in p) + assert "assert_uint256" in msg diff --git a/tests/test_prune_reachable.py b/tests/test_prune_reachable.py new file mode 100644 index 00000000..9f715906 --- /dev/null +++ b/tests/test_prune_reachable.py @@ -0,0 +1,94 @@ +"""Regression: prune_reachable claims an invariant proved ONLY when its top-level (ROOT) node is +VERIFIED. + +An invariant expands into many prover checks (base case + one preservation per method + sub-checks); POU +aggregates them into a single ROOT node whose status is the authoritative verdict. Reading the verdict +from the ROOT — not from leaf aggregation — is what keeps prune sound on any partially-finished run +(our own stop-on-first-violation cancel, a prover-side timeout, a halt): an absent/RUNNING leaf is not a +passing one. The leaf verdicts remain available (for the agent to see WHICH assert violated) but never +decide the proof verdict. +""" +import asyncio +from dataclasses import dataclass, field + +import smtool.verify as V +from smtool.verify import RuleVerdict, VerifyResult + + +@dataclass +class _FakeProject: + """Minimal stand-in exercising the prune bookkeeping (reachable_invariant_names / drop_invariants).""" + candidates: list + verified_invariants: set = field(default_factory=set) + dropped: list = field(default_factory=list) + + def reachable_invariant_names(self): + return list(self.candidates) + + def drop_invariants(self, names): + self.dropped.extend(sorted(names)) + + +def _root(rule, status): + return RuleVerdict(rule, status, status in ("VERIFIED", "SUCCESS"), node_type="ROOT") + + +def _leaf(rule, status, node_type="INVARIANT_SUBCHECK"): + return RuleVerdict(rule, status, status in ("VERIFIED", "SUCCESS"), node_type=node_type) + + +def _res(rules): + return VerifyResult(conf="r.conf", success=False, job_url=None, rules=rules) + + +def _prune(project, canned, monkeypatch): + async def fake_verify(*a, **k): + return canned + monkeypatch.setattr(V, "verify", fake_verify) + return asyncio.run(V.prune_reachable(project, "reachable.conf")) + + +def test_violated_root_drops_invariant(monkeypatch): + # balanceLeqSupply: many leaves pass, but the ROOT verdict is VIOLATED -> NOT proven. The passing + # leaves must NOT mask it (the original masking bug: 49 verified / 56 violated leaves, kept). + rules = [_leaf("balanceLeqSupply", "VERIFIED") for _ in range(49)] + rules += [_leaf("balanceLeqSupply", "VIOLATED", "VIOLATED_ASSERT") for _ in range(56)] + rules += [_root("balanceLeqSupply", "VIOLATED")] + proj = _FakeProject(candidates=["balanceLeqSupply"]) + kept, dropped, _ = _prune(proj, _res(rules), monkeypatch) + assert kept == [] + assert dropped == ["balanceLeqSupply"] + assert "balanceLeqSupply" not in proj.verified_invariants + + +def test_verified_root_keeps_invariant(monkeypatch): + rules = [_leaf("solventInv", "VERIFIED") for _ in range(20)] + [_root("solventInv", "VERIFIED")] + proj = _FakeProject(candidates=["solventInv"]) + kept, dropped, _ = _prune(proj, _res(rules), monkeypatch) + assert kept == ["solventInv"] + assert dropped == [] + assert "solventInv" in proj.verified_invariants + + +def test_passing_leaves_without_verified_root_not_kept(monkeypatch): + # Partial/halted run: every leaf seen so far passed, but the ROOT never reached VERIFIED (still + # RUNNING). Absence of a failing leaf is NOT a proof -> must NOT be kept. + rules = [_leaf("inv", "VERIFIED") for _ in range(5)] + [_root("inv", "RUNNING")] + proj = _FakeProject(candidates=["inv"]) + kept, dropped, _ = _prune(proj, _res(rules), monkeypatch) + assert kept == [] and dropped == ["inv"] + + +def test_no_root_node_not_kept(monkeypatch): + # Job died before the ROOT node was emitted: only leaves present, all passing. Still not proven. + rules = [_leaf("inv", "VERIFIED") for _ in range(3)] + proj = _FakeProject(candidates=["inv"]) + kept, dropped, _ = _prune(proj, _res(rules), monkeypatch) + assert kept == [] and dropped == ["inv"] + + +def test_timeout_root_drops_invariant(monkeypatch): + rules = [_leaf("inv", "VERIFIED") for _ in range(5)] + [_root("inv", "TIMEOUT")] + proj = _FakeProject(candidates=["inv"]) + kept, dropped, _ = _prune(proj, _res(rules), monkeypatch) + assert kept == [] and dropped == ["inv"] diff --git a/tests/test_trivial_invariant_guard.py b/tests/test_trivial_invariant_guard.py new file mode 100644 index 00000000..3df0d8d2 --- /dev/null +++ b/tests/test_trivial_invariant_guard.py @@ -0,0 +1,48 @@ +"""add_require_invariant rejects trivial invariants and replaces on re-add. + +An agent that adds a placeholder invariant (`true`, `x==x`) produces a vacuous — and, with no params, +syntactically invalid — reachable spec, then flails because a same-name re-add used to be a silent +no-op. Both are now closed: trivial expressions are rejected with guidance, and a re-add REPLACES. +""" +from smtool.cvl_parse import parse_expression +from smtool.agent.tools import _is_trivial_invariant + + +def test_trivial_forms_detected(): + for s in ["true", "false", "a == a", "x != x"]: + assert _is_trivial_invariant(parse_expression(s)), s + + +def test_real_invariants_not_trivial(): + for s in ["pm.moduleById(0) == 0", "bal <= supply", "balanceOf(a) <= totalSupply()"]: + assert not _is_trivial_invariant(parse_expression(s)), s + + +def test_readd_replaces_invariant(): + # a corrected invariant with the SAME name must REPLACE the stale one, not be dropped as a no-op. + from smtool import cvlx as x, driver + import composer.cvl.schema as S + + class _Proj: + def __init__(self): + self.reachable = x.spec_file(blocks=[x.func(driver.ASSUME, [("address", "a")], [], [])]) + self.verified_invariants = set() + def snapshot(self): + import copy + c = _Proj.__new__(_Proj) + c.reachable = copy.deepcopy(self.reachable) + c.verified_invariants = set(self.verified_invariants) + return c + + from smtool import mutations as mut + proj = _Proj() + # commit by hand: mutations._commit expects a real Project; exercise the block edit directly instead. + reach = proj.reachable + reach.blocks.append(x.invariant("inv", [("address", "a")], parse_expression("a == a"))) + # re-add with a corrected expr must replace, leaving exactly ONE 'inv' block + new = x.invariant("inv", [("address", "a")], parse_expression("balanceOf(a) <= totalSupply()")) + existing = next(b for b in reach.blocks if isinstance(b, S.Invariant) and b.name == "inv") + reach.blocks[reach.blocks.index(existing)] = new + invs = [b for b in reach.blocks if isinstance(b, S.Invariant) and b.name == "inv"] + assert len(invs) == 1 + assert invs[0].model_dump()["invariant_expression"] != parse_expression("a == a").model_dump()