Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Better check for store meta not stale
  • Loading branch information
alpe committed Mar 31, 2026
commit e2ad7e6d358a580f878f730e93a0ee021f1a0e43
60 changes: 38 additions & 22 deletions execution/evm/execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -756,24 +756,33 @@ func (c *EngineClient) reconcileExecutionAtHeight(ctx context.Context, height ui
// If we have a started execution with a payloadID, validate it still exists before resuming.
// After node restart, the EL's payload cache is ephemeral and the payloadID may be stale.
if execMeta.Stage == ExecStageStarted && len(execMeta.PayloadID) == 8 {
var pid engine.PayloadID
copy(pid[:], execMeta.PayloadID)
requestedTxHash := hashTxs(txs)
if execMeta.Timestamp != timestamp.Unix() || !bytes.Equal(execMeta.TxHash, requestedTxHash) {
c.logger.Debug().
Uint64("height", height).
Int64("execmeta_timestamp", execMeta.Timestamp).
Int64("requested_timestamp", timestamp.Unix()).
Msg("ExecuteTxs: ignoring stale in-progress execution for different block inputs")
} else {
var pid engine.PayloadID
copy(pid[:], execMeta.PayloadID)

// Validate payload still exists by attempting to retrieve it
if _, err = c.engineClient.GetPayload(ctx, pid); err == nil {
c.logger.Info().
// Validate payload still exists by attempting to retrieve it
if _, err = c.engineClient.GetPayload(ctx, pid); err == nil {
c.logger.Info().
Uint64("height", height).
Str("stage", execMeta.Stage).
Msg("ExecuteTxs: found in-progress execution with payloadID, returning payloadID for resume")
return nil, &pid, true, nil
}
// Payload is stale (expired or node restarted) - proceed with fresh execution
c.logger.Debug().
Uint64("height", height).
Str("stage", execMeta.Stage).
Msg("ExecuteTxs: found in-progress execution with payloadID, returning payloadID for resume")
return nil, &pid, true, nil
Str("payloadID", pid.String()).
Err(err).
Msg("ExecuteTxs: stale ExecMeta payloadID no longer valid in EL, will re-execute")
// Don't return - fall through to fresh execution
}
Comment on lines 758 to 785

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.

⚠️ Potential issue | 🔴 Critical

ExecStageStarted resumes still ignore parent-chain changes.

This gate only checks timestamp and tx hash before reusing pid. After a rollback/reorg, the parent at height-1 can change while both still match, so we can resume and later promote a payload that was built on the old parent. Please also validate the current parent hash before returning pid; ExecMeta already has a ParentHash field you can use.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@execution/evm/execution.go` around lines 758 - 785, The resume path for
ExecStageStarted currently only checks timestamp and tx hash
(execMeta.Timestamp, execMeta.TxHash via hashTxs(txs)) before returning the
stored PayloadID (pid); you must also validate that the current parent hash
matches execMeta.ParentHash to avoid resuming on a changed parent. Retrieve the
current parent hash for the block (height-1) from the chain/headers API
available in this package and compare it to execMeta.ParentHash; if they differ,
treat the payload as stale (log and fall through to fresh execution) rather than
returning pid even if c.engineClient.GetPayload(ctx, pid) succeeds. Ensure you
still keep the existing GetPayload existence check (c.engineClient.GetPayload)
but only return pid when both the payload exists and the parent hash matches
execMeta.ParentHash.

// Payload is stale (expired or node restarted) - proceed with fresh execution
c.logger.Debug().
Uint64("height", height).
Str("payloadID", pid.String()).
Err(err).
Msg("ExecuteTxs: stale ExecMeta payloadID no longer valid in EL, will re-execute")
// Don't return - fall through to fresh execution
}
}

Expand Down Expand Up @@ -1023,13 +1032,7 @@ func (c *EngineClient) saveExecMeta(ctx context.Context, height uint64, timestam
}

// Compute tx hash for sanity checks on retry
if len(txs) > 0 {
h := sha256.New()
for _, tx := range txs {
h.Write(tx)
}
execMeta.TxHash = h.Sum(nil)
}
execMeta.TxHash = hashTxs(txs)

if err := c.store.SaveExecMeta(ctx, execMeta); err != nil {
c.logger.Warn().Err(err).Uint64("height", height).Msg("saveExecMeta: failed to save exec meta")
Expand All @@ -1042,6 +1045,19 @@ func (c *EngineClient) saveExecMeta(ctx context.Context, height uint64, timestam
Msg("saveExecMeta: saved execution metadata")
}

func hashTxs(txs [][]byte) []byte {
if len(txs) == 0 {
return nil
}

h := sha256.New()
for _, tx := range txs {
h.Write(tx)
}

return h.Sum(nil)
}
Comment on lines +1048 to +1059

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.

⚠️ Potential issue | 🟠 Major

Make the tx-list hash unambiguous.

hashTxs hashes the raw concatenation of every tx, so different [][]byte values can collide when they flatten to the same byte stream. That weakens the stale-meta guard this PR is adding. Hash each element independently or include a separator/length per element.

Possible fix
 func hashTxs(txs [][]byte) []byte {
 	if len(txs) == 0 {
 		return nil
 	}
 
 	h := sha256.New()
 	for _, tx := range txs {
-		h.Write(tx)
+		txHash := sha256.Sum256(tx)
+		h.Write(txHash[:])
 	}
 
 	return h.Sum(nil)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func hashTxs(txs [][]byte) []byte {
if len(txs) == 0 {
return nil
}
h := sha256.New()
for _, tx := range txs {
h.Write(tx)
}
return h.Sum(nil)
}
func hashTxs(txs [][]byte) []byte {
if len(txs) == 0 {
return nil
}
h := sha256.New()
for _, tx := range txs {
txHash := sha256.Sum256(tx)
h.Write(txHash[:])
}
return h.Sum(nil)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@execution/evm/execution.go` around lines 1048 - 1059, The hashTxs function
currently concatenates raw tx bytes which can produce ambiguous collisions;
modify hashTxs to make the tx-list encoding unambiguous by prefixing each tx
with its length (or hashing each tx separately with a domain separator) before
writing into the sha256 hasher: e.g., for each tx in hashTxs write the tx length
as a fixed-size varint/uint64 or a clear separator and then write the tx bytes
(or write sha256(tx) with a separator), then return h.Sum(nil) so the digest
uniquely represents the ordered list.


// GetLatestHeight returns the current block height of the execution layer
func (c *EngineClient) GetLatestHeight(ctx context.Context) (uint64, error) {
header, err := c.ethClient.HeaderByNumber(ctx, nil) // nil = latest block
Expand Down
125 changes: 125 additions & 0 deletions execution/evm/execution_reconcile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package evm

import (
"context"
"errors"
"math/big"
"testing"
"time"

"github.com/ethereum/go-ethereum/beacon/engine"
"github.com/ethereum/go-ethereum/core/types"
ds "github.com/ipfs/go-datastore"
dssync "github.com/ipfs/go-datastore/sync"
"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
)

func TestReconcileExecutionAtHeight_StartedExecMeta(t *testing.T) {
specs := map[string]struct {
execMetaTimestamp int64
execMetaTxs [][]byte
requestedTxs [][]byte
requestedTime time.Time
expectFound bool
expectPayloadID bool
expectGetPayloads int
}{
"resume_when_inputs_match": {
execMetaTimestamp: 1700000012,
execMetaTxs: [][]byte{[]byte("tx-1")},
requestedTxs: [][]byte{[]byte("tx-1")},
requestedTime: time.Unix(1700000012, 0),
expectFound: true,
expectPayloadID: true,
expectGetPayloads: 1,
},
"ignore_when_timestamp_differs": {
execMetaTimestamp: 1700000010,
execMetaTxs: [][]byte{[]byte("tx-1")},
requestedTxs: [][]byte{[]byte("tx-1")},
requestedTime: time.Unix(1700000012, 0),
expectFound: false,
expectPayloadID: false,
expectGetPayloads: 0,
},
"ignore_when_txs_differ": {
execMetaTimestamp: 1700000012,
execMetaTxs: [][]byte{[]byte("tx-old")},
requestedTxs: [][]byte{[]byte("tx-new")},
requestedTime: time.Unix(1700000012, 0),
expectFound: false,
expectPayloadID: false,
expectGetPayloads: 0,
},
}
for name, spec := range specs {
t.Run(name, func(t *testing.T) {
store := NewEVMStore(dssync.MutexWrap(ds.NewMapDatastore()))
payloadID := engine.PayloadID{1, 2, 3, 4, 5, 6, 7, 8}
require.NoError(t, store.SaveExecMeta(t.Context(), &ExecMeta{
Height: 12,
PayloadID: payloadID[:],
TxHash: hashTxs(spec.execMetaTxs),
Timestamp: spec.execMetaTimestamp,
Stage: ExecStageStarted,
}))

engineRPC := &mockReconcileEngineRPCClient{
payloads: map[engine.PayloadID]*engine.ExecutionPayloadEnvelope{
payloadID: {},
},
}
client := &EngineClient{
engineClient: engineRPC,
ethClient: mockReconcileEthRPCClient{},
store: store,
logger: zerolog.Nop(),
}

stateRoot, gotPayloadID, found, err := client.reconcileExecutionAtHeight(t.Context(), 12, spec.requestedTime, spec.requestedTxs)

require.NoError(t, err)
require.Nil(t, stateRoot)
require.Equal(t, spec.expectFound, found)
require.Equal(t, spec.expectPayloadID, gotPayloadID != nil)
if spec.expectPayloadID {
require.Equal(t, payloadID, *gotPayloadID)
}
require.Equal(t, spec.expectGetPayloads, engineRPC.getPayloadCalls)
})
}
}

type mockReconcileEngineRPCClient struct {
payloads map[engine.PayloadID]*engine.ExecutionPayloadEnvelope
getPayloadCalls int
}

func (m *mockReconcileEngineRPCClient) ForkchoiceUpdated(_ context.Context, _ engine.ForkchoiceStateV1, _ map[string]any) (*engine.ForkChoiceResponse, error) {
return nil, errors.New("unexpected ForkchoiceUpdated call")
}

func (m *mockReconcileEngineRPCClient) GetPayload(_ context.Context, payloadID engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) {
m.getPayloadCalls++
payload, ok := m.payloads[payloadID]
if !ok {
return nil, errors.New("payload not found")
}

return payload, nil
}

func (m *mockReconcileEngineRPCClient) NewPayload(_ context.Context, _ *engine.ExecutableData, _ []string, _ string, _ [][]byte) (*engine.PayloadStatusV1, error) {
return nil, errors.New("unexpected NewPayload call")
}

type mockReconcileEthRPCClient struct{}

func (mockReconcileEthRPCClient) HeaderByNumber(_ context.Context, _ *big.Int) (*types.Header, error) {
return nil, errors.New("header not found")
}

func (mockReconcileEthRPCClient) GetTxs(_ context.Context) ([]string, error) {
return nil, errors.New("unexpected GetTxs call")
}
Loading