Support verifying a library main contract via a generated harness - #132
Conversation
The Prover skips libraries when instantiating parametric rules, so verifying a library as the main contract silently proves nothing. There is no error to key on; the run just comes back vacuous. certora_autosetup/harnesser generates a plain contract exposing one public wrapper per library function, which the caller verifies instead: - detect: solc --standard-json with stopAfter "parsing" reads contractKind without resolving imports or building, so detection costs milliseconds. - run: emit a placeholder harness, probe-build it alongside the library, read the library's API, then refill the file. The library is listed in the build's files explicitly, because an imported-but-unused library is not compiled as its own contract and the build then reports none of its functions. The placeholder carries one external function since a method-less contract is dropped by contract discovery and by the signature database. - read_build: the API comes from allMethods (external + internal + private), not internalFunctions, which holds autofinder instrumentation and is systematically empty for libraries. A library is located by (name, file): Solady ships 17 library names twice with differently scoped structs. - plan: classify each function, own a state variable per storage receiver, escape CVL keywords before mangling ABI collisions, derive storage readers, and synthesize a return for in-place memory mutators. render_wrapper_contract grows a parentless mode (a library cannot be a base contract) and a slot for file-scoped pragmas. Measured against the corpora, matching hardhat-exposed's independent counts where they overlap: OZ Math 14/14, EnumerableSet 18/24, Checkpoints 20/33, StorageSlot refused (all 8 return storage pointers); Solady LibBitmap 10/10, LibSort 57/68, EnumerableSetLib 45/51. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both entry points swap the main contract before anything derives state from its name. The name reaches the sanity spec, the base conf, the verify target and the result keys, so swapping later would leave those naming a contract the Prover instantiates no methods against. - autosetup CLI swaps right after the main handle is parsed. - composer swaps ahead of SourceFields, so component analysis, CVL authoring and the conf's verify target all agree on one name — AutoSetup keys its returned summary and config by that name too. The library stays in the scene beside the harness: the wrappers call into it, and the build only reports a library's own functions when it is named as a file in its own right. The LLM harness agent owns certora/harnesses and rewrites every entry it is given, so it now skips files carrying the generated-harness sentinel — that file is the verification target, not a wrapper to be improved on. Verified end to end: autosetup on OZ EnumerableSet emits a conf whose verify is CertoraLibraryHarness_EnumerableSet and whose files list both the harness and the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every storage receiver belongs to the library being harnessed, so repeating its name in each identifier only made them long: at_EnumerableSet_Bytes32Set and certoraStore_EnumerableSet_Bytes32Set_inner_indexes where the hand-written harness beside it writes at_ and _indexOf. Names now read at_Bytes32Set, length_Bytes32Set, _certoraStore_Bytes32Set and Bytes32Set_inner_indexes. A type from outside the library keeps its qualifier, which is what still tells it apart. Readers are named for the type and member path rather than the state variable, so they read as accessors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| from certora_autosetup.harnesser.run import ensure_library_harness | ||
|
|
||
|
|
||
| def _split_target(target: str) -> tuple[str, str]: |
There was a problem hiding this comment.
to Claude: I'm surprised we don't have such a utility function yet
There was a problem hiding this comment.
btw, autosetup uses the ContractHandle class
There was a problem hiding this comment.
Claude answers: we do. split_contract_spec in utils/contract_utils.py, and _split_target was a near duplicate of it. The CLI calls it now, and --extra-file goes through parse_contract_files, so a typo fails at parse time instead of as a probe build error minutes later.
There was a problem hiding this comment.
Claude answers: it does exist, split_contract_spec in utils/contract_utils.py. The CLI uses it now.
There was a problem hiding this comment.
Claude answers: done. ContractHandle carries the library and the harness through cli, run, plan, model and swap, so the (file, name) string pairs are gone.
Two places kept their current shape on purpose: the manifest still writes four flat JSON keys, because swap.library_behind_harness reads it back in a later process, and the probe build still spells the explicit :Name, which is what tells apart a file declaring several libraries.
|
@jar-ben before I push forward on this - does this conflict with your work on symbolic modeling? if yes maybe we should discuss merging in together |
A reader's key and leaf types come from struct members, and a member declaration carries no location to copy, so the location has to come from the type itself. Without it a bytes or string leaf renders as `returns (bytes)` and the harness does not compile. Seen on OpenZeppelin's EnumerableSet, whose BytesSet and StringSet reach exactly that path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Left to itself certora-cli concatenates package.json with remappings.txt and refuses the build on any key present in both. That is the normal state of a project whose remappings were generated with node_modules installed, so the probe build failed on OpenZeppelin while every other build in the run succeeded. AutoSetup already merges the four remapping sources with a priority order. The probe build is the one build that runs before AutoSetup, so it has to do the same merge itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Prover's scene is the files the conf names. solc inlines the library into the harness, so the build succeeds while the library itself is absent: curated summaries that name it cannot typecheck, and the build reports none of its functions. The library now joins the run's additional contracts. AutoProver's pipeline swaps in its own process, so on that path AutoSetup sees a contract that is not a library and has nothing to detect; the manifest written beside the harness is what still names the library, and `library_behind_harness` reads it back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both sides add to the CLI right after the main handle is parsed. The scope check runs first, on the contract the caller named, since that is the one auto-detection can drop; the swap then appends the harness and skips whatever is already in the scene. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A summary replaces the code it summarizes, and the harness exists so that library can be verified. Summarizing it means every rule is asserted against the summary while the library itself goes unverified. The curated half fails loudly today: OpenZeppelin's BitMaps summary reroutes to a companion contract and its spec does not typecheck. The LLM half would fail silently — its non-linear-ops recipe targets exactly the internal pure functions an arithmetic library is made of, so a harnessed Math-shaped library would come back green having proved nothing. Curated keys naming the library are subtracted after the match loop rather than skipped inside the matcher: the matcher also returns the tuples that shield those same methods from the LLM step, and skipping earlier would drop the summary and hand the methods to the LLM instead. Dependencies still summarize; only the library under verification is exempt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
certoraRun rejects the whole conf on a repeated entry in `files`, and the two sources merged there legitimately overlap: the harnessed library belongs to the scene, which covers the ordinary path, and to the additional contracts, which cover the path where `files` is rewritten from the main contract plus additional contracts. Deduped on the (file, contract) pair rather than the string, since to_config_str drops the contract name when it matches the file stem and so gives one contract two spellings. Two libraries sharing a source file stay two entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| AutoProver invokes this as a subprocess and reads the JSON record from the file named by | ||
| ``--output``, so the Solidity generation stays on the autosetup side while the decision | ||
| to swap the main contract stays with the caller. The result goes to a file rather than |
There was a problem hiding this comment.
Don't talk about AutoProver here. That's just one of the possible consumers of this tool. Just describe what the tool does and how to use it.
There was a problem hiding this comment.
Claude answers: done. The docstring now says what the tool does and shows the invocation, with no consumer named.
| from certora_autosetup.harnesser.run import ensure_library_harness | ||
|
|
||
|
|
||
| def _split_target(target: str) -> tuple[str, str]: |
| from certora_autosetup.harnesser.run import ensure_library_harness | ||
|
|
||
|
|
||
| def _split_target(target: str) -> tuple[str, str]: |
There was a problem hiding this comment.
btw, autosetup uses the ContractHandle class
|
|
||
| #: Identifier-shaped terminals of the CVL grammar. Operators and punctuation are | ||
| #: omitted: they cannot collide with a Solidity function name. | ||
| CVL_RESERVED_WORDS: FrozenSet[str] = frozenset( |
There was a problem hiding this comment.
Double check we don't have this defined somewhere in AutoProver yet. I am sure I saw a list like this (perhaps just a sublist, but if yes, it could be good to centralise similar frozen lists / enums to a single location)
There was a problem hiding this comment.
Claude answers: you did see it. CVL_RESERVED_WORDS was already in setup/setup_summaries.py with 60 words, and this branch added a second one of 82 under the same name. Both are gone; there is one list now, in utils/cvl_keywords.py.
The 22 extra words were also wrong. 20 of them are the grammar's usable_keywords, which CVL accepts wherever an identifier is expected. I checked with certoraRun --compilation_steps_only: a contract declaring exists, sum, old, forall and invariant typechecks, the same shape with havoc or rule is a syntax error. No generated name changes as a result, since at and sort are the only escapes real libraries trigger.
| return None | ||
|
|
||
|
|
||
| def _parse_only_ast(solc: str, source_file: Path, content: str) -> Optional[Dict]: |
There was a problem hiding this comment.
How about we reuse (or create if not existing) an AST manipulation utils in Autoprover? This seems like a pretty common function.
There was a problem hiding this comment.
Claude answers: moved to solidity_ast/contracts.py. It is now the one place that turns a ContractDefinition into name, kind, abstract and bases, for both the post-build .asts.json stream and this pre-build probe, and setup_prover's private _ContractDeclView / _iter_contract_declarations moved there with it. Kinds come back as ContractKind instead of raw strings.
One thing I could not do is read the probe's AST through the typed models. A stopAfter: "parsing" AST carries no analysis-phase fields (no scope, no linearizedBaseContracts, no fullyImplemented), so SourceUnit.model_validate fails on it with 16 errors on a two-declaration file. That entry point stays on raw nodes, and the module says why.
| BUILD_JSON_RELPATH = Path(".certora_internal/latest/.certora_build.json") | ||
|
|
||
|
|
||
| def _iter_contracts(build_data: Dict[str, Any]) -> Iterator[Dict[str, Any]]: |
There was a problem hiding this comment.
I am almost sure I have seen this somewhere. again seems like a shared util
There was a problem hiding this comment.
Claude answers: you have. That loop was written five times. It is now utils/build_json.py (build_json_path, iter_contracts, contract_source_file), used by setup_prover in four places, by signature_manager, by compile_check and here. The path .certora_internal/latest/.certora_build.json was hardcoded in three of them and is now in one, with compile_check's newest-run-dir fallback folded in.
|
|
||
| def library_behind_harness( | ||
| project_root: Path, main_contract_handle: ContractHandle | ||
| ) -> Optional[ContractHandle]: |
There was a problem hiding this comment.
So, I wanted to suggest to use ContractHandle already above (perhaps in cli.py) when you had a separated library name and path, but then thought that "well, it's not a contract so perhaps ContractName is not the right naming". But here, you use it. so use it everywhere perhaps.
There was a problem hiding this comment.
btw I know you have to use it here because it's the autosetup's interface. but the argument still mostly stands
There was a problem hiding this comment.
Claude answers: done, it is ContractHandle through the harnesser now, starting at the CLI.
There was a problem hiding this comment.
Claude answers: agreed, and the argument held. See the reply above.
| return list(additional_contracts) | ||
| # Compare on the (file, name) pair rather than the string: ``to_config_str`` drops the | ||
| # name when it matches the file stem, so one contract has two spellings. | ||
| already = {split_contract_spec(spec) for spec in additional_contracts} |
There was a problem hiding this comment.
ContractHandle would solve this? maybe
There was a problem hiding this comment.
Claude answers: yes. The dedup builds handles and compares those, rather than comparing (path, name) tuples.
| [ch.source_file for ch in surviving_contracts], main_contract_name | ||
| [ch.source_file for ch in surviving_contracts], | ||
| main_contract_name, | ||
| harnessed_library=harnessed_library.contract_name if harnessed_library else None, |
There was a problem hiding this comment.
wrongly named parameter. Instead of harnessed_library, we should call it excluded_library or something like that. Or perhaps not even library, just excluded. This seems to be a generic filter; the fact that we apply it on the harness library is just a matter of the consumer of run_setup_summaries. Similarly, drop the "harness" specific naming and comments in certora_autosetup/setup/setup_summaries.py
There was a problem hiding this comment.
Claude answers: renamed to excluded_library, with _excluded_library_keys and _excluded_library_methods, and the harness-specific comments in setup_summaries.py now describe the filter instead of the caller. I kept library in the name because the filter selects curated summary keys by library name.
setup_prover keeps its harness wording at the call site, since that file calls library_behind_harness and legitimately knows what it is excluding and why.
| ctor_forward: Optional[Tuple[str, List[str]]], | ||
| body_blocks: Optional[List[str]] = None, | ||
| header_comment_lines: Optional[List[str]] = None, | ||
| extra_pragma_lines: Optional[List[str]] = None, |
There was a problem hiding this comment.
how much is this change related to this PR?
There was a problem hiding this comment.
Claude answers: half of it belongs and half did not. parent_name: Optional[str] is needed here: a library cannot be a base contract, so the harness holds it at arm's length and passes None, which without this change renders contract X is None {. extra_pragma_lines was speculative, nothing ever populated it, and it is gone from the branch.
Two constants named CVL_RESERVED_WORDS existed with different contents, so an import could pick either. The harnesser's copy also escaped the 20 usable_keywords terminals, which CVL accepts wherever an identifier is expected: a contract declaring exists, sum, old, forall and invariant typechecks, while havoc or rule does not. No generated name changes from this. `at` and `sort` are the only escapes the libraries we harness actually trigger. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four things it duplicated now live where the rest of the repo can reach them: - solidity_ast/contracts.py answers "what contracts does this source declare, and of what kind", for both the post-build .asts.json stream (setup_prover's private _ContractDeclView moves here) and the pre-build solc probe. Kinds come back as ContractKind rather than raw strings. The probe keeps raw nodes: a stopAfter parsing AST has no scope, linearizedBaseContracts or fullyImplemented, so the typed models reject it. - utils/build_json.py holds the walk over .certora_build.json, which was written five times, and the path to the file, which was hardcoded in three. setup_prover, signature_manager, compile_check and read_build all go through it. - ContractHandle replaces the (file, name) string pairs through the harnesser. The manifest keeps its flat keys, since swap reads it back in a later process, and the probe build keeps the explicit :Name that tells apart a file declaring several libraries. - The summary filter is excluded_library, not harnessed_library. Excluding a library from summarization has nothing to do with harnesses; that is just who calls it. Also drops extra_pragma_lines from render_wrapper_contract. It was plumbed end to end and never populated. Generated output is unchanged: regenerating the SafeCast harness gives a byte-identical file and the same manifest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude answers: the review is addressed, in two commits, with a reply on each thread.
Two things stayed where they are, with the reasoning on the threads: Generated output is unchanged. Regenerating a 65-wrapper harness gives a byte-identical file and the same manifest, and no wrapper name in any fixture is one of the keywords that stopped being escaped. |
|
Claude answers: also re-ran the feature end to end on the refactored code, on a library main contract. AutoSetup swaps in the generated harness, puts the library in the conf's scene once, drops the curated summary that would have replaced it ( |
|
Claude answers: checked the other libraries on the refactored code too.
ABDK cannot go through AutoSetup locally, since that repo has no build system for the detector to find. That is not new here. |
| contract reached through several units appears once per unit. Five places used to walk | ||
| that structure with their own nesting checks and their own idea of where the file | ||
| lives, which is one place to get it wrong per caller. |
There was a problem hiding this comment.
Five places used to walk
that structure with their own nesting checks and their own idea of where the file
lives, which is one place to get it wrong per caller.
drop that part
There was a problem hiding this comment.
Claude answers: dropped. Only the first paragraph is left, describing the file's shape.
| single file parsed by ``solc --standard-json`` with ``stopAfter: "parsing"``, whose | ||
| nodes carry no analysis-phase fields at all: no ``scope``, no ``linearizedBaseContracts``, | ||
| no ``fullyImplemented``, so ``SourceUnit.model_validate`` rejects them (16 errors on a | ||
| two-declaration file) and the typed traversal is not available there. Both ends produce |
There was a problem hiding this comment.
(16 errors on a two-declaration file)
seems like particular test specific output, i.e. drop
Also, this whole paragraph seems like it should not be here. Don't discuss users, just say what contracts.py introduces.
There was a problem hiding this comment.
Claude answers: rewritten. It now names what the module offers and stops there:
Contract declarations and their kind, from either shape of solc AST.
ContractDeclViewis the uniform view.iter_contract_declarationsreads it from the typed models over anAstDumpstream.parse_only_declarationsreads it from the raw nodes of astopAfter: "parsing"AST, which the typed models reject: solc emits no analysis-phase fields (scope,linearizedBaseContracts,fullyImplemented) at that stage.
I kept that last clause because it is the only reason there are two entry points instead of one. The error count is gone.
| cand_parts = strip_sources_anchor(candidate) | ||
| want_parts = strip_sources_anchor(wanted) | ||
| depth = min(len(cand_parts), len(want_parts)) | ||
| return cand_parts[-depth:] == want_parts[-depth:] |
There was a problem hiding this comment.
What if there is a nested repeated folder structure, e.g. nested .certora_sources? That also applies to strip_sources_anchor. Perhaps if we have get_sources_anchor(path) that either gives us the anchor if presented or None, then we can in same_source_file say that candidate and wanted are the same if either they are string equal or if one of them has anchor and removing the anchor prefix yields the other one?
There was a problem hiding this comment.
Claude answers: good question, and chasing it produced a better answer than the helper. The anchor strip cannot affect same_source_file at all: it removes leading components, and the comparison is on trailing ones. I ran your nested case through it, .certora_sources/vendor/.certora_sources/contracts/A.sol, along with the absolute and instrumented-copy shapes, and the result is the same with and without the strip in every one.
So the fix is subtraction rather than get_sources_anchor: same_source_file keeps only the suffix comparison, and strip_sources_anchor is gone from paths.py. There are now unit tests for those shapes in the Autosetup PR.
I also put compile_check's _strip_anchor back where it was. Merging it into paths.py was my mistake: _is_touched is directional and length-sensitive, so it only resembles same_source_file. That file keeps the one change that is real reuse, build_json_path.
On the equality-modulo-anchor shape you suggested: it would reject the case this function exists for, a build reporting one file as /abs/proj/contracts/A.sol and the other as contracts/A.sol. The suffix rule is loose in the other direction, A.sol matches contracts/A.sol, which the caller bounds by also requiring the contract name to match. Tightening it properly means normalizing both sides to project-relative where the paths are produced. Worth doing, but bigger than this PR.
# Conflicts: # certora_autosetup/setup/setup_prover.py
The two docstrings say what the module is, not how it came to be. `same_source_file` keeps only the suffix comparison: it compares trailing components, and an anchor strip removes leading ones, so the strip could never change an answer. That makes the nested `.certora_sources` question moot there. `compile_check` keeps its own `_strip_anchor` again. Its `_is_touched` is directional and length-sensitive, so it only resembles `same_source_file`; folding the two together was wrong. It still shares `build_json_path`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Why
The Prover cannot verify a
libraryas the verification target, and fails silently — there is no error anywhere in certora-cli or the JVM.CalculateMethodParamFilters.kt:182filters libraries out of parametric rules, so the run reaches the prover, instantiates zero methods, and comes back vacuous.The Prover itself prescribes the fix: "Calling Library functions from spec is not supported. Use a harness function that calls the library one if needed." This automates it.
How
New
certora_autosetup/harnesser/package. Everything completes beforeAutosetup.runstarts:Three findings shaped this, each verified against real builds:
files. An imported-but-unused library is not compiled as its own contract — the build reports its structs and none of its functions.allMethods, notinternalFunctions. The latter is autofinder instrumentation, and certora-cli skips autofinders for library-hosted functions (certoraBuild.py:2751), so it is systematically empty for libraries. Reading it yields zero wrappers for a library whose surface is all-internal.setup_prover.py:327re-seeds from the base build-system dict, so a secondrun_compilation_analysiswould discard every workaround from the first; and a swap insidesetup_provercannot propagate (it returns no handle,ContractHandleis frozen).Detection uses
solc --standard-jsonwithstopAfter: "parsing"— real AST, no build, no import resolution, milliseconds. It keys oncontractKind, never on a message, since there is no message.Codegen
Storage receivers become harness-owned state; CVL keywords are escaped before ABI-collision mangling (
at→at_, then suffixed); collision suffixes come from the dropped receiver keyed positionally (overloads share a name); storage readers reach through nested structs and mappings; in-place memory mutators get a synthesized return.Skipped and reported, not silently dropped: private, storage-pointer returns, internal-only types, opaque
bytes32 ptrhandles. Zero surviving wrappers is a hard error — a method-less target proves nothing.Coverage
Matches
hardhat-exposed's independent counts where they overlap:pushutils/andg/Validation
Full local autosetup on OZ EnumerableSet: emits a conf whose
verifyandparametric_contractsareCertoraLibraryHarness_EnumerableSet, typechecker passes, local prover run succeeds,certoraRun --compilation_steps_onlyexits 0. A/B against OpenZeppelin's hand-writtenEnumerableSetHarness.sol— the generated names now readadd/remove/contains/at_Bytes32Set/Bytes32Set_inner_indexesagainst the human'sadd/remove/contains/at_/_indexOf; the remaining differences are the two extra set types the human harness does not cover.Tests live in the Autosetup repo (29 unit tests over real trimmed build fixtures); needs a submodule pin bump there once this merges.
Known limitation
Solady's
EnumerableSetLibsets arestruct { uint256 _spacer; }with assembly-derived slots, so the owned state variable may not model where the data actually lives. Treat its 45/51 as unproven until a prover run confirms it.🤖 Generated with Claude Code