EIP-2929: Gas cost increases for state access opcodes

Thanks. a good point.
but this is only for constant data. right?

Yes, so I am asking for a solution or different pattern so that upgradeable contracts are not worse off gas-wise because of this EIP compared to regular contracts when calling external functions on them. And by worse off I mean that the gas gap between upgradeable contracts and regular contracts is increased.

I’m not saying that upgradeable contracts should require the same amount of gas as regular contracts when calling external functions on them. I’m saying I’d like there to be way to prevent increasing the gas gap that is currently done by this EIP.

2 Likes

I agree with @mudgen that it would be great if we could come up with an EIP that mitigate the issue of increase SLOAD cost for Proxy based contract.
It could for example be a mechanism by which a contract could update its bytecode.
But it could be other mechanisms too.
But I guess this is out of scope for this particular EIP.

1 Like

I think the realistic long-term option is just charging per witness byte instead of per access, so SLOAD from contracts with only a small amount of storage would remain fairly cheap. I presume that these contracts that store these contract addresses per function would not have more than ~100 storage slots or so?

1 Like

FYI: I actually came up with an trick to not use storage at all to get implementation address for proxy : https://twitter.com/wighawag/status/1306180888154251270

The implementation address is basically stored in an external contract’s bytecode that can self-destruct and being recreated at the same address with different bytecode (same creation code)

There is still an overhead (the call) but this is not dependent on SLOAD cost

EDIT: Note that it does not solve the proxy issue as CALL in EIP-2929 are even more expensive than SLOAD

Yes, that long-term solution sounds good. I was also hoping for a short-term solution.

How many storage slots are used per proxy pattern depends on the proxy pattern implementation. Diamonds currently have two reference implementations.

The diamond-1 and diamond-2 implementations requires 58 storage slots for 50 external functions.

The diamond-3 implementation requires between 59 to 232 storage slots for 50 external functions (depending on how many facets are used).

Info about the three diamond reference implementations is here: https://github.com/mudgen/diamond

The sets are transaction-context-wide, implemented identically to other transaction-scoped constructs such as the self-destruct-list and global refund counter. In particular, if a scope reverts, the access lists should be in the state they were in before that scope was entered.

In this case, when we talk about caching → it would be more reasonable to not clear the scope on reverts since it is still in the cache and access should still be cheaper. This would also reduce the memory requirements for the transaction as we would not have to keep up to 300 different states of the access lists.

In Nethermind, I think, we get it for three as for any account and storage we can get a single number with info when it was last time touched and at the same time each scope memorizes the position that can be compared with the ‘last touch’ number. This is specific to our implementation and probably not readily available in every client.

This is not really going to help. Because if we implement this EIP, and then increase gas cost 3-4x to make the gas prices come down, such increase would negate the “protective” effect of this EIP as a DOS-attack deterrent. So the increase in gas limit needs to be much more conservative, if any. That is my main objection to the EIP - it only makes sense as a very short-term measure, but it introduces lots of extra long-term complexity. Not a good trade-off from my point of view

There are a couple of problems with this.

  • It’s IMO ‘semantically ugly’. A perfectly valid cheat, is that if you know that a transaction or scope exits on failure, it’s not necessary to actually execute it. It doesn’t matter how or where it exits, as long as it exits.
    • This became untrue back in the RipeMD touch OOG failure, since at that point a side-effect from within a reverting transaction did cause a side-effect, but that’s the only exception we have.
  • A perhaps larger problem, and definitely more practical than the one above, is that it introduces a way to cheat. Say you want to add N slots in contract A to the access list.
    • Call A with 0 gas (cost 2600). A is now added to acess list. A now tries to SLOAD slot 1. Fails on OOG. You now have A, (A:1) in access list
    • Call A again with 0 gas (cost 100). A now tries to SLOAD slot 1. Fails on OOG. You how have A, (A:1), (A:2) in access list.

So basically, you can add things to your access list at a cost of ~100 gas, instead of 2K+

it would be more reasonable to not clear the scope on reverts since it is still in the cache and access should still be cheaper.

In the scenario above, the failing SLOADs would not place the item in the cache, since the cost-check should be done before the item is loaded.

1 Like

The intention behind clearing the scope is that we already have code paths for how to implement transaction-scoped variables (namely, the refunds counter and the selfdestructs list), and it’s cleaner and simpler if we just reuse those code paths for all the transaction-scoped variables that we do now and in the future than if we start customizing revert rules depending on what’s optimal for each specific situation.

Do I understand it correctly that calling any account that has not been called before costs at least 2600 gas. Assuming this is correct it will not only increase the price gap, but break any contract that uses transfer or send. The mentioned reports focus only on the receivers that use too much gas, but I would say this might need to be reevaluated, as it is not even possible to perform the solidity call for transfer or send to an EOA. While I see that EIP-2930: Optional access lists can prevent this from happening, but this requires that wallets and dapps support this.

There is a solidity issue to remove these limits from the compiler (Remove .send and .transfer. ¡ Issue #7455 ¡ ethereum/solidity ¡ GitHub), but I think this is still a quite open discussion.

Looking at all of this I have to agree with Alexey

Edit: correct link

1 Like

There are a couple of problems with this.

I see. This can be prevented by changing the access list after successful execution of SLOAD / SSTORE.

Not reverting changes on failing calls would be transaction scoped but not call-scoped.
The destroy lists and refund counters are transaction-scoped but also have the call scoped clones that are committed on exiting a call.

I think my approach actually simplifies the code on the client side (at least in Nethermind). This EIP is quite complex by itself.

Call scope is cloned up to 300 times on deep calls.

That is correct. Most transactions will not break, it seems, but some will require more gas. A transaction which does a lot of “reaching out to new places” will cost more, but transactions which “interacts with few other contracts multiple times” will cost less.

While I see that EIP-2930: Optional access lists can prevent this from happening, but this requires that wallets and dapps support this.

Yes, it does. I personally don’t think 2929 is going to go live without 2930, for specifically that reason. However, 2930 doesn’t require widespread support, if the only usecase is to be able to salvage a tiny subset of txs that would otherwise fail. As long as there are some signers/wallets that support it, that’s enough to make it work.

Because if we implement this EIP, and then increase gas cost 3-4x to make the gas prices come down, such increase would negate the “protective” effect of this EIP as a DOS-attack deterrent.

Yeah I don’t see why we would increase the gas cost to 3-4x. Experimentation shows that the actual gas spending doesn’t change more than a few percent. In other words: an 8M block pre-2929, is still an 8M block post-2929.

Geth doesn’t operate like that. We did, once upon a time, and even used clone-copies of the state. That bit us hard in the shanghai attacks, and we eventually had to implement journalling instead. I don’t agree that clone-commit/discard is easier or better.

I don’t understand this argument. It would only break contracts that use transfer or send if those contracts themselves are called by other contracts and those other contracts make the call with a fixed gas limit (a very rare pattern nowadays). If you mean a contract calling send to immediately forward received funds inside the pre-allotted 2300 gas, I don’t think that’s possible anyway because value-transferring calls have a gas cost of 9000.

This is the current implementation in Solidity (setting the gas limit to the stipend) as far as I remember (I would need to read up again on the concrete limits used by solidity).

My main point was that the previous analysis done for EIP-1884 are not 1:1 valid for this EIP and I would love if something similar would be done again.

Edit: Just to avoid that I am misunderstanding something, afaik EIP-1884 only had a breaking effect when the receiving contract did additional logic (e.g. a proxy or emitting events) → this would result in an OOG is the receiver contract. This EIP would break in the calling contract (with an OOG). Would that be correct?

Edit 2: Stipend docs from Solidity → Security Considerations — Solidity 0.7.1 documentation

Here I have interesting case for a CALL with non-zero value.

  1. The cold destination D is added to accessed_accounts.
  2. It turns out D does not exist: additional penalty of 25000.
  3. This penalty causes out-of-gas exception.

Now we repeat the above steps. The difference is D is warm now although it still does not exist.

There’s nothing that says something in the accesslist must exist (e.g. 2930 can add whatever that doesn’t exist).
So it shouldn’t be an edgecase (as in, semantically it’s pretty clear-cut, IMO), but it may very well be, so good catch. We should make a test for this, and I’ll also try it out on Yolov3.

Actually, you can simplify this case by not relying on 25000 penalty. The call can simply fail because of the cold account access penalty of 2600. Still the D will be added to the accessed_accounts on the first call.

I don’t understand all consequences of this, but try exploring this mechanism as a way to bypass the cold account access cost. For a series of failing calls to non-existing account D only the first one will consider the D cold. But in practice, a node implementation must do full D lookup unless it maintain a cache of non-existing accounts. Of course, these failing calls must be wrapped with another calls to proceed.

I think this would work “safer” if the 2600 gas is strictly required up front to proceed with any call, independently of the actually initial call cost computed later. Similarly to the required minimal gas amount to proceed with a SSTORE.