cowprotocol / services

Off-chain services for CoW Protocol
https://cow.fi/
Other
175 stars 68 forks source link

Accesslist Support for SC Wallet Receiver #50

Closed nlordell closed 1 year ago

nlordell commented 2 years ago

This PR captures the work required for implementing support for EIP-2929/2930 access lists. This is required in order to support transferring ETH to SC wallets.

This is needed because of the gas increase in the recent Berlin hardfork which caused Solidity transfer to not use enough gas when sending ETH to a contract.

We should also consider increasing the gas amount for the transfer in the smart contracts.

Original issue https://github.com/gnosis/gp-v2-services/issues/685 by @nlordell

nlordell commented 1 year ago

For a bit more context:

We currently build access lists for our settlements 🎉. We mostly do this as a small gas optimization (as having access lists provides a small gas bonus to not using them).

However, one issue with generated access lists is that they will stop generating them once it encounters a revert. This means that, in our settlement, when we execute the following line to send Ether to a smart contract:

                payable(transfer.account).transfer(transfer.amount);

The call will revert, and the access list generation will stop! This leads us to a bit of a chicken-and-egg problem in the access list generation feature were:

The solution is to first:

  1. Generate an access list for an Ether transfer to the smart contract (you can generate an access list simulating transfering 1 wei of Ether from the solver account - which should always have balance - to the owner of an order)
  2. Use this partial access list:
    • If settlement access lists are enabled, then send the partial access list to the eth_createAccessList call for generating the full access list for a settlement
    • If settlement access lists are disabled, then just use this partial access list when executing the settlement

I hope this helps clarify things.

nlordell commented 1 year ago

Sorry for the missing context, I'll take another stab at it. There are a few moving parts here:

First of all Solidity provides a transfer function for payable address-es. This will (emphasis my own):

send given amount of Wei to Address, reverts on failure, forwards 2300 gas stipend, not adjustable

This means that the Solidity compiler will generate a CALL opcode instruction with a fixed, small amount of gas. Note that CALLs always execute code, in the case of transfer the calldata was empty - and Solidity has special fallback and receive functions for handling these kinds of CALLs. The reason for this is mostly for security, in that it does allows the contracts to do any expensive work (like change blockchain state), but allows smart contract wallets (like the Safe) to receive Ether - it is a wallet after all! SC wallets designed their fallback and/or receive functions to work within these gas constraints (2300 gas).

The Safe, and many SC wallets for that matter, are typically implemented as "proxy" contract. They are just very small and simple contracts that forward calls to an "implementation" contract. This allows for things like upgrading your SC wallet (by changing the implementation contract address) as well as reduced deployment fees (you deploy a tiny contract that is a handful of bytes instead of KBs of the implementation contract). The above gas stipend of 2300 worked fine as the Safe would so something like:

SLOAD implementationAddress ;; 800 gas
DELEGATECALL                ;; 700 gas
...                         ;; Roughly 800 gas left for the remaining work, which was enough

However, the Berlin hardfork introduced EIP-2929 which changed the gas costs for storage access. Specifically, the cost for a storage access changed in pretty fundamental ways. They added a concept of "cold" and "warm" access. "Cold" access is what you pay for accessing blockchain state (balance, code, storage, etc.) for the first time within a transaction which is much more expensive, while "warm" access is what you pay for subsequent accesses and is much cheaper. This means that the Safe's handling of receiving Ether if it was "cold" would now became problematic, since:

SLOAD implementationAddress ;; 2100 gas
DELEGATECALL                ;; 2600 gas
...                         ;; -2400 gas... Oops!

(Note that DELEGATECALL is a special variant of CALL which basically means "run this other SC's code as if it were in my address - the op-code that underpins the "proxy" pattern).

This means that the built-in transfer function in Solidity no longer provided a large enough stipend for many SC wallets (at least the ones that implemented this proxy pattern which was very popular) to be able to receive Ether (even if they did no "real" work as was the case with the Safe).

Note that sending Ether to a "warm" Safe (i.e. sending Ether the second time within a transaction for example) is not an issue, since the gas costs become:

SLOAD implementationAddress ;; 100 gas
DELEGATECALL                ;; 100 gas
...                         ;; 2100 gas left 🎉 

Since this would cause a lot of issues for existing contracts, the Ethereum developers also introduced EIP-2930 which allows transactions to optionally specify an optional "access list" - a list of storage to "warm" up that you pay upfront. This internal calls to use the cheaper "warm" variant by paying for the cold access before the transaction starts.

So, for the settlement contract to be able to send Ether to a Safe as part of a trade, we need to make sure to create an access list for the Safe's "receive Ether" call - so that we only pay for "warm" storage accesses. This means that:

eth_generateAccessList({ to: smart_contract_order_owner, value: 1, data: [] })

Would generate the access list required to "warm" of the storage needed for a smart contract order owner to receive Ether as part of a "buy Ether order".

PS: Sorry for open/closing the issue - was a misclick.