Skip to content
Open
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
197 changes: 167 additions & 30 deletions sei-tendermint/internal/evmonlyapp/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import (
"sync"
"sync/atomic"

"go.opentelemetry.io/otel/attribute"
otelmetric "go.opentelemetry.io/otel/metric"

"github.com/ethereum/go-ethereum/common"

ethcore "github.com/ethereum/go-ethereum/core"
Expand Down Expand Up @@ -82,12 +85,38 @@ type evmOnlyApplication struct {
// FinalizeBlock is serialized by executor, so one timer serves the app; it
// is only touched with that lock held.
finalizePhases *seidbmetrics.PhaseTimer
// prepared holds the block PrepareBlock decoded ahead of FinalizeBlock, if any.
prepared utils.Mutex[*utils.Option[preparedBlock]]
// preparedBlocks counts finalized blocks by whether prepared held them.
preparedBlocks otelmetric.Int64Counter
// preparePhases times PrepareBlock's decode of the next block. PrepareBlock is
// called from the single block fetcher, so one timer serves the app.
preparePhases *seidbmetrics.PhaseTimer
}

// preparedBlock is the stateless part of a FinalizeBlock request, computed before the
// request arrives. FinalizeBlock uses it only for the block with this height and hash.
type preparedBlock struct {
height int64
hash common.Hash
txs []evmonly.PreparedTx
}

// finalizeMeterName is the OTel meter FinalizeBlock's phase timer records to,
// as evmonly_finalize_phase_duration_seconds_total.
const finalizeMeterName = "evmonly_app"

func newPreparedBlocksCounter(meter otelmetric.Meter) otelmetric.Int64Counter {
counter, err := meter.Int64Counter(
"evmonly_finalize_prepared_blocks_total",
otelmetric.WithDescription("Finalized blocks by whether PrepareBlock had already decoded them"),
)
if err != nil {
panic(fmt.Sprintf("evmonly_finalize_prepared_blocks_total: %v", err))
}
return counter
}

// evmOnlyCursorState is the execution position: the block whose state is
// committed to storage and the block finalized but not yet acknowledged by
// Commit.
Expand Down Expand Up @@ -124,6 +153,9 @@ func NewEVMOnlyApplication(
validators: slices.Clone(validators),
executor: utils.NewMutex(new(utils.Option[*evmonly.Executor])),
finalizePhases: seidbmetrics.NewPhaseTimer(otel.Meter(finalizeMeterName), "evmonly_finalize"),
prepared: utils.NewMutex(new(utils.Option[preparedBlock])),
preparedBlocks: newPreparedBlocksCounter(otel.Meter(finalizeMeterName)),
preparePhases: seidbmetrics.NewPhaseTimer(otel.Meter(finalizeMeterName), "evmonly_prepare"),
settler: utils.NewAtomicSend(utils.None[*evmonly.Executor]()),
cursor: utils.NewMutex(&evmOnlyCursorState{}),
checkedSenders: utils.NewMutex(map[common.Hash]common.Address{}),
Expand Down Expand Up @@ -351,14 +383,30 @@ func (a *evmOnlyApplication) rememberSender(hash common.Hash, sender common.Addr
// raw transaction is the keccak of its bytes for every transaction type, so no
// decoding is needed.
func (a *evmOnlyApplication) takeSenders(txs [][]byte) []utils.Option[common.Address] {
return a.checkedSendersOf(txs, true)
}

// peekSenders is takeSenders without forgetting the entries.
func (a *evmOnlyApplication) peekSenders(txs [][]byte) []utils.Option[common.Address] {
return a.checkedSendersOf(txs, false)
}

// forgetSenders drops the CheckTx-recovered senders of txs.
func (a *evmOnlyApplication) forgetSenders(txs [][]byte) {
a.checkedSendersOf(txs, true)
}

func (a *evmOnlyApplication) checkedSendersOf(txs [][]byte, forget bool) []utils.Option[common.Address] {
out := make([]utils.Option[common.Address], len(txs))
// Hashed outside the lock; CheckTx writes this map constantly.
hashes := hashRawTxs(txs)
for senders := range a.checkedSenders.Lock() {
for i, hash := range hashes {
if sender, ok := senders[hash]; ok {
out[i] = utils.Some(sender)
delete(senders, hash)
if forget {
delete(senders, hash)
}
}
}
}
Expand Down Expand Up @@ -556,26 +604,106 @@ func (a *evmOnlyApplication) EvmCall(ctx context.Context, msg *ethcore.Message)
}
}

func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.RequestFinalizeBlock) (*abci.ResponseFinalizeBlock, error) {
// finalizeRequest is the block identity FinalizeBlock and PrepareBlock derive from a request.
type finalizeRequest struct {
height int64
number uint64
timestamp uint64
blockHash common.Hash
}

func parseFinalizeRequest(req *abci.RequestFinalizeBlock) (finalizeRequest, error) {
height := req.Header.Height
if height <= 0 {
return nil, fmt.Errorf("EVM-only block height must be positive: %d", height)
return finalizeRequest{}, fmt.Errorf("EVM-only block height must be positive: %d", height)
}
number, ok := utils.SafeCast[uint64](height)
if !ok {
return nil, fmt.Errorf("EVM-only block height exceeds uint64: %d", height)
return finalizeRequest{}, fmt.Errorf("EVM-only block height exceeds uint64: %d", height)
}
timestamp, ok := utils.SafeCast[uint64](req.Header.Time.Unix())
if !ok {
return nil, fmt.Errorf("EVM-only block timestamp is negative: %s", req.Header.Time)
return finalizeRequest{}, fmt.Errorf("EVM-only block timestamp is negative: %s", req.Header.Time)
}
return finalizeRequest{
height: height,
number: number,
timestamp: timestamp,
blockHash: common.BytesToHash(req.Hash),
}, nil
}

// PrepareBlock decodes a block's transactions and recovers their senders before
// FinalizeBlock is called for it, so that work runs while the previous block
// executes. It may run concurrently with FinalizeBlock. Only the most recent
// prepared block is kept, and FinalizeBlock uses it only for the same height and
// hash, so preparing the wrong block costs nothing but the work: the senders
// CheckTx cached stay cached until a prepared block is consumed. Anything that
// would fail the block is left for FinalizeBlock to report; the only error
// returned is ctx ending.
func (a *evmOnlyApplication) PrepareBlock(ctx context.Context, req *abci.RequestFinalizeBlock) error {
executor, ok := a.settler.Load().Get()
if !ok {
return nil
}
block, err := parseFinalizeRequest(req)
if err != nil {
return nil
}
// Only Number and Time reach the decoded transactions (through the signer); the
// parent-derived fields are filled in by FinalizeBlock.
a.preparePhases.SetPhase("parse")
prepared, err := executor.PrepareBlock(ctx, evmonly.BlockRequest{
Context: evmonly.BlockContext{
Number: block.number,
Time: block.timestamp,
GasLimit: a.EvmGasLimit(),
ChainID: new(big.Int).Set(a.chainID),
BaseFee: evmOnlyBaseFee(),
BlobBaseFee: new(big.Int),
},
Txs: req.Txs,
Senders: a.peekSenders(req.Txs),
})
a.preparePhases.Reset()
if err != nil {
return ctx.Err()
}
for slot := range a.prepared.Lock() {
*slot = utils.Some(preparedBlock{
height: block.height,
hash: block.blockHash,
txs: prepared.Txs,
})
}
return nil
}

// takePrepared returns the prepared transactions of the given block and removes
// them. A prepared block for another block is left in place.
func (a *evmOnlyApplication) takePrepared(height int64, hash common.Hash) ([]evmonly.PreparedTx, bool) {
for slot := range a.prepared.Lock() {
prepared, ok := slot.Get()
if !ok || prepared.height != height || prepared.hash != hash {
return nil, false
}
*slot = utils.None[preparedBlock]()
return prepared.txs, true
}
panic("unreachable")
}

func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.RequestFinalizeBlock) (*abci.ResponseFinalizeBlock, error) {
block, err := parseFinalizeRequest(req)
if err != nil {
return nil, err
}
blockHash := common.BytesToHash(req.Hash)
for executor := range a.executor.Lock() {
executor, ok := executor.Get()
if !ok {
return nil, fmt.Errorf("EVM-only block finalized before InitChain")
}
return a.finalizeBlockLocked(ctx, executor, req, number, timestamp, blockHash)
return a.finalizeBlockLocked(ctx, executor, req, block)
}
panic("unreachable")
}
Expand All @@ -586,38 +714,47 @@ func (a *evmOnlyApplication) finalizeBlockLocked(
ctx context.Context,
executor *evmonly.Executor,
req *abci.RequestFinalizeBlock,
number, timestamp uint64,
blockHash common.Hash,
block finalizeRequest,
) (*abci.ResponseFinalizeBlock, error) {
height := req.Header.Height
parent, err := a.beginBlock(height)
parent, err := a.beginBlock(block.height)
if err != nil {
return nil, err
}
blockCtx := evmonly.BlockContext{
Number: block.number,
Time: block.timestamp,
GasLimit: parent.gasLimit,
ChainID: new(big.Int).Set(a.chainID),
BaseFee: evmOnlyBaseFee(),
BlobBaseFee: new(big.Int),
ParentHash: parent.blockHash,
BlockHash: block.blockHash,
PrevRandao: parent.appHash,
}
// Closes the stage in flight, so the gap until the next block is charged to neither.
defer a.finalizePhases.Reset()
a.finalizePhases.SetPhase("take_senders")
senders := a.takeSenders(req.Txs)
result, err := executeBlockPipelined(ctx, executor, a.finalizePhases, evmonly.BlockRequest{
Context: evmonly.BlockContext{
Number: number,
Time: timestamp,
GasLimit: parent.gasLimit,
ChainID: new(big.Int).Set(a.chainID),
BaseFee: evmOnlyBaseFee(),
BlobBaseFee: new(big.Int),
ParentHash: parent.blockHash,
BlockHash: blockHash,
PrevRandao: parent.appHash,
},
Txs: req.Txs,
Senders: senders,
})
prepared, hit := a.takePrepared(block.height, block.blockHash)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Two things follow from keying the prepared block on (height, blockHash) alone.

First, on a hit req.Txs is never executed — result, and therefore TxResults, come entirely from prepared. That is safe only because the autobahn header hash commits to payloadHash, so (height, req.Hash) pins the tx set. Nothing in the app states that requirement, and Proxy.PrepareBlock is reachable for any ABCI application; a caller whose req.Hash does not commit to req.Txs (as TestEVMOnlyApplicationIgnoresAPreparedBlockForAnotherHash constructs by hand) would silently execute a different tx list than the one it passed.

Second, forgetSenders(req.Txs) on line 741 re-runs hashRawTxs over every raw transaction in the block on the critical path, purely to evict entries peekSenders already looked up during preparation.

One change covers both: have PrepareBlock keep the tx hashes it computed in preparedBlock, then verify them against the block's txs on the hit path and use them to drop the cached senders without hashing again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not changed here by design: this stack re-opens the already-merged code unmodified so the team can review what actually runs on giga-1 (the stack top equals current giga-1). Noted as a follow-up.

a.preparedBlocks.Add(ctx, 1, otelmetric.WithAttributes(attribute.Bool("prepared", hit)))
var result *evmonly.BlockResult
if hit {
a.finalizePhases.SetPhase("take_senders")
a.forgetSenders(req.Txs)
a.finalizePhases.SetPhase("execute")
result, err = executor.ExecutePreparedBlock(ctx, evmonly.PreparedBlock{Context: blockCtx, Txs: prepared})
} else {
a.finalizePhases.SetPhase("take_senders")
senders := a.takeSenders(req.Txs)
result, err = executeBlockPipelined(ctx, executor, a.finalizePhases, evmonly.BlockRequest{
Context: blockCtx,
Txs: req.Txs,
Senders: senders,
})
}
if err != nil {
return nil, errors.Join(err, a.abandonPending(executor, height))
return nil, errors.Join(err, a.abandonPending(executor, block.height))
}
defer result.Release()
pending, err := a.pendingCursor(height)
pending, err := a.pendingCursor(block.height)
if err != nil {
return nil, err
}
Expand Down
Loading
Loading