One of the critical meta-questions raised by Remediations for EIP-1283 reentrancy bug and the delay of the Constantinople upgrade is: Precisely what on Ethereum is immutable and what behavior should be considered invariant?
Since irregular state transitions are outside the scope of this conversation, for sake of argument let’s all agree that code and data (storage) are immutable.
However, we’re left with the challenge that EVM semantics can and do change during a hard fork, the most germane example here being a change in gas cost. In other words, as a smart contract developer, even though I know my code will not change, I do not have a guarantee that its behavior will not change.
In all other hard forks, the gas cost of operations were only increased
and it appears that many developers may have been relying on this to be invariant, as well as on the fact that send
and transfer
couldn’t result in reentrancy, which as @MicahZoltu points out here, was only “implied” and never explicit:
Not only was the invariant only implied, not explicitly stated, but if no one is depending on it what do we gain by maintaining it?
I assert that:
- in general (with the possible exception of an emergency fix to EVM behavior where the risk of not fixing it is greater than the risk of changing the behavior of deployed code) there is a tacit social contract with developers whereby not only code but behavior should be immutable. This has not always been true historically, but many people nevertheless believe it to be true, hence the tacit social contract and the problem of “implied” invariants.
- intended behavior of deployed code is extraordinarily hard to establish – e.g., did a developer write something a certain way intentionally, or did they make a mistake? Therefore, we should not be in the business of trying to figure out or maintain intended behavior of deployed code. For this reason I disagree with use of the word “break” as in “breaking changes” or “breaking someone’s code” since, without establishing intent, we cannot know whether behavior has been “broken” or not.
If you agree with both of these points, then I think it follows that:
- all upgrades should be backwards-compatible, which is to say, they should not change the behavior of on-chain code.
I see two potential ways of achieving this:
-
Introducing an “EVM version” flag to deployed code (like a solidity
pragma
) so that a developer knows that their code will always target a particular version of EVM. This adds the requirement that all clients implement all historical EVM semantics and can fire up a VM for any EVM version. In practice all major clients today do implement all historical EVM semantics, but future clients may not. Another challenge with this approach is that contracts can call other contracts, which may in turn call contracts that target a newer EVM, so it does not solve the underlying problem. This could be addressed using a form of snapshotting or “static linking” of contracts, but that introduces complexity and problems with upgradability. A final challenge here is that it makes analysis and auditing much harder. -
Another, simpler approach is to never change the behavior of an existing opcode (again, except in case of emergency). All changes to existing opcodes are introduced as new opcodes–in the case of EIP-1283, instead of changing
SSTORE
, a newSSTORE2
(orSSTORE_CHEAPER
) could be introduced. This has the upside of simplicity and the downside of making the EVM more complicated.
There are two big, outstanding questions, however:
- If we move forward with state rent or a similar solution, can it be done without changing the behavior of deployed code?
- Should Ethereum 1.x be in “maintenance mode” with no further EVM changes, and should all such changes instead target Eth 2 and perhaps be done in Ewasm?
Thanks.
[Thanks to Liam Horne, Dan Robinson, Joshua Goldbard, and James Prestwich for sharing thoughts and discussing this issue. This was inspired by the conversation we had on this topic.]