Skip to content
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

implement FILL_COSTS on top of devnet7 #524

Draft
wants to merge 3 commits into
base: kaustinen-with-shapella
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/evm/internal/t8ntool/execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ func (pre *Prestate) Apply(vmConfig vm.Config, chainConfig *params.ChainConfig,
// Amount is in gwei, turn into wei
amount := new(big.Int).Mul(new(big.Int).SetUint64(w.Amount), big.NewInt(params.GWei))
statedb.AddBalance(w.Address, amount)
statedb.Witness().TouchFullAccount(w.Address[:], true, math.MaxUint64)
statedb.Witness().TouchFullAccount(w.Address[:], true, true, math.MaxUint64)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this code line already shows something evident in the proposed isFill bool API.

Every caller should decide if the witness addition should charge a fill cost, which seems odd unless every caller knows if the underlying tree has a "hole" in the tree at that place.

For example, for this line, it doesn't seem obvious why a withdrawal would need to charge fill cost unconditionally since that seems to depend on runtime conditions. In this case isn't that relevant since we aren't charging that to anyone -- but just feels odd.

The much nicer alternative is not including this isFill bool in the APIs, and let AccessWitness figure out if the write should charge fill cost, probably assisted by statedb.

I think I mentioned this a while ago when we discussed about FILL_COST, so shouldn't be a big surprise this thought came again now -- resharing just in case.

Copy link
Owner Author

Choose a reason for hiding this comment

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

The much nicer alternative is not including this isFill bool in the APIs, and let AccessWitness figure out if the write should charge fill cost, probably assisted by statedb.

yeah that is right, we would save some typing for sure. This is meant for measurements so I won't do it before devcon, but you are correct that we should do it this way.

}
if chainConfig.IsVerkle(big.NewInt(int64(pre.Env.Number)), pre.Env.Timestamp) {
if err := overlay.OverlayVerkleTransition(statedb, common.Hash{}, chainConfig.OverlayStride); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion consensus/beacon/consensus.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ func (beacon *Beacon) Finalize(chain consensus.ChainHeaderReader, header *types.
state.AddBalance(w.Address, amount)

// The returned gas is not charged
state.Witness().TouchFullAccount(w.Address[:], true, math.MaxUint64)
state.Witness().TouchFullAccount(w.Address[:], true, true, math.MaxUint64)
}

if chain.Config().IsVerkle(header.Number, header.Time) {
Expand Down
71 changes: 45 additions & 26 deletions core/state/access_witness.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
package state

import (
"maps"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/math"
"github.com/ethereum/go-ethereum/params"
Expand All @@ -42,14 +44,16 @@ var zeroTreeIndex uint256.Int
type AccessWitness struct {
branches map[branchAccessKey]mode
chunks map[chunkAccessKey]mode
fills map[chunkAccessKey]struct{}

pointCache *utils.PointCache
}

func NewAccessWitness(pointCache *utils.PointCache) *AccessWitness {
func NewAccessWitness(pointCache *utils.PointCache, fills map[chunkAccessKey]struct{}) *AccessWitness {
return &AccessWitness{
branches: make(map[branchAccessKey]mode),
chunks: make(map[chunkAccessKey]mode),
fills: fills,
Comment on lines +52 to +56
Copy link
Collaborator

Choose a reason for hiding this comment

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

First reaction looking at this, is why you'd like to initialize a witness with a pre-filled map.

My intuition this is related with AccessWitness not having access to statedb, so now you have to deal with previous tx in the same block having done writes that this tx shouldn't charge. So.. kind of a second order "dirty" map.

This might add up to the previous comment I did why I think AccessWitness having access to statedb might be a good idea. You can avoid having to do this trick, and just rely on statedb in a state already committed by previous tx, so no "special consideration" is needed.

(Note: not saying we should do this in this PR or similar -- mostly talking about long term strategies)

Copy link
Owner Author

Choose a reason for hiding this comment

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

the difference is that writes are per-tx while the fills are per-block. Not sure the stateedb can always provide us with this info, I'll have to think about it when rewriting.

pointCache: pointCache,
}
}
Expand All @@ -64,6 +68,9 @@ func (aw *AccessWitness) Merge(other *AccessWitness) {
for k, chunk := range other.chunks {
aw.chunks[k] |= chunk
}
for k := range other.fills {
aw.fills[k] = struct{}{}
}
}

// Key returns, predictably, the list of keys that were touched during the
Expand All @@ -81,18 +88,18 @@ func (aw *AccessWitness) Keys() [][]byte {

func (aw *AccessWitness) Copy() *AccessWitness {
naw := &AccessWitness{
branches: make(map[branchAccessKey]mode),
chunks: make(map[chunkAccessKey]mode),
branches: maps.Clone(aw.branches),
chunks: maps.Clone(aw.chunks),
fills: maps.Clone(aw.fills),
pointCache: aw.pointCache,
}
naw.Merge(aw)
return naw
}

func (aw *AccessWitness) TouchFullAccount(addr []byte, isWrite bool, availableGas uint64) uint64 {
func (aw *AccessWitness) TouchFullAccount(addr []byte, isWrite, isFill bool, availableGas uint64) uint64 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

As mentioned before: assuming the caller always is write about knowing the tree doesn't have the value, then I guess this is fine.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Note this also shows as fundamental flaw in the API design: someone calling isWrite=false nad isFill=true.

I think I had said this before too and suggested maybe a flag-ish style API? Like isRead | isWrite | isWriteWithFill. Despite this solves the flaw, it can be somewhat annoying from the caller side -- but looks safer.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe we could add:

if !isWrite && isFill {
   panic("can't can't charge fill cost in a state read")
}

or similar.

Copy link
Owner Author

Choose a reason for hiding this comment

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

you can't charge fill costs in a state read, if write is false.

var gas uint64
for i := utils.BasicDataLeafKey; i <= utils.CodeHashLeafKey; i++ {
consumed, wanted := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, byte(i), isWrite, availableGas)
consumed, wanted := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, byte(i), isWrite, isFill, availableGas)
if consumed < wanted {
return wanted + gas
}
Expand All @@ -103,19 +110,23 @@ func (aw *AccessWitness) TouchFullAccount(addr []byte, isWrite bool, availableGa
}

func (aw *AccessWitness) TouchAndChargeMessageCall(addr []byte, availableGas uint64) uint64 {
_, wanted := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, false, availableGas)
_, wanted := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, false, false, availableGas)
if wanted == 0 {
wanted = params.WarmStorageReadCostEIP2929
}
return wanted
}

func (aw *AccessWitness) TouchAndChargeValueTransfer(callerAddr, targetAddr []byte, availableGas uint64) uint64 {
_, wanted1 := aw.touchAddressAndChargeGas(callerAddr, zeroTreeIndex, utils.BasicDataLeafKey, true, availableGas)
func (aw *AccessWitness) TouchAndChargeValueTransfer(callerAddr, targetAddr []byte, isFill bool, availableGas uint64) uint64 {
_, wanted1 := aw.touchAddressAndChargeGas(callerAddr, zeroTreeIndex, utils.BasicDataLeafKey, true, false, availableGas)
if wanted1 > availableGas {
return wanted1
}
_, wanted2 := aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.BasicDataLeafKey, true, availableGas-wanted1)
// The isFill here is a bit strange: one would have to pay for the fill costs
// and potentially run out of gas before the account is created. This is due
// to the charging for a potential account creation being split in two locations:
// the basic data is written in the gas function, and the code hash in opCall.
_, wanted2 := aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.BasicDataLeafKey, true, isFill, availableGas-wanted1)
Comment on lines +125 to +129
Copy link
Owner Author

Choose a reason for hiding this comment

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

@jsign this is the comment describing the situation we just discussed. I think it's terrible. Let me know if you have a better way to describe this, otherwise I'll take another shot at it tomorrow.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, I see. I don't think is that bad. If I look at this method in a vaccum, what is doing seems logical compared to how the API is proposed. i.e: the caller should already exist so looks reasonable L121 is ignoring isFill and setting to false, and the targetAddr we trust the callr to tell us if fill cost should be charged.

if wanted1+wanted2 == 0 {
return params.WarmStorageReadCostEIP2929
}
Expand All @@ -127,45 +138,45 @@ func (aw *AccessWitness) TouchAndChargeValueTransfer(callerAddr, targetAddr []by
// address collision is done before the transfer, and so no write
// are guaranteed to happen at this point.
func (aw *AccessWitness) TouchAndChargeContractCreateCheck(addr []byte, availableGas uint64) uint64 {
gas1, wanted1 := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, false, availableGas)
_, wanted2 := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, false, availableGas-gas1)
gas1, wanted1 := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, false, false, availableGas)
_, wanted2 := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, false, false, availableGas-gas1)
return wanted1 + wanted2
}

// TouchAndChargeContractCreateInit charges access costs to initiate
// a contract creation.
func (aw *AccessWitness) TouchAndChargeContractCreateInit(addr []byte, availableGas uint64) (uint64, uint64) {
gas1, wanted1 := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, true, availableGas)
gas2, wanted2 := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, true, availableGas-gas1)
func (aw *AccessWitness) TouchAndChargeContractCreateInit(addr []byte, availableGas uint64, isFill bool) (uint64, uint64) {
gas1, wanted1 := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, true, isFill, availableGas)
gas2, wanted2 := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, true, isFill, availableGas-gas1)
return gas1 + gas2, wanted1 + wanted2
}

func (aw *AccessWitness) TouchTxOriginAndComputeGas(originAddr []byte) {
for i := utils.BasicDataLeafKey; i <= utils.CodeHashLeafKey; i++ {
aw.touchAddressAndChargeGas(originAddr, zeroTreeIndex, byte(i), i == utils.BasicDataLeafKey, math.MaxUint64)
aw.touchAddressAndChargeGas(originAddr, zeroTreeIndex, byte(i), i == utils.BasicDataLeafKey, false, math.MaxUint64)
}
}

func (aw *AccessWitness) TouchTxTarget(targetAddr []byte, sendsValue, doesntExist bool) {
aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.BasicDataLeafKey, sendsValue, math.MaxUint64)
aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.BasicDataLeafKey, sendsValue, doesntExist, math.MaxUint64)
// Note that we do a write-event in CodeHash without distinguishing if the tx target account
// exists or not. Pre-7702, there's no situation in which an existing codeHash can be mutated, thus
// doing a write-event shouldn't cause an observable difference in gas usage.
// TODO(7702): re-check this in the spec and implementation to be sure is a correct solution after
// EIP-7702 is implemented.
aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.CodeHashLeafKey, doesntExist, math.MaxUint64)
aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.CodeHashLeafKey, doesntExist, doesntExist, math.MaxUint64)
}

func (aw *AccessWitness) TouchSlotAndChargeGas(addr []byte, slot common.Hash, isWrite bool, availableGas uint64, warmCostCharging bool) uint64 {
func (aw *AccessWitness) TouchSlotAndChargeGas(addr []byte, slot common.Hash, isWrite, isFill bool, availableGas uint64, warmCostCharging bool) uint64 {
treeIndex, subIndex := utils.GetTreeKeyStorageSlotTreeIndexes(slot.Bytes())
_, wanted := aw.touchAddressAndChargeGas(addr, *treeIndex, subIndex, isWrite, availableGas)
_, wanted := aw.touchAddressAndChargeGas(addr, *treeIndex, subIndex, isWrite, isFill, availableGas)
if wanted == 0 && warmCostCharging {
wanted = params.WarmStorageReadCostEIP2929
}
return wanted
}

func (aw *AccessWitness) touchAddressAndChargeGas(addr []byte, treeIndex uint256.Int, subIndex byte, isWrite bool, availableGas uint64) (uint64, uint64) {
func (aw *AccessWitness) touchAddressAndChargeGas(addr []byte, treeIndex uint256.Int, subIndex byte, isWrite, isFill bool, availableGas uint64) (uint64, uint64) {
branchKey := newBranchAccessKey(addr, treeIndex)
chunkKey := newChunkAccessKey(branchKey, subIndex)

Expand All @@ -189,6 +200,11 @@ func (aw *AccessWitness) touchAddressAndChargeGas(addr []byte, treeIndex uint256
if (chunkValue & AccessWitnessWriteFlag) == 0 {
chunkWrite = true
}

_, ok := aw.fills[chunkKey]
if isFill && !ok {
chunkFill = true
}
}

var gas uint64
Expand Down Expand Up @@ -225,6 +241,9 @@ func (aw *AccessWitness) touchAddressAndChargeGas(addr []byte, treeIndex uint256
if chunkWrite {
aw.chunks[chunkKey] |= AccessWitnessWriteFlag
}
if chunkFill {
aw.fills[chunkKey] = struct{}{}
}

// consumed == wanted
return gas, gas
Expand Down Expand Up @@ -255,7 +274,7 @@ func newChunkAccessKey(branchKey branchAccessKey, leafKey byte) chunkAccessKey {
}

// touchCodeChunksRangeOnReadAndChargeGas is a helper function to touch every chunk in a code range and charge witness gas costs
func (aw *AccessWitness) TouchCodeChunksRangeAndChargeGas(contractAddr []byte, startPC, size uint64, codeLen uint64, isWrite bool, availableGas uint64) (uint64, uint64) {
func (aw *AccessWitness) TouchCodeChunksRangeAndChargeGas(contractAddr []byte, startPC, size uint64, codeLen uint64, isWrite, isFill bool, availableGas uint64) (uint64, uint64) {
// note that in the case where the copied code is outside the range of the
// contract code but touches the last leaf with contract code in it,
// we don't include the last leaf of code in the AccessWitness. The
Expand All @@ -278,7 +297,7 @@ func (aw *AccessWitness) TouchCodeChunksRangeAndChargeGas(contractAddr []byte, s
for chunkNumber := startPC / 31; chunkNumber <= endPC/31; chunkNumber++ {
treeIndex := *uint256.NewInt((chunkNumber + 128) / 256)
subIndex := byte((chunkNumber + 128) % 256)
consumed, wanted := aw.touchAddressAndChargeGas(contractAddr, treeIndex, subIndex, isWrite, availableGas)
consumed, wanted := aw.touchAddressAndChargeGas(contractAddr, treeIndex, subIndex, isWrite, isFill, availableGas)
// did we OOG ?
if wanted > consumed {
return statelessGasCharged + consumed, statelessGasCharged + wanted
Expand All @@ -294,8 +313,8 @@ func (aw *AccessWitness) TouchCodeChunksRangeAndChargeGas(contractAddr []byte, s
return statelessGasCharged, statelessGasCharged
}

func (aw *AccessWitness) TouchBasicData(addr []byte, isWrite bool, availableGas uint64, warmCostCharging bool) uint64 {
_, wanted := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, isWrite, availableGas)
func (aw *AccessWitness) TouchBasicData(addr []byte, isWrite, isFill bool, availableGas uint64, warmCostCharging bool) uint64 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Feel some tension here between isFill and warmCostCharging. Looks like when isFill=true, whatever warmCostCharging say is not relevant.

Not saying this is a huge problem... just feels a bit weird.

Copy link
Owner Author

Choose a reason for hiding this comment

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

agreed, not a problem but we're starting to accumulate booleans and that creates for non-sensical situations that should never happen ™️ but might be triggered at some point

_, wanted := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, isWrite, isFill, availableGas)
if wanted == 0 && warmCostCharging {
if availableGas < params.WarmStorageReadCostEIP2929 {
return availableGas
Expand All @@ -306,7 +325,7 @@ func (aw *AccessWitness) TouchBasicData(addr []byte, isWrite bool, availableGas
}

func (aw *AccessWitness) TouchCodeHash(addr []byte, isWrite bool, availableGas uint64, chargeWarmCosts bool) uint64 {
_, wanted := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, isWrite, availableGas)
_, wanted := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, isWrite, false, availableGas)
if wanted == 0 && chargeWarmCosts {
if availableGas < params.WarmStorageReadCostEIP2929 {
return availableGas
Expand Down
7 changes: 7 additions & 0 deletions core/state/state_object.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ func (s *stateObject) GetState(key common.Hash) common.Hash {
return s.GetCommittedState(key)
}

func (s *stateObject) GetOriginState(key common.Hash) common.Hash {
if value, cached := s.originStorage[key]; cached {
return value
}
return common.Hash{}
}
Comment on lines +170 to +175
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could be somehow better signal that originStorage is a cache and not a fully reliable source of truth? Maybe renaming to originStorageCache and also rename the method to GetOriginStateCache or something?

I don't like the rename to GetOriginStateCache but L309 of core/state/statedb.go was quite surprising to me, and then I realized it's because it's only asking for a cache.

And even a more important reason to be super-clear about this GetOriginState being a cache, is that this method is public. If I see that name in an API and returns common.Hash{} I'd assume it doesn't exist, not that "potentially I don't know".


// GetCommittedState retrieves a value from the committed account storage trie.
func (s *stateObject) GetCommittedState(key common.Hash) common.Hash {
// If we have a pending write or clean cached, return that
Expand Down
19 changes: 18 additions & 1 deletion core/state/statedb.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ func (s *StateDB) Snaps() *snapshot.Tree {
}

func (s *StateDB) NewAccessWitness() *AccessWitness {
return NewAccessWitness(s.db.(*cachingDB).addrToPoint)
return NewAccessWitness(s.db.(*cachingDB).addrToPoint, make(map[chunkAccessKey]struct{}))
}

func (s *StateDB) Witness() *AccessWitness {
Expand Down Expand Up @@ -300,6 +300,23 @@ func (s *StateDB) SubRefund(gas uint64) {
func (s *StateDB) Exist(addr common.Address) bool {
return s.getStateObject(addr) != nil
}
func (s *StateDB) StorageExist(addr common.Address, slot common.Hash) bool {
so := s.getStateObject(addr)
if so == nil {
return false
}
val := so.GetOriginState(slot)
if val == (common.Hash{}) {
// We got a 0, check if there was something in the tree
vtr := s.GetTrie().(*trie.VerkleTrie)
v, err := vtr.GetStorage(addr, slot[:])
if err != nil {
panic(err)
}
return v == nil
}
return false
}

// Empty returns whether the state object is either non-existent
// or empty according to the EIP161 specification (balance = nonce = code = 0)
Expand Down
4 changes: 2 additions & 2 deletions core/state_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func ApplyTransaction(config *params.ChainConfig, bc ChainContext, author *commo

func InsertBlockHashHistoryAtEip2935Fork(statedb *state.StateDB, prevNumber uint64, prevHash common.Hash, chain consensus.ChainHeaderReader) {
// Make sure that the historical contract is added to the witness
statedb.Witness().TouchFullAccount(params.HistoryStorageAddress[:], true, math.MaxUint64)
statedb.Witness().TouchFullAccount(params.HistoryStorageAddress[:], true, true, math.MaxUint64)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Kind of. This is only purely correct on the (maybe) first call. After that the account exists and isFill=true doesn't make sense.

Not a big deal since we ignore the output, but looks weird (due to the API design).

Copy link
Owner Author

Choose a reason for hiding this comment

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

right this is only set to true so that if someone, later, writes to the contract (which should not happen ™️ , see comment above) then they don't pay for the fill costs.


ancestor := chain.GetHeader(prevHash, prevNumber)
for i := prevNumber; i > 0 && i >= prevNumber-params.Eip2935BlockHashHistorySize; i-- {
Expand All @@ -192,5 +192,5 @@ func ProcessParentBlockHash(statedb *state.StateDB, prevNumber uint64, prevHash
var key common.Hash
binary.BigEndian.PutUint64(key[24:], ringIndex)
statedb.SetState(params.HistoryStorageAddress, key, prevHash)
statedb.Witness().TouchSlotAndChargeGas(params.HistoryStorageAddress[:], key, true, math.MaxUint64, false)
statedb.Witness().TouchSlotAndChargeGas(params.HistoryStorageAddress[:], key, true, true, math.MaxUint64, false)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Ditto. It's correct for some time while the ring buffer is empty, but after some point is actually always false. (Again can argue doesn't matter since we ignore the output, but still).

}
Loading
Loading