Skip to content

fix: convert every struct field, not just the first of a repeated type - #3065

Open
ataberk-xyz wants to merge 1 commit into
crytic:masterfrom
ataberk-xyz:fix/solidity-signature-repeated-struct-fields
Open

fix: convert every struct field, not just the first of a repeated type#3065
ataberk-xyz wants to merge 1 commit into
crytic:masterfrom
ataberk-xyz:fix/solidity-signature-repeated-struct-fields

Conversation

@ataberk-xyz

Copy link
Copy Markdown

Fixes #3064.

The bug

convert_type_for_solidity_signature uses a seen set to stop recursive struct definitions from
looping forever. That set was mutated in place and shared across the entire traversal, including
across sibling fields of the same struct — so a type used by two fields was treated as a recursive
back-edge on its second appearance and returned unconverted.

type Amount is uint256;
struct Pair { Amount a; Amount b; }
function f(Pair calldata p) external;
before   f((uint256,Amount))
after    f((uint256,uint256))     # matches solc --combined-json abi

It is not specific to user-defined value types — any sibling type that needs conversion collides:

struct fields before after
Amount a; Amount b; (UDVT) fAlias((uint256,Amount)) fAlias((uint256,uint256))
Flag a; Flag b; (enum) fEnum((uint8,Flag)) fEnum((uint8,uint8))
IThing a; IThing b; (interface) fContract((address,IThing)) fContract((address,address))

Two uint256 fields were never affected, which is why this stayed hidden: returning a type
unconverted is harmless exactly when no conversion was needed.

Because get_function_from_signature matches strictly, affected functions were unresolvable by
signature — silently, with no exception or warning.

The fix

Treat seen as the ancestors of the current descent rather than every type visited anywhere:
rebind it to a new set on entry instead of mutating the caller's. Siblings then each see only their
shared ancestors, not each other.

One subtlety worth flagging for review: the shared mutable set was also load-bearing for
termination. _convert_type_for_solidity_signature_to_string re-enters
convert_type_for_solidity_signature when it unwraps an ArrayType, and relied on entries left
behind by the first phase to stop struct St { St[] a; uint b; } from recursing forever. Scoping
seen without accounting for that reintroduces infinite recursion — I hit it while testing. So the
array branch now passes the element type down as an ancestor, which preserves the guard across the
phase boundary.

The existing test_function_id_rec_structure (the recursive-struct case) still passes. Its rendering
does change: that struct now expands one level further before the cycle is cut, since the outer St
is no longer in seen when the array branch is first reached. The existing test asserts only that a
signature is produced, and the value is not part of any ABI-meaningful signature — recursive structs
cannot appear in public/external functions, which is the only reason they are permitted at all.

Tests

Added test_solidity_signature_repeated_struct_field_types covering all three conversion kinds
(UDVT, enum, contract). It fails on master:

E  AssertionError: assert 'fAlias((uint256,Amount))' == 'fAlias((uint256,uint256))'

tests/unit/utils/ and tests/unit/core/test_function_declaration.py pass — 36 passed, with the
one unrelated test_vyper_functions failure reproducing identically on unpatched master in my
environment (local vyper version). ruff format --check and ruff check are clean.

`convert_type_for_solidity_signature` guards against recursive struct
definitions with a `seen` set, but that set was mutated in place and shared
across the whole traversal rather than tracking the current descent. A type
appearing twice as a *sibling* -- two fields of the same user defined value
type, enum, or contract -- was therefore treated as a recursive back-edge, and
every occurrence after the first was returned unconverted:

    struct Pair { Amount a; Amount b; }   // type Amount is uint256

    actual    f((uint256,Amount))
    expected  f((uint256,uint256))

Since `get_function_from_signature` matches strictly, affected functions became
unresolvable by signature, silently and with no error.

Track ancestors instead: rebind `seen` to a new set per descent so siblings do
not shadow each other. The array branch of the string conversion re-enters the
type conversion, so it now passes the element type down as an ancestor -- that
is what keeps a struct reaching itself through an array terminating.

Fixes crytic#3064

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ataberk-xyz
ataberk-xyz requested a review from smonicas as a code owner July 29, 2026 01:01
@CLAassistant

CLAassistant commented Jul 29, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

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.

solidity_signature is wrong for structs with repeated convertible field types (shared seen set)

2 participants