Skip to content

Commit 3d9b7f9

Browse files
committed
add a way to verify current apphashes
1 parent 8b2b8d6 commit 3d9b7f9

7 files changed

Lines changed: 217 additions & 99 deletions

File tree

block/internal/executing/executor.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,32 @@ func (e *Executor) produceBlock() error {
395395
return fmt.Errorf("failed to apply block: %w", err)
396396
}
397397

398+
if len(newState.AppHash) == 0 {
399+
return fmt.Errorf("execution client returned empty app hash")
400+
}
401+
402+
// Update header's AppHash if needed and recompute state's LastHeaderHash to match
403+
headerModified := false
404+
switch {
405+
case len(header.Header.AppHash) == 0:
406+
header.Header.AppHash = bytes.Clone(newState.AppHash)
407+
headerModified = true
408+
case bytes.Equal(header.Header.AppHash, newState.AppHash):
409+
// already matches expected state root
410+
case bytes.Equal(header.Header.AppHash, currentState.AppHash):
411+
// header still carries previous state's apphash; update it to the new post-state value
412+
header.Header.AppHash = bytes.Clone(newState.AppHash)
413+
headerModified = true
414+
default:
415+
return fmt.Errorf("header app hash mismatch - got: %x, want: %x", header.Header.AppHash, newState.AppHash)
416+
}
417+
418+
// If we modified the header's AppHash, we need to update the state's LastHeaderHash
419+
// to match the new header hash (since the hash includes AppHash)
420+
if headerModified {
421+
newState.LastHeaderHash = header.Hash()
422+
}
423+
398424
// set the DA height in the sequencer
399425
newState.DAHeight = e.sequencer.GetDAHeight()
400426

block/internal/syncing/syncer.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,13 @@ func (s *Syncer) trySyncNextBlock(event *common.DAHeightEvent) error {
551551
return fmt.Errorf("failed to apply block: %w", err)
552552
}
553553

554+
if len(newState.AppHash) == 0 {
555+
return fmt.Errorf("execution client returned empty app hash")
556+
}
557+
if len(header.Header.AppHash) != 0 && !bytes.Equal(header.Header.AppHash, newState.AppHash) {
558+
return fmt.Errorf("header app hash mismatch - got: %x, want: %x", header.Header.AppHash, newState.AppHash)
559+
}
560+
554561
// Update DA height if needed
555562
// This height is only updated when a height is processed from DA as P2P
556563
// events do not contain DA height information

block/internal/syncing/syncer_test.go

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,13 @@ func TestProcessHeightEvent_SyncsAndUpdatesState(t *testing.T) {
187187
// Create signed header & data for height 1
188188
lastState := s.GetLastState()
189189
data := makeData(gen.ChainID, 1, 0)
190-
_, hdr := makeSignedHeaderBytes(t, gen.ChainID, 1, addr, pub, signer, lastState.AppHash, data, nil)
190+
// Header should have post-execution AppHash (what the producer would set after execution)
191+
postExecAppHash := []byte("app1")
192+
_, hdr := makeSignedHeaderBytes(t, gen.ChainID, 1, addr, pub, signer, postExecAppHash, data, nil)
191193

192194
// Expect ExecuteTxs call for height 1
193195
mockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, uint64(1), mock.Anything, lastState.AppHash).
194-
Return([]byte("app1"), uint64(1024), nil).Once()
196+
Return(postExecAppHash, uint64(1024), nil).Once()
195197

196198
evt := common.DAHeightEvent{Header: hdr, Data: data, DaHeight: 1}
197199
s.processHeightEvent(&evt)
@@ -240,19 +242,23 @@ func TestSequentialBlockSync(t *testing.T) {
240242
// Sync two consecutive blocks via processHeightEvent so ExecuteTxs is called and state stored
241243
st0 := s.GetLastState()
242244
data1 := makeData(gen.ChainID, 1, 1) // non-empty
243-
_, hdr1 := makeSignedHeaderBytes(t, gen.ChainID, 1, addr, pub, signer, st0.AppHash, data1, st0.LastHeaderHash)
245+
// Header should have post-execution AppHash (what the producer would set after execution)
246+
postExecAppHash1 := []byte("app1")
247+
_, hdr1 := makeSignedHeaderBytes(t, gen.ChainID, 1, addr, pub, signer, postExecAppHash1, data1, st0.LastHeaderHash)
244248
// Expect ExecuteTxs call for height 1
245249
mockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, uint64(1), mock.Anything, st0.AppHash).
246-
Return([]byte("app1"), uint64(1024), nil).Once()
250+
Return(postExecAppHash1, uint64(1024), nil).Once()
247251
evt1 := common.DAHeightEvent{Header: hdr1, Data: data1, DaHeight: 10}
248252
s.processHeightEvent(&evt1)
249253

250254
st1, _ := st.GetState(context.Background())
251255
data2 := makeData(gen.ChainID, 2, 0) // empty data
252-
_, hdr2 := makeSignedHeaderBytes(t, gen.ChainID, 2, addr, pub, signer, st1.AppHash, data2, st1.LastHeaderHash)
256+
// Header should have post-execution AppHash (what the producer would set after execution)
257+
postExecAppHash2 := []byte("app2")
258+
_, hdr2 := makeSignedHeaderBytes(t, gen.ChainID, 2, addr, pub, signer, postExecAppHash2, data2, st1.LastHeaderHash)
253259
// Expect ExecuteTxs call for height 2
254260
mockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, uint64(2), mock.Anything, st1.AppHash).
255-
Return([]byte("app2"), uint64(1024), nil).Once()
261+
Return(postExecAppHash2, uint64(1024), nil).Once()
256262
evt2 := common.DAHeightEvent{Header: hdr2, Data: data2, DaHeight: 11}
257263
s.processHeightEvent(&evt2)
258264

@@ -389,17 +395,20 @@ func TestSyncLoopPersistState(t *testing.T) {
389395
Time: uint64(blockTime.UnixNano()),
390396
},
391397
}
392-
_, sigHeader := makeSignedHeaderBytes(t, gen.ChainID, chainHeight, addr, pub, signer, prevAppHash, emptyData, prevHeaderHash)
398+
// Compute post-execution AppHash (same as DummyExecutor.ExecuteTxs does)
399+
// This is what the producer would set in the header after execution
400+
hasher := sha512.New()
401+
hasher.Write(prevAppHash)
402+
postExecAppHash := hasher.Sum(nil)
403+
_, sigHeader := makeSignedHeaderBytes(t, gen.ChainID, chainHeight, addr, pub, signer, postExecAppHash, emptyData, prevHeaderHash)
393404
evts := []common.DAHeightEvent{{
394405
Header: sigHeader,
395406
Data: emptyData,
396407
DaHeight: daHeight,
397408
}}
398409
daRtrMock.On("RetrieveFromDA", mock.Anything, daHeight).Return(evts, nil)
399410
prevHeaderHash = sigHeader.Hash()
400-
hasher := sha512.New()
401-
hasher.Write(prevAppHash)
402-
prevAppHash = hasher.Sum(nil)
411+
prevAppHash = postExecAppHash
403412
}
404413

405414
// stop at next height

execution/evm/execution.go

Lines changed: 127 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,30 @@ func retryWithBackoffOnPayloadStatus(ctx context.Context, fn func() error, maxRe
110110
return fmt.Errorf("max retries (%d) exceeded for %s", maxRetries, operation)
111111
}
112112

113+
// appendUniqueHash ensures we only keep unique, non-zero hash candidates while
114+
// preserving order so we can try canonical first before falling back to legacy.
115+
func appendUniqueHash(candidates []common.Hash, candidate common.Hash) []common.Hash {
116+
if candidate == (common.Hash{}) {
117+
return candidates
118+
}
119+
for _, existing := range candidates {
120+
if existing == candidate {
121+
return candidates
122+
}
123+
}
124+
return append(candidates, candidate)
125+
}
126+
127+
// buildHashCandidates returns a deduplicated list of hash candidates in the
128+
// order they should be tried.
129+
func buildHashCandidates(hashes ...common.Hash) []common.Hash {
130+
candidates := make([]common.Hash, 0, len(hashes))
131+
for _, h := range hashes {
132+
candidates = appendUniqueHash(candidates, h)
133+
}
134+
return candidates
135+
}
136+
113137
// EngineClient represents a client that interacts with an Ethereum execution engine
114138
// through the Engine API. It manages connections to both the engine and standard Ethereum
115139
// APIs, and maintains state related to block processing.
@@ -185,42 +209,63 @@ func (c *EngineClient) InitChain(ctx context.Context, genesisTime time.Time, ini
185209
return nil, 0, fmt.Errorf("initialHeight must be 1, got %d", initialHeight)
186210
}
187211

188-
// Acknowledge the genesis block with retry logic for SYNCING status
189-
err := retryWithBackoffOnPayloadStatus(ctx, func() error {
190-
var forkchoiceResult engine.ForkChoiceResponse
191-
err := c.engineClient.CallContext(ctx, &forkchoiceResult, "engine_forkchoiceUpdatedV3",
192-
engine.ForkchoiceStateV1{
193-
HeadBlockHash: c.genesisHash,
194-
SafeBlockHash: c.genesisHash,
195-
FinalizedBlockHash: c.genesisHash,
196-
},
197-
nil,
198-
)
199-
if err != nil {
200-
return fmt.Errorf("engine_forkchoiceUpdatedV3 failed: %w", err)
212+
genesisBlockHash, stateRoot, gasLimit, _, err := c.getBlockInfo(ctx, 0)
213+
if err != nil {
214+
return nil, 0, fmt.Errorf("failed to get block info: %w", err)
215+
}
216+
217+
candidates := buildHashCandidates(c.genesisHash, genesisBlockHash, stateRoot)
218+
var selectedGenesisHash common.Hash
219+
220+
for idx, candidate := range candidates {
221+
args := engine.ForkchoiceStateV1{
222+
HeadBlockHash: candidate,
223+
SafeBlockHash: candidate,
224+
FinalizedBlockHash: candidate,
201225
}
202226

203-
// Validate payload status
204-
if err := validatePayloadStatus(forkchoiceResult.PayloadStatus); err != nil {
227+
err = retryWithBackoffOnPayloadStatus(ctx, func() error {
228+
var forkchoiceResult engine.ForkChoiceResponse
229+
err := c.engineClient.CallContext(ctx, &forkchoiceResult, "engine_forkchoiceUpdatedV3", args, nil)
230+
if err != nil {
231+
return fmt.Errorf("engine_forkchoiceUpdatedV3 failed: %w", err)
232+
}
233+
234+
if err := validatePayloadStatus(forkchoiceResult.PayloadStatus); err != nil {
235+
c.logger.Warn().
236+
Str("status", forkchoiceResult.PayloadStatus.Status).
237+
Str("latestValidHash", forkchoiceResult.PayloadStatus.LatestValidHash.Hex()).
238+
Interface("validationError", forkchoiceResult.PayloadStatus.ValidationError).
239+
Msg("InitChain: engine_forkchoiceUpdatedV3 returned non-VALID status")
240+
return err
241+
}
242+
243+
return nil
244+
}, MaxPayloadStatusRetries, InitialRetryBackoff, "InitChain")
245+
246+
if err == nil {
247+
selectedGenesisHash = candidate
248+
break
249+
}
250+
251+
if errors.Is(err, ErrInvalidPayloadStatus) && idx+1 < len(candidates) {
205252
c.logger.Warn().
206-
Str("status", forkchoiceResult.PayloadStatus.Status).
207-
Str("latestValidHash", forkchoiceResult.PayloadStatus.LatestValidHash.Hex()).
208-
Interface("validationError", forkchoiceResult.PayloadStatus.ValidationError).
209-
Msg("InitChain: engine_forkchoiceUpdatedV3 returned non-VALID status")
210-
return err
253+
Str("blockHash", candidate.Hex()).
254+
Msg("InitChain: execution engine rejected hash candidate, trying alternate")
255+
continue
211256
}
212257

213-
return nil
214-
}, MaxPayloadStatusRetries, InitialRetryBackoff, "InitChain")
215-
if err != nil {
216258
return nil, 0, err
217259
}
218260

219-
_, stateRoot, gasLimit, _, err := c.getBlockInfo(ctx, 0)
220-
if err != nil {
221-
return nil, 0, fmt.Errorf("failed to get block info: %w", err)
261+
if selectedGenesisHash == (common.Hash{}) {
262+
return nil, 0, fmt.Errorf("execution engine rejected all genesis hash candidates")
222263
}
223264

265+
c.genesisHash = selectedGenesisHash
266+
c.currentHeadBlockHash = selectedGenesisHash
267+
c.currentSafeBlockHash = selectedGenesisHash
268+
c.currentFinalizedBlockHash = selectedGenesisHash
224269
c.initialHeight = initialHeight
225270

226271
return stateRoot[:], gasLimit, nil
@@ -292,16 +337,12 @@ func (c *EngineClient) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight
292337
}
293338
txsPayload := validTxs
294339

295-
prevBlockHash, _, prevGasLimit, _, err := c.getBlockInfo(ctx, blockHeight-1)
340+
prevBlockHash, prevBlockStateRoot, prevGasLimit, _, err := c.getBlockInfo(ctx, blockHeight-1)
296341
if err != nil {
297342
return nil, 0, fmt.Errorf("failed to get block info: %w", err)
298343
}
299344

300-
args := engine.ForkchoiceStateV1{
301-
HeadBlockHash: prevBlockHash,
302-
SafeBlockHash: prevBlockHash,
303-
FinalizedBlockHash: prevBlockHash,
304-
}
345+
parentHashCandidates := buildHashCandidates(prevBlockHash, prevBlockStateRoot)
305346

306347
// update forkchoice to get the next payload id
307348
// Create evolve-compatible payloadtimestamp.Unix()
@@ -325,46 +366,69 @@ func (c *EngineClient) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight
325366

326367
// Call forkchoice update with retry logic for SYNCING status
327368
var payloadID *engine.PayloadID
328-
err = retryWithBackoffOnPayloadStatus(ctx, func() error {
329-
var forkchoiceResult engine.ForkChoiceResponse
330-
err := c.engineClient.CallContext(ctx, &forkchoiceResult, "engine_forkchoiceUpdatedV3", args, evPayloadAttrs)
331-
if err != nil {
332-
return fmt.Errorf("forkchoice update failed: %w", err)
369+
for idx, candidate := range parentHashCandidates {
370+
args := engine.ForkchoiceStateV1{
371+
HeadBlockHash: candidate,
372+
SafeBlockHash: candidate,
373+
FinalizedBlockHash: candidate,
333374
}
334375

335-
// Validate payload status
336-
if err := validatePayloadStatus(forkchoiceResult.PayloadStatus); err != nil {
337-
c.logger.Warn().
338-
Str("status", forkchoiceResult.PayloadStatus.Status).
339-
Str("latestValidHash", forkchoiceResult.PayloadStatus.LatestValidHash.Hex()).
340-
Interface("validationError", forkchoiceResult.PayloadStatus.ValidationError).
341-
Uint64("blockHeight", blockHeight).
342-
Msg("ExecuteTxs: engine_forkchoiceUpdatedV3 returned non-VALID status")
343-
return err
344-
}
376+
err = retryWithBackoffOnPayloadStatus(ctx, func() error {
377+
var forkchoiceResult engine.ForkChoiceResponse
378+
err := c.engineClient.CallContext(ctx, &forkchoiceResult, "engine_forkchoiceUpdatedV3", args, evPayloadAttrs)
379+
if err != nil {
380+
return fmt.Errorf("forkchoice update failed: %w", err)
381+
}
345382

346-
if forkchoiceResult.PayloadID == nil {
347-
c.logger.Error().
348-
Str("status", forkchoiceResult.PayloadStatus.Status).
349-
Str("latestValidHash", forkchoiceResult.PayloadStatus.LatestValidHash.Hex()).
350-
Interface("validationError", forkchoiceResult.PayloadStatus.ValidationError).
351-
Interface("forkchoiceState", args).
352-
Interface("payloadAttributes", evPayloadAttrs).
353-
Uint64("blockHeight", blockHeight).
354-
Msg("returned nil PayloadID")
383+
// Validate payload status
384+
if err := validatePayloadStatus(forkchoiceResult.PayloadStatus); err != nil {
385+
c.logger.Warn().
386+
Str("status", forkchoiceResult.PayloadStatus.Status).
387+
Str("latestValidHash", forkchoiceResult.PayloadStatus.LatestValidHash.Hex()).
388+
Interface("validationError", forkchoiceResult.PayloadStatus.ValidationError).
389+
Uint64("blockHeight", blockHeight).
390+
Msg("ExecuteTxs: engine_forkchoiceUpdatedV3 returned non-VALID status")
391+
return err
392+
}
393+
394+
if forkchoiceResult.PayloadID == nil {
395+
c.logger.Error().
396+
Str("status", forkchoiceResult.PayloadStatus.Status).
397+
Str("latestValidHash", forkchoiceResult.PayloadStatus.LatestValidHash.Hex()).
398+
Interface("validationError", forkchoiceResult.PayloadStatus.ValidationError).
399+
Interface("forkchoiceState", args).
400+
Interface("payloadAttributes", evPayloadAttrs).
401+
Uint64("blockHeight", blockHeight).
402+
Msg("returned nil PayloadID")
403+
404+
return fmt.Errorf("returned nil PayloadID - (status: %s, latestValidHash: %s)",
405+
forkchoiceResult.PayloadStatus.Status,
406+
forkchoiceResult.PayloadStatus.LatestValidHash.Hex())
407+
}
408+
409+
payloadID = forkchoiceResult.PayloadID
410+
return nil
411+
}, MaxPayloadStatusRetries, InitialRetryBackoff, "ExecuteTxs forkchoice")
355412

356-
return fmt.Errorf("returned nil PayloadID - (status: %s, latestValidHash: %s)",
357-
forkchoiceResult.PayloadStatus.Status,
358-
forkchoiceResult.PayloadStatus.LatestValidHash.Hex())
413+
if err == nil {
414+
break
415+
}
416+
417+
if errors.Is(err, ErrInvalidPayloadStatus) && idx+1 < len(parentHashCandidates) {
418+
c.logger.Warn().
419+
Str("blockHash", candidate.Hex()).
420+
Uint64("blockHeight", blockHeight-1).
421+
Msg("ExecuteTxs: execution engine rejected parent hash candidate, trying alternate")
422+
continue
359423
}
360424

361-
payloadID = forkchoiceResult.PayloadID
362-
return nil
363-
}, MaxPayloadStatusRetries, InitialRetryBackoff, "ExecuteTxs forkchoice")
364-
if err != nil {
365425
return nil, 0, err
366426
}
367427

428+
if payloadID == nil {
429+
return nil, 0, fmt.Errorf("engine returned nil PayloadID after trying %d parent hash candidates", len(parentHashCandidates))
430+
}
431+
368432
// get payload
369433
var payloadResult engine.ExecutionPayloadEnvelope
370434
err = c.engineClient.CallContext(ctx, &payloadResult, "engine_getPayloadV4", *payloadID)

execution/evm/execution_status_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,41 @@ func TestRetryWithBackoffOnPayloadStatus_WrappedRPCErrors(t *testing.T) {
249249
// Should fail immediately without retries on non-syncing errors
250250
assert.Equal(t, 1, attempts, "expected exactly 1 attempt, got %d", attempts)
251251
}
252+
253+
func TestBuildHashCandidates(t *testing.T) {
254+
t.Parallel()
255+
256+
hashA := common.HexToHash("0x01")
257+
hashB := common.HexToHash("0x02")
258+
var zeroHash common.Hash
259+
260+
tests := []struct {
261+
name string
262+
hashes []common.Hash
263+
expects []common.Hash
264+
}{
265+
{
266+
name: "deduplicates while preserving order",
267+
hashes: []common.Hash{hashA, hashB, hashA},
268+
expects: []common.Hash{hashA, hashB},
269+
},
270+
{
271+
name: "skips zero hash",
272+
hashes: []common.Hash{zeroHash, hashB},
273+
expects: []common.Hash{hashB},
274+
},
275+
{
276+
name: "handles empty input",
277+
hashes: []common.Hash{},
278+
expects: []common.Hash{},
279+
},
280+
}
281+
282+
for _, tt := range tests {
283+
tt := tt
284+
t.Run(tt.name, func(t *testing.T) {
285+
t.Parallel()
286+
require.Equal(t, tt.expects, buildHashCandidates(tt.hashes...))
287+
})
288+
}
289+
}

0 commit comments

Comments
 (0)