Skip to content

Commit 1ca8457

Browse files
tac0turtletac0turtle
andauthored
feat: execution verification (#2377)
<!-- Please read and fill out this form before submitting your PR. Please make sure you have reviewed our contributors guide before submitting your first PR. NOTE: PR titles should follow semantic commits: https://www.conventionalcommits.org/en/v1.0.0/ --> ## Overview fixes #2250 this pr adds apphash verification based on the execution environment. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for immediate and delayed execution modes, allowing more flexible block processing and validation. * Exposed execution mode information in executors and related interfaces. * **Bug Fixes** * Re-enabled strict block time validation to ensure block times are strictly increasing. * Improved AppHash validation logic based on execution mode. * **Tests** * Reactivated and updated tests for block time and AppHash validation. * Extended mocks to support execution mode queries. * **Chores** * Updated dependency management and Go toolchain versions. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: tac0turtle <you@example.com>
1 parent c3096b5 commit 1ca8457

11 files changed

Lines changed: 240 additions & 62 deletions

File tree

apps/testapp/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ require (
1313
cosmossdk.io/log v1.6.0
1414
github.com/ipfs/go-datastore v0.8.2
1515
github.com/rollkit/rollkit v0.0.0-00010101000000-000000000000
16+
github.com/rollkit/rollkit/core v0.0.0-20250312114929-104787ba1a4c
1617
github.com/rollkit/rollkit/da v0.0.0-00010101000000-000000000000
1718
github.com/rollkit/rollkit/sequencers/single v0.0.0-00010101000000-000000000000
1819
github.com/rs/zerolog v1.34.0
@@ -147,7 +148,6 @@ require (
147148
github.com/quic-go/quic-go v0.50.1 // indirect
148149
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 // indirect
149150
github.com/raulk/go-watchdog v1.3.0 // indirect
150-
github.com/rollkit/rollkit/core v0.0.0-20250312114929-104787ba1a4c // indirect
151151
github.com/sagikazarmark/locafero v0.4.0 // indirect
152152
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
153153
github.com/sourcegraph/conc v0.3.0 // indirect

apps/testapp/kv/kvexecutor.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
ds "github.com/ipfs/go-datastore"
1212
"github.com/ipfs/go-datastore/query"
13+
"github.com/rollkit/rollkit/core/execution"
1314
"github.com/rollkit/rollkit/pkg/store"
1415
)
1516

@@ -243,6 +244,12 @@ func (k *KVExecutor) SetFinal(ctx context.Context, blockHeight uint64) error {
243244
return k.db.Put(ctx, ds.NewKey("/finalizedHeight"), []byte(fmt.Sprintf("%d", blockHeight)))
244245
}
245246

247+
// GetExecutionMode returns the execution mode for this executor.
248+
// KVExecutor uses delayed execution mode.
249+
func (k *KVExecutor) GetExecutionMode() execution.ExecutionMode {
250+
return execution.ExecutionModeDelayed
251+
}
252+
246253
// InjectTx adds a transaction to the mempool channel.
247254
// Uses a non-blocking send to avoid blocking the caller if the channel is full.
248255
func (k *KVExecutor) InjectTx(tx []byte) {

block/manager.go

Lines changed: 114 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ type Manager struct {
147147
metrics *Metrics
148148

149149
exec coreexecutor.Executor
150+
// executionMode caches the execution mode from the executor
151+
executionMode coreexecutor.ExecutionMode
150152

151153
// daIncludedHeight is rollkit height at which all blocks have been included
152154
// in the DA
@@ -351,6 +353,8 @@ func NewManager(
351353
daH := atomic.Uint64{}
352354
daH.Store(s.DAHeight)
353355

356+
executionMode := exec.GetExecutionMode()
357+
354358
m := &Manager{
355359
signer: signer,
356360
config: config,
@@ -380,6 +384,7 @@ func NewManager(
380384
metrics: seqMetrics,
381385
sequencer: sequencer,
382386
exec: exec,
387+
executionMode: executionMode,
383388
da: da,
384389
gasPrice: gasPrice,
385390
gasMultiplier: gasMultiplier,
@@ -800,22 +805,26 @@ func (m *Manager) execValidate(lastState types.State, header *types.SignedHeader
800805
}
801806

802807
// // Verify that the header's timestamp is strictly greater than the last block's time
803-
// headerTime := header.Time()
804-
// if header.Height() > 1 && lastState.LastBlockTime.After(headerTime) {
805-
// return fmt.Errorf("block time must be strictly increasing: got %v, last block time was %v",
806-
// headerTime.UnixNano(), lastState.LastBlockTime)
807-
// }
808-
809-
// // Validate that the header's AppHash matches the lastState's AppHash
810-
// // Note: Assumes deferred execution
811-
// if !bytes.Equal(header.AppHash, lastState.AppHash) {
812-
// return fmt.Errorf("app hash mismatch: expected %x, got %x", lastState.AppHash, header.AppHash)
813-
// }
808+
headerTime := header.Time()
809+
if header.Height() > 1 && lastState.LastBlockTime.After(headerTime) {
810+
return fmt.Errorf("block time must be strictly increasing: got %v, last block time was %v",
811+
headerTime.UnixNano(), lastState.LastBlockTime)
812+
}
813+
814+
// Validate AppHash based on execution mode
815+
if m.executionMode == coreexecutor.ExecutionModeDelayed {
816+
// In delayed mode, AppHash should match the last state's AppHash
817+
if !bytes.Equal(header.AppHash, lastState.AppHash) {
818+
return fmt.Errorf("appHash mismatch in delayed mode: expected %x, got %x", lastState.AppHash, header.AppHash)
819+
}
820+
}
821+
// Note: For immediate mode, we can't validate AppHash against lastState because
822+
// it represents the state after executing the current block's transactions
814823

815824
return nil
816825
}
817826

818-
func (m *Manager) execCreateBlock(_ context.Context, height uint64, lastSignature *types.Signature, lastHeaderHash types.Hash, _ types.State, batchData *BatchData) (*types.SignedHeader, *types.Data, error) {
827+
func (m *Manager) execCreateBlock(ctx context.Context, height uint64, lastSignature *types.Signature, lastHeaderHash types.Hash, lastState types.State, batchData *BatchData) (*types.SignedHeader, *types.Data, error) {
819828
// Use when batchData is set to data IDs from the DA layer
820829
// batchDataIDs := convertBatchDataToBytes(batchData.Data)
821830

@@ -840,21 +849,54 @@ func (m *Manager) execCreateBlock(_ context.Context, height uint64, lastSignatur
840849
// Determine if this is an empty block
841850
isEmpty := batchData.Batch == nil || len(batchData.Transactions) == 0
842851

852+
// Create block data with appropriate transactions
853+
blockData := &types.Data{
854+
Txs: make(types.Txs, 0), // Start with empty transaction list
855+
}
856+
857+
// Only add transactions if this is not an empty block
858+
if !isEmpty {
859+
blockData.Txs = make(types.Txs, len(batchData.Transactions))
860+
for i := range batchData.Transactions {
861+
blockData.Txs[i] = types.Tx(batchData.Transactions[i])
862+
}
863+
}
864+
865+
// Determine AppHash based on execution mode
866+
var appHash []byte
867+
if m.executionMode == coreexecutor.ExecutionModeImmediate && !isEmpty {
868+
// For immediate execution, execute transactions now to get the new state root
869+
rawTxs := make([][]byte, len(blockData.Txs))
870+
for i := range blockData.Txs {
871+
rawTxs[i] = blockData.Txs[i]
872+
}
873+
874+
// Execute transactions
875+
newStateRoot, _, err := m.exec.ExecuteTxs(ctx, rawTxs, height, batchData.Time, lastState.AppHash)
876+
if err != nil {
877+
return nil, nil, fmt.Errorf("failed to execute transactions for immediate mode: %w", err)
878+
}
879+
appHash = newStateRoot
880+
} else {
881+
// For delayed execution or empty blocks, use the app hash from last state
882+
appHash = lastState.AppHash
883+
}
884+
843885
header := &types.SignedHeader{
844886
Header: types.Header{
845887
Version: types.Version{
846-
Block: m.lastState.Version.Block,
847-
App: m.lastState.Version.App,
888+
Block: lastState.Version.Block,
889+
App: lastState.Version.App,
848890
},
849891
BaseHeader: types.BaseHeader{
850-
ChainID: m.lastState.ChainID,
892+
ChainID: lastState.ChainID,
851893
Height: height,
852894
Time: uint64(batchData.UnixNano()), //nolint:gosec // why is time unix? (tac0turtle)
853895
},
854896
LastHeaderHash: lastHeaderHash,
855-
// DataHash is set at the end of the function
897+
// DataHash is set below
856898
ConsensusHash: make(types.Hash, 32),
857-
AppHash: m.lastState.AppHash,
899+
AppHash: appHash,
858900
ProposerAddress: m.genesis.ProposerAddress,
859901
},
860902
Signature: *lastSignature,
@@ -864,17 +906,8 @@ func (m *Manager) execCreateBlock(_ context.Context, height uint64, lastSignatur
864906
},
865907
}
866908

867-
// Create block data with appropriate transactions
868-
blockData := &types.Data{
869-
Txs: make(types.Txs, 0), // Start with empty transaction list
870-
}
871-
872-
// Only add transactions if this is not an empty block
909+
// Set DataHash
873910
if !isEmpty {
874-
blockData.Txs = make(types.Txs, len(batchData.Transactions))
875-
for i := range batchData.Transactions {
876-
blockData.Txs[i] = types.Tx(batchData.Transactions[i])
877-
}
878911
header.DataHash = blockData.DACommitment()
879912
} else {
880913
header.DataHash = dataHashForEmptyTxs
@@ -884,15 +917,61 @@ func (m *Manager) execCreateBlock(_ context.Context, height uint64, lastSignatur
884917
}
885918

886919
func (m *Manager) execApplyBlock(ctx context.Context, lastState types.State, header *types.SignedHeader, data *types.Data) (types.State, error) {
887-
rawTxs := make([][]byte, len(data.Txs))
888-
for i := range data.Txs {
889-
rawTxs[i] = data.Txs[i]
890-
}
920+
var newStateRoot []byte
921+
922+
// Check if we need to execute transactions
923+
if m.executionMode == coreexecutor.ExecutionModeImmediate {
924+
// For immediate mode, the aggregator already executed during block creation
925+
// But syncing nodes need to execute to verify the AppHash
926+
// Check if we're the block creator by verifying we signed this block
927+
isBlockCreator := false
928+
if m.signer != nil {
929+
myAddress, err := m.signer.GetAddress()
930+
if err == nil && bytes.Equal(header.ProposerAddress, myAddress) {
931+
// Also verify this is the current height we're producing
932+
currentHeight, err := m.store.Height(ctx)
933+
if err == nil && header.Height() == currentHeight+1 {
934+
isBlockCreator = true
935+
}
936+
}
937+
}
891938

892-
ctx = context.WithValue(ctx, types.SignedHeaderContextKey, header)
893-
newStateRoot, _, err := m.exec.ExecuteTxs(ctx, rawTxs, header.Height(), header.Time(), lastState.AppHash)
894-
if err != nil {
895-
return types.State{}, fmt.Errorf("failed to execute transactions: %w", err)
939+
if isBlockCreator {
940+
// We created this block, so we already executed transactions in execCreateBlock
941+
// Just use the AppHash from the header
942+
newStateRoot = header.AppHash
943+
} else {
944+
// We're syncing this block, need to execute to verify AppHash
945+
rawTxs := make([][]byte, len(data.Txs))
946+
for i := range data.Txs {
947+
rawTxs[i] = data.Txs[i]
948+
}
949+
950+
ctx = context.WithValue(ctx, types.SignedHeaderContextKey, header)
951+
computedStateRoot, _, err := m.exec.ExecuteTxs(ctx, rawTxs, header.Height(), header.Time(), lastState.AppHash)
952+
if err != nil {
953+
return types.State{}, fmt.Errorf("failed to execute transactions: %w", err)
954+
}
955+
956+
// Verify the AppHash matches
957+
if !bytes.Equal(header.AppHash, computedStateRoot) {
958+
return types.State{}, fmt.Errorf("AppHash mismatch in immediate mode: expected %x, got %x", computedStateRoot, header.AppHash)
959+
}
960+
newStateRoot = computedStateRoot
961+
}
962+
} else {
963+
// For delayed mode, always execute transactions
964+
rawTxs := make([][]byte, len(data.Txs))
965+
for i := range data.Txs {
966+
rawTxs[i] = data.Txs[i]
967+
}
968+
969+
ctx = context.WithValue(ctx, types.SignedHeaderContextKey, header)
970+
var err error
971+
newStateRoot, _, err = m.exec.ExecuteTxs(ctx, rawTxs, header.Height(), header.Time(), lastState.AppHash)
972+
if err != nil {
973+
return types.State{}, fmt.Errorf("failed to execute transactions: %w", err)
974+
}
896975
}
897976

898977
s, err := lastState.NextState(header, newStateRoot)

block/manager_test.go

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -575,28 +575,26 @@ func TestManager_execValidate(t *testing.T) {
575575
require.NoError(err)
576576
})
577577

578-
// TODO: https://github.com/rollkit/rollkit/issues/2250
579-
580-
// t.Run("non-monotonic block time with height > 1", func(t *testing.T) {
581-
// state, header, data, privKey := makeValid()
582-
// state.LastBlockTime = time.Now().Add(time.Minute)
583-
// state.LastBlockHeight = 1
584-
// header.BaseHeader.Height = state.LastBlockHeight + 1
585-
// data.Metadata.Height = state.LastBlockHeight + 1
586-
// signer, err := noopsigner.NewNoopSigner(privKey)
587-
// require.NoError(err)
588-
// header.Signature, err = types.GetSignature(header.Header, signer)
589-
// require.NoError(err)
590-
// err = m.execValidate(state, header, data)
591-
// require.ErrorContains(err, "block time must be strictly increasing")
592-
// })
593-
594-
// t.Run("app hash mismatch", func(t *testing.T) {
595-
// state, header, data, _ := makeValid()
596-
// state.AppHash = []byte("different")
597-
// err := m.execValidate(state, header, data)
598-
// require.ErrorContains(err, "app hash mismatch")
599-
// })
578+
t.Run("non-monotonic block time with height > 1", func(t *testing.T) {
579+
state, header, data, privKey := makeValid()
580+
state.LastBlockTime = time.Now().Add(time.Minute)
581+
state.LastBlockHeight = 1
582+
header.BaseHeader.Height = state.LastBlockHeight + 1
583+
data.Metadata.Height = state.LastBlockHeight + 1
584+
signer, err := noopsigner.NewNoopSigner(privKey)
585+
require.NoError(err)
586+
header.Signature, err = types.GetSignature(header.Header, signer)
587+
require.NoError(err)
588+
err = m.execValidate(state, header, data)
589+
require.ErrorContains(err, "block time must be strictly increasing")
590+
})
591+
592+
t.Run("app hash mismatch", func(t *testing.T) {
593+
state, header, data, _ := makeValid()
594+
state.AppHash = []byte("different")
595+
err := m.execValidate(state, header, data)
596+
require.ErrorContains(err, "appHash mismatch in delayed mode")
597+
})
600598
}
601599

602600
// TestGetterMethods tests simple getter methods for the Manager

block/publish_block_p2p_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/stretchr/testify/assert"
2121
"github.com/stretchr/testify/require"
2222

23+
coreexecution "github.com/rollkit/rollkit/core/execution"
2324
coresequencer "github.com/rollkit/rollkit/core/sequencer"
2425
"github.com/rollkit/rollkit/pkg/config"
2526
genesispkg "github.com/rollkit/rollkit/pkg/genesis"
@@ -236,6 +237,10 @@ func (m mockExecutor) SetFinal(ctx context.Context, blockHeight uint64) error {
236237
return nil
237238
}
238239

240+
func (m mockExecutor) GetExecutionMode() coreexecution.ExecutionMode {
241+
return coreexecution.ExecutionModeDelayed
242+
}
243+
239244
var rnd = rand.New(rand.NewSource(1)) //nolint:gosec // test code only
240245

241246
func bytesN(n int) []byte {

core/execution/dummy.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ func (e *DummyExecutor) SetFinal(ctx context.Context, blockHeight uint64) error
9191
return fmt.Errorf("cannot set finalized block at height %d", blockHeight)
9292
}
9393

94+
// GetExecutionMode returns the execution mode for this executor.
95+
// DummyExecutor uses delayed execution mode.
96+
func (e *DummyExecutor) GetExecutionMode() ExecutionMode {
97+
return ExecutionModeDelayed
98+
}
99+
94100
func (e *DummyExecutor) removeExecutedTxs(txs [][]byte) {
95101
e.injectedTxs = slices.DeleteFunc(e.injectedTxs, func(tx []byte) bool {
96102
return slices.ContainsFunc(txs, func(t []byte) bool { return bytes.Equal(tx, t) })

core/execution/execution.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ import (
55
"time"
66
)
77

8+
// ExecutionMode defines when state transitions are applied relative to block creation
9+
type ExecutionMode int
10+
11+
const (
12+
// ExecutionModeDelayed means the AppHash in block N represents the state after executing block N-1
13+
// This is the traditional approach used by CometBFT/Tendermint
14+
ExecutionModeDelayed ExecutionMode = iota
15+
16+
// ExecutionModeImmediate means the AppHash in block N represents the state after executing block N
17+
// Transactions are executed during block creation and the resulting state root is included
18+
ExecutionModeImmediate
19+
20+
// ExecutionModeOptimistic means transactions are executed optimistically and finalized later
21+
// This allows for faster block creation but requires a finalization step to confirm state
22+
// ExecutionModeOptimistic
23+
)
24+
825
// Executor defines the interface that execution clients must implement to be compatible with Rollkit.
926
// This interface enables the separation between consensus and execution layers, allowing for modular
1027
// and pluggable execution environments.
@@ -53,6 +70,7 @@ type Executor interface {
5370
// - Must maintain deterministic execution
5471
// - Must respect context cancellation/timeout
5572
// - The rest of the rules are defined by the specific execution layer
73+
// - Must verify state root after execution
5674
//
5775
// Parameters:
5876
// - ctx: Context for timeout/cancellation control
@@ -82,4 +100,17 @@ type Executor interface {
82100
// Returns:
83101
// - error: Any errors during finalization
84102
SetFinal(ctx context.Context, blockHeight uint64) error
103+
104+
// GetExecutionMode returns the execution mode supported by this executor.
105+
// This determines when state transitions are applied:
106+
// - Delayed: AppHash in block N represents state after executing block N-1
107+
// - Immediate: AppHash in block N represents state after executing block N
108+
//
109+
// Requirements:
110+
// - Must return consistent value across all calls
111+
// - Mode cannot change after initialization
112+
//
113+
// Returns:
114+
// - ExecutionMode: The execution mode supported by this executor
115+
GetExecutionMode() ExecutionMode
85116
}

execution/evm/execution.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,13 @@ func (c *EngineClient) SetFinal(ctx context.Context, blockHeight uint64) error {
302302
return c.setFinal(ctx, blockHash, true)
303303
}
304304

305+
// GetExecutionMode returns the execution mode for this executor.
306+
// EngineClient uses immediate execution mode since it calculates the state root
307+
// during transaction execution via engine_getPayloadV4.
308+
func (c *EngineClient) GetExecutionMode() execution.ExecutionMode {
309+
return execution.ExecutionModeImmediate
310+
}
311+
305312
func (c *EngineClient) derivePrevRandao(blockHeight uint64) common.Hash {
306313
return common.BigToHash(new(big.Int).SetUint64(blockHeight))
307314
}

0 commit comments

Comments
 (0)