Skip to content

feat[lang]!: strengthen init checks#5103

Open
Sporarum wants to merge 29 commits into
vyperlang:masterfrom
Sporarum:enforce-init
Open

feat[lang]!: strengthen init checks#5103
Sporarum wants to merge 29 commits into
vyperlang:masterfrom
Sporarum:enforce-init

Conversation

@Sporarum

@Sporarum Sporarum commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

What I did

Close #4126
Close #3779
Close #4855

Now we check:

  • init are really called exactly once
    • now allowed:
      @deploy
      def __init__():
          if cond:
              other.__init__()
          else:
              other.__init__()
    • not allowed anymore:
      @deploy
      def __init__():
          if False:
              other.__init__()
    • allowed:
      @deploy
      def __init__():
          if cond:
              other.__init__()
          else:
             raise "oops"
  • init are called in the correct order: if A uses B, B.__init__() must be called before A.__init__()
    • not allowed anymore (if a uses b):
      @deploy
      def __init__():
          a.__init__() # tried to initialize `a`, but it depends on modules whose __init__() functions were not called beforehand: b
          b.__init__()

This allows complex and statically checked initialization logic:

@deploy
def __init__(action: String[20]):
    if action == "run":
        lib.__init__(debug = False)
    elif action == "test":
        lib.__init__(debug = True)
    else:
        raise "action not found"
    bound = <some computation>
    other_lib.__init__(bound)

How I did it

Traverse the __init__ method, keeping track of which modules get initialized, and:

  1. if only one branch initializes, raise
  2. if a dependency is not already initialized, raise

The restriction from 1. is what allows us to have a concept of "modules which have already been initialized", since branches must be similar, we don't need to reason about execution paths.

What I didn't do (aka future work)

  • Check that accesses happen after their module is initialized (lib1.foo should only be valid after lib1.__init__())
  • Make the immutable assignment check also semantics-aware, and
  • Check that local immutable accesses happen after assignments

How to verify it

Commit message

this commit reworks the init checker such that it's semantic-aware. it
enforces a strict guarantee that all execution paths call the
constructor of each 'initializes'-ed module (or explicitly reverts). it
furthermore enforces that all exectuion paths call the constructors in a
valid order, one where all dependencies are initialized before what
depends on them. this is a breaking change! implemented by traversing
the constructor of each module and checking that at each step, if
constructors are present that they are in the correct order, and that
all branches contain the same ones. at the end of the constructor, or at
a 'return', we compare the set of initialized modules to the set that
had to be, raising in case of a mismatch.

Description for the changelog

Cute Animal Picture

Put a link to a cute animal picture inside the parenthesis-->

UnfoldableNode,
)
from vyper.semantics.analysis.base import Modifiability
from vyper.semantics.analysis.base import InitializesInfo, Modifiability
UnfoldableNode,
)
from vyper.semantics.analysis.base import Modifiability
from vyper.semantics.analysis.base import InitializesInfo, Modifiability

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 597fdbe6cb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread vyper/semantics/analysis/module.py Outdated
Comment on lines +234 to +236
if isinstance(node, vy_ast.Raise):
# If we raise, return the wildcard
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Track early returns when validating initializers

When a constructor branch exits with a bare return, this walker treats it like a normal statement and continues accumulating initializer calls from later statements. For example, if cond: return; lib.__init__() is accepted as initializing lib, but the cond path deploys after skipping lib.__init__(). Since visit_Return allows value-less returns in functions without a return type and constructor codegen jumps to the deploy epilogue, Return needs to terminate the path here without acting like the reverting wildcard used for raise.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done: c8a5687

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown

Gas Changes

No changes detected.

Summary

  • Total tests measured: 815
  • Changed: 0
  • Regressions (gas up): 0
  • Improvements (gas down): 0
  • New tests: 0
  • Deleted tests: 0
  • Newly failing: 0
  • Newly passing: 0

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown

📊 Bytecode Size Changes (venom)

No changes detected.

Full bytecode sizes

Contract legacy-O2 legacy-Os -O2 -O3 -Os
curvefi/amm/stableswap/meta_implementation/meta_implementation_v_700.vy 23610 22805 20786 19755 19315
curvefi/legacy/CurveStableSwapMetaNG.vy 24952 23578 20490 19871 19175
curvefi/amm/stableswap/implementation/implementation_v_700.vy 24962 23769 20264 19393 18845
curvefi/legacy/CurveStableSwapNG.vy 24473 23298 19677 18844 18314
curvefi/amm/tricryptoswap/implementation/implementation_v_200.vy 20724 19959 18020 17518 16961
yearnfi/VaultV3.vy 19972 19063 17017 15131 14477
curvefi/amm/twocryptoswap/implementation/implementation_v_210.vy 17634 16894 15685 15132 14648
curvefi/legacy/CurveCryptoSwap2.vy 18947 18382 15634 15092 14711
yearnfi/VaultV2.vy 16676 15763 13863 13405 12574
curvefi/amm/stableswap/factory/factory_v_100.vy 14558 13978 13317 11825 11788
curvefi/gauge/child_gauge/implementation/implementation_v_110.vy 12338 11561 10270 9775 9182
curvefi/gauge/child_gauge/implementation/implementation_v_100.vy 12017 11249 9998 9502 8920
curvefi/amm/stableswap/views/views_v_120.vy 12784 12368 9970 9413 9532
curvefi/amm/tricryptoswap/math/math_v_200.vy 11189 11126 9520 8162 8424
curvefi/legacy/CurveCryptoMathOptimized3.vy 11188 11125 9519 8162 8424
curvefi/gauge/child_gauge/implementation/implementation_v_020.vy 10665 9947 9013 8565 8028
curvefi/helpers/router/router_v_110.vy 6717 6717 7003 6376 6607
curvefi/amm/tricryptoswap/views/views_v_200.vy 7821 7776 6697 6445 6514
curvefi/registries/metaregistry/metaregistry_v_110.vy 7590 6732 6651 5888 5727
curvefi/helpers/stable_swap_meta_zap/stable_swap_meta_zap_v_100.vy 7302 7067 6379 6012 6114
curvefi/amm/twocryptoswap/views/views_v_200.vy 6991 6946 6272 6026 6089
curvefi/amm/twocryptoswap/factory/factory_v_200.vy 5540 5252 5994 4697 4845
curvefi/amm/tricryptoswap/factory/factory_v_200.vy 5246 5021 5923 4926 5039
curvefi/registries/metaregistry/registry_handlers/stableswap/handler_v_110.vy 6633 6259 5859 5094 5562
curvefi/amm/twocryptoswap/math/math_v_210.vy 6800 6800 5581 5107 5112
curvefi/gauge/child_gauge/factory/factory_v_201.vy 4844 4547 4266 4014 3780
yearnfi/VaultFactory.vy 3765 3617 3985 2505 2965
curvefi/registries/metaregistry/registry_handlers/tricryptoswap/handler_v_110.vy 4241 3939 3838 3547 3547
curvefi/registries/metaregistry/registry_handlers/twocryptoswap/handler_v_110.vy 4186 3884 3777 3417 3433
curvefi/gauge/child_gauge/factory/factory_v_100.vy 4183 3914 3694 3428 3213
curvefi/helpers/rate_provider/rate_provider_v_101.vy 3260 3260 2779 2437 2450
curvefi/registries/address_provider/address_provider_v_201.vy 2973 2782 2713 2569 2424
curvefi/amm/stableswap/math/math_v_100.vy 3067 3046 2598 2403 2423
curvefi/helpers/rate_provider/rate_provider_v_100.vy 2847 2841 2357 2050 2058
curvefi/helpers/deposit_and_stake_zap/deposit_and_stake_zap_v_100.vy 2322 2316 2123 1889 1921
curvefi/governance/relayer/taiko/relayer_v_001.vy 2068 2064 1784 1579 1612
curvefi/governance/relayer/polygon_cdk/relayer_v_101.vy 1556 1523 1587 1405 1406
curvefi/governance/relayer/arb_orbit/relayer_v_101.vy 1266 1262 1280 1124 1155
curvefi/governance/relayer/op_stack/relayer_v_101.vy 1186 1182 1219 1066 1094
curvefi/governance/relayer/not_rollup/relayer_v_100.vy 1168 1153 1208 1061 1073
curvefi/governance/vault/vault_v_100.vy 964 941 885 866 864
curvefi/governance/relayer/relayer_v_100.vy 496 496 600 505 510
curvefi/governance/agent/agent_v_100.vy 541 541 443 415 419
curvefi/governance/agent/agent_v_101.vy 541 541 443 415 419

@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.41284% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.96%. Comparing base (09acf77) to head (dbda0a9).
⚠️ Report is 16 commits behind head on master.

Files with missing lines Patch % Lines
vyper/semantics/analysis/module.py 97.02% 1 Missing and 2 partials ⚠️
vyper/semantics/analysis/base.py 50.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5103      +/-   ##
==========================================
+ Coverage   92.77%   92.96%   +0.18%     
==========================================
  Files         188      188              
  Lines       27729    27911     +182     
  Branches     4802     4852      +50     
==========================================
+ Hits        25726    25947     +221     
+ Misses       1351     1312      -39     
  Partials      652      652              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Sporarum

Copy link
Copy Markdown
Collaborator Author

TODO: Handle

initializes: lib

@deploy
def __init__():
    return
    lib.__init__()

and

initializes: lib

@deploy
def __init__():
    if True:
        return
    lib.__init__()

@Sporarum

Sporarum commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Note:
"Do all code paths do the correct thing" is undecidable, so this implementation does not allow all valid constructors:

@deploy
def __init__():
    if <cond1>:
        lib.__init__()
        
    if <cond2 such that it's true iff cond1 is false>:
        lib.__init__()

Or:

@deploy
def __init__():
    for i in <seq1 such that it can never be empty>:
        lib.__init__()

But I was not able to find a case where this would actually be a problem, for example the two examples above can be rewritten in a cleaner way as follows:

@deploy
def __init__():
    if <cond1>:
        lib.__init__()
    else:
        #optional: assert <cond2>
        lib.__init__()
@deploy
def __init__():
    #optional: assert len(<seq1>) > 0
    lib.__init__()

And remember the previous condition was "exactly one call to __init__, somewhere", so it was hard to make branching conditions in the first place

Comment thread vyper/semantics/types/module.py Outdated
Comment thread vyper/semantics/analysis/local.py
Comment thread vyper/semantics/analysis/module.py Outdated
"""
Check a block for wether it calls all required __init__() methods, and in the right order.
(Dependencies must be initialized before dependents.)
Reverting acts as a wildcard with respects to initialization: when a branch contains a revert,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

clever!

Comment thread vyper/semantics/analysis/module.py Outdated
Comment thread vyper/semantics/analysis/module.py Outdated
# Return or Raise, terminates block with wildcard
return None
for module_info, calls in node_init_calls.items():
init_calls[module_info] += calls

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

iiuc this modifies the input init_calls but returns something else? that's kind of confusing.. why is init_calls passed as an argument instead of just self.init_calls? since it is being modified by callees

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It doesn't modify the input init_calls, we make a copy of it, should I name the copy something different ?

The reason we can't mutate init_calls is because of branching:

if cond:
  a.__init__()
else:
  a.__init__()

Would do the same sequence of mutations (and observe the same sequence of states) as:

if cond:
  a.__init__()
  a.__init__()
else:
  pass

Basically init_calls (the copy) = init_calls (the parameter) + local_init_calls, but mutating both is simpler than combining them every time.

The reason we return local_init_calls is to preserve the semantics of visit_:
It returns the init calls of the given expression (and not given expression + above)
This simplifies the logic of branches/loops (since otherwise we would have to check there whether the calls are from the expression, or from the preceding nodes)

Comment on lines +338 to +343
# def __init__(xs):
# for x in xs:
# if is_valid(x):
# lib.__init__(x)
# return
# raise "No valid x in xs"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It's an example for the text above

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b1ef08793

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread vyper/semantics/analysis/module.py Outdated
if then_nodes is None or else_nodes is None:
# If either branch reverts/returns, return the other
# (if both revert, will return None)
return then_nodes or else_nodes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not drop empty fall-through branches

When the else branch raises and the then branch falls through without local initializer calls, then_nodes is an empty dict and else_nodes is None, so this truthiness check returns None and makes visit_block treat the whole if as terminating. A constructor with if cond: pass else: raise "x" is then accepted for an initializes: module even though the cond path deploys without calling lib.__init__(); choose the non-None branch with explicit is None checks instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

truthiness was a mistake

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in fb3eecc

Comment on lines +390 to +391
dependent_init_calls = init_calls[dependent]
if len(dependent_init_calls) == 0:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count dependencies initialized by earlier constructors

This lookup only recognizes a direct dep.__init__() call in the current constructor, so it rejects dependencies that were initialized transitively by an already-called module constructor. For example, when owner initializes dep and user uses dep, a parent can declare initializes: user[dep := owner.dep] and run owner.__init__(); user.__init__(), but init_calls[dep] remains empty even though owner.__init__() is required to initialize dep; account for modules initialized by already-called constructors before reporting the dependency as missing.

Useful? React with 👍 / 👎.

Comment on lines +404 to +406
def visit_VyperNode(
self, node: vy_ast.VyperNode, init_calls: dict[ModuleInfo, list[vy_ast.VyperNode]]
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor raw_revert as a terminating branch

Expression statements that terminate without a Raise node, such as raw_revert(b""), fall through this generic visitor as ordinary statements because it only scans for constructor calls. With if cond: raw_revert(b"") else: lib.__init__(), the checker reports that lib.__init__() is present in only one branch even though the cond path cannot deploy; treat revert-like terminus expressions the same way as raise.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants