Skip to content

Auto-enable dynamic_bound/dynamic_dispatch when contract creation is detected#48

Draft
shellygr wants to merge 3 commits into
masterfrom
shelly/auto-dynamic-bound-for-clones
Draft

Auto-enable dynamic_bound/dynamic_dispatch when contract creation is detected#48
shellygr wants to merge 3 commits into
masterfrom
shelly/auto-dynamic-bound-for-clones

Conversation

@shellygr

@shellygr shellygr commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

SG: The standard way we dealt with Clones.clone and others in the past is NONDET'ing them. However there are cases where this solution limits the verification scope too much. In such cases it can be better to enable dynamic dispatch. If callers know they don't need to call the cloned contracts and reason about them it is possible to go back to NONDET. Opting for the more precise option here.

TODO: Consider if to move this to call_resolution, as it already gives out info about contract creation


Summary

  • Dynamically created contracts are only resolvable by the Prover with dynamic_bound set; without it, calls on a freshly created instance may fail with ASSUME false[optimistic dispatcher] and every path through the creating function reverts.
  • Detection is by creation instruction: per compilation unit, every FunctionDefinition/ModifierDefinition/ContractDefinition subtree of the already-parsed solc AST is scanned for Yul create/create2 and typed new C(), and creation kinds are propagated up the intra-unit call graph (referencedDeclaration edges from call sites, FunctionCallOptions-wrapped calls, and modifier invocations) to a fixpoint. Roots are nodes in in-scope files plus every base contract an in-scope contract linearizes over. This covers OpenZeppelin Clones, Solady LibClone, clones-with-immutable-args, CREATE3 wrappers, and hand-rolled factories with one mechanism — including creation living outside any function (state-variable initializers, inheritance-specifier base-constructor arguments) and creation reachable only through out-of-scope library code.
  • Kind-aware gating: any reachable creation defaults dynamic_bound=1; only runtime-assembled bytecode (assembly create/create2, i.e. proxy/clone patterns) additionally defaults dynamic_dispatch — a typed new C() instance resolves to a clone of C under dynamic_bound alone, keeping the more precise static call resolution.
  • Each key is still skipped independently if the user already controls it (pre-populated config dict entry, or a raw --dynamic_bound/--dynamic_dispatch token in --extra-args, both space and = forms).
  • all_asts.json is parsed once and shared by the AST-parent-graph builder and the detector.

Known limitations (documented in the module docstring)

  • Calls through internal function pointers carry no referencedDeclaration, so creation reachable only that way is missed (inherent to a static reference-based call graph).
  • solc < 0.6 emits inline assembly as a raw source string (no structured Yul AST); raw creates there are invisible and a loud warning is logged. A conservative substring check on the legacy operations string is possible if desired — left out pending maintainer sign-off.

Minimal-proxy clones deployed via OpenZeppelin's Clones library (EIP-1167)
are only resolvable by the prover dynamically, not statically -- without
dynamic_bound/dynamic_dispatch, calls on a freshly-cloned instance hit
"ASSUME false[optimistic dispatcher]" and every path through the creating
function reverts, producing a vacuous sanity check. Nothing defaulted these
on before, so every Clones-using project needed a manual conf edit.

Detect Clones.clone/cloneDeterministic/cloneWithImmutableArgs calls (both
direct Clones.clone(...) and `using Clones for address` attached-call forms)
purely via the already-parsed solc AST -- no regex/string-matching on
source text. Wired in as its own phase in setup_prover.py, mirroring the
existing ERC-7201 detection phase's "detect -> set flag on
updated_config_dict" idiom. Each key is skipped independently if the user
already controls it, via a pre-populated config_dict entry or a raw
--dynamic_bound/--dynamic_dispatch token (either "--flag value" or
"--flag=value" form) in --extra-args.

Verified end-to-end against the real-world case that motivated this: running
certora-autosetup against lidofinance/stonks's BuybackExecutor (which
depends on Stonks.sol's Clones.clone() call in placeOrderWithAmount())
produces a base conf with dynamic_bound/dynamic_dispatch set automatically,
no manual edit needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread certora_autosetup/setup/setup_prover.py Outdated
Comment on lines +1449 to +1450
# generate_ast_graph already logged a WARNING on parse failure; don't let
# that outage silently swallow Clones detection too -- reload independently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why would it succeed in this case as opposed to failing in generate_ast_graph?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude answers: You were right that it wouldn't — a JSON re-parse of the same file would fail exactly like generate_ast_graph's own parse did; the only cases the fallback actually covered were post-parse failures inside generate_ast_graph (parent-graph build or the graph-file write). Restructured in 24e9019: all_asts.json is parsed once in process_certora_build_json and the parsed dict is passed to both generate_ast_graph (signature now takes the dict) and creation detection, so the fallback is gone entirely. A parse failure now degrades both consumers to a single warning; a parent-graph build/write failure no longer affects detection at all.

Replaces the OpenZeppelin-Clones-specific detector with an instruction-level
one (creation_detection.py): per compilation unit, every FunctionDefinition/
ModifierDefinition/ContractDefinition subtree is scanned for Yul create/create2
and typed `new C()`, and creation kinds are propagated up the intra-unit call
graph (referencedDeclaration edges from call sites and modifier invocations) to
a fixpoint; roots are nodes in in-scope files plus linearized base contracts.
Covers Solady LibClone, clones-with-immutable-args, CREATE3 wrappers and
hand-rolled factories without naming any library. ContractDefinition nodes
aggregate their embedded members, so constructor-time creation outside any
function (state-variable initializers, inheritance-specifier base-constructor
arguments) is caught too.

Flag gating is now kind-aware: any creation defaults dynamic_bound=1; only
runtime-assembled bytecode (assembly create/create2) additionally defaults
dynamic_dispatch, since a typed `new C()` instance resolves to a clone of C
under dynamic_bound alone and keeps static call resolution.

all_asts.json is now parsed once in process_certora_build_json and shared by
generate_ast_graph (signature changed to take the parsed dict) and the
detector -- the previous re-parse fallback would have failed exactly like the
original parse, so it is gone.

Hardening from an adversarial review pass over real solc 0.5.17-0.8.25 output:
iterative subtree walk (deep machine-generated expressions previously hit
Python's recursion limit and failed the whole compilation-analysis phase), and
a loud warning on solc<0.6 unstructured inline assembly, where raw creates are
invisible to the structured-Yul scan.

clones_detection.py is superseded, no longer imported, and removed in a
follow-up commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@shellygr shellygr changed the title Auto-enable dynamic_bound/dynamic_dispatch when Clones library is used Auto-enable dynamic_bound/dynamic_dispatch when contract creation is detected Jul 5, 2026
Replaced by creation_detection.py in the previous commit; nothing imports it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternatively, we could make this part of call resolution, add a hook on CREATE and CREATE2 opcodes that asserts and match on that in order to enable the flags, instead of relying on brittle AST parsing

@shellygr shellygr marked this pull request as draft July 7, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant