Skip to content

Commit c8c8584

Browse files
Copilottac0turtle
andcommitted
Implement rollback support for Rollkit and EVM execution
Co-authored-by: tac0turtle <24299864+tac0turtle@users.noreply.github.com>
1 parent 0cfab48 commit c8c8584

11 files changed

Lines changed: 710 additions & 0 deletions

File tree

block/manager.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,3 +1071,66 @@ func (m *Manager) isValidSignedData(signedData *types.SignedData) bool {
10711071
valid, err := signedData.Signer.PubKey.Verify(dataBytes, signedData.Signature)
10721072
return err == nil && valid
10731073
}
1074+
1075+
// RollbackLastBlock reverts the chain state to the previous block.
1076+
// This method allows recovery from unrecoverable errors by rolling back
1077+
// the most recent block that has not been finalized.
1078+
func (m *Manager) RollbackLastBlock(ctx context.Context) error {
1079+
m.lastStateMtx.Lock()
1080+
defer m.lastStateMtx.Unlock()
1081+
1082+
currentHeight := m.lastState.LastBlockHeight
1083+
if currentHeight <= 1 {
1084+
return fmt.Errorf("cannot rollback from height %d: must be > 1", currentHeight)
1085+
}
1086+
1087+
m.logger.Info("Rolling back last block", "currentHeight", currentHeight, "targetHeight", currentHeight-1)
1088+
1089+
// First, rollback the execution layer
1090+
prevStateRoot, err := m.exec.Rollback(ctx, currentHeight)
1091+
if err != nil {
1092+
return fmt.Errorf("failed to rollback execution layer: %w", err)
1093+
}
1094+
1095+
// Then, rollback the store to the previous height
1096+
targetHeight := currentHeight - 1
1097+
if err := m.store.RollbackToHeight(ctx, targetHeight); err != nil {
1098+
return fmt.Errorf("failed to rollback store: %w", err)
1099+
}
1100+
1101+
// Update the manager's internal state to reflect the rollback
1102+
// Get the previous block's state from the store
1103+
prevState, err := m.store.GetState(ctx)
1104+
if err != nil {
1105+
return fmt.Errorf("failed to get state after rollback: %w", err)
1106+
}
1107+
1108+
// Verify that the state root matches what the execution layer returned
1109+
if !bytes.Equal(prevState.AppHash, prevStateRoot) {
1110+
m.logger.Warn("State root mismatch after rollback",
1111+
"storeStateRoot", fmt.Sprintf("%x", prevState.AppHash),
1112+
"execStateRoot", fmt.Sprintf("%x", prevStateRoot))
1113+
}
1114+
1115+
// Update the last state to the rolled-back state
1116+
m.lastState = prevState
1117+
1118+
// Clear any cached data for the rolled-back block
1119+
_, err = m.store.GetHeader(ctx, currentHeight)
1120+
if err == nil {
1121+
// Note: We can't remove from cache as there's no Remove method in the interface
1122+
// This is acceptable as the cache will eventually expire or be overwritten
1123+
}
1124+
1125+
// Reset DA included height if it was at the rolled-back height
1126+
if m.daIncludedHeight.Load() >= currentHeight {
1127+
m.daIncludedHeight.Store(targetHeight)
1128+
}
1129+
1130+
m.logger.Info("Successfully rolled back block",
1131+
"rolledBackHeight", currentHeight,
1132+
"newHeight", targetHeight,
1133+
"newStateRoot", fmt.Sprintf("%x", prevStateRoot))
1134+
1135+
return nil
1136+
}

block/publish_block_p2p_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,10 @@ func (m mockExecutor) SetFinal(ctx context.Context, blockHeight uint64) error {
241241
return nil
242242
}
243243

244+
func (m mockExecutor) Rollback(ctx context.Context, currentHeight uint64) ([]byte, error) {
245+
return bytesN(32), nil
246+
}
247+
244248
var rnd = rand.New(rand.NewSource(1)) //nolint:gosec // test code only
245249

246250
func bytesN(n int) []byte {

block/rollback_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package block
2+
3+
import (
4+
"context"
5+
"sync"
6+
"testing"
7+
"time"
8+
9+
logging "github.com/ipfs/go-log/v2"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/mock"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/rollkit/rollkit/pkg/config"
15+
"github.com/rollkit/rollkit/pkg/genesis"
16+
"github.com/rollkit/rollkit/test/mocks"
17+
"github.com/rollkit/rollkit/types"
18+
)
19+
20+
func TestManager_RollbackLastBlock(t *testing.T) {
21+
tests := []struct {
22+
name string
23+
currentHeight uint64
24+
expectError bool
25+
expectedErrorMsg string
26+
setupMocks func(*mocks.MockStore, *mocks.MockExecutor)
27+
}{
28+
{
29+
name: "cannot rollback genesis block",
30+
currentHeight: 1,
31+
expectError: true,
32+
expectedErrorMsg: "cannot rollback from height 1: must be > 1",
33+
setupMocks: func(mockStore *mocks.MockStore, mockExec *mocks.MockExecutor) {
34+
// No mocks needed as error should be returned early
35+
},
36+
},
37+
{
38+
name: "successful rollback from height 2",
39+
currentHeight: 2,
40+
expectError: false,
41+
setupMocks: func(mockStore *mocks.MockStore, mockExec *mocks.MockExecutor) {
42+
prevStateRoot := []byte{1, 2, 3, 4}
43+
prevState := types.State{
44+
ChainID: "test-chain",
45+
LastBlockHeight: 1,
46+
LastBlockTime: time.Now().Add(-time.Minute),
47+
AppHash: prevStateRoot,
48+
}
49+
50+
// Mock executor rollback
51+
mockExec.On("Rollback", mock.Anything, uint64(2)).Return(prevStateRoot, nil)
52+
53+
// Mock store rollback
54+
mockStore.On("RollbackToHeight", mock.Anything, uint64(1)).Return(nil)
55+
56+
// Mock getting state after rollback
57+
mockStore.On("GetState", mock.Anything).Return(prevState, nil)
58+
59+
// Mock getting header for cache cleanup
60+
mockStore.On("GetHeader", mock.Anything, uint64(2)).Return(nil, assert.AnError)
61+
},
62+
},
63+
{
64+
name: "executor rollback fails",
65+
currentHeight: 3,
66+
expectError: true,
67+
expectedErrorMsg: "failed to rollback execution layer",
68+
setupMocks: func(mockStore *mocks.MockStore, mockExec *mocks.MockExecutor) {
69+
// Mock executor rollback failure
70+
mockExec.On("Rollback", mock.Anything, uint64(3)).Return(nil, assert.AnError)
71+
},
72+
},
73+
{
74+
name: "store rollback fails",
75+
currentHeight: 2,
76+
expectError: true,
77+
expectedErrorMsg: "failed to rollback store",
78+
setupMocks: func(mockStore *mocks.MockStore, mockExec *mocks.MockExecutor) {
79+
prevStateRoot := []byte{1, 2, 3, 4}
80+
81+
// Mock executor rollback success
82+
mockExec.On("Rollback", mock.Anything, uint64(2)).Return(prevStateRoot, nil)
83+
84+
// Mock store rollback failure
85+
mockStore.On("RollbackToHeight", mock.Anything, uint64(1)).Return(assert.AnError)
86+
},
87+
},
88+
}
89+
90+
for _, tt := range tests {
91+
t.Run(tt.name, func(t *testing.T) {
92+
// Setup mocks
93+
mockStore := mocks.NewMockStore(t)
94+
mockExec := mocks.NewMockExecutor(t)
95+
96+
// Setup the specific mocks for this test
97+
tt.setupMocks(mockStore, mockExec)
98+
99+
// Create manager with mocks
100+
manager := &Manager{
101+
lastState: types.State{
102+
ChainID: "test-chain",
103+
LastBlockHeight: tt.currentHeight,
104+
LastBlockTime: time.Now(),
105+
AppHash: []byte{5, 6, 7, 8},
106+
},
107+
lastStateMtx: &sync.RWMutex{},
108+
store: mockStore,
109+
exec: mockExec,
110+
config: config.Config{},
111+
genesis: genesis.Genesis{},
112+
logger: logging.Logger("test"),
113+
}
114+
115+
// Set DA included height to current height for testing
116+
manager.daIncludedHeight.Store(tt.currentHeight)
117+
118+
// Execute rollback
119+
err := manager.RollbackLastBlock(context.Background())
120+
121+
// Verify results
122+
if tt.expectError {
123+
require.Error(t, err)
124+
if tt.expectedErrorMsg != "" {
125+
assert.Contains(t, err.Error(), tt.expectedErrorMsg)
126+
}
127+
} else {
128+
require.NoError(t, err)
129+
130+
// Verify state was updated
131+
assert.Equal(t, uint64(1), manager.lastState.LastBlockHeight)
132+
133+
// Verify DA included height was updated
134+
assert.Equal(t, uint64(1), manager.daIncludedHeight.Load())
135+
}
136+
})
137+
}
138+
}

core/execution/execution.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,24 @@ type Executor interface {
8282
// Returns:
8383
// - error: Any errors during finalization
8484
SetFinal(ctx context.Context, blockHeight uint64) error
85+
86+
// Rollback reverts the state to the previous block height.
87+
// This method allows recovery from unrecoverable errors by rolling back
88+
// the most recent block that has not been finalized.
89+
// Requirements:
90+
// - Must only rollback the most recent non-finalized block
91+
// - Must restore state to the exact state before the last block
92+
// - Must be atomic - either fully succeeds or leaves state unchanged
93+
// - Must respect context cancellation/timeout
94+
// - Must return error if rollback is not possible (e.g., no blocks to rollback)
95+
// - Must not rollback finalized blocks
96+
//
97+
// Parameters:
98+
// - ctx: Context for timeout/cancellation control
99+
// - currentHeight: Current block height to rollback from
100+
//
101+
// Returns:
102+
// - previousStateRoot: State root after rollback
103+
// - error: Any errors during rollback
104+
Rollback(ctx context.Context, currentHeight uint64) (previousStateRoot []byte, err error)
85105
}

execution/evm/execution.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,52 @@ func (c *EngineClient) SetFinal(ctx context.Context, blockHeight uint64) error {
305305
return c.setFinal(ctx, blockHash, true)
306306
}
307307

308+
// Rollback reverts the execution state to the previous block height.
309+
// This method allows recovery from unrecoverable errors by rolling back
310+
// the most recent block that has not been finalized.
311+
func (c *EngineClient) Rollback(ctx context.Context, currentHeight uint64) ([]byte, error) {
312+
if currentHeight <= 1 {
313+
return nil, fmt.Errorf("cannot rollback from height %d: must be > 1", currentHeight)
314+
}
315+
316+
// Get the previous block (target of rollback)
317+
prevHeight := currentHeight - 1
318+
prevBlockHash, prevStateRoot, _, _, err := c.getBlockInfo(ctx, prevHeight)
319+
if err != nil {
320+
return nil, fmt.Errorf("failed to get previous block info at height %d: %w", prevHeight, err)
321+
}
322+
323+
c.mu.Lock()
324+
defer c.mu.Unlock()
325+
326+
// Update forkchoice to the previous block as the new head
327+
// This effectively "rolls back" the chain to the previous block
328+
args := engine.ForkchoiceStateV1{
329+
HeadBlockHash: prevBlockHash,
330+
SafeBlockHash: prevBlockHash,
331+
FinalizedBlockHash: c.currentFinalizedBlockHash, // Keep finalized block unchanged
332+
}
333+
334+
// Update internal state tracking
335+
c.currentHeadBlockHash = prevBlockHash
336+
c.currentSafeBlockHash = prevBlockHash
337+
338+
var forkchoiceResult engine.ForkChoiceResponse
339+
err = c.engineClient.CallContext(ctx, &forkchoiceResult, "engine_forkchoiceUpdatedV3",
340+
args,
341+
nil, // No payload attributes needed for rollback
342+
)
343+
if err != nil {
344+
return nil, fmt.Errorf("forkchoice update for rollback failed: %w", err)
345+
}
346+
347+
if forkchoiceResult.PayloadStatus.Status != engine.VALID {
348+
return nil, fmt.Errorf("rollback forkchoice update returned invalid status: %s", forkchoiceResult.PayloadStatus.Status)
349+
}
350+
351+
return prevStateRoot.Bytes(), nil
352+
}
353+
308354
func (c *EngineClient) derivePrevRandao(blockHeight uint64) common.Hash {
309355
return common.BigToHash(new(big.Int).SetUint64(blockHeight))
310356
}

execution/evm/rollback_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package evm
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/ethereum/go-ethereum/common"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestEngineClient_Rollback(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
currentHeight uint64
16+
expectError bool
17+
expectedErrorMsg string
18+
}{
19+
{
20+
name: "cannot rollback from height 1",
21+
currentHeight: 1,
22+
expectError: true,
23+
expectedErrorMsg: "cannot rollback from height 1: must be > 1",
24+
},
25+
{
26+
name: "cannot rollback from height 0",
27+
currentHeight: 0,
28+
expectError: true,
29+
expectedErrorMsg: "cannot rollback from height 0: must be > 1",
30+
},
31+
}
32+
33+
for _, tt := range tests {
34+
t.Run(tt.name, func(t *testing.T) {
35+
// Create a mock EVM client
36+
client := &EngineClient{
37+
genesisHash: common.HexToHash("0x1234"),
38+
feeRecipient: common.HexToAddress("0x5678"),
39+
currentHeadBlockHash: common.HexToHash("0xabcd"),
40+
currentSafeBlockHash: common.HexToHash("0xabcd"),
41+
currentFinalizedBlockHash: common.HexToHash("0x1234"),
42+
}
43+
44+
// Execute rollback
45+
prevStateRoot, err := client.Rollback(context.Background(), tt.currentHeight)
46+
47+
// Verify results
48+
if tt.expectError {
49+
require.Error(t, err)
50+
if tt.expectedErrorMsg != "" {
51+
assert.Contains(t, err.Error(), tt.expectedErrorMsg)
52+
}
53+
assert.Nil(t, prevStateRoot)
54+
} else {
55+
// This case won't be tested without real EVM connection
56+
t.Skip("Requires real EVM connection for success case")
57+
}
58+
})
59+
}
60+
}
61+
62+
func TestEngineClient_RollbackValidation(t *testing.T) {
63+
client := &EngineClient{}
64+
65+
// Test validation without network calls
66+
_, err := client.Rollback(context.Background(), 1)
67+
require.Error(t, err)
68+
assert.Contains(t, err.Error(), "cannot rollback from height 1: must be > 1")
69+
70+
_, err = client.Rollback(context.Background(), 0)
71+
require.Error(t, err)
72+
assert.Contains(t, err.Error(), "cannot rollback from height 0: must be > 1")
73+
}

0 commit comments

Comments
 (0)