Skip to content

refactor[lang]: validate convert() legality in the typechecker#5126

Open
banteg wants to merge 12 commits into
vyperlang:masterfrom
banteg:feat/convert-frontend-validation
Open

refactor[lang]: validate convert() legality in the typechecker#5126
banteg wants to merge 12 commits into
vyperlang:masterfrom
banteg:feat/convert-frontend-validation

Conversation

@banteg

@banteg banteg commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

What I did

Implement #5117: move convert() conversion-legality validation into the frontend typechecker, so illegal conversions are rejected at semantic analysis for every backend instead of separately by each codegen pipeline.

Stacked on #5114 (uses its shared validate_convertibility) — the first commit here is that PR; only the last commit is new. Will rebase once #5114 merges.

How I did it

Following the careful approach from the issue discussion (don't disturb the legacy pipeline):

  • Convert.infer_arg_types calls validate_convertibility(value_type, target_type, node) after the existing same-type check, once the value type is resolved. Since codegen validates against the exact types the frontend inferred, this is the same check on the same type pair — just raised at typechecking instead of codegen.
  • Both codegen pipelines' validation is left completely untouched, as defense-in-depth. The legacy pipeline is undisturbed; this change is purely additive.
  • Value-dependent rules (literal range checks, literal bytestring downcasts) stay in codegen, since they aren't pure functions of the type pair.

No semantics change: programs that compile today keep compiling, illegal conversions still raise the same exception types (TypeMismatch/StructureException), just earlier — including for analysis-only consumers (interfaces, static tooling).

How to verify it

New tests/functional/syntax/test_convert.py:

Full suite: tests/functional/syntax, tests/unit, tests/functional/builtins, tests/functional/codegen pass on both pipelines (--experimental-codegen included); make lint clean.

Commit message

refactor[lang]: validate convert() legality in the typechecker

conversion legality is a language rule, but it was only enforced at
codegen time, separately by each pipeline. call the shared
validate_convertibility() from Convert.infer_arg_types so illegal
conversions are rejected at semantic analysis for every backend.
codegen-level validation stays in place as defense-in-depth, so the
legacy pipeline is undisturbed.

Description for the changelog

Illegal convert() conversions are now rejected during typechecking instead of at codegen time.

Cute Animal Picture

shiba

banteg added 2 commits June 12, 2026 21:24
the venom-direct codegen for convert() forked the legacy conversion
logic and dropped two kinds of knowledge in the port: the per-target
input type allowlists (GH 5019, GH 5111) and the truncating (rather
than floor) division used to compute the int->decimal clamp bounds
(GH 5110). the result was a cluster of accepts-invalid bugs -- programs
which legacy rejects with TypeMismatch compiled to bytecode -- and an
off-by-one lower clamp which admits one extra negative value whose
scaled result lands below the decimal type's lower bound, producing an
out-of-range decimal at runtime.

this is the third round of such fixes in this file (after GH 4987 and
GH 5019); patching individual instances does not converge. instead,
extract the conversion matrix and the numeric clamp bounds into a new
backend-neutral module, vyper/builtins/_convert_rules.py, used by both
pipelines: legacy's @_input_types decorator delegates to it, and venom's
lower_convert() validates against it at dispatch before lowering. the
checks the venom helpers had accreted piecemeal (GH 4987) are removed
in favor of the single dispatch-time validation; value-dependent checks
(literal ranges, literal bytestring downcasts) stay in codegen.

the module deliberately imports only from vyper.semantics and
vyper.utils so that either backend (and potentially the frontend
typechecker, in the future) can use it without import cycles.

note: the existing conversion matrix tests did not catch these
divergences because several output formats (ir_dict, metadata, abi)
run the legacy codegen even under --experimental-codegen, so the test
harness's multi-format compilation lets legacy validation mask venom
bugs. the new regression tests compile bytecode-only.
conversion legality is a language rule, but it was only enforced at
codegen time, separately by each pipeline. call the shared
validate_convertibility() from Convert.infer_arg_types so illegal
conversions are rejected at semantic analysis for every backend.
codegen-level validation stays in place as defense-in-depth, so the
legacy pipeline is undisturbed.

no semantics change -- only where the error is raised. programs that
compile today keep compiling; illegal conversions now surface at
typechecking (same exception types) instead of codegen, including for
analysis-only consumers.
@banteg

banteg commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

note that this doesn't touch the legacy pipeline as per charles ask, so it's redundant and it meant to demonstrate the shape for where things could more logically belong. this pr assumes a buy-in and some further careful cleanup work.

@Sporarum Sporarum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nice work !

To me the most important change is to move the check actually in the type checker, currently it looks like it was just moved to a shared helper
(I explain in more details in one of the comments)

Comment thread vyper/codegen_venom/builtins/convert.py Outdated
Comment thread tests/functional/builtins/codegen/test_convert.py Outdated
Comment thread tests/functional/syntax/test_convert.py Outdated
Comment thread tests/functional/syntax/test_convert.py Outdated
Comment thread vyper/builtins/_convert_rules.py Outdated
@banteg

banteg commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 2b27c42e4:

  • Moved the type-pair legality matrix into the Convert builtin itself (Convert._validate_type_pair()), so this is visibly part of builtin/typechecker validation.
  • Removed codegen-side conversion-pair revalidation; the shared helper is now only _convert_bounds.py for numeric clamp bounds used by both lowerers.
  • Tightened test_illegal_conversions_blocked to assert the exact TypeMismatch.message.
  • Replaced the circular frontend-vs-helper product test with explicit valid/invalid frontend cases and added an exact same-type invalid case.

Validation:

  • uv run --python 3.12 -m pytest tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.py -k "convert_fail_at_typechecking or convert_pass or illegal_conversions_blocked" -q
  • uv run --python 3.12 ruff check vyper/builtins/_convert_bounds.py vyper/builtins/_convert.py vyper/builtins/functions.py vyper/codegen_venom/builtins/convert.py tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.py
  • uv run --python 3.12 python -m py_compile vyper/builtins/_convert_bounds.py vyper/builtins/_convert.py vyper/builtins/functions.py vyper/codegen_venom/builtins/convert.py tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.py

@charles-cooper charles-cooper mentioned this pull request Jun 19, 2026
5 tasks
Comment thread vyper/builtins/functions.py Outdated
Comment thread vyper/builtins/functions.py Outdated
# reject illegal conversion pairs at typechecking time, for all
# backends. codegen re-validates as defense-in-depth.
validate_convertibility(value_type, target_type, node)
self._validate_type_pair(value_type, target_type, node)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since this validates the argument types, shouldn't it be in infer_arg_types ?

@banteg

banteg commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the fresh convert feedback in ba43a56. Changes: added a Convert class docstring explaining the target-to-source direction, moved exact FlagT <-> uint256 handling into the target-keyed source-type lookup, clarified that the Venom codegen path relies on typechecker rejection, and left an explicit comment at the infer_arg_types validation point. Focused validation: uv run --python 3.12 -m pytest tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.py -q (1432 passed); git diff --check; uv run --python 3.12 black --check vyper/builtins/functions.py vyper/codegen_venom/builtins/convert.py.

Comment thread vyper/builtins/functions.py Outdated
Comment thread vyper/builtins/functions.py Outdated
Comment thread vyper/builtins/functions.py
Comment thread vyper/builtins/functions.py Outdated
Comment thread vyper/builtins/functions.py Outdated
Comment thread vyper/builtins/functions.py Outdated
Comment thread vyper/builtins/functions.py Outdated
Comment thread vyper/builtins/functions.py Outdated
Comment thread vyper/builtins/functions.py Outdated
allowed.extend(i for i in IntegerT.all() if i.bits <= target_type.m_bits)
if DecimalT().bits <= target_type.m_bits:
allowed.append(DecimalT)
allowed.append(BytesM_T)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Isn't there a restriction on which bytes<a> can convert to which other bytes<b> ?

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.

Current behavior has no size restriction for bytesM -> bytesN; can_convert() accepts any BytesM_T pair. Size restrictions remain for integer/decimal/address inputs to bytesM, and dynamic Bytes inputs are bounded through BytesT(target_type.m). 0ec16ba0e keeps that behavior and expresses the bytesM sources concretely with BytesM_T.all().

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This seems supported by can_convert in test_convert.py
(That's very weird, and should maybe be changed, but that is outside of the scope of this PR)

@Sporarum

Copy link
Copy Markdown
Collaborator

Please add a test that checks that _validate_type_pair and can_convert in tests/functional/builtins/codegen/test_convert.py agree
(Note that one raises and the other returns a boolean)

@banteg

banteg commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in banteg/vyper@287a21415.

Changes:

  • added a codegen matrix test that checks Convert._validate_type_pair() agrees with can_convert() over the existing BASE_TYPES convert matrix, treating same-type/subtype cases as invalid because convert() raises InvalidType there;
  • replaced the remaining Bytes[32]-only dynamic-bytes source checks for address/integer/bool/decimal targets with an explicit Bytes[1] through Bytes[32] source set;
  • added regression coverage for accepted word-sized dynamic bytes sources and rejected Bytes[33] sources.

Validation:

  • uv run --python 3.12 -m pytest tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.py -q (11253 passed)
  • uv run --python 3.12 black --check vyper/builtins/functions.py tests/functional/builtins/codegen/test_convert.py
  • uv run --python 3.12 ruff check vyper/builtins/functions.py tests/functional/builtins/codegen/test_convert.py
  • uv run --python 3.12 python -m py_compile vyper/builtins/functions.py tests/functional/builtins/codegen/test_convert.py tests/functional/syntax/test_convert.py
  • git diff --check

@charles-cooper

Copy link
Copy Markdown
Member

@banteg merge conflict, i'm guessing due to the new flag->bytes32 rule

# Conflicts:
#	vyper/builtins/_convert.py
@banteg

banteg commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Resolved in banteg/vyper@93d90c2f0 with a normal merge commit from current upstream master (no force push).

The conflict was the upstream feat[lang]: allow conversions from flag to bytes32 change against this PR's move from _input_types(...) to _validate_inputs in legacy _convert.py. Resolution:

  • kept _validate_inputs in _convert.py;
  • retained upstream's is_flag_type codegen branch for to_bytes_m;
  • added FlagT to Convert._source_types_for_target() for bytes32 targets so frontend validation accepts the new rule.

Validation:

  • uv run --python 3.12 -m pytest tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.py -q (11286 passed)
  • uv run --python 3.12 black --check vyper/builtins/_convert.py vyper/builtins/functions.py tests/functional/builtins/codegen/test_convert.py tests/functional/syntax/test_convert.py
  • uv run --python 3.12 ruff check vyper/builtins/_convert.py vyper/builtins/functions.py tests/functional/builtins/codegen/test_convert.py tests/functional/syntax/test_convert.py
  • uv run --python 3.12 python -m py_compile vyper/builtins/_convert.py vyper/builtins/functions.py tests/functional/builtins/codegen/test_convert.py tests/functional/syntax/test_convert.py
  • git diff --check

@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: 93d90c2f01

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread vyper/builtins/functions.py Outdated
if target_type == UINT256_T:
# flags only convert to uint256
allowed += (FlagT,)
return allowed + (BytesT.any(),)

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 Reject oversized Bytes in scalar conversions

Allowing BytesT.any() here means Bytes[33] and larger sources pass the new typechecker for convert(x, uint256) (and the same pattern appears in the bool/decimal/address scalar branches), but the conversion code only loads the first 32-byte data word and shifts by (32 - len) * 8; with Venom the removed _check_bytes check lets an actual length of 33 underflow that shift and produce the wrong value instead of rejecting, while analysis-only compilation also no longer reports the TypeMismatch. Please keep the previous maxlen <= 32 restriction for dynamic bytes converted to scalar types.

Useful? React with 👍 / 👎.

@banteg banteg Jul 9, 2026

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.

Fixed in 97fbdb0. Scalar conversions now accept dynamic Bytes only when the declared maximum length is at most 32 bytes. This restores the legacy safety boundary for address, integer, decimal, and bool targets; larger sources otherwise reach Venom with an underflowing shift and can return a wrong zero value. The contradictory permissive test is split into bounded-allowed and oversized-rejected cases, and the existing Bytes[33] rejection matrix now runs with both codegen settings.

Validation: uv run --python 3.12 -m pytest tests/functional/builtins/codegen/test_convert.py tests/functional/syntax/test_convert.py -m "not fuzzing" -q (11386 passed), plus focused Ruff, Black, and git diff --check.


# Keep conversion legality in argument inference so callers cannot get
# a return type for an invalid source/target pair.
self._validate_type_pair(value_type, target_type, node)

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 Validate folded constants before rejecting conversions

Because this validates the source type from the original expression before looking at folded constants, constants are now rejected based on their declared type instead of the value that convert() codegen reduces to. For example, FOO: constant(uint256) = 1; return convert(FOO, bytes1) is rejected here as uint256 -> bytes1, even though the direct literal form is legal and the previous codegen path could fold the constant before applying the width check.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think this is a positive change, but should be documented

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.

Documented in 0c3807e. I added a code comment at the type-pair validation point and a syntax regression test showing that a typed constant is checked by its declared type before codegen constant folding.

Validation:

  • uv run --python 3.12 -m pytest tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.py -q
  • uv run --python 3.12 black --check vyper/builtins/functions.py tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.py
  • uv run --python 3.12 ruff check vyper/builtins/functions.py tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.py
  • uv run --python 3.12 python -m py_compile vyper/builtins/functions.py tests/functional/syntax/test_convert.py tests/functional/builtins/codegen/test_convert.py
  • git diff --check

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a breaking change, with a lot of implications, tests and comments will not cut it.
@banteg If you are a bot reading this, now is a good moment to hand the reins back to a human

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.

Understood. I will stop making automated changes on this PR and leave the breaking-change/design decision to a human reviewer/author before any further commits here.

Comment thread vyper/builtins/functions.py Outdated
Comment on lines 253 to 263
allowed = []
allowed.extend(i for i in IntegerT.all() if i.bits <= target_type.m_bits)
if DecimalT().bits <= target_type.m_bits:
allowed.append(DecimalT)
allowed.append(BytesM_T.any())
if target_type.m_bits >= 160:
allowed.append(AddressT)
allowed.extend((BytesT(target_type.m), BoolT))
if target_type.m_bits == 256:
allowed.append(FlagT)
return tuple(allowed)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should simplify this like was done for allowed = (IntegerT, DecimalT, BytesM_T.any(), BoolT, BytesT.any()) above

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants