docs: update ADR-001 (#122)

* docs: update ADR-001

* update

* apply transaction changes:
This commit is contained in:
Federico Kunze 2021-06-14 15:13:31 -04:00 committed by GitHub
parent bb91d8d93d
commit 5af5dd9956
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 157 additions and 110 deletions

View File

@ -2,16 +2,17 @@
## Changelog
- 2021-06-14: updates after implementation
- 2021-05-15: first draft
## Status
DRAFT, Not Implemented
PROPOSED, Implemented
## Abstract
The current ADR proposes a state machine breaking change to the EVM module state operations
(`Keeper`, `StateDB` and `StateTransition`) with the goal of reducing code maintainance, increase
(`Keeper`, `StateDB` and `StateTransition`) with the goal of reducing code maintenance, increase
performance, and document all the transaction and state cycles and flows.
## Context
@ -50,18 +51,18 @@ state transition".
Upon a state transition, these objects will be modified and marked as 'dirty' (a.k.a stateless
update) on the `CommitStateDB`. Then, at every `EndBlock`, the state of these modified objects will
be 'finalized' and commited to the store, resetting all the dirty list of objects.
be 'finalized' and committed to the store, resetting all the dirty list of objects.
The core issue arises when a chain that uses the EVM module can have also have their account and
balances updated through operations from other modules. This means that an EVM state object can be
modified through an EVM transaction (`evm.MsgEthereumTx`) and other transactions like `bank.MsgSend`
or `ibctransfer.MsgTransfer`. This can lead to unexpected behaviors like state overwrites, due to
the current behaviour that caches the dirty state on the EVM instead of commiting any changes
the current behavior that caches the dirty state on the EVM instead of committing any changes
directly.
### State Transition
A general EVM state transition is performed by calling the ethereum `vm.EVM` `Create` or `Call` functions, depending on wheather the transaction creates a contract or performs a transfer or call to a given contract.
A general EVM state transition is performed by calling the ethereum `vm.EVM` `Create` or `Call` functions, depending on whether the transaction creates a contract or performs a transfer or call to a given contract.
In the case of the `x/evm` module, it currently uses a modified version of Geth's `TransitionDB`, that wraps these two `vm.EVM` methods. The reason for using this modified function, is due to several reasons:
@ -97,28 +98,49 @@ type Keeper struct {
This means that a `Keeper` pointer will now directly be passed to the `vm.EVM` for accessing the state and performing state transitions.
The ABCI `BeginBlock` and `EndBlock` are have now been refactored to only (1) reset cached fields, and (2) keep track of internal mappings (hashes, height, etc).
The ABCI `BeginBlock` and `EndBlock` are have now been refactored to only keep track of internal fields (hashes, block bloom, etc).
```go
func (k *Keeper) BeginBlock(ctx sdk.Context, req abci.RequestBeginBlock) {
// ...
// reset cache values and context
k.ResetCacheFields(ctx)
// update context
k.WithContext(ctx)
//...
}
func (k Keeper) EndBlock(ctx sdk.Context, req abci.RequestEndBlock) []abci.ValidatorUpdate {
// NOTE: UpdateAccounts, Commit and Reset execution steps have been removed in favor of directly
// updating the state.
// set the block bloom filter bytes to store
bloom := ethtypes.BytesToBloom(k.Bloom.Bytes())
k.SetBlockBloom(ctx, req.Height, bloom)
// Gas costs are handled within msg handler so costs should be ignored
infCtx := ctx.WithGasMeter(sdk.NewInfiniteGasMeter())
k.WithContext(ctx)
// get the block bloom bytes from the transient store and set it to the persistent storage
bloomBig, found := k.GetBlockBloomTransient()
if !found {
bloomBig = big.NewInt(0)
}
bloom := ethtypes.BytesToBloom(bloomBig.Bytes())
k.SetBlockBloom(infCtx, req.Height, bloom)
k.WithContext(ctx)
return []abci.ValidatorUpdate{}
}
```
The new `StateDB` (`Keeper`) will adopt the use of the [`TransientStore`](https://docs.cosmos.network/master/core/store.html#transient-store) that discards the existing values of the store when the block is commited.
The fields that have been modified to use the `TransientStore` are:
- Block bloom filter (cleared at the end of every block)
- Tx index (updated on every transaction)
- Gas amount refunded (updated on every transaction)
- Suicided account (cleared at the end of every block)
- `AccessList` address and slot (cleared at the end of every block)
### State Objects
The `stateObject` type will be completely removed in favor of updating the store directly through
@ -126,95 +148,106 @@ the use of the auth `AccountKeeper` and the bank `Keeper`. For the storage `Stat
evm module `Keeper` will store these values directly on the KVStore using the EVM module store key
and corresponding prefix keys.
For accounts marked as 'suicided', a new relationship will be added to the `Keeper` to map `Address
(bytes) -> suicided (bool)`.
```go
// HasSuicided implements the vm.StoreDB interface
func (k Keeper) HasSuicided(address common.Address) bool {
store := prefix.NewStore(k.ctx.KVStore(csdb.storeKey), KeyPrefixSuicide)
key := types.KeySuicide(address.Bytes())
return store.Has(key)
}
// Suicide implements the vm.StoreDB interface
func (k Keeper) Suicide(address common.Address) bool {
store := prefix.NewStore(k.ctx.KVStore(csdb.storeKey), KeyPrefixSuicide)
key := types.KeySuicide(address.Bytes())
store.Set(key, []byte{0x1})
return true
}
```
### State Transition
The state transition logic will be refactored to use the `ApplyMessage` function from the `core/`
package of go-ethereum as the backbone. This method calls creates a go-ethereum `StateTransition`
The state transition logic will be refactored to use the [`ApplyTransaction`](https://github.com/ethereum/go-ethereum/blob/v1.10.3/core/state_processor.go#L137-L150) function from the `core`
package of go-ethereum as reference. This method calls creates a go-ethereum `StateTransition`
instance and, as it name implies, applies a Ethereum message to execute it and update the state.
This `ApplyMessage` call will be wrapped in the `Keeper`'s `TransitionDb` function, which will
generate the required arguments for this call (EVM, chain config, and gas pool), thus performing the
This `ApplyMessage` call will be wrapped in the `Keeper`'s `ApplyTransaction` function, which will
generate the required arguments for this call (EVM, `core.Message`, chain config, and gas pool), thus performing the
same gas accounting as before.
This will lead to the switching from the existing Ethermint's evm `StateTransition` type to the
go-ethereum `vm.ApplyMessage` type, thus reducing code necessary perform a state transition.
```go
func (k *Keeper) TransitionDb(ctx sdk.Context, msg core.Message) (*types.ExecutionResult, error) {
defer telemetry.ModuleMeasureSince(types.ModuleName, time.Now(), types.MetricKeyTransitionDB)
initialGasMeter := ctx.GasMeter()
// NOTE: Since CRUD operations on the SDK store consume gasm we need to set up an infinite gas meter so that we only consume
// the gas used by the Ethereum message execution.
// Not setting the infinite gas meter here would mean that we are incurring in additional gas costs
k.ctx = ctx.WithGasMeter(sdk.NewInfiniteGasMeter())
params := k.GetParams(ctx)
cfg, found := k.GetChainConfig(ctx)
func (k *Keeper) ApplyTransaction(tx *ethtypes.Transaction) (*types.MsgEthereumTxResponse, error) {
// ...
cfg, found := k.GetChainConfig(infCtx)
if !found {
// error
// return error
}
evm := k.NewEVM(msg, cfg.EthereumConfig(chainID))
gasPool := &core.GasPool(ctx.BlockGasMeter().Limit()) // available gas left in the block for the tx execution
ethCfg := cfg.EthereumConfig(chainID)
signer := MakeSigner(ethCfg, height)
msg, err := tx.AsMessage(signer)
if err != nil {
// return error
}
evm := k.NewEVM(msg, ethCfg)
k.IncreaseTxIndexTransient()
// create an ethereum StateTransition instance and run TransitionDb
result, err := core.ApplyMessage(evm, msg, gasPool)
// return precheck errors (nonce, signature, balance and gas)
// NOTE: these should be checked previously on the AnteHandler
res, err := k.ApplyMessage(evm, msg, ethCfg)
if err != nil {
// log error
return err
// return error
}
// The gas used on the state transition will
// be returned in the execution result so we need to deduct it from the transaction (?) GasMeter // TODO: double-check
initialGasMeter.ConsumeGas(resp.UsedGas, "evm state transition")
// ...
// set the gas meter to current_gas = initial_gas - used_gas
k.ctx = k.ctx.WithGasMeter(initialGasMeter)
// return the VM Execution error (see go-ethereum/core/vm/errors.go)
if result.Err != nil {
// log error
return result.Err
}
// return logs
executionRes := &ExecutionResult{
Response: &MsgEthereumTxResponse{
Ret: result.ret,
},
GasInfo: GasInfo{
GasConsumed: result.UsedGas,
GasLimit: gasPool,
}
return executionRes, nil
return res, nil
}
```
The EVM is created then as follows:
`ApplyMessage` computes the new state by applying the given message against the existing state. If
the message fails, the VM execution error with the reason will be returned to the client and the
transaction won't be committed to the store.
```go
func (k *Keeper) ApplyMessage(evm *vm.EVM, msg core.Message, cfg *params.ChainConfig) (*types.MsgEthereumTxResponse, error) {
var (
ret []byte // return bytes from evm execution
vmErr error // vm errors do not effect consensus and are therefore not assigned to err
)
sender := vm.AccountRef(msg.From())
contractCreation := msg.To() == nil
// transaction gas meter (tracks limit and usage)
gasConsumed := k.ctx.GasMeter().GasConsumed()
leftoverGas := k.ctx.GasMeter().Limit() - k.ctx.GasMeter().GasConsumedToLimit()
// NOTE: Since CRUD operations on the SDK store consume gas we need to set up an infinite gas meter so that we only consume
// the gas used by the Ethereum message execution.
// Not setting the infinite gas meter here would mean that we are incurring in additional gas costs
k.WithContext(k.ctx.WithGasMeter(sdk.NewInfiniteGasMeter()))
// NOTE: gas limit is the GasLimit defined in the message minus the Intrinsic Gas that has already been
// consumed on the AnteHandler.
// ensure gas is consistent during CheckTx
if k.ctx.IsCheckTx() {
// check gas consumption correctness
}
if contractCreation {
ret, _, leftoverGas, vmErr = evm.Create(sender, msg.Data(), leftoverGas, msg.Value())
} else {
ret, leftoverGas, vmErr = evm.Call(sender, *msg.To(), msg.Data(), leftoverGas, msg.Value())
}
// refund gas prior to handling the vm error in order to set the updated gas meter
if err := k.RefundGas(msg, leftoverGas); err != nil {
// return error
}
if vmErr != nil {
if errors.Is(vmErr, vm.ErrExecutionReverted) {
// return error with revert reason
}
// return execution error
}
return &types.MsgEthereumTxResponse{
Ret: ret,
Reverted: false,
}, nil
}
```
The EVM is created as follows:
```go
func (k *Keeper) NewEVM(msg core.Message, config *params.ChainConfig) *vm.EVM {
@ -223,32 +256,17 @@ func (k *Keeper) NewEVM(msg core.Message, config *params.ChainConfig) *vm.EVM {
Transfer: core.Transfer,
GetHash: k.GetHashFn(),
Coinbase: common.Address{}, // there's no beneficiary since we're not mining
BlockNumber: big.NewInt(k.ctx.BlockHeight()),
Time: big.NewInt(k.ctx.BlockHeader().Time.Unix()),
Difficulty: big.NewInt(0), // unused. Only required in PoW context
GasLimit: gasLimit,
GasLimit: blockGasMeter.Limit(),
BlockNumber: blockHeight,
Time: blockTime,
Difficulty: 0, // unused. Only required in PoW context
}
txCtx := core.NewEVMTxContext(msg)
vmConfig := k.VMConfig(st.Debug)
vmConfig := k.VMConfig()
return vm.NewEVM(blockCtx, txCtx, k, config, vmConfig)
}
func (k Keeper) VMConfig(debug bool) vm.Config{
params := k.GetParams(ctx)
eips := make([]int, len(params.ExtraEIPs))
for i, eip := range params.ExtraEIPs {
eips[i] = int(eip)
}
return vm.Config{
ExtraEips: eips,
Tracer: vm.NewJSONLogger(&vm.LogConfig{Debug: debug}, os.Stderr),
Debug: debug,
}
}
```
## Consequences
@ -268,20 +286,18 @@ since no chain that uses this code is in a production ready-state (at the moment
- Defines a single option for accessing the store through the `Keeper`, thus removing the
`CommitStateDB` type.
- State operations and tests are now all located in the `evm/keeper/` package
- Removes the concept of `stateObject` by commiting to the store directly
- Delete operations on `EndBlock` for updating and commiting dirty state objects.
- Split the state transition functionality (`NewEVM` from `TransitionDb`) allows to further
modularize certain components that can be beneficial for customization (eg: using other EVMs other
than Geth's)
- Removes the concept of `stateObject` by committing to the store directly
- Delete operations on `EndBlock` for updating and committing dirty state objects.
- Split the state transition functionality to modularize components that can be beneficial for further customization (eg: using an alternative EVM)
### Negative
- Increases the dependency of external packages (eg: `go-ethereum`)
- Some state changes will have to be kept in store (eg: suicide state)
### Neutral
- Some of the fields from the `CommitStateDB` will have to be added to the `Keeper`
- Some state changes will have to be kept in store (eg: suicide state)
## Further Discussions

View File

@ -114,6 +114,8 @@ func (k *Keeper) ApplyTransaction(tx *ethtypes.Transaction) (*types.MsgEthereumT
defer telemetry.ModuleMeasureSince(types.ModuleName, time.Now(), types.MetricKeyTransitionDB)
gasMeter := k.ctx.GasMeter() // tx gas meter
// ignore gas consumption costs
infCtx := k.ctx.WithGasMeter(sdk.NewInfiniteGasMeter())
cfg, found := k.GetChainConfig(infCtx)
@ -122,6 +124,7 @@ func (k *Keeper) ApplyTransaction(tx *ethtypes.Transaction) (*types.MsgEthereumT
}
ethCfg := cfg.EthereumConfig(k.eip155ChainID)
// get the latest signer according to the chain rules from the config
signer := ethtypes.MakeSigner(ethCfg, big.NewInt(k.ctx.BlockHeight()))
msg, err := tx.AsMessage(signer)
@ -133,6 +136,8 @@ func (k *Keeper) ApplyTransaction(tx *ethtypes.Transaction) (*types.MsgEthereumT
k.IncreaseTxIndexTransient()
// set the original gas meter to apply the message and perform the state transition
k.WithContext(k.ctx.WithGasMeter(gasMeter))
// create an ethereum StateTransition instance and run TransitionDb
res, err := k.ApplyMessage(evm, msg, ethCfg)
@ -140,6 +145,8 @@ func (k *Keeper) ApplyTransaction(tx *ethtypes.Transaction) (*types.MsgEthereumT
return nil, stacktrace.Propagate(err, "failed to apply ethereum core message")
}
// set the ethereum-formatted hash to the tx result as the tendermint hash is different
// NOTE: see https://github.com/tendermint/tendermint/issues/6539 for reference.
txHash := tx.Hash()
res.Hash = txHash.Hex()
res.Logs = types.NewLogsFromEth(k.GetTxLogs(txHash))
@ -163,6 +170,30 @@ func (k *Keeper) ApplyTransaction(tx *ethtypes.Transaction) (*types.MsgEthereumT
// TODO: (@fedekunze) currently we consume the entire gas limit in the ante handler, so if a transaction fails
// the amount spent will be grater than the gas spent in an Ethereum tx (i.e here the leftover gas won't be refunded).
// ApplyMessage computes the new state by applying the given message against the existing state.
// If the message fails, the VM execution error with the reason will be returned to the client
// and the transaction won't be committed to the store.
//
// Reverted state
//
// The transaction is never "reverted" since there is no snapshot + rollback performed on the StateDB.
// Only successful transactions are written to the store during DeliverTx mode.
//
// Prechecks and Preprocessing
//
// All relevant state transition prechecks for the MsgEthereumTx are performed on the AnteHandler,
// prior to running the transaction against the state. The prechecks run are the following:
//
// 1. the nonce of the message caller is correct
// 2. caller has enough balance to cover transaction fee(gaslimit * gasprice)
// 3. the amount of gas required is available in the block
// 4. the purchased gas is enough to cover intrinsic usage
// 5. there is no overflow when calculating intrinsic gas
// 6. caller has enough balance to cover asset transfer for **topmost** call
//
// The preprocessing steps performed by the AnteHandler are:
//
// 1. set up the initial access list (iff fork > Berlin)
func (k *Keeper) ApplyMessage(evm *vm.EVM, msg core.Message, cfg *params.ChainConfig) (*types.MsgEthereumTxResponse, error) {
var (
ret []byte // return bytes from evm execution
@ -176,7 +207,7 @@ func (k *Keeper) ApplyMessage(evm *vm.EVM, msg core.Message, cfg *params.ChainCo
gasConsumed := k.ctx.GasMeter().GasConsumed()
leftoverGas := k.ctx.GasMeter().Limit() - k.ctx.GasMeter().GasConsumedToLimit()
// NOTE: Since CRUD operations on the SDK store consume gasm we need to set up an infinite gas meter so that we only consume
// NOTE: Since CRUD operations on the SDK store consume gas we need to set up an infinite gas meter so that we only consume
// the gas used by the Ethereum message execution.
// Not setting the infinite gas meter here would mean that we are incurring in additional gas costs
k.WithContext(k.ctx.WithGasMeter(sdk.NewInfiniteGasMeter()))
@ -204,7 +235,7 @@ func (k *Keeper) ApplyMessage(evm *vm.EVM, msg core.Message, cfg *params.ChainCo
if vmErr != nil {
if errors.Is(vmErr, vm.ErrExecutionReverted) {
// unpack the return data bytes from the err if the execution has been reverted on the VM
// unpack the return data bytes from the err if the execution has been "reverted" on the VM
return nil, stacktrace.Propagate(types.NewExecErrorWithReson(ret), "transaction reverted")
}