Skip to content

fix[codegen]: widen ternary arms to the result type layout - #5236

Merged
harkal merged 13 commits into
vyperlang:masterfrom
harkal:fix/codegen/ifexp_widening
Sep 4, 2026
Merged

harkal merged 13 commits into
vyperlang:masterfrom
harkal:fix/codegen/ifexp_widening

Conversation

@harkal

@harkal harkal commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

What I did

Fix a silent miscompile in both codegen pipelines: a ternary (x if c else y) over an aggregate returned data laid out per the arm's type but read with the widened result type. For example

@external
def f(c: bool, a: DynArray[Bytes[10], 5], b: DynArray[Bytes[512], 5]) -> DynArray[Bytes[512], 5]:
    return a if c else b

returned [b"a", b"", b""] for a = [b"a", b"0123456789", b""]. Same for nested DynArray capacity widening, static arrays of DynArrays, String elements and tuple members (via locals or internal call results). Legacy and venom codegen are both affected.

How I did it

Each arm is converted to the result layout inside its own branch, before the join. Legacy parse_IfExp copies the arm into an internal variable of the result type with make_setter; venom lower_IfExp uses its typed memory store. Both already widen element-wise.

The gate is a shared structural layout check, same_memory_layout in codegen/core.py, not type equality. Same-type arms, bytestring arms with different bounds, capacity-only widening and unbounded result types produce byte-identical IR to master on both pipelines.

How to verify it

Commit message

both codegen pipelines lower a ternary by lowering each arm to a pointer
into the arm's own memory layout and typing the result with the expected
(widened) type that semantic analysis stamps on the node. semantic
analysis only stamps the root node; the arms keep their exact types.
when an arm is narrower than the result -- e.g. `DynArray[Bytes[10], 5]`
for a `DynArray[Bytes[512], 5]` result -- every consumer walks the
narrow data with the wide element stride and reads garbage. the same
happens for nested dynarray capacity, static arrays of dynarrays, string
elements and widened tuple members reached through locals or internal
call results. the legacy pipeline (`parse_IfExp`) and the venom pipeline
(`lower_IfExp`) have the same defect.

convert each arm to the result layout inside its own branch, before the
join, so the result pointer genuinely has the result type's layout.
legacy does this with `make_setter` into an internal variable of the
result type; venom with its typed memory store. both already widen
element-wise. the gate is a shared structural layout check
(`same_memory_layout` in codegen/core.py) comparing element and member
layout of dynarrays, static arrays and tuples, rather than type
equality: same-type arms, bytestring arms with different bounds,
capacity-only widening and unbounded result types stay copy-free and
produce identical IR, and the check works for tuples even though
`TupleT.__eq__` always returns True.

Description for the changelog

Fix ternary expressions over aggregates whose arms are narrower than the result type.

Cute Animal Picture

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Gas Changes

No changes detected.

Summary

  • Total tests measured: 560
  • 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 Sep 3, 2026

Copy link
Copy Markdown

📊 Bytecode Size Changes (venom)

No changes detected.

Full bytecode sizes

Contract legacy-O2 legacy-Os -O2 -O3 -Os
curvefi/legacy/CurveStableSwapMetaNG.vy 24952 23578 19847 19107 18571
curvefi/amm/stableswap/meta_implementation/meta_implementation_v_700.vy 23610 22805 19618 18650 18337
curvefi/amm/stableswap/implementation/implementation_v_700.vy 24962 23769 19237 18382 18038
curvefi/legacy/CurveStableSwapNG.vy 24473 23298 18802 18004 17659
curvefi/amm/tricryptoswap/implementation/implementation_v_200.vy 20724 19959 17250 16689 16325
curvefi/amm/twocryptoswap/implementation/implementation_v_210.vy 17634 16894 14958 14376 14027
yearnfi/VaultV3.vy 19972 19063 14749 13828 13279
curvefi/legacy/CurveCryptoSwap2.vy 18947 18382 14619 14129 13954
yearnfi/VaultV2.vy 16676 15763 13262 12470 12053
curvefi/amm/stableswap/factory/factory_v_100.vy 14558 13978 11864 10792 10892
curvefi/gauge/child_gauge/implementation/implementation_v_110.vy 12338 11561 9840 9213 8824
curvefi/amm/stableswap/views/views_v_120.vy 12784 12368 9705 9059 9294
curvefi/gauge/child_gauge/implementation/implementation_v_100.vy 12017 11249 9573 8953 8567
curvefi/amm/tricryptoswap/math/math_v_200.vy 11189 11126 9029 8144 8170
curvefi/legacy/CurveCryptoMathOptimized3.vy 11188 11125 9028 8144 8170
curvefi/gauge/child_gauge/implementation/implementation_v_020.vy 10665 9947 8685 8129 7743
curvefi/registries/metaregistry/metaregistry_v_110.vy 7590 6732 6491 5710 5603
curvefi/helpers/router/router_v_110.vy 6717 6717 6251 5733 6035
curvefi/amm/tricryptoswap/views/views_v_200.vy 7821 7776 6114 5899 6048
curvefi/helpers/stable_swap_meta_zap/stable_swap_meta_zap_v_100.vy 7302 7067 5878 5351 5611
curvefi/amm/twocryptoswap/views/views_v_200.vy 6991 6946 5685 5484 5619
curvefi/registries/metaregistry/registry_handlers/stableswap/handler_v_110.vy 6633 6259 5533 4695 5238
curvefi/amm/twocryptoswap/math/math_v_210.vy 6800 6800 5506 5012 5039
curvefi/amm/twocryptoswap/factory/factory_v_200.vy 5540 5252 4617 3917 4047
curvefi/amm/tricryptoswap/factory/factory_v_200.vy 5246 5021 4483 3890 4020
curvefi/gauge/child_gauge/factory/factory_v_201.vy 4844 4547 3901 3675 3511
curvefi/registries/metaregistry/registry_handlers/tricryptoswap/handler_v_110.vy 4241 3939 3718 3334 3429
curvefi/registries/metaregistry/registry_handlers/twocryptoswap/handler_v_110.vy 4186 3884 3675 3255 3333
curvefi/gauge/child_gauge/factory/factory_v_100.vy 4183 3914 3408 3144 2971
yearnfi/VaultFactory.vy 3765 3617 3031 2193 2494
curvefi/registries/address_provider/address_provider_v_201.vy 2973 2782 2617 2443 2357
curvefi/helpers/rate_provider/rate_provider_v_101.vy 3260 3260 2544 2272 2305
curvefi/amm/stableswap/math/math_v_100.vy 3067 3046 2457 2252 2309
curvefi/helpers/rate_provider/rate_provider_v_100.vy 2847 2841 2313 1994 2014
curvefi/helpers/deposit_and_stake_zap/deposit_and_stake_zap_v_100.vy 2322 2316 1782 1611 1670
curvefi/governance/relayer/taiko/relayer_v_001.vy 2068 2064 1730 1509 1557
curvefi/governance/relayer/polygon_cdk/relayer_v_101.vy 1556 1523 1530 1324 1347
curvefi/governance/relayer/arb_orbit/relayer_v_101.vy 1266 1262 1242 1066 1115
curvefi/governance/relayer/op_stack/relayer_v_101.vy 1186 1182 1183 1014 1056
curvefi/governance/relayer/not_rollup/relayer_v_100.vy 1168 1153 1174 1011 1037
curvefi/governance/vault/vault_v_100.vy 964 941 862 823 839
curvefi/governance/relayer/relayer_v_100.vy 496 496 593 490 503
curvefi/governance/agent/agent_v_100.vy 541 541 430 402 406
curvefi/governance/agent/agent_v_101.vy 541 541 430 402 406

Comment thread vyper/codegen_venom/context.py Outdated
Comment thread vyper/codegen_venom/context.py Outdated
@harkal harkal changed the title fix[codegen]: ifexp widening fix[codegen]: widen ternary arms to the result type layout Sep 3, 2026
@harkal
harkal marked this pull request as ready for review September 3, 2026 11:12

@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.

Good find !

It might be possible to remove the need for a lot of added logic by setting the type in metadata in a smarter way instead, see one of the comments below

Comment thread vyper/codegen/core.py
Comment thread vyper/codegen/core.py
Comment on lines +1227 to +1251
def punnable(src_typ, dst_typ):
"""
Return True if memory laid out as `src_typ` can be read (punned) as `dst_typ`.

Compatible types can differ in element or member layout, e.g.
DynArray[Bytes[10], 5] vs DynArray[Bytes[512], 5] (element stride 64
vs 544). A wider bytestring bound or DynArray capacity at the top level
does not change the layout of the data that is present.
"""
if isinstance(dst_typ, (DArrayT, SArrayT)):
assert isinstance(src_typ, (DArrayT, SArrayT))
pairs = [(src_typ.value_type, dst_typ.value_type)]
elif isinstance(dst_typ, TupleT):
assert isinstance(src_typ, TupleT)
n = len(dst_typ.member_types)
assert len(src_typ.member_types) == n
pairs = [(src_typ.member_types[i], dst_typ.member_types[i]) for i in range(n)]
else:
# primitive words, bytestrings ([length][data]) and (nominal) structs
return True

# nested values also need equal sizes: they determine strides and offsets
return all(
s.memory_bytes_required == d.memory_bytes_required and punnable(s, d) for s, d in pairs
)

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 can be simplified by moving the length check to the beginning:

Suggested change
def punnable(src_typ, dst_typ):
"""
Return True if memory laid out as `src_typ` can be read (punned) as `dst_typ`.
Compatible types can differ in element or member layout, e.g.
DynArray[Bytes[10], 5] vs DynArray[Bytes[512], 5] (element stride 64
vs 544). A wider bytestring bound or DynArray capacity at the top level
does not change the layout of the data that is present.
"""
if isinstance(dst_typ, (DArrayT, SArrayT)):
assert isinstance(src_typ, (DArrayT, SArrayT))
pairs = [(src_typ.value_type, dst_typ.value_type)]
elif isinstance(dst_typ, TupleT):
assert isinstance(src_typ, TupleT)
n = len(dst_typ.member_types)
assert len(src_typ.member_types) == n
pairs = [(src_typ.member_types[i], dst_typ.member_types[i]) for i in range(n)]
else:
# primitive words, bytestrings ([length][data]) and (nominal) structs
return True
# nested values also need equal sizes: they determine strides and offsets
return all(
s.memory_bytes_required == d.memory_bytes_required and punnable(s, d) for s, d in pairs
)
def punnable(src_typ, dst_typ):
"""
Return True if memory laid out as `src_typ` can be read (punned) as `dst_typ`.
Compatible types can differ in element or member layout, e.g.
DynArray[Bytes[10], 5] vs DynArray[Bytes[512], 5] (element stride 64
vs 544). A wider bytestring bound or DynArray capacity at the top level
does not change the layout of the data that is present.
"""
if src_typ.memory_bytes_required == dst_typ.memory_bytes_required:
return False
if isinstance(dst_typ, (DArrayT, SArrayT)):
assert isinstance(src_typ, (DArrayT, SArrayT))
pairs = [(src_typ.value_type, dst_typ.value_type)]
elif isinstance(dst_typ, TupleT):
assert isinstance(src_typ, TupleT)
n = len(dst_typ.member_types)
assert len(src_typ.member_types) == n
pairs = [(src_typ.member_types[i], dst_typ.member_types[i]) for i in range(n)]
else:
# primitive words, bytestrings ([length][data]) and (nominal) structs
return True
# nested values also need equal sizes: they determine strides and offsets
return all(punnable(s, d) for s, d in pairs)

Which then allows us to recurse immediately:

Suggested change
def punnable(src_typ, dst_typ):
"""
Return True if memory laid out as `src_typ` can be read (punned) as `dst_typ`.
Compatible types can differ in element or member layout, e.g.
DynArray[Bytes[10], 5] vs DynArray[Bytes[512], 5] (element stride 64
vs 544). A wider bytestring bound or DynArray capacity at the top level
does not change the layout of the data that is present.
"""
if isinstance(dst_typ, (DArrayT, SArrayT)):
assert isinstance(src_typ, (DArrayT, SArrayT))
pairs = [(src_typ.value_type, dst_typ.value_type)]
elif isinstance(dst_typ, TupleT):
assert isinstance(src_typ, TupleT)
n = len(dst_typ.member_types)
assert len(src_typ.member_types) == n
pairs = [(src_typ.member_types[i], dst_typ.member_types[i]) for i in range(n)]
else:
# primitive words, bytestrings ([length][data]) and (nominal) structs
return True
# nested values also need equal sizes: they determine strides and offsets
return all(
s.memory_bytes_required == d.memory_bytes_required and punnable(s, d) for s, d in pairs
)
def punnable(src_typ, dst_typ):
"""
Return True if memory laid out as `src_typ` can be read (punned) as `dst_typ`.
Compatible types can differ in element or member layout, e.g.
DynArray[Bytes[10], 5] vs DynArray[Bytes[512], 5] (element stride 64
vs 544). A wider bytestring bound or DynArray capacity at the top level
does not change the layout of the data that is present.
"""
if src_typ.memory_bytes_required == dst_typ.memory_bytes_required:
return False
if isinstance(dst_typ, (DArrayT, SArrayT)):
assert isinstance(src_typ, (DArrayT, SArrayT))
return punnable(src_typ.value_type, dst_typ.value_type)
elif isinstance(dst_typ, TupleT):
assert isinstance(src_typ, TupleT)
assert len(src_typ.member_types) == len(dst_typ.member_types)
return all(punnable(s, d) for s,d in zip(src_typ.member_types, dst_typ.member_types))
else:
# primitive words, bytestrings ([length][data]) and (nominal) structs
return True

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.

The top-level size is deliberately not compared. Bytes[10] vs Bytes[40] and DynArray[uint256, 2] vs DynArray[uint256, 5] are punnable despite different sizes (the same-type test asserts no copy for those), and size_in_bytes panics for a DynArray[..., INF] result. Sizes only matter one level down, where they fix the stride.

Also the suggested early return is inverted (returns False on equal sizes), and the second version brings zip back, which @charles-cooper asked to remove above.

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 see, thanks

Comment thread tests/functional/codegen/features/test_ternary_widening.py
Comment thread tests/functional/codegen/features/test_ternary_widening.py Outdated

@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: 22ada0e45d

ℹ️ 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/codegen_venom/expr.py
Comment thread tests/functional/codegen/features/test_ternary_widening.py Outdated
@harkal
harkal enabled auto-merge (squash) September 4, 2026 07:18
@harkal
harkal disabled auto-merge September 4, 2026 07:18
@harkal
harkal enabled auto-merge (squash) September 4, 2026 07:18
@harkal
harkal merged commit 1fb7dfe into vyperlang:master Sep 4, 2026
171 checks passed
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.

4 participants