Skip to content

Better safeguard against incompatible builtin handles between dialects #15961

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from

Conversation

clonker
Copy link
Member

@clonker clonker commented Mar 20, 2025

Inspired from the remarks by @cameel in #15952 (comment), I have refactored EVMDialect a bit so that there is a class for generating the builtin vector that will automatically insert blanks if conditions aren't met and not give direct access to the underlying raw datastructure.
Also, I have added a unit test which takes the default dialect (without EOF) as well as the latest dialect with EOF and tests whether the populated builtin functions with their handles contained in the respective dialects are preserved when compared to all other dialects with and without object access.

Copy link
Member

@cameel cameel left a comment

Choose a reason for hiding this comment

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

Looks like a step in a good direction, but if we're refactoring these builtins, there's a way to do it in a way that will more completely eliminate the possibility of introducing these kinds of mistakes. We should simply separate they declarations with the logic that defines them. See comments below for details.

Comment on lines 313 to 316
if (!_eofVersion.has_value())
{
builtins.emplace_back(createIfObjectAccess("datasize", 1, 1, SideEffects{}, ControlFlowSideEffects{}, {LiteralKind::String}, [](
builtins.addIfObjectAccess(_objectAccess, "datasize", 1, 1, SideEffects{}, ControlFlowSideEffects{}, {LiteralKind::String}, [](
FunctionCall const& _call,
Copy link
Member

@cameel cameel Mar 20, 2025

Choose a reason for hiding this comment

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

The fact that now some conditions are handled here and some in Builtins still bothers me a bit. I think it would be best if we made the whole thing more declarative. I.e. have one place that declares the builtins along with their scopes (i.e. EVM/EOF version range and state of object access). Then have another place that loops over all builtins that decides whether to actually define them based on their scopes.

The definition loop would then be the single place that decides whether the opcode should be skipped and whether to leave an empty spot in m_functions.

This would be a more comprehensive solution for the problem, because we would no longer touch that definition loop when adding new opcodes. We would only touch declarations and scopes without the risk of accidentally breaking the logic.

Copy link
Member

Choose a reason for hiding this comment

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

BTW, not sure "scope" is the best way to call it, but I could not think of anything better so far. Could be just "conditions", but that sounds very non-specific.

Copy link
Member Author

Choose a reason for hiding this comment

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

After thinking about it for a while, I think I do like Scope!

Comment on lines 217 to 232
!(_opcode >= evmasm::Instruction::DUP1 && _opcode <= evmasm::Instruction::DUP16) &&
!(_opcode >= evmasm::Instruction::SWAP1 && _opcode <= evmasm::Instruction::SWAP16) &&
!isPushInstruction(_opcode) &&
_opcode != evmasm::Instruction::JUMP &&
_opcode != evmasm::Instruction::JUMPI &&
_opcode != evmasm::Instruction::JUMPDEST &&
_opcode != evmasm::Instruction::DATALOADN &&
_opcode != evmasm::Instruction::EOFCREATE &&
_opcode != evmasm::Instruction::RETURNCONTRACT &&
_opcode != evmasm::Instruction::RJUMP &&
_opcode != evmasm::Instruction::RJUMPI &&
_opcode != evmasm::Instruction::CALLF &&
_opcode != evmasm::Instruction::JUMPF &&
_opcode != evmasm::Instruction::DUPN &&
_opcode != evmasm::Instruction::SWAPN &&
_opcode != evmasm::Instruction::RETF &&
Copy link
Member

Choose a reason for hiding this comment

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

For builtins based on opcodes, this part of the condition is the declarative part. I'd move it to a separate function that returns a boolean. Then when adding a new opcode we'd only really modify that function.

In fact, I'd actually create more than one function. I think we should make it more semantic by having smaller functions expressing specific properties the excluded opcodes satisfy:

  • All the DUP/SWAP/PUSH variants: low-level stack manipulation opcodes.
    • We should also use SemanticInformation::isSwapInstruction()/SemanticInformation::isDupInstruction() here.
  • All the JUMP variants and RETF/CALLF: low-level control flow opcodes
  • DATALOADN, EOFCREATE, RETURNCONTRACT: these are actually exposed, but not directly. We have builtins replacing them. We can define a separate function for them, but a nicer alternative would be to be able to declare for a builtin that it replaces an instruction and have the definition logic exclude that instruction automatically.

Executing these functions and EVMVersion::hasOpcode() would then be a fixed part of the definition logic and would not have to be adjusted. It would be the equivalent of checking the scope of a builtin.

Copy link
Member Author

Choose a reason for hiding this comment

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

I have separated out the builtin function definitions into its own class which then can serve as reference database for all EVM dialects. They can iterate over it and declare that they have a specific function by simply referring to it.
That should also reduce memory pressure a bit, as there's only ever one copy of a builtin function (ignoring these no-output builtins).
So in principle the dialect is then simply a bool field (or implicitly convertible to one at least) over all possible builtins.


/// Make sure to only add builtins in a way that is consistent over EVM versions. If the order depends on the
/// EVM version - which can easily happen using conditionals -, different dialects' builtin handles
/// become inherently incompatible.
Copy link
Member

Choose a reason for hiding this comment

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

Thinking about it, the compatibility between dialects with and without object access should be more prominently documented in the header. I'd mention it for strictAssemblyForEVMObjects() and maybe also for the whole EVMDialect class.

Copy link
Member

@cameel cameel Mar 20, 2025

Choose a reason for hiding this comment

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

TBH we guarantee so much compatibility between them that I'm not sure we should even be calling these distinct dialects. Move like flavors of the same dialect. It seems like an orthogonal property that could apply to any dialect.

Comment on lines 54 to 56
builtin_function_handle_compatibility_eof,
bdata::monomorphic::grid(
bdata::make(generateEVMDialectConfigurationsToTest(std::nullopt)) + bdata::make(generateEVMDialectConfigurationsToTest(1)),
Copy link
Member

Choose a reason for hiding this comment

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

This seems to actually be testing both EOF and non-EOF despite the name.

Copy link
Member

Choose a reason for hiding this comment

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

And a much more relevant difference is that it's using the latest rather than the default EVM version. In not so distant future both current and latest will have EOF, but the fact that current is not always the latest will remain. If you replace withEOF check with withEOF && evmVersion.supportsEOF() it will be more future-proof. You can then unify the tests and make the source EVM version just another dimension.

Copy link
Member

Choose a reason for hiding this comment

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

Also, I think we should have test case where we cover the "inline" -> "with objects" conversion for all dialects. Converting between distinct dialects is nice to have but these pairs are the most relevant, because for them we actually do perform such a conversion in practice.

@clonker clonker force-pushed the safeguard_builtin_handles branch from 106775f to 3e3258d Compare April 4, 2025 05:27
Copy link

This pull request is stale because it has been open for 14 days with no activity.
It will be closed in 7 days unless the stale label is removed.

@github-actions github-actions bot added the stale The issue/PR was marked as stale because it has been open for too long. label Apr 18, 2025
@cameel cameel removed the stale The issue/PR was marked as stale because it has been open for too long. label Apr 18, 2025
@clonker clonker force-pushed the safeguard_builtin_handles branch 19 times, most recently from 5128ef7 to 3ed3248 Compare April 29, 2025 08:26
@clonker clonker requested a review from cameel April 29, 2025 08:38
@clonker clonker force-pushed the safeguard_builtin_handles branch from 3ed3248 to 8a7d619 Compare April 29, 2025 08:47
@clonker clonker force-pushed the safeguard_builtin_handles branch 3 times, most recently from 5784211 to b19cde2 Compare May 9, 2025 12:47
Copy link
Member

@cameel cameel left a comment

Choose a reason for hiding this comment

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

Looks good overall, I see no big issues, but there a few minor things that could be improved or cleaned up.

Especially the instruction and replaced scopes seem redundant to me.

m_functions.emplace_back(objectAccess | requiresEOF, eofcreateBuiltin());
m_functions.emplace_back(objectAccess | requiresEOF, returncontractBuiltin());

using namespace std::string_literals;
Copy link
Member

Choose a reason for hiding this comment

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

Let's keep these at the top of the file.

Copy link
Member Author

Choose a reason for hiding this comment

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

It seems better to me if using namespaces are as local as possible. Less potential for conflicting definitions. I have rewritten it to just not use the string literal so your comment is resolved but still. This is a general pet-peeve of mine with the style we use in implementation units. I'd prefer if we just explicitly declare namespace(hierarchies) and the implementation lived in them.

Comment on lines +327 to +330
opcode == evmasm::Instruction::SWAPN ||
opcode == evmasm::Instruction::DUPN ||
evmasm::SemanticInformation::isSwapInstruction(opcode) ||
evmasm::SemanticInformation::isDupInstruction(opcode)
Copy link
Member

Choose a reason for hiding this comment

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

These functions already check for SWAPN/DUPN.

Suggested change
opcode == evmasm::Instruction::SWAPN ||
opcode == evmasm::Instruction::DUPN ||
evmasm::SemanticInformation::isSwapInstruction(opcode) ||
evmasm::SemanticInformation::isDupInstruction(opcode)
SemanticInformation::isSwapInstruction(opcode) ||
SemanticInformation::isDupInstruction(opcode)

Copy link
Member

Choose a reason for hiding this comment

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

Same in isLowLevelStackManipulationInstruction().

Copy link
Member

Choose a reason for hiding this comment

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

Also, shouldn't you be skipping PUSH and isLowLevelControlFlowInstruction() here as well? We don't expose them in any dialect.

Copy link
Member Author

Choose a reason for hiding this comment

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

These functions already check for SWAPN/DUPN.
Same in isLowLevelStackManipulationInstruction().

I purposefully did it that way because of how AssemblyItem is designed. We have the opcode here (type evmasm::Instruction) which is implicitly converted to evmasm::AssemblyItem. We added asserts to not create assembly items for SWAPN/DUPN with the implicit conversion constructor but to always use the static helper methods. This makes it necessary to test for them individually and beforehand. It is quite unfortunate.

Also, shouldn't you be skipping PUSH and isLowLevelControlFlowInstruction() here as well? We don't expose them in any dialect.

Not if we want the builtins thing to be a field over all possible (except for swap/dup) functions - which was my intention at least.

Comment on lines +109 to +115
static Scopes constexpr instruction{1 << instructionBit};
static Scopes constexpr replaced{1 << replacedInstructionBit};
static Scopes constexpr objectAccess{1 << objectAccessBit};
static Scopes constexpr requiresEOF{1 << requiresEOFBit};
static Scopes constexpr requiresNonEOF{1 << requiresNonEOFBit};
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
static Scopes constexpr instruction{1 << instructionBit};
static Scopes constexpr replaced{1 << replacedInstructionBit};
static Scopes constexpr objectAccess{1 << objectAccessBit};
static Scopes constexpr requiresEOF{1 << requiresEOFBit};
static Scopes constexpr requiresNonEOF{1 << requiresNonEOFBit};
static Scopes constexpr instructionScope{1 << instructionBit};
static Scopes constexpr nonInstructionScope{1 << replacedInstructionBit};
static Scopes constexpr objectAccessScope{1 << objectAccessBit};
static Scopes constexpr eofScope{1 << requiresEOFBit};
static Scopes constexpr nonEOFScope{1 << requiresNonEOFBit};

Alternatively, might be enough to place them inside Scopes without the suffix so that you can refer to them as e.g. Scope::eof, but that would conflict with functions you already have there.

Copy link
Member Author

Choose a reason for hiding this comment

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

I prefer it without the Scope suffix tbh. The type already tells the rest of the story.

Comment on lines +214 to +215
builtinShouldBeAdded &= !scopes.requiresEOF() || _eofVersion.has_value();
builtinShouldBeAdded &= !scopes.requiresNonEOF() || !_eofVersion.has_value();
Copy link
Member

Choose a reason for hiding this comment

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

Please assert that both flags are not set at the same time.

Same for instruction and replaced.

Copy link
Member Author

@clonker clonker May 22, 2025

Choose a reason for hiding this comment

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

Agree for the EOF ones, added an assert for that. Not sure why instruction and replaced should be mutually exclusive though. One means that it is the builtin belonging to an instruction, replaced means it was replaced by something else and shouldn't be added to a dialect (in favor of whatever it was replaced with).

Copy link
Member Author

@clonker clonker May 22, 2025

Choose a reason for hiding this comment

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

Ah you mean that replaced should only be set if instruction is set? I'm not sure if that isn't too restrictive. Happy to add it if you insist but I don't find it particularly necessary.

Comment on lines +73 to +77
/// whether the corresponding evm builtin function is an instruction builtin
bool instruction() const { return value.test(instructionBit); }
/// whether the corresponding evm builtin has been replaced by another builtin, ie, should be skipped
bool replaced() const { return value.test(replacedInstructionBit); }
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure we need these two. As long as an instruction matches the EVM version and EOF, we always add it.

And when we replace an instruction we do not use the original in any dialect. We can just replace it permanently in EVMBuiltins and not even expose the fact that it has been replaced to EVMDialect.

Copy link
Member

Choose a reason for hiding this comment

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

I mean, it could make sense to also go the other way and just define builtins for all instructions and only have EVMDialect worry about choosing the ones it wants (possibly never choosing some of them). But you don't seem to be going for that here, since you don't create builtins for SWAP and DUP instructions. If you're skipping some of them, you can just as well skip all the ones we never use.

Copy link
Member Author

Choose a reason for hiding this comment

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

My idea was indeed to just declare all of them. Then the dialect is essentially just a bool field over all functions. For SWAP and DUP I made an exception because we currently cannot define functions for them because we can't define side effects:

assertThrow(!isDupInstruction(_instruction) && !isSwapInstruction(_instruction), AssemblyException, "");

}

return modifiedBuiltins;
}();
Copy link
Member

Choose a reason for hiding this comment

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

I'm not a fan of this pattern. The () at the end just stand out enough. And it's not just me: Improving Readability of IIFE .

This two especially would be perfectly fine as normal, static functions with well defined inputs and outputs and descriptive names.

Copy link
Member Author

@clonker clonker May 22, 2025

Choose a reason for hiding this comment

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

Conversely you'd probably be able to find people and evidence who like the pattern. I do, for example. It makes it very easy for me to see that a variable is assigned the result of an expression and the implementation of the expression is right below it. Matter of preference and what your brain can easily digest when reading it, as it is the case with many things :)

Here especially I think the function is quite localized and does not necessarily warrant a 'full' static function, the IIFE improves readability and also clarifies scoping for definition of m_functions. The second one is purposefully not static as it depends on the input dialect.

I have modified it so that (I hope) it is more to your liking but I want to note that I think it rather hurts readability (for me at least) and also doesn't change anything in terms of semantics.

@clonker clonker force-pushed the safeguard_builtin_handles branch from b19cde2 to 4a48ff7 Compare May 22, 2025 08:53
@clonker clonker force-pushed the safeguard_builtin_handles branch from 4a48ff7 to a8e5124 Compare May 22, 2025 09:21
clonker added 4 commits May 22, 2025 11:33
This makes for a better separation between the declarations and the logic that defines builtins.

# Conflicts:
#	libyul/backends/evm/EVMDialect.cpp
Between current default dialect as well as latest dialect and all other dialects.
@clonker clonker force-pushed the safeguard_builtin_handles branch from a8e5124 to 1d8b77d Compare May 22, 2025 09:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants