diff --git a/bindings/go.mod b/bindings/go.mod index a3f3893c3..d726dc4d9 100644 --- a/bindings/go.mod +++ b/bindings/go.mod @@ -4,7 +4,7 @@ go 1.24.0 replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.7 -require github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 +require github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 require ( github.com/VictoriaMetrics/fastcache v1.12.2 // indirect diff --git a/bindings/go.sum b/bindings/go.sum index ff67044dc..c55500f27 100644 --- a/bindings/go.sum +++ b/bindings/go.sum @@ -109,8 +109,8 @@ github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqky github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 h1:SSJRj6BFZ9uJm29WuVonClXeUE+lPD43i19J0uTuAFw= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 h1:RvKSy6ApUxDaA8gprbvYZVz/vpchwQStW34YdKxppHE= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= diff --git a/common/batch/batch_cache.go b/common/batch/batch_cache.go index 508ed63e4..0c7da1a58 100644 --- a/common/batch/batch_cache.go +++ b/common/batch/batch_cache.go @@ -521,7 +521,7 @@ func (bc *BatchCache) CalculateCapWithProposalBlock(blockNumber uint64, withdraw } // Parse transactions, distinguish L1 and L2 transactions - txsPayload, l1TxHashes, newTotalL1MessagePopped, l2TxNum, err := parsingTxs(block.Transactions(), bc.totalL1MessagePopped) + txsPayload, l1TxHashes, newTotalL1MessagePopped, l2TxNum, err := ParsingTxs(block.Transactions(), bc.totalL1MessagePopped) if err != nil { return false, fmt.Errorf("failed to parse transactions: %w", err) } @@ -530,7 +530,7 @@ func (bc *BatchCache) CalculateCapWithProposalBlock(blockNumber uint64, withdraw txsNum := l2TxNum + l1TxNum // Build BlockContext (60 bytes) - blockContext := buildBlockContext(header, txsNum, l1TxNum) + blockContext := BuildBlockContext(header, txsNum, l1TxNum) // Store to current, do not immediately append to batch bc.currentBlockContext = blockContext @@ -934,8 +934,14 @@ func (bc *BatchCache) createBatchHeader(dataHash common.Hash, sidecar *ethtypes. return batchHeaderV0.Bytes() } -// parsingTxs parses transactions, distinguishes L1 and L2 transactions -func parsingTxs(transactions []*ethtypes.Transaction, totalL1MessagePoppedBefore uint64) ( +// ParsingTxs encodes a block's transactions into the on-chain payload format +// used by the batch builder: L2 transactions are RLP-marshalled and concatenated +// in order; L1 message transactions are excluded from the payload but their +// hashes and queue indices are tracked separately. +// +// Exported for derivation local verify (SPEC-005), which must rebuild blob bytes from +// local L2 blocks using the same encoding the sequencer applied at seal time. +func ParsingTxs(transactions []*ethtypes.Transaction, totalL1MessagePoppedBefore uint64) ( txsPayload []byte, l1TxHashes []common.Hash, totalL1MessagePopped uint64, @@ -1010,9 +1016,12 @@ func (bc *BatchCache) sealEffectiveBlobCount(blockTimestamp uint64, replayCommit return replayProtocolMaxBlobs } -// buildBlockContext builds BlockContext from block header (60 bytes) +// BuildBlockContext serialises a block header + tx counts into the 60-byte +// BlockContext blob the batch builder writes for each block. // Format: Number(8) || Timestamp(8) || BaseFee(32) || GasLimit(8) || numTxs(2) || numL1Messages(2) -func buildBlockContext(header *ethtypes.Header, txsNum, l1MsgNum int) []byte { +// +// Exported for derivation local verify (SPEC-005); see ParsingTxs. +func BuildBlockContext(header *ethtypes.Header, txsNum, l1MsgNum int) []byte { blsBytes := make([]byte, 60) // Number (8 bytes) diff --git a/common/go.mod b/common/go.mod index 7cee9aa04..794853f92 100644 --- a/common/go.mod +++ b/common/go.mod @@ -6,7 +6,7 @@ replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3. require ( github.com/holiman/uint256 v1.2.4 - github.com/morph-l2/go-ethereum v1.10.14-0.20260506071313-045be0fdc7ca + github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 github.com/stretchr/testify v1.10.0 github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a ) diff --git a/common/go.sum b/common/go.sum index 7570e7331..7b7d90dcb 100644 --- a/common/go.sum +++ b/common/go.sum @@ -148,8 +148,8 @@ github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqky github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/morph-l2/go-ethereum v1.10.14-0.20260506071313-045be0fdc7ca h1:ogHsgxvm1wzyNKYDSAsIi0PJZeu9VhQECSL91X/KTWI= -github.com/morph-l2/go-ethereum v1.10.14-0.20260506071313-045be0fdc7ca/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 h1:RvKSy6ApUxDaA8gprbvYZVz/vpchwQStW34YdKxppHE= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= diff --git a/contracts/go.mod b/contracts/go.mod index 5ec231020..44848fe12 100644 --- a/contracts/go.mod +++ b/contracts/go.mod @@ -6,7 +6,7 @@ replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3. require ( github.com/iden3/go-iden3-crypto v0.0.16 - github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 + github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 github.com/stretchr/testify v1.10.0 ) diff --git a/contracts/go.sum b/contracts/go.sum index 8662f0f9f..cb96f9612 100644 --- a/contracts/go.sum +++ b/contracts/go.sum @@ -136,8 +136,8 @@ github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqky github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 h1:SSJRj6BFZ9uJm29WuVonClXeUE+lPD43i19J0uTuAFw= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 h1:RvKSy6ApUxDaA8gprbvYZVz/vpchwQStW34YdKxppHE= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= diff --git a/go-ethereum b/go-ethereum index 01e8a4291..045be0fdc 160000 --- a/go-ethereum +++ b/go-ethereum @@ -1 +1 @@ -Subproject commit 01e8a4291b888cb9b338d3ea1911edf2da58c2b3 +Subproject commit 045be0fdc7ca6f80e18eb4e26f7452500292ccec diff --git a/go.work.sum b/go.work.sum index 210c2a1a1..41954c47a 100644 --- a/go.work.sum +++ b/go.work.sum @@ -543,7 +543,6 @@ github.com/esimonov/ifshort v1.0.4 h1:6SID4yGWfRae/M7hkVDVVyppy8q/v9OuxNdmjLQStB github.com/esimonov/ifshort v1.0.4/go.mod h1:Pe8zjlRrJ80+q2CxHLfEOfTwxCZ4O+MuhcHcfgNWTk0= github.com/ethereum/c-kzg-4844/bindings/go v0.0.0-20230126171313-363c7d7593b4 h1:B2mpK+MNqgPqk2/KNi1LbqwtZDy5F7iy0mynQiBr8VA= github.com/ethereum/c-kzg-4844/bindings/go v0.0.0-20230126171313-363c7d7593b4/go.mod h1:y4GA2JbAUama1S4QwYjC2hefgGLU8Ul0GMtL/ADMF1c= -github.com/ethereum/go-ethereum v1.10.26/go.mod h1:EYFyF19u3ezGLD4RqOkLq+ZCXzYbLoNDdZlMt7kyKFg= github.com/ettle/strcase v0.1.1 h1:htFueZyVeE1XNnMEfbqp5r67qAN/4r6ya1ysq8Q+Zcw= github.com/ettle/strcase v0.1.1/go.mod h1:hzDLsPC7/lwKyBOywSHEP89nt2pDgdy+No1NBA9o9VY= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -592,9 +591,7 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -904,7 +901,6 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGi github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -1021,9 +1017,8 @@ github.com/morph-l2/go-ethereum v1.10.14-0.20260227074910-324c53b65341 h1:kupvcg github.com/morph-l2/go-ethereum v1.10.14-0.20260227074910-324c53b65341/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/morph-l2/go-ethereum v1.10.14-0.20260303114154-29281e501802 h1:9gu7AklnN0a0+Fshc/lBvi/2OeatXaN38yqsJryvMRA= github.com/morph-l2/go-ethereum v1.10.14-0.20260303114154-29281e501802/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= -github.com/morph-l2/go-ethereum v1.10.14-0.20260506071313-045be0fdc7ca/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 h1:SSJRj6BFZ9uJm29WuVonClXeUE+lPD43i19J0uTuAFw= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 h1:RvKSy6ApUxDaA8gprbvYZVz/vpchwQStW34YdKxppHE= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/morph-l2/tendermint v0.3.3-0.20260226075902-3692a2a2889c h1:CzaQ/rK3nrqylN8JVr2htAsnu2xlg4u99SjzudzxrpM= github.com/morph-l2/tendermint v0.3.3-0.20260226075902-3692a2a2889c/go.mod h1:TtCzp9l6Z6yDUiwv3TbqKqw8Q8RKp3fSz5+adO1/Y8w= github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae h1:VeRdUYdCw49yizlSbMEn2SZ+gT+3IUKx8BqxyQdz+BY= @@ -1383,7 +1378,6 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -1463,7 +1457,6 @@ golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= diff --git a/node/blocktag/config.go b/node/blocktag/config.go deleted file mode 100644 index 43c282800..000000000 --- a/node/blocktag/config.go +++ /dev/null @@ -1,52 +0,0 @@ -package blocktag - -import ( - "fmt" - "time" - - "github.com/morph-l2/go-ethereum/common" - "github.com/urfave/cli" - - node "morph-l2/node/core" - "morph-l2/node/flags" -) - -const ( - // DefaultSafeConfirmations is the default number of L1 blocks to wait before considering a batch as safe - DefaultSafeConfirmations = 10 - // DefaultPollInterval is the default interval to poll L1 for batch status updates - DefaultPollInterval = 10 * time.Second -) - -// Config holds the configuration for BlockTagService -type Config struct { - RollupAddress common.Address - SafeConfirmations uint64 - PollInterval time.Duration -} - -// DefaultConfig returns the default configuration -func DefaultConfig() *Config { - return &Config{ - SafeConfirmations: DefaultSafeConfirmations, - PollInterval: DefaultPollInterval, - } -} - -// SetCliContext sets the configuration from CLI context -func (c *Config) SetCliContext(ctx *cli.Context) error { - // Determine RollupAddress: use explicit flag, or mainnet default, or error - if ctx.GlobalBool(flags.MainnetFlag.Name) { - c.RollupAddress = node.MainnetRollupContractAddress - } else if ctx.GlobalIsSet(flags.RollupContractAddress.Name) { - c.RollupAddress = common.HexToAddress(ctx.GlobalString(flags.RollupContractAddress.Name)) - } else { - return fmt.Errorf("rollup contract address is required: either specify --%s or use --%s for mainnet default", - flags.RollupContractAddress.Name, flags.MainnetFlag.Name) - } - - if ctx.GlobalIsSet(flags.BlockTagSafeConfirmations.Name) { - c.SafeConfirmations = ctx.GlobalUint64(flags.BlockTagSafeConfirmations.Name) - } - return nil -} diff --git a/node/blocktag/service.go b/node/blocktag/service.go deleted file mode 100644 index 45f7ecda3..000000000 --- a/node/blocktag/service.go +++ /dev/null @@ -1,457 +0,0 @@ -package blocktag - -import ( - "context" - "fmt" - "math/big" - "time" - - "github.com/morph-l2/go-ethereum/accounts/abi/bind" - "github.com/morph-l2/go-ethereum/common" - "github.com/morph-l2/go-ethereum/ethclient" - "github.com/morph-l2/go-ethereum/rpc" - tmlog "github.com/tendermint/tendermint/libs/log" - - "morph-l2/bindings/bindings" - "morph-l2/node/types" -) - -// BlockTagType represents the type of block tag (safe or finalized) -type BlockTagType int - -const ( - TagTypeSafe BlockTagType = iota - TagTypeFinalized -) - -// BlockTagService is responsible for tracking and updating safe/finalized block tags -// based on L1 batch commit tx status. -// -// Key logic: -// - Safe: batch tx is committed to L1 with N block confirmations (configurable) -// - Finalized: batch tx's L1 block is finalized (using L1 finalized block tag) -type BlockTagService struct { - ctx context.Context - cancel context.CancelFunc - - // Current safe and finalized L2 block hashes - safeL2BlockHash common.Hash - finalizedL2BlockHash common.Hash - // Last notified hashes (to avoid redundant RPC calls) - lastNotifiedSafeHash common.Hash - lastNotifiedFinalizedHash common.Hash - - // Cached batch index for optimization (avoid full binary search each time) - // Separate caches for safe and finalized since they have different maxBatchIndex - lastKnownSafeBatchIndex uint64 - lastKnownFinalizedBatchIndex uint64 - - // Clients - l1Client *ethclient.Client - l2Client *types.RetryableClient - rollup *bindings.Rollup - - // Configuration - rollupAddress common.Address - safeConfirmations uint64 // Number of L1 blocks to wait before considering a batch as safe - pollInterval time.Duration - - logger tmlog.Logger - stop chan struct{} -} - -// NewBlockTagService creates a new BlockTagService -func NewBlockTagService( - ctx context.Context, - l1Client *ethclient.Client, - l2Client *types.RetryableClient, - config *Config, - logger tmlog.Logger, -) (*BlockTagService, error) { - if l1Client == nil { - return nil, fmt.Errorf("L1 client is required") - } - if config.RollupAddress == (common.Address{}) { - return nil, fmt.Errorf("Rollup contract address is required") - } - - rollup, err := bindings.NewRollup(config.RollupAddress, l1Client) - if err != nil { - return nil, fmt.Errorf("failed to create rollup binding: %w", err) - } - - ctx, cancel := context.WithCancel(ctx) - - return &BlockTagService{ - ctx: ctx, - cancel: cancel, - l1Client: l1Client, - l2Client: l2Client, - rollup: rollup, - rollupAddress: config.RollupAddress, - safeConfirmations: config.SafeConfirmations, - pollInterval: config.PollInterval, - logger: logger.With("module", "blocktag"), - stop: make(chan struct{}), - }, nil -} - -// Start starts the BlockTagService -func (s *BlockTagService) Start() error { - s.logger.Info("Starting BlockTagService", - "safeConfirmations", s.safeConfirmations, - "pollInterval", s.pollInterval, - ) - - // Initialize by checking current L1 batch status - if err := s.initialize(); err != nil { - s.logger.Error("Failed to initialize BlockTagService", "error", err) - // Don't return error, let the service start and retry - } - - go s.loop() - return nil -} - -// Stop stops the BlockTagService -func (s *BlockTagService) Stop() { - s.logger.Info("Stopping BlockTagService") - s.cancel() - <-s.stop - s.logger.Info("BlockTagService stopped") -} - -// initialize initializes the service by checking current L1 batch status -func (s *BlockTagService) initialize() error { - s.logger.Info("Initializing BlockTagService") - return s.updateBlockTags() -} - -// loop is the main loop that polls L1 for batch status updates -func (s *BlockTagService) loop() { - defer close(s.stop) - - ticker := time.NewTicker(s.pollInterval) - defer ticker.Stop() - - for { - select { - case <-s.ctx.Done(): - return - case <-ticker.C: - if err := s.updateBlockTags(); err != nil { - s.logger.Error("Failed to update block tags", "error", err) - } - } - } -} - -// updateBlockTags updates the safe and finalized block tags based on L1 batch tx status -func (s *BlockTagService) updateBlockTags() error { - l2Head, err := s.l2Client.BlockNumber(s.ctx) - if err != nil { - return fmt.Errorf("failed to get L2 head: %w", err) - } - - var safeBlockNum uint64 - var safeBlockHash common.Hash - - // Update safe block - safeBlockNum, safeBlockHash, err = s.getL2BlockForTag(TagTypeSafe, l2Head) - if err != nil { - s.logger.Error("Failed to get safe L2 block", "error", err) - } else if safeBlockHash != (common.Hash{}) { - s.setSafeL2Block(safeBlockHash) - } - - // Update finalized block - finalizedBlockNum, finalizedBlockHash, err := s.getL2BlockForTag(TagTypeFinalized, l2Head) - if err != nil { - s.logger.Error("Failed to get finalized L2 block", "error", err) - } else if finalizedBlockHash != (common.Hash{}) { - // If finalized > safe, update safe to finalized (finalized is a stronger state) - if finalizedBlockNum > safeBlockNum { - safeBlockHash = finalizedBlockHash - s.setSafeL2Block(safeBlockHash) - } - s.setFinalizedL2Block(finalizedBlockHash) - } - - // Notify geth - if err := s.notifyGeth(); err != nil { - s.logger.Error("Failed to notify geth of block tags", "error", err) - } - - s.logger.Debug("Block tags updated", - "l2Head", l2Head, - "safeL2BlockHash", s.safeL2BlockHash.Hex(), - "finalizedL2BlockHash", s.finalizedL2BlockHash.Hex(), - ) - - return nil -} - -// getL2BlockForTag gets the L2 block number and hash based on the L1 block tag -// Also validates state root matches between L1 batch and L2 block -func (s *BlockTagService) getL2BlockForTag(tagType BlockTagType, l2Head uint64) (uint64, common.Hash, error) { - var l1BlockTag rpc.BlockNumber - - switch tagType { - case TagTypeSafe: - latestL1, err := s.l1Client.BlockNumber(s.ctx) - if err != nil { - return 0, common.Hash{}, fmt.Errorf("failed to get L1 latest block: %w", err) - } - if latestL1 <= s.safeConfirmations { - return 0, common.Hash{}, nil - } - l1BlockTag = rpc.BlockNumber(latestL1 - s.safeConfirmations) - - case TagTypeFinalized: - l1BlockTag = rpc.FinalizedBlockNumber - - default: - return 0, common.Hash{}, fmt.Errorf("unknown tag type: %d", tagType) - } - - // Query rollup contract at specified L1 block - lastCommittedBatchIndex, err := s.getLastCommittedBatchAtBlock(l1BlockTag) - if err != nil { - return 0, common.Hash{}, fmt.Errorf("failed to get last committed batch: %w", err) - } - if lastCommittedBatchIndex == 0 { - return 0, common.Hash{}, nil - } - - // Find the largest completed batch (lastL2Block <= l2Head) - // This works for both synced and syncing scenarios - targetBatchIndex, targetBatchLastBlockNum, err := s.findCompletedBatchForL2Block(tagType, l2Head, lastCommittedBatchIndex) - if err != nil { - s.logger.Debug("No completed batch found", "l2Head", l2Head, "error", err) - return 0, common.Hash{}, nil - } - - // Validate state root. - // Skip validation for already finalized batches, as their state roots may have been - // deleted from the L1 contract after finalization - lastFinalizedBatchIndex, err := s.rollup.LastFinalizedBatchIndex(nil) - if err != nil { - s.logger.Info("Failed to get last finalized batch index, skipping state root validation", "error", err) - return 0, common.Hash{}, nil - } - if targetBatchIndex < lastFinalizedBatchIndex.Uint64() { - // Batch data may have been deleted after finalization, cannot validate - // Return error so caller skips this batch and keeps previous safe/finalized value - // TODO: optimize this by using a different approach to get the state root - s.logger.Info("batch already finalized, state root may be deleted", - "batchIndex", targetBatchIndex, - "lastFinalized", lastFinalizedBatchIndex.Uint64()) - return 0, common.Hash{}, nil - } - if err := s.validateBatchStateRoot(targetBatchIndex, targetBatchLastBlockNum); err != nil { - s.logger.Error("State root validation failed", - "tagType", tagType, - "batchIndex", targetBatchIndex, - "l2Block", targetBatchLastBlockNum, - "error", err, - ) - return 0, common.Hash{}, err - } - - // Get L2 block header for hash - l2Header, err := s.l2Client.HeaderByNumber(s.ctx, big.NewInt(int64(targetBatchLastBlockNum))) - if err != nil { - return 0, common.Hash{}, fmt.Errorf("failed to get L2 block header: %w", err) - } - - l2BlockHash := l2Header.Hash() - - s.logger.Debug("Got L2 block for tag", - "tagType", tagType, - "l1BlockTag", l1BlockTag, - "batchIndex", targetBatchIndex, - "l2Block", targetBatchLastBlockNum, - "l2BlockHash", l2BlockHash.Hex(), - ) - - return targetBatchLastBlockNum, l2BlockHash, nil -} - -// validateBatchStateRoot validates that the state root of batch's lastL2Block matches L1 -func (s *BlockTagService) validateBatchStateRoot(batchIndex uint64, batchLastBlockNum uint64) error { - // Get L2 block header - l2Header, err := s.l2Client.HeaderByNumber(s.ctx, big.NewInt(int64(batchLastBlockNum))) - if err != nil { - return fmt.Errorf("failed to get L2 block header for block %d: %w", batchLastBlockNum, err) - } - - // Get state root from L1 committed batch - stateRoot, err := s.rollup.CommittedStateRoots(nil, big.NewInt(int64(batchIndex))) - if err != nil { - return fmt.Errorf("failed to get state root from L1: %w", err) - } - - // Compare state roots - l1StateRoot := common.BytesToHash(stateRoot[:]) - if l1StateRoot != l2Header.Root { - return fmt.Errorf("state root mismatch for batch %d: L1=%s, L2=%s", batchIndex, l1StateRoot.Hex(), l2Header.Root.Hex()) - } - - return nil -} - -// findCompletedBatchForL2Block finds the largest batch where lastL2Block <= l2BlockNum. -// Uses cached index for optimization: first call binary search, subsequent calls search forward. -// Separate caches for safe and finalized to avoid conflicts. -func (s *BlockTagService) findCompletedBatchForL2Block(tagType BlockTagType, l2HeaderNum uint64, lastCommittedBatchIndex uint64) (uint64, uint64, error) { - return s.findCompletedBatchForL2BlockWithDepth(tagType, l2HeaderNum, lastCommittedBatchIndex, 0) -} - -// findCompletedBatchForL2BlockWithDepth is the internal implementation with recursion depth limit. -// maxDepth is set to 1 to allow one retry after cache reset. -func (s *BlockTagService) findCompletedBatchForL2BlockWithDepth(tagType BlockTagType, l2HeaderNum uint64, lastCommittedBatchIndex uint64, depth int) (uint64, uint64, error) { - const maxDepth = 2 - - if lastCommittedBatchIndex == 0 { - return 0, 0, fmt.Errorf("no batches available") - } - - // Get cached index based on tag type - startIdx := s.getCachedBatchIndex(tagType) - if startIdx == 0 || startIdx > lastCommittedBatchIndex { - // First time or cache invalid: use binary search to find starting point - startIdx = s.binarySearchBatch(l2HeaderNum, lastCommittedBatchIndex) - if startIdx == 0 { - return 0, 0, fmt.Errorf("no completed batch found for L2 block %d", l2HeaderNum) - } - } - - // Search forward from startIdx - var resultIdx, resultLastL2Block uint64 - for idx := startIdx; idx <= lastCommittedBatchIndex; idx++ { - batchData, err := s.rollup.BatchDataStore(nil, big.NewInt(int64(idx))) - if err != nil { - return 0, 0, fmt.Errorf("failed to get batch data for index %d: %w", idx, err) - } - - lastL2Block := batchData.BlockNumber.Uint64() - if lastL2Block <= l2HeaderNum { - resultIdx = idx - resultLastL2Block = lastL2Block - s.setCachedBatchIndex(tagType, idx) - } else { - break - } - } - - // Handle L2 reorg: if cache was too new, reset and use binary search - if resultIdx == 0 { - if depth >= maxDepth { - return 0, 0, fmt.Errorf("no completed batch found for L2 block %d after retry", l2HeaderNum) - } - s.setCachedBatchIndex(tagType, 0) - return s.findCompletedBatchForL2BlockWithDepth(tagType, l2HeaderNum, lastCommittedBatchIndex, depth+1) - } - - return resultIdx, resultLastL2Block, nil -} - -func (s *BlockTagService) getCachedBatchIndex(tagType BlockTagType) uint64 { - if tagType == TagTypeSafe { - return s.lastKnownSafeBatchIndex - } - return s.lastKnownFinalizedBatchIndex -} - -func (s *BlockTagService) setCachedBatchIndex(tagType BlockTagType, idx uint64) { - if tagType == TagTypeSafe { - s.lastKnownSafeBatchIndex = idx - } else { - s.lastKnownFinalizedBatchIndex = idx - } -} - -// binarySearchBatch finds the largest batch index where lastL2BlockInBatch <= l2HeaderNum -func (s *BlockTagService) binarySearchBatch(l2HeaderNum uint64, maxBatchIndex uint64) uint64 { - low, high := uint64(1), maxBatchIndex - var result uint64 - - for low <= high { - mid := (low + high) / 2 - batchData, err := s.rollup.BatchDataStore(nil, big.NewInt(int64(mid))) - if err != nil { - return result // Return best result so far on error - } - - if batchData.BlockNumber.Uint64() <= l2HeaderNum { - result = mid - low = mid + 1 - } else { - high = mid - 1 - } - } - - return result -} - -// getLastCommittedBatchAtBlock queries the rollup contract at a specific L1 block -func (s *BlockTagService) getLastCommittedBatchAtBlock(l1BlockTag rpc.BlockNumber) (uint64, error) { - var blockNum *big.Int - if l1BlockTag == rpc.FinalizedBlockNumber { - blockNum = big.NewInt(int64(rpc.FinalizedBlockNumber)) - } else if l1BlockTag >= 0 { - blockNum = big.NewInt(int64(l1BlockTag)) - } - - lastCommitted, err := s.rollup.LastCommittedBatchIndex(&bind.CallOpts{ - BlockNumber: blockNum, - Context: s.ctx, - }) - if err != nil { - return 0, err - } - - return lastCommitted.Uint64(), nil -} - -// setSafeL2Block sets the safe L2 block hash -func (s *BlockTagService) setSafeL2Block(blockHash common.Hash) { - if blockHash != s.safeL2BlockHash { - s.safeL2BlockHash = blockHash - s.logger.Info("Updated safe L2 block", "hash", blockHash.Hex()) - } -} - -// setFinalizedL2Block sets the finalized L2 block hash -func (s *BlockTagService) setFinalizedL2Block(blockHash common.Hash) { - if blockHash != s.finalizedL2BlockHash { - s.finalizedL2BlockHash = blockHash - s.logger.Info("Updated finalized L2 block", "hash", blockHash.Hex()) - } -} - -// notifyGeth notifies geth of the new block tags via RPC -// Only calls RPC if there are changes since last notification -func (s *BlockTagService) notifyGeth() error { - safeBlockHash := s.safeL2BlockHash - finalizedBlockHash := s.finalizedL2BlockHash - - // Skip if no changes - if safeBlockHash == s.lastNotifiedSafeHash && finalizedBlockHash == s.lastNotifiedFinalizedHash { - return nil - } - - // Skip if both are empty - if safeBlockHash == (common.Hash{}) && finalizedBlockHash == (common.Hash{}) { - return nil - } - - if err := s.l2Client.SetBlockTags(s.ctx, safeBlockHash, finalizedBlockHash); err != nil { - return err - } - - // Update last notified hashes - s.lastNotifiedSafeHash = safeBlockHash - s.lastNotifiedFinalizedHash = finalizedBlockHash - return nil -} diff --git a/node/cmd/node/main.go b/node/cmd/node/main.go index 5884fe6fd..294665461 100644 --- a/node/cmd/node/main.go +++ b/node/cmd/node/main.go @@ -20,7 +20,6 @@ import ( "github.com/urfave/cli" "morph-l2/bindings/bindings" - "morph-l2/node/blocktag" node "morph-l2/node/core" "morph-l2/node/db" "morph-l2/node/derivation" @@ -30,7 +29,6 @@ import ( "morph-l2/node/sequencer/mock" "morph-l2/node/sync" "morph-l2/node/types" - "morph-l2/node/validator" ) func main() { @@ -50,21 +48,18 @@ func main() { func L2NodeMain(ctx *cli.Context) error { var ( - err error - executor *node.Executor - syncer *sync.Syncer - ms *mock.Sequencer - tmNode *tmnode.Node - dvNode *derivation.Derivation - blockTagSvc *blocktag.BlockTagService - tracker *l1sequencer.L1Tracker - verifier *l1sequencer.SequencerVerifier - signer l1sequencer.Signer - + err error + executor *node.Executor + syncer *sync.Syncer + ms *mock.Sequencer + tmNode *tmnode.Node + dvNode *derivation.Derivation + tracker *l1sequencer.L1Tracker + verifier *l1sequencer.SequencerVerifier + signer l1sequencer.Signer nodeConfig = node.DefaultConfig() ) isMockSequencer := ctx.GlobalBool(flags.MockEnabled.Name) - isValidator := ctx.GlobalBool(flags.ValidatorEnable.Name) // Apply consensus switch height if explicitly set via flag if ctx.GlobalIsSet(flags.ConsensusSwitchHeight.Name) { @@ -79,97 +74,82 @@ func L2NodeMain(ctx *cli.Context) error { return err } - if isValidator { - // configure store - dbConfig := db.DefaultConfig() - dbConfig.SetCliContext(ctx) - store, err := db.NewStore(dbConfig, home) - if err != nil { - return err - } - derivationCfg := derivation.DefaultConfig() - if err := derivationCfg.SetCliContext(ctx); err != nil { - return fmt.Errorf("derivation set cli context error: %v", err) - } - syncConfig := sync.DefaultConfig() - if err = syncConfig.SetCliContext(ctx); err != nil { - return err - } - syncer, err = sync.NewSyncer(context.Background(), store, syncConfig, nodeConfig.Logger) - if err != nil { - return fmt.Errorf("failed to create syncer, error: %v", err) - } - validatorCfg := validator.NewConfig() - if err := validatorCfg.SetCliContext(ctx); err != nil { - return fmt.Errorf("validator set cli context error: %v", err) - } - l1Client, err := ethclient.Dial(derivationCfg.L1.Addr) - if err != nil { - return fmt.Errorf("dial l1 node error:%v", err) - } - rollup, err := bindings.NewRollup(derivationCfg.RollupContractAddress, l1Client) - if err != nil { - return fmt.Errorf("NewRollup error:%v", err) - } - vt, err := validator.NewValidator(validatorCfg, rollup, nodeConfig.Logger) - if err != nil { - return fmt.Errorf("new validator client error: %v", err) - } + // ========== Shared store + syncer (used by both executor and derivation) ========== + dbConfig := db.DefaultConfig() + dbConfig.SetCliContext(ctx) + store, err := db.NewStore(dbConfig, home) + if err != nil { + return err + } + syncConfig := sync.DefaultConfig() + if err = syncConfig.SetCliContext(ctx); err != nil { + return err + } + syncer, err = sync.NewSyncer(context.Background(), store, syncConfig, nodeConfig.Logger) + if err != nil { + return fmt.Errorf("failed to create syncer, error: %v", err) + } - dvNode, err = derivation.NewDerivationClient(context.Background(), derivationCfg, syncer, store, vt, rollup, nodeConfig.Logger) - if err != nil { - return fmt.Errorf("new derivation client error: %v", err) - } - dvNode.Start() - nodeConfig.Logger.Info("derivation node starting") - } else { - // ========== Create L1 Client ========== - l1RPC := ctx.GlobalString(flags.L1NodeAddr.Name) - l1Client, err := ethclient.Dial(l1RPC) - if err != nil { - return fmt.Errorf("failed to dial L1 node: %w", err) - } + // ========== Derivation config + L1 client + rollup binding ========== + // All non-mock nodes self-verify against L1; the L1 client + rollup binding + // is shared by L1 sequencer components and derivation. + derivationCfg := derivation.DefaultConfig() + if err := derivationCfg.SetCliContext(ctx); err != nil { + return fmt.Errorf("derivation set cli context error: %v", err) + } + l1Client, err := ethclient.Dial(derivationCfg.L1.Addr) + if err != nil { + return fmt.Errorf("dial l1 node error: %v", err) + } + rollup, err := bindings.NewRollup(derivationCfg.RollupContractAddress, l1Client) + if err != nil { + return fmt.Errorf("NewRollup error: %v", err) + } - tracker, verifier, signer, err = initL1SequencerComponents(ctx, l1Client, nodeConfig.Logger) - if err != nil { - return fmt.Errorf("failed to init L1 sequencer components: %w", err) - } + tracker, verifier, signer, err = initL1SequencerComponents(ctx, l1Client, nodeConfig.Logger) + if err != nil { + return fmt.Errorf("failed to init L1 sequencer components: %w", err) + } + + // ========== Executor + sequencer / mock ========== + tmCfg, err := sequencer.LoadTmConfig(ctx, home) + if err != nil { + return err + } + tmVal := privval.LoadOrGenFilePV(tmCfg.PrivValidatorKeyFile(), tmCfg.PrivValidatorStateFile()) + pubKey, _ := tmVal.GetPubKey() - // ========== Launch Tendermint Node ========== - tmCfg, err := sequencer.LoadTmConfig(ctx, home) + // Reuse the shared syncer instance -- DevSequencer mode is the only path + // that pulls a syncer out of NewExecutor, so we hand back the same one + // rather than letting NewExecutor open a second store + syncer. + newSyncerFunc := func() (*sync.Syncer, error) { return syncer, nil } + executor, err = node.NewExecutor(newSyncerFunc, nodeConfig, pubKey) + if err != nil { + return err + } + if isMockSequencer { + ms, err = mock.NewSequencer(executor) if err != nil { return err } - tmVal := privval.LoadOrGenFilePV(tmCfg.PrivValidatorKeyFile(), tmCfg.PrivValidatorStateFile()) - pubKey, _ := tmVal.GetPubKey() - - newSyncerFunc := func() (*sync.Syncer, error) { return node.NewSyncer(ctx, home, nodeConfig) } - executor, err = node.NewExecutor(newSyncerFunc, nodeConfig, pubKey) + go ms.Start() + } else { + tmNode, err = sequencer.SetupNode(tmCfg, tmVal, executor, nodeConfig.Logger, verifier, signer) if err != nil { - return err + return fmt.Errorf("failed to setup consensus node: %v", err) } - if isMockSequencer { - ms, err = mock.NewSequencer(executor) - if err != nil { - return err - } - go ms.Start() - } else { - tmNode, err = sequencer.SetupNode(tmCfg, tmVal, executor, nodeConfig.Logger, verifier, signer) - if err != nil { - return fmt.Errorf("failed to setup consensus node: %v", err) - } - if err = tmNode.Start(); err != nil { - return fmt.Errorf("failed to start consensus node, error: %v", err) - } + if err = tmNode.Start(); err != nil { + return fmt.Errorf("failed to start consensus node, error: %v", err) } + } - // ========== Initialize BlockTagService ========== - blockTagSvc, err = initBlockTagService(ctx, l1Client, executor, nodeConfig.Logger) - if err != nil { - return fmt.Errorf("failed to init BlockTagService: %w", err) - } + // ========== Derivation (SPEC-005: self-verifies + drives safe/finalized tags) ========== + dvNode, err = derivation.NewDerivationClient(context.Background(), derivationCfg, syncer, store, rollup, nodeConfig.Logger) + if err != nil { + return fmt.Errorf("new derivation client error: %v", err) } + dvNode.Start() + nodeConfig.Logger.Info("derivation started") interruptChannel := make(chan os.Signal, 1) signal.Notify(interruptChannel, []os.Signal{ @@ -195,9 +175,6 @@ func L2NodeMain(ctx *cli.Context) error { if dvNode != nil { dvNode.Stop() } - if blockTagSvc != nil { - blockTagSvc.Stop() - } if tracker != nil { tracker.Stop() } @@ -269,31 +246,6 @@ func initL1SequencerComponents( return tracker, verifier, signer, nil } -// initBlockTagService initializes the block tag service -func initBlockTagService( - ctx *cli.Context, - l1Client *ethclient.Client, - executor *node.Executor, - logger tmlog.Logger, -) (*blocktag.BlockTagService, error) { - config := blocktag.DefaultConfig() - if err := config.SetCliContext(ctx); err != nil { - return nil, err - } - - svc, err := blocktag.NewBlockTagService(context.Background(), l1Client, executor.L2Client(), config, logger) - if err != nil { - return nil, err - } - - if err := svc.Start(); err != nil { - return nil, err - } - - logger.Info("BlockTagService started") - return svc, nil -} - func homeDir(ctx *cli.Context) (string, error) { home := ctx.GlobalString(flags.Home.Name) if home == "" { diff --git a/node/db/keys.go b/node/db/keys.go index b0d50ddcd..6bb7494bf 100644 --- a/node/db/keys.go +++ b/node/db/keys.go @@ -7,7 +7,8 @@ var ( L1MessagePrefix = []byte("l1") BatchBlockNumberPrefix = []byte("batch") - derivationL1HeightKey = []byte("LastDerivationL1Height") + derivationL1HeightKey = []byte("LastDerivationL1Height") + derivationL1BlockPrefix = []byte("derivL1Block") ) // encodeBlockNumber encodes an L1 enqueue index as big endian uint64 @@ -26,3 +27,11 @@ func L1MessageKey(enqueueIndex uint64) []byte { func BatchBlockNumberKey(batchIndex uint64) []byte { return append(BatchBlockNumberPrefix, encodeEnqueueIndex(batchIndex)...) } + +// DerivationL1BlockKey = derivationL1BlockPrefix + l1Height (uint64 big endian). +// Used by SPEC-005 §4.7.6 L1 reorg detection: derivation records the hash of +// each L1 block it has scanned for commit batch logs so a later poll can +// detect a divergence and rewind the cursor. +func DerivationL1BlockKey(l1Height uint64) []byte { + return append(derivationL1BlockPrefix, encodeEnqueueIndex(l1Height)...) +} diff --git a/node/db/store.go b/node/db/store.go index 1a87a227c..3ff1a32b4 100644 --- a/node/db/store.go +++ b/node/db/store.go @@ -156,6 +156,74 @@ func (s *Store) WriteSyncedL1Messages(messages []types.L1Message, latestSynced u return batch.Write() } +// DerivationL1Block stores the (number, hash) pair for an L1 block that +// derivation has scanned for commit batch logs. SPEC-005 §4.7.6 reorg +// detection compares saved hashes against fresh L1 reads; on mismatch the +// derivation cursor is rewound. +type DerivationL1Block struct { + Number uint64 + Hash [32]byte +} + +func (s *Store) WriteDerivationL1Block(block *DerivationL1Block) { + data, err := rlp.EncodeToBytes(block) + if err != nil { + panic(fmt.Sprintf("failed to RLP encode DerivationL1Block, err: %v", err)) + } + if err := s.db.Put(DerivationL1BlockKey(block.Number), data); err != nil { + panic(fmt.Sprintf("failed to write DerivationL1Block, err: %v", err)) + } +} + +func (s *Store) ReadDerivationL1Block(l1Height uint64) *DerivationL1Block { + data, err := s.db.Get(DerivationL1BlockKey(l1Height)) + if err != nil && !isNotFoundErr(err) { + panic(fmt.Sprintf("failed to read DerivationL1Block, err: %v", err)) + } + if len(data) == 0 { + return nil + } + var block DerivationL1Block + if err := rlp.DecodeBytes(data, &block); err != nil { + panic(fmt.Sprintf("invalid DerivationL1Block RLP, err: %v", err)) + } + return &block +} + +// ReadDerivationL1BlockRange returns saved L1 block records in [from, to] +// inclusive. Missing entries are skipped silently; the slice is dense over +// the heights actually present. +func (s *Store) ReadDerivationL1BlockRange(from, to uint64) []*DerivationL1Block { + var blocks []*DerivationL1Block + for h := from; h <= to; h++ { + b := s.ReadDerivationL1Block(h) + if b != nil { + blocks = append(blocks, b) + } + } + return blocks +} + +// DeleteDerivationL1BlocksFrom drops every saved L1 block record at height +// >= the given height. Used by handleL1Reorg to clear hashes that are no +// longer canonical so subsequent polls record the new chain afresh. +func (s *Store) DeleteDerivationL1BlocksFrom(height uint64) { + batch := s.db.NewBatch() + for h := height; ; h++ { + key := DerivationL1BlockKey(h) + has, err := s.db.Has(key) + if err != nil || !has { + break + } + if err := batch.Delete(key); err != nil { + panic(fmt.Sprintf("failed to delete DerivationL1Block at %d, err: %v", h, err)) + } + } + if err := batch.Write(); err != nil { + panic(fmt.Sprintf("failed to write batch delete for DerivationL1Blocks, err: %v", err)) + } +} + func isNotFoundErr(err error) bool { return err.Error() == leveldb.ErrNotFound.Error() || err.Error() == types.ErrMemoryDBNotFound.Error() } diff --git a/node/derivation/batch_decode.go b/node/derivation/batch_decode.go deleted file mode 100644 index 693b4cfc6..000000000 --- a/node/derivation/batch_decode.go +++ /dev/null @@ -1,75 +0,0 @@ -package derivation - -import ( - "bytes" - "encoding/binary" - "math/big" - - "github.com/morph-l2/go-ethereum/common" - "github.com/morph-l2/go-ethereum/core/types" - "github.com/morph-l2/go-ethereum/rlp" -) - -type BatchData struct { - Txs []*types.Transaction - BlockContexts []*BlockInfo - //Signature *bindings.RollupBatchSignature -} - -// number || timestamp || base_fee || gas_limit || num_txs || tx_hashs -type BlockInfo struct { - Number *big.Int - Timestamp uint64 - BaseFee *big.Int - GasLimit uint64 - NumTxs uint16 -} - -// decode blockcontext -func (b *BatchData) DecodeBlockContext(endBlock uint64, bs []byte) error { - b.BlockContexts = []*BlockInfo{} - // [block1, block2, ..., blockN] - reader := bytes.NewReader(bs) - for { - block := new(BlockInfo) - // number || timestamp || base_fee || gas_limit || num_txs - // Number(8) || Timestamp(8) || BaseFee(32) || GasLimit(8) || numTxs(2) - bsBlockNumber := make([]byte, 8) - if _, err := reader.Read(bsBlockNumber[:]); err != nil { - return err - } - block.Number = new(big.Int).SetBytes(bsBlockNumber) - - if err := binary.Read(reader, binary.BigEndian, &block.Timestamp); err != nil { - return err - } - // [32]byte uint256 - bsBaseFee := make([]byte, 32) - if _, err := reader.Read(bsBaseFee[:]); err != nil { - return err - } - block.BaseFee = new(big.Int).SetBytes(bsBaseFee) - if err := binary.Read(reader, binary.BigEndian, &block.GasLimit); err != nil { - return err - } - if err := binary.Read(reader, binary.BigEndian, &block.NumTxs); err != nil { - return err - } - for i := 0; i < int(block.NumTxs); i++ { - txHash := common.Hash{} - if _, err := reader.Read(txHash[:]); err != nil { - return err - } - // drop txHash - } - b.BlockContexts = append(b.BlockContexts, block) - if block.Number.Uint64() == endBlock { - break - } - } - return nil -} - -func (b *BatchData) DecodeTransactions(bs []byte) error { - return rlp.DecodeBytes(bs, &b.Txs) -} diff --git a/node/derivation/batch_info.go b/node/derivation/batch_info.go index d9616634b..fa8f6bd15 100644 --- a/node/derivation/batch_info.go +++ b/node/derivation/batch_info.go @@ -59,6 +59,11 @@ type BatchInfo struct { root common.Hash withdrawalRoot common.Hash parentTotalL1MessagePopped uint64 + + // blobHashes is the ordered list of EIP-4844 blob versioned hashes + // declared by the L1 commitBatch tx. local verify uses this to compare + // against locally-rebuilt versioned hashes (SPEC-005 section 4). + blobHashes []common.Hash } func (bi *BatchInfo) FirstBlockNumber() uint64 { diff --git a/node/derivation/config.go b/node/derivation/config.go index 9d896f0b6..9433a9e05 100644 --- a/node/derivation/config.go +++ b/node/derivation/config.go @@ -29,8 +29,37 @@ const ( // DefaultLogProgressInterval is the frequency at which we log progress. DefaultLogProgressInterval = time.Second * 10 + + VerifyModeLayer1 = "layer1" + VerifyModeLocal = "local" + + // DefaultVerifyMode is "local": rebuild + compare locally on the happy + // path, no beacon blob fetch. Operators who need the legacy "always + // pull blob" behavior can set --derivation.verify-mode=layer1. + DefaultVerifyMode = VerifyModeLocal + + // DefaultReorgCheckDepth is the number of recent L1 blocks to check for + // reorgs in SPEC-005 §4.7.6 detection. 64 covers the post-Merge "finality + // distance" rule of thumb and provides safety margin if Confirmations is + // configured below finalized. + DefaultReorgCheckDepth = uint64(64) ) +// validateAndDefaultVerifyMode normalises an empty VerifyMode to the default +// and rejects unknown values. Extracted from SetCliContext so the validation +// can be unit-tested without building a cli.Context. +func validateAndDefaultVerifyMode(s string) (string, error) { + switch s { + case VerifyModeLayer1, VerifyModeLocal: + return s, nil + case "": + return DefaultVerifyMode, nil + default: + return "", fmt.Errorf("invalid derivation.verify-mode %q (must be %q or %q)", + s, VerifyModeLayer1, VerifyModeLocal) + } +} + type Config struct { L1 *types.L1Config `json:"l1"` L2 *types.L2Config `json:"l2"` @@ -41,6 +70,8 @@ type Config struct { PollInterval time.Duration `json:"poll_interval"` LogProgressInterval time.Duration `json:"log_progress_interval"` FetchBlockRange uint64 `json:"fetch_block_range"` + VerifyMode string `json:"verify_mode"` + ReorgCheckDepth uint64 `json:"reorg_check_depth"` MetricsPort uint64 `json:"metrics_port"` MetricsHostname string `json:"metrics_hostname"` MetricsServerEnable bool `json:"metrics_server_enable"` @@ -49,11 +80,22 @@ type Config struct { func DefaultConfig() *Config { return &Config{ L1: &types.L1Config{ - Confirmations: rpc.FinalizedBlockNumber, + // Default to L1 safe (~1 epoch / ~6 min lag) rather than finalized + // (~2 epochs / ~13 min lag). L1 safe blocks can theoretically be + // reorg'd if a Casper FFG slashing condition fires, so this default + // is paired with always-on L1 reorg detection (SPEC-005 §4.7.6 in + // reorg.go) which rewinds the derivation cursor and resets the tag + // advancer when an L1 hash mismatch is observed. Operators wanting + // strict no-reorg-possible reads can still set + // --derivation.confirmations=-3 (rpc.FinalizedBlockNumber) or + // --l1.confirmations=-3 to revert to the previous behavior. + Confirmations: rpc.SafeBlockNumber, }, PollInterval: DefaultPollInterval, LogProgressInterval: DefaultLogProgressInterval, FetchBlockRange: DefaultFetchBlockRange, + VerifyMode: DefaultVerifyMode, + ReorgCheckDepth: DefaultReorgCheckDepth, L2: new(types.L2Config), } } @@ -110,6 +152,22 @@ func (c *Config) SetCliContext(ctx *cli.Context) error { } } + if ctx.GlobalIsSet(flags.DerivationVerifyMode.Name) { + c.VerifyMode = ctx.GlobalString(flags.DerivationVerifyMode.Name) + } + normalized, err := validateAndDefaultVerifyMode(c.VerifyMode) + if err != nil { + return err + } + c.VerifyMode = normalized + + if ctx.GlobalIsSet(flags.DerivationReorgCheckDepth.Name) { + c.ReorgCheckDepth = ctx.GlobalUint64(flags.DerivationReorgCheckDepth.Name) + } + if c.ReorgCheckDepth == 0 { + c.ReorgCheckDepth = DefaultReorgCheckDepth + } + l2EthAddr := ctx.GlobalString(flags.L2EthAddr.Name) l2EngineAddr := ctx.GlobalString(flags.L2EngineAddr.Name) fileName := ctx.GlobalString(flags.L2EngineJWTSecret.Name) diff --git a/node/derivation/config_test.go b/node/derivation/config_test.go new file mode 100644 index 000000000..e26ce5889 --- /dev/null +++ b/node/derivation/config_test.go @@ -0,0 +1,68 @@ +package derivation + +import ( + "strings" + "testing" +) + +// SPEC-005 section 4.2 + 5.1 verify-mode dispatch tests. The mode is bound at +// startup; the validation switch in SetCliContext rejects unknown values +// fail-fast so a typo never reaches the main loop. + +func TestVerifyMode_DefaultIsLocal(t *testing.T) { + if got := DefaultConfig().VerifyMode; got != VerifyModeLocal { + t.Fatalf("DefaultConfig().VerifyMode = %q, want %q", got, VerifyModeLocal) + } + + got, err := validateAndDefaultVerifyMode("") + if err != nil { + t.Fatalf("empty verify-mode rejected: %v", err) + } + if got != VerifyModeLocal { + t.Fatalf("empty verify-mode normalised to %q, want %q", got, VerifyModeLocal) + } +} + +func TestVerifyMode_AcceptsExplicitModes(t *testing.T) { + for _, mode := range []string{VerifyModeLayer1, VerifyModeLocal} { + got, err := validateAndDefaultVerifyMode(mode) + if err != nil { + t.Fatalf("%s rejected: %v", mode, err) + } + if got != mode { + t.Fatalf("%s normalised to %q, want %q", mode, got, mode) + } + } +} + +func TestVerifyMode_RejectsUnknown(t *testing.T) { + // "hybrid" was the old default; ensure post-removal it's rejected so + // stale operator configs fail loud rather than silently falling back to + // local. + for _, bad := range []string{"pathC", "hybrid"} { + err := validateAndDefaultVerifyModeErr(t, bad) + if !strings.Contains(err.Error(), bad) { + t.Fatalf("error should mention the offending value %q; got: %v", bad, err) + } + // Error message should enumerate the valid modes so a typo's fix + // is obvious from the log line alone. + for _, mode := range []string{VerifyModeLayer1, VerifyModeLocal} { + if !strings.Contains(err.Error(), mode) { + t.Fatalf("error should list %q as a valid mode; got: %v", mode, err) + } + } + } + + if _, err := validateAndDefaultVerifyMode("PATHA"); err == nil { + t.Fatal("verify-mode is case-sensitive; uppercase should be rejected") + } +} + +func validateAndDefaultVerifyModeErr(t *testing.T, s string) error { + t.Helper() + _, err := validateAndDefaultVerifyMode(s) + if err == nil { + t.Fatalf("expected error on verify-mode %q, got nil", s) + } + return err +} diff --git a/node/derivation/database.go b/node/derivation/database.go index a63f4eba1..134c83890 100644 --- a/node/derivation/database.go +++ b/node/derivation/database.go @@ -1,6 +1,7 @@ package derivation import ( + "morph-l2/node/db" "morph-l2/node/sync" ) @@ -12,8 +13,17 @@ type Database interface { type Reader interface { ReadLatestDerivationL1Height() *uint64 + // ReadDerivationL1BlockRange returns saved L1 block records in [from, to] + // inclusive. Used by SPEC-005 §4.7.6 reorg detection. + ReadDerivationL1BlockRange(from, to uint64) []*db.DerivationL1Block } type Writer interface { WriteLatestDerivationL1Height(latest uint64) + // WriteDerivationL1Block records a scanned L1 block's (number, hash) for + // later reorg detection. + WriteDerivationL1Block(block *db.DerivationL1Block) + // DeleteDerivationL1BlocksFrom drops saved L1 block records at height >= + // height; used after a reorg is detected to clear stale hashes. + DeleteDerivationL1BlocksFrom(height uint64) } diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 05c4606b6..a7b168a1a 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "github.com/morph-l2/go-ethereum/eth/catalyst" "math/big" "time" @@ -27,7 +28,6 @@ import ( nodecommon "morph-l2/node/common" "morph-l2/node/sync" "morph-l2/node/types" - "morph-l2/node/validator" ) var ( @@ -42,7 +42,6 @@ type Derivation struct { RollupContractAddress common.Address confirmations rpc.BlockNumber l2Client *types.RetryableClient - validator *validator.Validator logger tmlog.Logger rollup *bindings.Rollup metrics *Metrics @@ -62,7 +61,12 @@ type Derivation struct { fetchBlockRange uint64 pollInterval time.Duration logProgressInterval time.Duration - stop chan struct{} + verifyMode string // SPEC-005 section 4.2: "layer1" or "local" (default); bound at startup, never switches. + reorgCheckDepth uint64 // SPEC-005 section 4.7.6: how far back to scan for L1 hash divergence each poll. + + tagAdvancer *tagAdvancer + + stop chan struct{} } type DeployContractBackend interface { @@ -72,7 +76,7 @@ type DeployContractBackend interface { ethereum.TransactionReader } -func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, db Database, validator *validator.Validator, rollup *bindings.Rollup, logger tmlog.Logger) (*Derivation, error) { +func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, db Database, rollup *bindings.Rollup, logger tmlog.Logger) (*Derivation, error) { l1Client, err := ethclient.Dial(cfg.L1.Addr) if err != nil { return nil, err @@ -117,12 +121,14 @@ func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, baseHttp := NewBasicHTTPClient(cfg.BeaconRpc, logger) l1BeaconClient := NewL1BeaconClient(baseHttp) + l2Client := types.NewRetryableClient(aClient, eClient, logger) + tagAdv := newTagAdvancer(l2Client, metrics, logger) + return &Derivation{ ctx: ctx, db: db, l1Client: l1Client, syncer: syncer, - validator: validator, rollup: rollup, rollupABI: rollupAbi, legacyRollupABI: legacyRollupAbi, @@ -130,7 +136,7 @@ func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, logger: logger, RollupContractAddress: cfg.RollupContractAddress, confirmations: cfg.L1.Confirmations, - l2Client: types.NewRetryableClient(aClient, eClient, logger), + l2Client: l2Client, cancel: cancel, stop: make(chan struct{}), startHeight: cfg.StartHeight, @@ -138,6 +144,9 @@ func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, fetchBlockRange: cfg.FetchBlockRange, pollInterval: cfg.PollInterval, logProgressInterval: cfg.LogProgressInterval, + verifyMode: cfg.VerifyMode, + reorgCheckDepth: cfg.ReorgCheckDepth, + tagAdvancer: tagAdv, metrics: metrics, l1BeaconClient: l1BeaconClient, L2ToL1MessagePasser: msgPasser, @@ -145,7 +154,11 @@ func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, } func (d *Derivation) Start() { - // block node startup during initial sync and print some helpful logs + // Single-goroutine design: the SPEC-005 finalizer step runs at the end + // of each derivationBlock iteration (see finalizer.go::finalizerTick). + // Folded into the main loop so the cursor-rewind recovery paths + // (handleL1Reorg + finalizerTick canonicality fail) don't race with + // each other on the L1 cursor / tagAdvancer. go func() { d.syncer.Start() t := time.NewTicker(d.pollInterval) @@ -154,6 +167,7 @@ func (d *Derivation) Start() { for { // don't wait for ticker during startup d.derivationBlock(d.ctx) + d.finalizerTick() select { case <-d.ctx.Done(): @@ -182,6 +196,24 @@ func (d *Derivation) Stop() { } func (d *Derivation) derivationBlock(ctx context.Context) { + // SPEC-005 §4.7.6: check for an L1 reorg before processing any new logs. + // The scan is a no-op when --derivation.confirmations=finalized (L1 + // finalized doesn't reorg by Ethereum consensus assumption) and + // load-bearing when configured below finalized; the gate is intentionally + // absent so behavior is uniform across configs. + if reorgAt, err := d.detectReorg(ctx); err != nil { + d.logger.Error("L1 reorg detection failed; skipping this poll", "err", err) + return + } else if reorgAt != nil { + if err := d.handleL1Reorg(*reorgAt); err != nil { + d.logger.Error("handle L1 reorg failed", "err", err) + } + // Don't process further this cycle: cursor was rewound, let the next + // poll re-fetch from the new starting point. Avoids recording + // potentially-still-unstable L1 hashes if the chain is mid-reorg. + return + } + latestDerivation := d.db.ReadLatestDerivationL1Height() latest, err := d.getLatestConfirmedBlockNumber(d.ctx) if err != nil { @@ -216,60 +248,113 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.logger.Info("fetched rollup tx", "txNum", len(logs), "latestBatchIndex", latestBatchIndex) for _, lg := range logs { - batchInfo, err := d.fetchRollupDataByTxHash(lg.TxHash, lg.BlockNumber) - if err != nil { - if errors.Is(err, types.ErrNotCommitBatchTx) { - continue + var ( + batchInfo *BatchInfo + lastHeader *eth.Header + ) + switch d.verifyMode { + case VerifyModeLocal: + batchInfo, err = d.fetchBatchInfoOutline(ctx, lg.TxHash, lg.BlockNumber) + if err != nil { + if errors.Is(err, types.ErrNotCommitBatchTx) { + continue + } + d.logger.Error("fetch batch info outline failed", "err", err) + return + } + d.logger.Info("local verify fetched batch metadata", + "batchIndex", batchInfo.batchIndex, + "version", batchInfo.version, + "parentTotalL1Popped", batchInfo.parentTotalL1MessagePopped, + "expectedBlobs", len(batchInfo.blobHashes), + "txNonce", batchInfo.nonce, "txHash", batchInfo.txHash, + "l1BlockNumber", batchInfo.l1BlockNumber, "firstL2BlockNumber", batchInfo.firstBlockNumber, "lastL2BlockNumber", batchInfo.lastBlockNumber) + rebuilt, err := d.rebuildBlob(ctx, batchInfo) + if err != nil { + d.logger.Error("rebuildBlob failed", "err", err) + return + } + lastHeader, err = d.fetchLocalLastHeader(ctx, batchInfo) + if err != nil { + d.logger.Error("local verify local last-header fetch failed", "batchIndex", batchInfo.batchIndex, "error", err) + return + } + for i := range rebuilt { + if rebuilt[i] != batchInfo.blobHashes[i] { + // TODO reorg + batchInfoFull, fetchErr := d.fetchRollupDataByTxHash(lg.TxHash, lg.BlockNumber) + if fetchErr != nil { + d.logger.Error("local verify self-heal: fetch real batch failed", + "batchIndex", batchInfo.batchIndex, "error", fetchErr) + return + } + lastHeader, err = d.deriveForce(batchInfoFull) + if err != nil { + d.logger.Error("local verify self-heal: derive failed", + "batchIndex", batchInfo.batchIndex, "error", err) + return + } + break + } } - d.logger.Error("fetch batch info failed", "txHash", lg.TxHash, "blockNumber", lg.BlockNumber, "error", err) - return - } - d.logger.Info("fetch rollup transaction success", "txNonce", batchInfo.nonce, "txHash", batchInfo.txHash, - "l1BlockNumber", batchInfo.l1BlockNumber, "firstL2BlockNumber", batchInfo.firstBlockNumber, "lastL2BlockNumber", batchInfo.lastBlockNumber) - // derivation - lastHeader, err := d.derive(batchInfo) - if err != nil { - d.logger.Error("derive blocks interrupt", "error", err) + d.metrics.SetL2DeriveHeight(batchInfo.lastBlockNumber) + d.metrics.SetSyncedBatchIndex(batchInfo.batchIndex) + case VerifyModeLayer1: + batchInfo, err = d.fetchRollupDataByTxHash(lg.TxHash, lg.BlockNumber) + if err != nil { + if errors.Is(err, types.ErrNotCommitBatchTx) { + continue + } + d.logger.Error("fetch batch info failed", "txHash", lg.TxHash, "blockNumber", lg.BlockNumber, "error", err) + return + } + d.logger.Info("fetch rollup transaction success", "txNonce", batchInfo.nonce, "txHash", batchInfo.txHash, + "l1BlockNumber", batchInfo.l1BlockNumber, "firstL2BlockNumber", batchInfo.firstBlockNumber, "lastL2BlockNumber", batchInfo.lastBlockNumber) + lastHeader, err = d.derive(batchInfo) + if err != nil { + d.logger.Error("derive blocks interrupt", "error", err) + return + } + d.logger.Info("batch derivation complete", "batch_index", batchInfo.batchIndex, "currentBatchEndBlock", lastHeader.Number.Uint64()) + d.metrics.SetL2DeriveHeight(lastHeader.Number.Uint64()) + d.metrics.SetSyncedBatchIndex(batchInfo.batchIndex) + default: + // Unreachable: validateAndDefaultVerifyMode rejects unknown values + // at startup and normalises empty to DefaultVerifyMode (local). + // If we get here it's a programming error -- a new mode added to + // the constant set without a switch arm. Fail loud rather than + // silently fall through to stale semantics. + d.logger.Error("unknown verifyMode reached derivationBlock; refusing to process batch", "verifyMode", d.verifyMode) return } - // only last block of batch - d.logger.Info("batch derivation complete", "batch_index", batchInfo.batchIndex, "currentBatchEndBlock", lastHeader.Number.Uint64()) - d.metrics.SetL2DeriveHeight(lastHeader.Number.Uint64()) - d.metrics.SetSyncedBatchIndex(batchInfo.batchIndex) + if lastHeader.Number.Uint64() <= d.baseHeight { continue } - withdrawalRoot, err := d.L2ToL1MessagePasser.MessageRoot(&bind.CallOpts{ - BlockNumber: lastHeader.Number, - }) - if err != nil { - d.logger.Error("get withdrawal root failed", "error", err) - return - } - - rootMismatch := !bytes.Equal(lastHeader.Root.Bytes(), batchInfo.root.Bytes()) - withdrawalMismatch := !bytes.Equal(withdrawalRoot[:], batchInfo.withdrawalRoot.Bytes()) - - if rootMismatch || withdrawalMismatch { - d.metrics.SetBatchStatus(stateException) - // TODO The challenge switch is currently on and will be turned on in the future - if d.validator != nil && d.validator.ChallengeEnable() { - if err := d.validator.ChallengeState(batchInfo.batchIndex); err != nil { - d.logger.Error("challenge state failed", "batchIndex", batchInfo.batchIndex, "error", err) - return - } + if err := d.verifyBatchRoots(batchInfo, lastHeader); err != nil { + // stateException only when the verifier produced a real mismatch + // verdict (root or withdrawal root). Transient failures (e.g. + // MessageRoot RPC error) just log and retry next poll. + if errors.Is(err, ErrBatchVerifyDivergence) { + d.metrics.SetBatchStatus(stateException) } - d.logger.Error("root hash or withdrawal hash is not equal", - "originStateRootHash", batchInfo.root, - "deriveStateRootHash", lastHeader.Root.Hex(), - "batchWithdrawalRoot", batchInfo.withdrawalRoot.Hex(), - "deriveWithdrawalRoot", common.BytesToHash(withdrawalRoot[:]).Hex(), - ) + d.logger.Error("batch roots verification failed", "batchIndex", batchInfo.batchIndex, "error", err) return } d.metrics.SetBatchStatus(stateNormal) d.metrics.SetL1SyncHeight(lg.BlockNumber) + + // SPEC-005 section 4.7.3: a verified batch (layer1 or local verify) advances safe. + d.tagAdvancer.advanceSafe(d.ctx, batchInfo.batchIndex, lastHeader) + } + + // SPEC-005 §4.7.6: record this poll's L1 block hashes so the next poll + // can detect a reorg. Failure here must NOT advance the cursor -- a gap + // in the recorded hashes would defeat detection across that gap. + if err := d.recordL1Blocks(ctx, start, end); err != nil { + d.logger.Error("recordL1Blocks failed; skipping cursor advance, will retry next poll", "err", err) + return } d.db.WriteLatestDerivationL1Height(end) @@ -416,6 +501,7 @@ func (d *Derivation) fetchRollupDataByTxHash(txHash common.Hash, blockNumber uin rollupData.l1BlockNumber = blockNumber rollupData.txHash = txHash rollupData.nonce = tx.Nonce() + rollupData.blobHashes = tx.BlobHashes() return rollupData, nil } @@ -532,6 +618,7 @@ func (d *Derivation) handleL1Message(rollupData *BatchInfo, parentTotalL1Message for bIndex, block := range rollupData.blockContexts { // This may happen to nodes started from snapshot, in which case we will no longer handle L1Msg if block.Number <= l2Height { + totalL1MessagePopped += uint64(block.l1MsgNum) continue } var l1Transactions []*eth.Transaction @@ -540,7 +627,7 @@ func (d *Derivation) handleL1Message(rollupData *BatchInfo, parentTotalL1Message return fmt.Errorf("get l1 message error:%v", err) } if len(l1Messages) != int(block.l1MsgNum) { - return fmt.Errorf("invalid l1 msg num,expect %v,have %v", block.l1MsgNum, l1Messages) + return fmt.Errorf("invalid l1 msg num,expect %v,have %v", block.l1MsgNum, len(l1Messages)) } totalL1MessagePopped += uint64(block.l1MsgNum) if len(l1Messages) > 0 { @@ -596,6 +683,73 @@ func (d *Derivation) derive(rollupData *BatchInfo) (*eth.Header, error) { return lastHeader, nil } +func (d *Derivation) deriveForce(rollupData *BatchInfo) (*eth.Header, error) { + firstNum := rollupData.firstBlockNumber + if firstNum == 0 { + return nil, fmt.Errorf("invalid firstBlockNumber 0 for batch %d", rollupData.batchIndex) + } + + // Anchor: parent of the batch's first block must already exist locally. + parentHeader, err := d.l2Client.HeaderByNumber(d.ctx, big.NewInt(int64(firstNum-1))) + if err != nil { + return nil, fmt.Errorf("read parent header at %d: %w", firstNum-1, err) + } + if parentHeader == nil { + return nil, fmt.Errorf("parent header at %d missing", firstNum-1) + } + parentHash := parentHeader.Hash() + + var lastHeader *eth.Header + for _, blockData := range rollupData.blockContexts { + execData := safeL2DataToExecutable(blockData.SafeL2Data, parentHash) + err = func() error { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(60)*time.Second) + defer cancel() + err = d.l2Client.NewL2BlockV2(ctx, execData, true /* isSafe */) + if err != nil { + d.logger.Error("NewL2BlockV2 failed", + "batchIndex", rollupData.batchIndex, + "blockNumber", execData.Number, + "parent", parentHash.Hex(), + "error", err, + ) + return err + } + return nil + }() + + // Read back to chain the next iteration's parent and to feed + // verifyBatchRoots at the end. + h, err := d.l2Client.HeaderByNumber(d.ctx, big.NewInt(int64(execData.Number))) + if err != nil { + return nil, fmt.Errorf("read header at %d after NewL2BlockV2: %w", execData.Number, err) + } + if h == nil { + return nil, fmt.Errorf(" header at %d missing after NewL2BlockV2", execData.Number) + } + parentHash = h.Hash() + lastHeader = h + + d.logger.Info("block written via NewL2BlockV2", + "batchIndex", rollupData.batchIndex, + "blockNumber", execData.Number, + "hash", h.Hash().Hex(), + ) + } + return lastHeader, nil +} + +func safeL2DataToExecutable(s *catalyst.SafeL2Data, parentHash common.Hash) *catalyst.ExecutableL2Data { + return &catalyst.ExecutableL2Data{ + ParentHash: parentHash, + Number: s.Number, + GasLimit: s.GasLimit, + BaseFee: s.BaseFee, + Timestamp: s.Timestamp, + Transactions: s.Transactions, + } +} + func (d *Derivation) getLatestConfirmedBlockNumber(ctx context.Context) (uint64, error) { return nodecommon.GetLatestConfirmedBlockNumber(ctx, d.l1Client, d.confirmations) } diff --git a/node/derivation/finalizer.go b/node/derivation/finalizer.go new file mode 100644 index 000000000..faacf5133 --- /dev/null +++ b/node/derivation/finalizer.go @@ -0,0 +1,168 @@ +package derivation + +import ( + "math/big" + + "github.com/morph-l2/go-ethereum/accounts/abi/bind" + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/rpc" +) + +// SPEC-005 §4.7.4 finalized-head tick. +// +// Originally a separate goroutine (with its own ticker / stopped channel), +// folded into the derivation main loop because the only justification for +// a separate goroutine was "L1 RPC could be slow" -- which was already true +// for the main loop's eth_getLogs / eth_getTransactionByHash calls, so the +// extra goroutine bought nothing and introduced cross-goroutine state writes +// (cursor / tagAdvancer) that complicated the canonicality recovery path. +// +// The lookup is intentionally driven by L2 block numbers (not batch +// indices) so it doesn't depend on Rollup.BatchDataStore being populated +// for arbitrarily-old batches. The contract clears +// +// delete batchDataStore[_batchIndex - 1]; +// +// on every finalize, so an older batchIndex returns zero -- but the +// LATEST committed batch index (queried at the L1 finalized block) is +// always populated, since at that block its delete has not yet happened. +// Pinning both contract calls to the L1 finalized block makes the read +// reliable, and from there the math becomes a number comparison against +// the local safe head. +// +// Cost: 1 L1 RPC + 2 L1 contract calls + 1 L2 RPC per main-loop poll. +// Plus 1 L2 RPC for the rare "local verified beyond L1 finalized" branch. +func (d *Derivation) finalizerTick() { + // 1. Resolve the L1 finalized header. + finHeader, err := d.l1Client.HeaderByNumber(d.ctx, big.NewInt(int64(rpc.FinalizedBlockNumber))) + if err != nil { + d.logger.Info("finalizer: read L1 finalized header failed", "err", err) + return + } + if finHeader == nil { + return + } + + // 2. Pin the rollup queries to the L1 finalized block. At that block, + // `lastCommittedBatchIndex` always references a batch whose + // `batchDataStore` slot is still populated: the on-chain GC only + // deletes `batchIndex - 1` on each finalizeBatch call, so for any + // batchIndex >= lastFinalizedBatchIndex@thatBlock the slot is intact + // at that block's state. Using the same `BlockNumber: finHeader.Number` + // for both calls is what makes the lookup reliable. + callOpts := &bind.CallOpts{ + BlockNumber: finHeader.Number, + Context: d.ctx, + } + + committedAtFin, err := d.rollup.LastCommittedBatchIndex(callOpts) + if err != nil { + d.logger.Info("finalizer: query LastCommittedBatchIndex@finalized failed", + "l1Block", finHeader.Number.Uint64(), "err", err) + return + } + if committedAtFin == nil || committedAtFin.Uint64() == 0 { + // chain not yet committed any batch. + return + } + + bd, err := d.rollup.BatchDataStore(callOpts, committedAtFin) + if err != nil { + d.logger.Info("finalizer: query BatchDataStore@finalized failed", + "l1Block", finHeader.Number.Uint64(), "batchIndex", committedAtFin.Uint64(), "err", err) + return + } + if bd.BlockNumber == nil || bd.BlockNumber.Uint64() == 0 { + // Shouldn't happen for the latest committed batch at L1 finalized + // (see comment above). If it does, log and skip rather than risk + // finalizing genesis. + d.logger.Info("finalizer: BatchDataStore[committedAtFin]@finalized has zero blockNumber; skipping", + "l1Block", finHeader.Number.Uint64(), "batchIndex", committedAtFin.Uint64()) + return + } + l1FinalizedLastBlock := bd.BlockNumber.Uint64() + + // 3. Read local safe head. If derivation hasn't verified anything + // since process start, there's nothing to anchor finalized to. + safeHash, safeNum := d.tagAdvancer.Safe() + if safeNum == 0 { + return + } + + // 4. Defensive canonicality check. Re-read the L2 client's header at + // safeNum and verify it still matches safeHash. On mismatch we rewind + // the derivation cursor (op-stack-style "reset to a known good parent + // and re-derive forward" -- shared with the L1 reorg recovery path + // via rewindAndReset). This catches: + // - L2 client state divergence (rare; would surface other bugs too) + // - L1 reorg propagation that detectReorg missed (race or bug in the + // reorg detection window) + safeHdr, err := d.l2Client.HeaderByNumber(d.ctx, big.NewInt(int64(safeNum))) + if err != nil { + d.logger.Info("finalizer: read local L2 safe header failed; skipping advance", + "safeNumber", safeNum, "err", err) + return + } + if safeHdr == nil || safeHdr.Hash() != safeHash { + actualHash := (common.Hash{}).Hex() + if safeHdr != nil { + actualHash = safeHdr.Hash().Hex() + } + // Rewind by reorgCheckDepth from the current cursor so the next + // derivationBlock poll re-fetches recent batches and re-verifies. + // Persistent breakage will resurface as verifyBatchRoots failure on + // re-derivation; transient state-client weirdness self-heals. + var rewindTo uint64 + if cur := d.db.ReadLatestDerivationL1Height(); cur != nil { + if *cur > d.reorgCheckDepth { + rewindTo = *cur - d.reorgCheckDepth + } else { + rewindTo = d.startHeight + } + } else { + rewindTo = d.startHeight + } + d.logger.Error("finalizer: local safe head no longer canonical; rewinding cursor and resetting tag advancer", + "safeNumber", safeNum, + "expected", safeHash.Hex(), + "actual", actualHash, + "rewindTo", rewindTo) + d.rewindAndReset(rewindTo) + return + } + + // 5. Decide which side to anchor finalized to. + // + // In the common case (steady-state operation), L1FinalizedLastBlock >= + // safeNum because derivation only walks L1-finalized commits and + // verifies them in-order; both sides advance together with safe + // trailing slightly. We anchor finalized to the local safe head -- no + // extra L2 RPC needed, and finalized exactly tracks "what the local + // node has verified". + // + // The other branch (safeNum > L1FinalizedLastBlock) only fires if + // derivation runs ahead of L1 finalized -- e.g. operator set + // Confirmations < finalized so derivation processes batches before + // L1 has finalized them. We then anchor finalized to + // L1FinalizedLastBlock and pull the L2 header from the local client + // (we know that block exists locally because L1FinalizedLastBlock < + // safeNum and we verified up to safeNum). + if l1FinalizedLastBlock >= safeNum { + d.tagAdvancer.advanceFinalized(d.ctx, committedAtFin.Uint64(), safeHash, safeNum) + return + } + + finalizedHdr, err := d.l2Client.HeaderByNumber(d.ctx, big.NewInt(int64(l1FinalizedLastBlock))) + if err != nil { + d.logger.Info("finalizer: read L2 header at L1FinalizedLastBlock failed", + "l2Block", l1FinalizedLastBlock, "err", err) + return + } + if finalizedHdr == nil { + d.logger.Info("finalizer: L2 header at L1FinalizedLastBlock missing locally; skipping", + "l2Block", l1FinalizedLastBlock) + return + } + + d.tagAdvancer.advanceFinalized(d.ctx, committedAtFin.Uint64(), finalizedHdr.Hash(), l1FinalizedLastBlock) +} diff --git a/node/derivation/metrics.go b/node/derivation/metrics.go index da5e8937d..285525157 100644 --- a/node/derivation/metrics.go +++ b/node/derivation/metrics.go @@ -24,6 +24,23 @@ type Metrics struct { BatchStatus metrics.Gauge LatestBatchIndex metrics.Gauge SyncedBatchIndex metrics.Gauge + + // LocalVerifyTriggered increments once per batch processed under + // VerifyModeLocal -- presence/absence on dashboards confirms the local + // verifier is running. Failure tracking is intentionally not split into + // separate counters; failures surface as Error logs and propagate as + // ErrBatchVerifyDivergence to BatchStatus=stateException. + LocalVerifyTriggered metrics.Counter + + // Tag management metrics. SafeL2BlockNumber / FinalizedL2BlockNumber are + // the canonical "where is the chain now" gauges; the counters track + // transitions for rate-based alerts. + SafeAdvanceTotal metrics.Counter + FinalizedAdvanceTotal metrics.Counter + SafeL2BlockNumber metrics.Gauge + FinalizedL2BlockNumber metrics.Gauge + L1ReorgResetTotal metrics.Counter + TagInvariantViolationTotal metrics.Counter } func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { @@ -68,6 +85,48 @@ func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { Name: "synced_batch_index", Help: "", }, labels).With(labelsAndValues...), + LocalVerifyTriggered: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "local_verify_triggered_total", + Help: "Number of batches processed by the local-rebuild verifier.", + }, labels).With(labelsAndValues...), + SafeAdvanceTotal: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "safe_advance_total", + Help: "Times derivation advanced the safe L2 head after a verified batch.", + }, labels).With(labelsAndValues...), + FinalizedAdvanceTotal: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "finalized_advance_total", + Help: "Times the finalizer advanced the finalized L2 head from L1 finalized state.", + }, labels).With(labelsAndValues...), + SafeL2BlockNumber: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "safe_l2_block_number", + Help: "Current in-memory safe L2 block number (mirror of derivation tag advancer).", + }, labels).With(labelsAndValues...), + FinalizedL2BlockNumber: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "finalized_l2_block_number", + Help: "Current in-memory finalized L2 block number (mirror of derivation tag advancer).", + }, labels).With(labelsAndValues...), + L1ReorgResetTotal: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "l1_reorg_reset_total", + Help: "Times an L1 reorg triggered a tag advancer reset (safe cleared, refilled by re-derivation).", + }, labels).With(labelsAndValues...), + TagInvariantViolationTotal: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "tag_invariant_violation_total", + Help: "Times the finalized <= safe <= unsafe invariant failed; SetBlockTags is skipped on each occurrence.", + }, labels).With(labelsAndValues...), } } @@ -95,6 +154,34 @@ func (m *Metrics) SetSyncedBatchIndex(batchIndex uint64) { m.SyncedBatchIndex.Set(float64(batchIndex)) } +func (m *Metrics) IncLocalVerifyTriggered() { + m.LocalVerifyTriggered.Add(1) +} + +func (m *Metrics) IncSafeAdvance() { + m.SafeAdvanceTotal.Add(1) +} + +func (m *Metrics) IncFinalizedAdvance() { + m.FinalizedAdvanceTotal.Add(1) +} + +func (m *Metrics) SetSafeL2BlockNumber(n uint64) { + m.SafeL2BlockNumber.Set(float64(n)) +} + +func (m *Metrics) SetFinalizedL2BlockNumber(n uint64) { + m.FinalizedL2BlockNumber.Set(float64(n)) +} + +func (m *Metrics) IncL1ReorgReset() { + m.L1ReorgResetTotal.Add(1) +} + +func (m *Metrics) IncTagInvariantViolation() { + m.TagInvariantViolationTotal.Add(1) +} + func (m *Metrics) Serve(hostname string, port uint64) (*http.Server, error) { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) diff --git a/node/derivation/reorg.go b/node/derivation/reorg.go new file mode 100644 index 000000000..ed457ec0e --- /dev/null +++ b/node/derivation/reorg.go @@ -0,0 +1,168 @@ +package derivation + +import ( + "context" + "fmt" + "math/big" + + "github.com/morph-l2/go-ethereum/common" + + "morph-l2/node/db" +) + +// SPEC-005 §4.7.6 L1 reorg detection. +// +// derivation persists the (number, hash) of every L1 block it has scanned for +// commit batch logs (via recordL1Blocks at the end of each successful poll). +// The next poll cycle calls detectReorg first; if any of the last +// reorgCheckDepth saved blocks no longer matches the live L1 hash, the +// earliest divergence height is returned and handleL1Reorg rewinds the +// derivation cursor + clears stale records. +// +// This is always-on regardless of the --derivation.confirmations setting. +// When confirmations=finalized (default), L1 finalized doesn't reorg by +// Ethereum consensus assumption, so detectReorg's fast path always returns +// (no reorg) at one L1 RPC per poll. When confirmations is configured below +// finalized (e.g. safe), detection becomes load-bearing without any code +// path divergence. +// +// L1 reorg does NOT directly trigger an L2 chain rollback in this PR. The +// L2 rollback executor (verifyBlockContext + halted state machine + +// rollbackLocalChain) is out of SPEC-005 scope (§3 non-goals). When a +// reorg replaces a committed batch with different content, derivation will +// re-derive on the next poll: if the L2 blocks come out identical (the +// common case -- same calldata, deterministic decoder), nothing further +// happens; if they differ, verifyBatchRoots fails and derivation halts at +// that batch with an error log, requiring operator intervention to re-sync. + +// detectReorg checks recent L1 blocks for hash mismatches indicating a reorg. +// Returns the earliest L1 height where a mismatch was found, or nil if +// none. +// +// Optimisation: checks the newest saved block first. If it matches, there +// is no reorg (1 RPC call in the common case). Only when the newest block +// mismatches does it do a full oldest-to-newest scan to find the earliest +// divergence point. +func (d *Derivation) detectReorg(ctx context.Context) (*uint64, error) { + latestDerivation := d.db.ReadLatestDerivationL1Height() + if latestDerivation == nil { + return nil, nil + } + + checkFrom := d.startHeight + if *latestDerivation > d.reorgCheckDepth && (*latestDerivation-d.reorgCheckDepth) > checkFrom { + checkFrom = *latestDerivation - d.reorgCheckDepth + } + + savedBlocks := d.db.ReadDerivationL1BlockRange(checkFrom, *latestDerivation) + if len(savedBlocks) == 0 { + return nil, nil + } + + // Fast path: check the newest block first. If it matches, no reorg occurred. + newest := savedBlocks[len(savedBlocks)-1] + newestHeader, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(newest.Number))) + if err != nil { + return nil, fmt.Errorf("failed to get L1 header at %d: %w", newest.Number, err) + } + if newestHeader.Hash() == common.BytesToHash(newest.Hash[:]) { + return nil, nil + } + + // Slow path: reorg detected. Scan oldest-to-newest to find the earliest + // divergence so handleL1Reorg can rewind only the affected window. + for i := 0; i < len(savedBlocks); i++ { + block := savedBlocks[i] + header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(block.Number))) + if err != nil { + return nil, fmt.Errorf("failed to get L1 header at %d: %w", block.Number, err) + } + savedHash := common.BytesToHash(block.Hash[:]) + if header.Hash() != savedHash { + d.logger.Info("L1 block hash mismatch detected", + "height", block.Number, + "savedHash", savedHash.Hex(), + "currentHash", header.Hash().Hex(), + ) + return &block.Number, nil + } + } + return nil, nil +} + +// handleL1Reorg responds to a reorg detected at the given L1 height. It is a +// thin wrapper around rewindAndReset chosen so the call site reads as the +// reorg-handling phase of derivationBlock. +func (d *Derivation) handleL1Reorg(reorgAtL1Height uint64) error { + d.logger.Info("L1 reorg detected, cleaning DB records and restarting derivation from reorg point", + "reorgAtL1Height", reorgAtL1Height) + d.rewindAndReset(reorgAtL1Height) + return nil +} + +// rewindAndReset rewinds the derivation L1 cursor to (rewindToL1Height - 1), +// clears any saved L1 block hashes at or above rewindToL1Height, and resets +// the tag advancer's safe head. Used by: +// +// - handleL1Reorg, after detectReorg finds an L1 hash divergence +// - finalizer's canonicality check, when the local L2 client's safe block +// hash no longer matches what tagAdvancer recorded +// +// Both situations are recovered by the same op-stack-style "reset to a known +// good parent and re-derive forward" pattern: the next derivationBlock poll +// re-fetches L1 commit batch logs from the rewound cursor, re-runs Path A or +// local verify verification, and re-populates safe via advanceSafe. Persistent +// problems surface naturally when verifyBatchRoots fails on re-derivation. +// +// L2 chain rollback is intentionally NOT performed here -- the same commit +// tx typically gets re-included with identical content, so L2 blocks remain +// valid. If they don't, derivation halts at the offending batch with an +// error log, requiring operator intervention (SPEC-005 §3 non-goal). +// +// finalized is intentionally NOT cleared -- L1 finalized is monotonic, so +// the previous finalized value remains valid. +func (d *Derivation) rewindAndReset(rewindToL1Height uint64) { + if rewindToL1Height < d.startHeight { + rewindToL1Height = d.startHeight + } + + d.db.DeleteDerivationL1BlocksFrom(rewindToL1Height) + + if rewindToL1Height > 0 { + d.db.WriteLatestDerivationL1Height(rewindToL1Height - 1) + } else { + d.db.WriteLatestDerivationL1Height(0) + } + + if d.tagAdvancer != nil { + safeMax := d.tagAdvancer.SafeMaxBatchIndex() + if safeMax > 0 { + d.tagAdvancer.reset(safeMax - 1) + } else { + d.tagAdvancer.reset(0) + } + } +} + +// recordL1Blocks saves L1 block hashes for reorg detection, called at the +// end of a successful poll cycle. Returns an error if any header fetch +// fails -- the caller must NOT advance the derivation cursor in that case +// to avoid permanent gaps in the L1 hash record (which would defeat +// detection). +func (d *Derivation) recordL1Blocks(ctx context.Context, from, to uint64) error { + for h := from; h <= to; h++ { + header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(h))) + if err != nil { + return fmt.Errorf("failed to get L1 header at %d: %w", h, err) + } + + var hashBytes [32]byte + copy(hashBytes[:], header.Hash().Bytes()) + + d.db.WriteDerivationL1Block(&db.DerivationL1Block{ + Number: h, + Hash: hashBytes, + }) + } + return nil +} diff --git a/node/derivation/static_scan_test.go b/node/derivation/static_scan_test.go new file mode 100644 index 000000000..dea87dbb1 --- /dev/null +++ b/node/derivation/static_scan_test.go @@ -0,0 +1,144 @@ +package derivation + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// SPEC-005 section 5.1 static-assertion tests. These guard against regressions where +// someone accidentally re-introduces validator/blocktag references or pulls +// the wrong common package after a refactor. + +// walkNodeRepoSourceFiles walks up from this test file to the morph repo +// root (parent of node/) and yields every .go source file under node/ +// (excluding test files and vendored code). +func walkNodeRepoSourceFiles(t *testing.T) (string, []string) { + t.Helper() + + wd, err := os.Getwd() // .../morph/node/derivation + if err != nil { + t.Fatalf("getwd: %v", err) + } + nodeRoot := filepath.Dir(wd) // .../morph/node + + var files []string + err = filepath.WalkDir(nodeRoot, func(path string, d fs.DirEntry, e error) error { + if e != nil { + return e + } + if d.IsDir() { + // Skip vendored / test-fixtures dirs if any; nothing matches today + // but cheap to keep the door closed. + name := d.Name() + if name == "node_modules" || name == "vendor" || name == "ops-morph" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + if strings.HasSuffix(path, "_test.go") { + return nil + } + files = append(files, path) + return nil + }) + if err != nil { + t.Fatalf("walk node tree: %v", err) + } + return nodeRoot, files +} + +func TestNoValidatorReferences(t *testing.T) { + _, files := walkNodeRepoSourceFiles(t) + + // Symbols that the SPEC-005 validator-role removal must keep out of node/. + // We are specifically guarding against accidental re-introduction; the + // patterns are narrow on purpose so legitimate uses (e.g., Tendermint + // consensus validator pubkeys) don't false-positive. + banned := []string{ + "node/validator", // import path + "validator.NewValidator", // factory call + "validator.NewConfig", // config call + "flags.ValidatorEnable", // role flag + "validator.challengeEnable", // legacy flag string + "validator.privateKey", // legacy flag string + "VALIDATOR_PRIVATE_KEY", // legacy envvar + "VALIDATOR_CHALLENGE_ENABLE", // legacy envvar + // We deliberately do NOT ban "ChallengeEnable" / "ChallengeState" + // in source -- they appear in the Rollup contract ABI string in + // node/types/batch.go and are immutable on-chain identifiers we + // must keep in sync with. The node-side challenge bypass that + // SPEC-005 removes is keyed by validator.* flags above, which + // uniquely identify the deleted code paths. + } + + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + body := string(b) + for _, p := range banned { + if strings.Contains(body, p) { + t.Errorf("validator residue: %q found in %s", p, f) + } + } + } +} + +func TestNoBlocktagReferences(t *testing.T) { + _, files := walkNodeRepoSourceFiles(t) + + banned := []string{ + "node/blocktag", // import path + "BlockTagService", // service type + "NewBlockTagService", // factory + "BlockTagSafeConfirmations", // flag symbol + "BLOCKTAG_SAFE_CONFIRMATIONS", // envvar + "blocktag.safeConfirmations", // flag name string + "blocktag.DefaultConfig", // config factory + } + + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + body := string(b) + for _, p := range banned { + if strings.Contains(body, p) { + t.Errorf("blocktag residue: %q found in %s", p, f) + } + } + } +} + +// TestLocalVerifyUsesCommonBlobPackage guards SPEC-005 section 3.4: local verify must use +// `common/blob` helpers (the same set tx-submitter calls), not the duplicate +// implementations under `common/batch/blob.go`. Codec drift between the two +// would cause permanent versioned hash mismatches. +func TestLocalVerifyUsesCommonBlobPackage(t *testing.T) { + body, err := os.ReadFile("verify_local.go") + if err != nil { + t.Fatalf("read verify_local.go: %v", err) + } + src := string(body) + + if !strings.Contains(src, `"morph-l2/common/blob"`) { + t.Fatalf("verify_local.go must import morph-l2/common/blob") + } + // Sanity check the actual call sites -- import is necessary but not + // sufficient; mismatched calls (e.g., commonbatch.CompressBatchBytes) + // would still drift codecs. + required := []string{"commonblob.CompressBatchBytes", "commonblob.MakeBlobTxSidecar"} + for _, sym := range required { + if !strings.Contains(src, sym) { + t.Errorf("verify_local.go missing required call %q", sym) + } + } +} diff --git a/node/derivation/tag_advance.go b/node/derivation/tag_advance.go new file mode 100644 index 000000000..4fc2f1a2d --- /dev/null +++ b/node/derivation/tag_advance.go @@ -0,0 +1,207 @@ +package derivation + +import ( + "context" + "sync" + + "github.com/morph-l2/go-ethereum/common" + eth "github.com/morph-l2/go-ethereum/core/types" + tmlog "github.com/tendermint/tendermint/libs/log" +) + +// tagL2Client narrows the dependency on types.RetryableClient to the two +// methods the tag advancer actually calls. Keeping this local makes +// tagAdvancer trivially mockable from tests without dragging in an authclient +// stack. +type tagL2Client interface { + BlockNumber(ctx context.Context) (uint64, error) + SetBlockTags(ctx context.Context, safe common.Hash, finalized common.Hash) error +} + +// tagAdvancer is the SPEC-005 section 4.7 single source of truth for safe and +// finalized L2 head propagation. It replaces the previous standalone +// polling service: derivation main loop drives `advanceSafe` per +// verified batch; the in-process finalizer subcomponent drives +// `advanceFinalized`. Both paths converge on `flushTags` which enforces the +// `finalized <= safe <= unsafe` invariant before calling the existing +// `RetryableClient.SetBlockTags` engine RPC. +// +// In-memory only by design: SPEC-005 section 4.7.7 -- restart starts from zero and +// derivation refills naturally as it walks its cursor. +type tagAdvancer struct { + mu sync.Mutex + + l2Client tagL2Client + metrics *Metrics + logger tmlog.Logger + + // safe head -- last verified batch's lastL2Block. + safeL2Hash common.Hash + safeL2Number uint64 + safeMaxBatchIndex uint64 + + // finalized head -- L1 finalized derived verified batch's lastL2Block. + finalizedL2Hash common.Hash + finalizedL2Number uint64 + + // Suppress redundant SetBlockTags RPCs (mirrors blocktag's + // lastNotifiedSafeHash / lastNotifiedFinalizedHash semantics). + lastNotifiedSafe common.Hash + lastNotifiedFinalized common.Hash +} + +func newTagAdvancer(l2Client tagL2Client, metrics *Metrics, logger tmlog.Logger) *tagAdvancer { + return &tagAdvancer{ + l2Client: l2Client, + metrics: metrics, + logger: logger.With("component", "tag-advancer"), + } +} + +// advanceSafe is called by the derivation main loop after a batch passes both +// content verification (layer1 or local verify) and verifyBatchRoots. It records the +// new safe head and flushes via SetBlockTags. +func (t *tagAdvancer) advanceSafe(ctx context.Context, batchIndex uint64, lastHeader *eth.Header) { + if lastHeader == nil { + return + } + t.mu.Lock() + t.safeL2Hash = lastHeader.Hash() + t.safeL2Number = lastHeader.Number.Uint64() + if batchIndex > t.safeMaxBatchIndex { + t.safeMaxBatchIndex = batchIndex + } + t.metrics.IncSafeAdvance() + t.metrics.SetSafeL2BlockNumber(t.safeL2Number) + t.mu.Unlock() + + t.flushTags(ctx) +} + +// Safe returns a snapshot of the current safe head's hash and number under +// the tagAdvancer mutex. The finalizer reads these to decide whether to +// anchor the new finalized to the local safe directly (the common case +// where L1 finalized has caught up to or past our verified ceiling) or to +// the L1-finalized batch's lastL2Block (the rare case where local has +// verified beyond what L1 has finalized). +func (t *tagAdvancer) Safe() (common.Hash, uint64) { + t.mu.Lock() + defer t.mu.Unlock() + return t.safeL2Hash, t.safeL2Number +} + +// advanceFinalized is called by the finalizer subcomponent each tick once +// it has resolved the new finalized L2 head from L1 state. finalized never +// moves backwards; if a lower number is provided we log and keep the +// previous value (SPEC-005 section 4.7.4 monotonicity check). +// +// Takes hash + number directly rather than *eth.Header so the finalizer's +// "anchor to local safe" path can pass safeL2Hash / safeL2Number without +// fabricating a synthetic header. +func (t *tagAdvancer) advanceFinalized(ctx context.Context, batchIndex uint64, hash common.Hash, number uint64) { + if hash == (common.Hash{}) { + return + } + t.mu.Lock() + if t.finalizedL2Number != 0 && number < t.finalizedL2Number { + t.logger.Error("finalized monotonicity violated; ignoring", + "prev", t.finalizedL2Number, "next", number) + t.mu.Unlock() + return + } + if number == t.finalizedL2Number && hash == t.finalizedL2Hash { + t.mu.Unlock() + return + } + t.finalizedL2Hash = hash + t.finalizedL2Number = number + t.metrics.IncFinalizedAdvance() + t.metrics.SetFinalizedL2BlockNumber(t.finalizedL2Number) + t.mu.Unlock() + + _ = batchIndex // reserved for future telemetry + t.flushTags(ctx) +} + +// SafeMaxBatchIndex returns the highest verified batch index recorded so far. +// Currently kept around for diagnostics and for the L1-reorg reset path; the +// finalizer does NOT use it for header lookup (see SPEC-005 §4.7.4 redesign). +func (t *tagAdvancer) SafeMaxBatchIndex() uint64 { + t.mu.Lock() + defer t.mu.Unlock() + return t.safeMaxBatchIndex +} + +// reset clears safe head when the derivation main loop detects an L1 reorg +// and rewinds its cursor. Safe head is zeroed (not rewound to a "real" value) +// because reorged batches may have different content; the next advanceSafe +// re-establishes truth from re-derivation. finalized is intentionally NOT +// reset -- see SPEC-005 section 4.7.6: L1 finalized is assumed monotonic, and +// finalizer.tick will re-evaluate on the next iteration. +// +// SafeL2BlockNumber gauge is intentionally NOT reset: it represents the +// highest verified L2 block watermark for ops dashboards, not the in-memory +// state. The next advanceSafe overwrites it; reorg recovery stalls are +// detected via l1_reorg_reset_total + unsafe-safe lag instead. +func (t *tagAdvancer) reset(toBatchIndex uint64) { + t.mu.Lock() + defer t.mu.Unlock() + + t.safeL2Hash = common.Hash{} + t.safeL2Number = 0 + t.safeMaxBatchIndex = toBatchIndex + t.lastNotifiedSafe = common.Hash{} + t.metrics.IncL1ReorgReset() + t.logger.Info("tag advancer reset on L1 reorg", "to_batch_index", toBatchIndex) +} + +// flushTags enforces the finalized <= safe <= unsafe invariant and calls +// SetBlockTags exactly once per state change. On invariant violation we log +// error and skip -- no panic, no halt -- matching op-node's +// tryUpdateEngineInternal behavior. +func (t *tagAdvancer) flushTags(ctx context.Context) { + unsafeNum, err := t.l2Client.BlockNumber(ctx) + if err != nil { + t.logger.Info("flushTags: read L2 latest failed", "err", err) + return + } + + t.mu.Lock() + safeHash := t.safeL2Hash + safeNum := t.safeL2Number + finalizedHash := t.finalizedL2Hash + finalizedNum := t.finalizedL2Number + notifiedSafe := t.lastNotifiedSafe + notifiedFinalized := t.lastNotifiedFinalized + t.mu.Unlock() + + if finalizedNum > safeNum { + t.metrics.IncTagInvariantViolation() + t.logger.Error("invariant violation: finalized > safe", + "finalized", finalizedNum, "safe", safeNum) + return + } + if safeNum > unsafeNum { + t.metrics.IncTagInvariantViolation() + t.logger.Error("invariant violation: safe > unsafe", + "safe", safeNum, "unsafe", unsafeNum) + return + } + + if safeHash == notifiedSafe && finalizedHash == notifiedFinalized { + return + } + if safeHash == (common.Hash{}) && finalizedHash == (common.Hash{}) { + return + } + + if err := t.l2Client.SetBlockTags(ctx, safeHash, finalizedHash); err != nil { + t.logger.Error("SetBlockTags failed", "err", err) + return + } + + t.mu.Lock() + t.lastNotifiedSafe = safeHash + t.lastNotifiedFinalized = finalizedHash + t.mu.Unlock() +} diff --git a/node/derivation/tag_advance_test.go b/node/derivation/tag_advance_test.go new file mode 100644 index 000000000..9e9f15b2a --- /dev/null +++ b/node/derivation/tag_advance_test.go @@ -0,0 +1,208 @@ +package derivation + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/go-kit/kit/metrics/discard" + "github.com/morph-l2/go-ethereum/common" + eth "github.com/morph-l2/go-ethereum/core/types" + tmlog "github.com/tendermint/tendermint/libs/log" +) + +// fakeTagL2Client implements tagL2Client for unit tests. It records each +// SetBlockTags call so tests can assert on call count and arguments, and +// lets the test set the unsafe upper bound returned by BlockNumber. +type fakeTagL2Client struct { + unsafe uint64 + blockNumberErr error + calls []setBlockTagsCall + setErr error +} + +type setBlockTagsCall struct { + safe common.Hash + finalized common.Hash +} + +func (f *fakeTagL2Client) BlockNumber(_ context.Context) (uint64, error) { + if f.blockNumberErr != nil { + return 0, f.blockNumberErr + } + return f.unsafe, nil +} + +func (f *fakeTagL2Client) SetBlockTags(_ context.Context, safe common.Hash, finalized common.Hash) error { + if f.setErr != nil { + return f.setErr + } + f.calls = append(f.calls, setBlockTagsCall{safe: safe, finalized: finalized}) + return nil +} + +// newDiscardMetrics returns a *Metrics whose collectors discard all updates. +// Avoids prometheus default-registry double-registration across multiple +// tests in the same process. +func newDiscardMetrics() *Metrics { + return &Metrics{ + L1SyncHeight: discard.NewGauge(), + RollupL2Height: discard.NewGauge(), + DeriveL2Height: discard.NewGauge(), + BatchStatus: discard.NewGauge(), + LatestBatchIndex: discard.NewGauge(), + SyncedBatchIndex: discard.NewGauge(), + LocalVerifyTriggered: discard.NewCounter(), + SafeAdvanceTotal: discard.NewCounter(), + FinalizedAdvanceTotal: discard.NewCounter(), + SafeL2BlockNumber: discard.NewGauge(), + FinalizedL2BlockNumber: discard.NewGauge(), + L1ReorgResetTotal: discard.NewCounter(), + TagInvariantViolationTotal: discard.NewCounter(), + } +} + +func newTestTagAdvancer(t *testing.T, unsafe uint64) (*tagAdvancer, *fakeTagL2Client, *Metrics) { + t.Helper() + fake := &fakeTagL2Client{unsafe: unsafe} + m := newDiscardMetrics() + logger := tmlog.NewNopLogger() + return newTagAdvancer(fake, m, logger), fake, m +} + +func headerAt(num uint64, mark byte) *eth.Header { + h := ð.Header{Number: new(big.Int).SetUint64(num)} + // Mutate ParentHash so different "mark" values produce different block + // hashes -- header.Hash() mixes everything. + h.ParentHash = common.BytesToHash([]byte{mark, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}) + return h +} + +func TestTagAdvance_Safe_CallsSetBlockTags(t *testing.T) { + tagAdv, fake, _ := newTestTagAdvancer(t, 100) + h := headerAt(50, 'a') + + tagAdv.advanceSafe(context.Background(), 7, h) + + if len(fake.calls) != 1 { + t.Fatalf("expected 1 SetBlockTags call, got %d", len(fake.calls)) + } + if fake.calls[0].safe != h.Hash() { + t.Fatalf("safe hash mismatch") + } + if tagAdv.SafeMaxBatchIndex() != 7 { + t.Fatalf("safeMaxBatchIndex got %d, want 7", tagAdv.SafeMaxBatchIndex()) + } +} + +func TestTagAdvance_DedupSetBlockTags(t *testing.T) { + tagAdv, fake, _ := newTestTagAdvancer(t, 100) + h := headerAt(50, 'a') + + tagAdv.advanceSafe(context.Background(), 7, h) + tagAdv.advanceSafe(context.Background(), 7, h) // identical state + + if len(fake.calls) != 1 { + t.Fatalf("expected dedup to suppress 2nd call; got %d total", len(fake.calls)) + } +} + +func TestTagAdvance_InvariantSafeGtUnsafe_Skips(t *testing.T) { + tagAdv, fake, _ := newTestTagAdvancer(t, 30) // unsafe = 30 + h := headerAt(50, 'a') // safe wants 50 -- invalid + + tagAdv.advanceSafe(context.Background(), 7, h) + + if len(fake.calls) != 0 { + t.Fatalf("expected SetBlockTags skipped on invariant violation, got %d calls", len(fake.calls)) + } +} + +func TestTagAdvance_InvariantFinalizedGtSafe_Skips(t *testing.T) { + tagAdv, fake, _ := newTestTagAdvancer(t, 200) + + // safe at 50, finalized would be 80 -> finalized > safe. + tagAdv.advanceSafe(context.Background(), 5, headerAt(50, 'a')) + // reset the call recorder so we only inspect the finalized call. + fake.calls = nil + + finHdr := headerAt(80, 'b') + tagAdv.advanceFinalized(context.Background(), 6, finHdr.Hash(), finHdr.Number.Uint64()) + + if len(fake.calls) != 0 { + t.Fatalf("expected SetBlockTags skipped on finalized > safe; got %d calls", len(fake.calls)) + } +} + +func TestTagAdvance_FinalizedMonotonic(t *testing.T) { + tagAdv, fake, _ := newTestTagAdvancer(t, 200) + tagAdv.advanceSafe(context.Background(), 10, headerAt(120, 'a')) + fake.calls = nil + + finHdr1 := headerAt(100, 'b') + tagAdv.advanceFinalized(context.Background(), 8, finHdr1.Hash(), finHdr1.Number.Uint64()) + if got := tagAdv.finalizedL2Number; got != 100 { + t.Fatalf("finalized first advance: got %d, want 100", got) + } + + // Second advance with smaller number should be ignored. + prevHash := tagAdv.finalizedL2Hash + finHdr2 := headerAt(80, 'c') + tagAdv.advanceFinalized(context.Background(), 7, finHdr2.Hash(), finHdr2.Number.Uint64()) + if tagAdv.finalizedL2Number != 100 || tagAdv.finalizedL2Hash != prevHash { + t.Fatalf("finalized regressed: number=%d, hash unchanged=%v", + tagAdv.finalizedL2Number, tagAdv.finalizedL2Hash == prevHash) + } +} + +func TestTagAdvance_L1ReorgReset(t *testing.T) { + tagAdv, _, _ := newTestTagAdvancer(t, 200) + tagAdv.advanceSafe(context.Background(), 10, headerAt(120, 'a')) + + tagAdv.reset(8) + + if tagAdv.safeL2Number != 0 { + t.Fatalf("safeL2Number not cleared after reset: got %d", tagAdv.safeL2Number) + } + if tagAdv.safeL2Hash != (common.Hash{}) { + t.Fatalf("safeL2Hash not cleared after reset") + } + if got := tagAdv.SafeMaxBatchIndex(); got != 8 { + t.Fatalf("safeMaxBatchIndex after reset: got %d, want 8", got) + } + if tagAdv.lastNotifiedSafe != (common.Hash{}) { + t.Fatalf("lastNotifiedSafe not cleared after reset") + } +} + +func TestTagAdvance_BlockNumberError_SkipsFlush(t *testing.T) { + tagAdv, fake, _ := newTestTagAdvancer(t, 100) + fake.blockNumberErr = errors.New("rpc down") + + tagAdv.advanceSafe(context.Background(), 7, headerAt(50, 'a')) + + if len(fake.calls) != 0 { + t.Fatalf("expected SetBlockTags skipped when BlockNumber fails; got %d", len(fake.calls)) + } +} + +// TestTagAdvance_SafeGetter covers the snapshot returned to the finalizer. +// The finalizer reads (hash, number) atomically under the tagAdvancer mutex +// to decide whether to anchor finalized to the local safe head or to the +// L1-finalized batch's lastL2Block. +func TestTagAdvance_SafeGetter(t *testing.T) { + tagAdv, _, _ := newTestTagAdvancer(t, 1000) + + if hash, num := tagAdv.Safe(); num != 0 || hash != (common.Hash{}) { + t.Fatalf("expected zero safe before any advance; got (hash=%s, num=%d)", hash.Hex(), num) + } + + hdr := headerAt(50, 'a') + tagAdv.advanceSafe(context.Background(), 7, hdr) + + hash, num := tagAdv.Safe() + if num != 50 || hash != hdr.Hash() { + t.Fatalf("Safe() got (hash=%s, num=%d), want (hash=%s, num=50)", hash.Hex(), num, hdr.Hash().Hex()) + } +} diff --git a/node/derivation/verify.go b/node/derivation/verify.go new file mode 100644 index 000000000..2a73d6615 --- /dev/null +++ b/node/derivation/verify.go @@ -0,0 +1,57 @@ +package derivation + +import ( + "bytes" + "errors" + "fmt" + + "github.com/morph-l2/go-ethereum/accounts/abi/bind" + "github.com/morph-l2/go-ethereum/common" + eth "github.com/morph-l2/go-ethereum/core/types" +) + +// ErrBatchVerifyDivergence is wrapped by verification errors that represent +// a true "verifier reached a verdict of inconsistent" — i.e. the local +// chain disagrees with what L1 committed. Currently produced by: +// - verifyBatchRoots, when local stateRoot or withdrawalRoot ≠ L1 calldata +// - verify_local's rebuildBlob, for kinds versioned_hash_mismatch and +// blob_count_mismatch +// +// Call sites in derivation.go gate `metrics.SetBatchStatus(stateException)` +// on errors.Is(err, ErrBatchVerifyDivergence). Transient or runtime errors +// (RPC down, tx parsing failure, encoding bug, ...) intentionally do NOT +// wrap this sentinel: they reflect "verifier could not run", not "verifier +// determined divergence", and must not light up the divergence alert. +var ErrBatchVerifyDivergence = errors.New("batch verify: divergence verdict") + +// verifyBatchRoots verifies the local state root and withdrawal root against the +// values recorded in the L1 commit batch tx calldata. +// +// SPEC-005 section 3.4 invariant: this check is independent of blob data -- both +// batchInfo.root (postStateRoot) and batchInfo.withdrawalRoot are extracted +// from L1 calldata at parse time, so this function runs identically under +// layer1 (beacon blob) and local-rebuild verification modes. +// +// Returns nil on match. On mismatch the error wraps ErrBatchVerifyDivergence +// so callers can distinguish a real divergence verdict from a transient +// failure (e.g. MessageRoot RPC error). Transient failures are returned +// without the sentinel. +func (d *Derivation) verifyBatchRoots(batchInfo *BatchInfo, lastHeader *eth.Header) error { + withdrawalRoot, err := d.L2ToL1MessagePasser.MessageRoot(&bind.CallOpts{ + BlockNumber: lastHeader.Number, + }) + if err != nil { + return fmt.Errorf("get withdrawal root failed: %w", err) + } + + rootMismatch := !bytes.Equal(lastHeader.Root.Bytes(), batchInfo.root.Bytes()) + withdrawalMismatch := !bytes.Equal(withdrawalRoot[:], batchInfo.withdrawalRoot.Bytes()) + + if rootMismatch || withdrawalMismatch { + return fmt.Errorf("root mismatch: stateRoot(l1=%s, local=%s) withdrawalRoot(l1=%s, local=%s): %w", + batchInfo.root.Hex(), lastHeader.Root.Hex(), + batchInfo.withdrawalRoot.Hex(), common.BytesToHash(withdrawalRoot[:]).Hex(), + ErrBatchVerifyDivergence) + } + return nil +} diff --git a/node/derivation/verify_local.go b/node/derivation/verify_local.go new file mode 100644 index 000000000..798411571 --- /dev/null +++ b/node/derivation/verify_local.go @@ -0,0 +1,242 @@ +package derivation + +import ( + "context" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/morph-l2/go-ethereum/common" + eth "github.com/morph-l2/go-ethereum/core/types" + + commonbatch "morph-l2/common/batch" + commonblob "morph-l2/common/blob" +) + +// SPEC-005 section 4 local verify: blob-independent batch content verification. +// +// In VerifyModeLocal the node does not pull blobs from the beacon chain on +// the happy path. Instead it reads the L2 blocks in the batch range from +// local storage, reapplies the sequencer's encoding to rebuild the blob +// bytes, and compares the resulting versioned hashes against the values +// declared by the L1 commitBatch tx (carried in BatchInfo.blobHashes). +// +// State / withdrawal root verification (verify.go::verifyBatchRoots) is +// independent of this path and runs after success. +// +// On versioned_hash_mismatch the spec (SPEC-005 §4.3) calls for a +// single-batch self-heal: pull the real blob from beacon, decode + derive +// the batch via the layer1 engine API path (which would replace the +// locally divergent blocks via EL forkchoice), then re-run the shared +// verifyBatchRoots. That self-heal is **currently TODO** and not wired +// up here -- it is blocked on the EL number-continuity check (`params.Number +// == latestNumber + 1` in morph-reth `crates/engine-api/src/builder.rs` +// and go-ethereum `eth/catalyst/l2_api.go`) being relaxed in a separate +// spec. Until then a versioned_hash_mismatch falls through to the legacy +// failure path (log + return + retry next poll). +// +// Mode is selected at startup via --derivation.verify-mode and is not +// switchable at runtime. + +// fetchBatchInfoOutline pulls the L1 commitBatch tx, decodes its calldata, +// and populates a BatchInfo using only the calldata + tx blob hashes -- no +// beacon blob fetch. Returned BatchInfo is sufficient for +// verifyBatchContentLocal and verifyBatchRoots. +// +// Only the new commitBatch ABI (rollupABI commitBatch / commitBatchWithProof) +// is supported. lastBlockNumber comes from batch.LastBlockNumber and +// firstBlockNumber from parent header's LastBlockNumber + 1. Legacy-ABI +// batches (calldata BlockContexts + V1 blob encoding) are not handled here +// -- they only exist on historical batches that have long since been +// finalized. +func (d *Derivation) fetchBatchInfoOutline(ctx context.Context, txHash common.Hash, blockNumber uint64) (*BatchInfo, error) { + tx, pending, err := d.l1Client.TransactionByHash(ctx, txHash) + if err != nil { + return nil, err + } + if pending { + return nil, errors.New("pending transaction") + } + batch, err := d.UnPackData(tx.Data()) + if err != nil { + return nil, err + } + + parentHeader := commonbatch.BatchHeaderBytes(batch.ParentBatchHeader) + parentBatchIndex, err := parentHeader.BatchIndex() + if err != nil { + return nil, fmt.Errorf("decode batch header index error:%v", err) + } + parentTotalL1Popped, err := parentHeader.TotalL1MessagePopped() + if err != nil { + return nil, fmt.Errorf("decode batch header totalL1MessagePopped error:%v", err) + } + + bi := &BatchInfo{ + batchIndex: parentBatchIndex + 1, + version: uint64(batch.Version), + root: batch.PostStateRoot, + withdrawalRoot: batch.WithdrawRoot, + parentTotalL1MessagePopped: parentTotalL1Popped, + lastBlockNumber: batch.LastBlockNumber, + l1BlockNumber: blockNumber, + txHash: txHash, + nonce: tx.Nonce(), + blobHashes: tx.BlobHashes(), + } + + parentLast, err := parentHeader.LastBlockNumber() + if err != nil { + return nil, fmt.Errorf("decode parent batch header lastBlockNumber error:%v", err) + } + bi.firstBlockNumber = parentLast + 1 + + return bi, nil +} + +// verifyBatchContentLocal rebuilds blob versioned hashes from local L2 +// blocks in the [batchInfo.firstBlockNumber, batchInfo.lastBlockNumber] +// range and compares them against batchInfo.blobHashes (taken from the L1 +// commitBatch tx). Returns nil on match. +// +// Failure paths intentionally inline metric inc + structured log + error +// construction at each kind site rather than route through a shared +// helper. One error-wrapping invariant the call site (derivation.go) +// relies on: +// +// - kind=versioned_hash_mismatch and kind=blob_count_mismatch wrap +// ErrBatchVerifyDivergence so the call site flips BatchStatus to +// stateException ONLY on a real "verifier reached unequal verdict"; +// transient / runtime errors must NOT light up the divergence alert. +// versioned_hash_mismatch will additionally be the self-heal trigger +// once the EL change lands (see file-level comment). +// +// All other kinds are plain errors. When you add a new kind, decide +// deliberately whether it represents "verifier could not run" (no +// sentinel) vs "verifier produced a divergence verdict" (wrap +// ErrBatchVerifyDivergence) and update the SentinelContract test. +func (d *Derivation) rebuildBlob(ctx context.Context, batchInfo *BatchInfo) ([]common.Hash, error) { + d.metrics.IncLocalVerifyTriggered() + + // Standard log fields used by every failure-path Error log. Per-site + // kvs are appended at the call site. + logBase := []interface{}{ + "batchIndex", batchInfo.batchIndex, + "version", batchInfo.version, + "firstBlock", batchInfo.firstBlockNumber, + "lastBlock", batchInfo.lastBlockNumber, + "parentTotalL1Popped", batchInfo.parentTotalL1MessagePopped, + "expectedBlobs", len(batchInfo.blobHashes), + } + + if batchInfo.firstBlockNumber == 0 || batchInfo.lastBlockNumber < batchInfo.firstBlockNumber { + d.logger.Error("local verify verification failed: invalid block range", + append([]interface{}{"kind", "invalid_block_range"}, logBase...)...) + return nil, fmt.Errorf("local verify [invalid_block_range]: invalid block range [%d, %d]", + batchInfo.firstBlockNumber, batchInfo.lastBlockNumber) + } + if len(batchInfo.blobHashes) == 0 { + d.logger.Error("local verify verification failed: no blob hashes recorded", + append([]interface{}{"kind", "empty_blob_hashes"}, logBase...)...) + return nil, fmt.Errorf("local verify [empty_blob_hashes]: no blob hashes recorded for batch %d", batchInfo.batchIndex) + } + + bd := commonbatch.NewBatchData() + totalL1MessagePopped := batchInfo.parentTotalL1MessagePopped + + for n := batchInfo.firstBlockNumber; n <= batchInfo.lastBlockNumber; n++ { + block, err := d.l2Client.BlockByNumber(ctx, big.NewInt(int64(n))) + if err != nil { + d.logger.Error("local verify verification failed: read local block", + append([]interface{}{"kind", "local_block_read_error", "blockNumber", n, "cause", err}, logBase...)...) + return nil, fmt.Errorf("local verify [local_block_read_error]: read local block %d failed: %w", n, err) + } + if block == nil { + d.logger.Error("local verify verification failed: local block missing", + append([]interface{}{"kind", "local_block_missing", "blockNumber", n}, logBase...)...) + return nil, fmt.Errorf("local verify [local_block_missing]: local block %d missing", n) + } + + txsPayload, l1TxHashes, newTotal, l2TxNum, err := commonbatch.ParsingTxs(block.Transactions(), totalL1MessagePopped) + if err != nil { + d.logger.Error("local verify verification failed: parse local block txs", + append([]interface{}{"kind", "parsing_txs_error", "blockNumber", n, "cause", err}, logBase...)...) + return nil, fmt.Errorf("local verify [parsing_txs_error]: parsingTxs failed at block %d: %w", n, err) + } + l1MsgNum := int(newTotal - totalL1MessagePopped) + blockCtx := commonbatch.BuildBlockContext(block.Header(), l2TxNum+l1MsgNum, l1MsgNum) + bd.Append(blockCtx, txsPayload, l1TxHashes) + totalL1MessagePopped = newTotal + } + + // New-ABI only: blob payload is V2-encoded (blockContexts || txs at the + // blob head). Legacy-ABI batches are out of scope for local verify. + payload := bd.TxsPayloadV2() + const chosenEncoding = "V2" + + compressed, err := commonblob.CompressBatchBytes(payload) + if err != nil { + d.logger.Error("local verify verification failed: compress", + append([]interface{}{ + "kind", "compress_error", + "encoding", chosenEncoding, "payloadLen", len(payload), "cause", err, + }, logBase...)...) + return nil, fmt.Errorf("local verify [compress_error]: compress failed: %w", err) + } + + // maxBlobs is only an upper bound for sidecar capacity; the actual + // blob count is determined by the size of `compressed`. We pass + // len(blobHashes) so a payload that would require more blobs than L1 + // declared is rejected up front rather than producing a sidecar with + // the wrong blob count and a confusing hash mismatch later. + sidecar, err := commonblob.MakeBlobTxSidecar(compressed, len(batchInfo.blobHashes)) + if err != nil { + d.logger.Error("local verify verification failed: build sidecar", + append([]interface{}{ + "kind", "sidecar_build_error", + "encoding", chosenEncoding, "payloadLen", len(payload), "compressedLen", len(compressed), "cause", err, + }, logBase...)...) + return nil, fmt.Errorf("local verify [sidecar_build_error]: build sidecar failed: %w", err) + } + + rebuilt := sidecar.BlobHashes() + if len(rebuilt) != len(batchInfo.blobHashes) { + d.logger.Error("local verify verification failed: blob count mismatch", + append([]interface{}{ + "kind", "blob_count_mismatch", + "encoding", chosenEncoding, "payloadLen", len(payload), "compressedLen", len(compressed), + "rebuiltBlobs", len(rebuilt), + "rebuiltHashes", hashesHexCSV(rebuilt), + "expectedHashes", hashesHexCSV(batchInfo.blobHashes), + }, logBase...)...) + return nil, fmt.Errorf("local verify [blob_count_mismatch]: blob count mismatch (rebuilt=%d, l1=%d): %w", + len(rebuilt), len(batchInfo.blobHashes), ErrBatchVerifyDivergence) + } + return rebuilt, nil +} + +// hashesHexCSV renders a small slice of hashes as a comma-separated hex +// list, suitable for a one-line log field. Used in divergence diagnostics +// where the per-index hex helps an operator spot which blob diverged. +func hashesHexCSV(hs []common.Hash) string { + parts := make([]string, len(hs)) + for i, h := range hs { + parts[i] = h.Hex() + } + return strings.Join(parts, ",") +} + +// fetchLocalLastHeader returns the local L2 header at +// batchInfo.lastBlockNumber. Used by local verify after content verification +// succeeds, to feed verifyBatchRoots. +func (d *Derivation) fetchLocalLastHeader(ctx context.Context, batchInfo *BatchInfo) (*eth.Header, error) { + header, err := d.l2Client.HeaderByNumber(ctx, big.NewInt(int64(batchInfo.lastBlockNumber))) + if err != nil { + return nil, fmt.Errorf("local verify: read local header at %d failed: %w", batchInfo.lastBlockNumber, err) + } + if header == nil { + return nil, fmt.Errorf("local verify: local header at %d missing", batchInfo.lastBlockNumber) + } + return header, nil +} diff --git a/node/flags/flags.go b/node/flags/flags.go index 19325a4b0..cfe004e73 100644 --- a/node/flags/flags.go +++ b/node/flags/flags.go @@ -162,25 +162,6 @@ var ( EnvVar: prefixEnvVar("MOCK_SEQUENCER"), } - ValidatorEnable = cli.BoolFlag{ - Name: "validator", - Usage: "Enable the validator mode", - EnvVar: prefixEnvVar("VALIDATOR"), - } - - ChallengeEnable = cli.BoolFlag{ - Name: "validator.challengeEnable", - Usage: "Enable the validator challenge", - EnvVar: prefixEnvVar("VALIDATOR_CHALLENGE_ENABLE"), - } - - // validator - ValidatorPrivateKey = cli.StringFlag{ - Name: "validator.privateKey", - Usage: "Private Key corresponding to SUBSIDY Owner", - EnvVar: prefixEnvVar("VALIDATOR_PRIVATE_KEY"), - } - // derivation RollupContractAddress = cli.StringFlag{ Name: "derivation.rollupAddress", @@ -218,14 +199,6 @@ var ( EnvVar: prefixEnvVar("DERIVATION_FETCH_BLOCK_RANGE"), } - // BlockTag options - BlockTagSafeConfirmations = cli.Uint64Flag{ - Name: "blocktag.safeConfirmations", - Usage: "Number of L1 blocks to wait before considering a batch as safe", - EnvVar: prefixEnvVar("BLOCKTAG_SAFE_CONFIRMATIONS"), - Value: 10, - } - // L1 Sequencer options L1SequencerContractAddr = cli.StringFlag{ Name: "l1.sequencerContract", @@ -265,6 +238,20 @@ var ( Usage: "The number of confirmations needed on L1 for finalization. If not set, the default value is l1.confirmations", EnvVar: prefixEnvVar("DERIVATION_CONFIRMATIONS"), } + + DerivationVerifyMode = cli.StringFlag{ + Name: "derivation.verify-mode", + Usage: `Batch verification mode (SPEC-005 §4.2). "layer1" pulls beacon blob, decodes, and derives blocks via engine. "local" (default) rebuilds blob bytes from local L2 blocks and compares versioned hashes against L1 (no beacon fetch on the happy path); on versioned hash mismatch the verifier is designed to self-heal by pulling the real blob and re-deriving the batch — currently TODO, blocked on EL number-continuity check relaxation in morph-reth/go-ethereum (separate spec). Selected at startup; not switchable at runtime.`, + EnvVar: prefixEnvVar("DERIVATION_VERIFY_MODE"), + Value: "local", + } + + DerivationReorgCheckDepth = cli.Uint64Flag{ + Name: "derivation.reorg-check-depth", + Usage: "Number of recent L1 blocks to check for reorgs (SPEC-005 §4.7.6). The scan is a no-op when --derivation.confirmations=finalized (L1 finalized doesn't reorg) and load-bearing when set lower; the gate is intentionally absent so behavior is uniform across configs. Default 64.", + EnvVar: prefixEnvVar("DERIVATION_REORG_CHECK_DEPTH"), + Value: 64, + } // Logger LogLevel = &cli.StringFlag{ Name: "log.level", @@ -350,11 +337,6 @@ var Flags = []cli.Flag{ DevSequencer, TendermintConfigPath, MockEnabled, - ValidatorEnable, - ChallengeEnable, - - // validator - ValidatorPrivateKey, // derivation RollupContractAddress, @@ -364,11 +346,10 @@ var Flags = []cli.Flag{ DerivationLogProgressInterval, DerivationFetchBlockRange, DerivationConfirmations, + DerivationVerifyMode, + DerivationReorgCheckDepth, L1BeaconAddr, - // blocktag options - BlockTagSafeConfirmations, - // L1 Sequencer options L1SequencerContractAddr, L1SyncLagThreshold, diff --git a/node/go.mod b/node/go.mod index 24a535dc3..902878ce4 100644 --- a/node/go.mod +++ b/node/go.mod @@ -10,7 +10,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/golang-lru v1.0.2 github.com/klauspost/compress v1.17.9 - github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 + github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 github.com/prometheus/client_golang v1.17.0 github.com/spf13/viper v1.13.0 github.com/stretchr/testify v1.10.0 diff --git a/node/go.sum b/node/go.sum index 8dc33803b..b1503d8a4 100644 --- a/node/go.sum +++ b/node/go.sum @@ -359,8 +359,8 @@ github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqky github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 h1:SSJRj6BFZ9uJm29WuVonClXeUE+lPD43i19J0uTuAFw= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 h1:RvKSy6ApUxDaA8gprbvYZVz/vpchwQStW34YdKxppHE= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/morph-l2/tendermint v0.3.7 h1:6dHC0GYGKxP2eHzC3e/l1NBtjuqE3H6S1N/RgM0LOBI= github.com/morph-l2/tendermint v0.3.7/go.mod h1:TtCzp9l6Z6yDUiwv3TbqKqw8Q8RKp3fSz5+adO1/Y8w= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= diff --git a/node/ops-morph/docker-compose-validator.yml b/node/ops-morph/docker-compose-validator.yml deleted file mode 100644 index 09a1efa74..000000000 --- a/node/ops-morph/docker-compose-validator.yml +++ /dev/null @@ -1,41 +0,0 @@ -version: '3.8' - -volumes: - validator_node_data: - -services: - - validator_node: - build: - context: .. - dockerfile: ./ops-morph/Dockerfile - image: morph-node:latest - ports: - - "26660:26660" - environment: - - EMPTY_BLOCK_DELAY=true - - MORPH_NODE_L2_ETH_RPC=http://host.docker.internal:7545 - - MORPH_NODE_L2_ENGINE_RPC=http://host.docker.internal:7551 - - MORPH_NODE_L2_ENGINE_AUTH=jwt-secret.txt - - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=0xcf7ed3acca5a467e9e704c703e8d87f634fb0fc9 - ## todo need to replace it to a public network - - MORPH_NODE_L1_ETH_RPC=http://host.docker.internal:9545 - - MORPH_NODE_L1_ETH_BEACON_RPC=http://host.docker.internal:3500 - - MORPH_NODE_VALIDATOR_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000001 - - MORPH_NODE_ROLLUP_ADDRESS=0xa513e6e4b8f2a923d98304ec87f64353c4d5c853 - - MORPH_NODE_DERIVATION_START_HEIGHT=1 - - MORPH_NODE_DERIVATION_FETCH_BLOCK_RANGE=1000 - - MORPH_NODE_L1_CHAIN_ID=900 - - MORPH_NODE_VALIDATOR=true - - MORPH_NODE_MOCK_SEQUENCER=false - - MORPH_NODE_L1_CONFIRMATIONS=1 - - MORPH_NODE_METRICS_SERVER_ENABLE=true - - MORPH_NODE_METRICS_PORT=26660 - - MORPH_NODE_SYNC_START_HEIGHT=1 - volumes: - - "validator_node_data:${NODE_DATA_DIR}" - - "${PWD}/jwt-secret.txt:${JWT_SECRET_PATH}" - command: > - morphnode - --validator - --home $NODE_DATA_DIR diff --git a/node/sync/syncer.go b/node/sync/syncer.go index c9948983a..38b88c782 100644 --- a/node/sync/syncer.go +++ b/node/sync/syncer.go @@ -3,6 +3,7 @@ package sync import ( "context" "errors" + "sync" "time" "github.com/morph-l2/go-ethereum/common" @@ -13,6 +14,8 @@ import ( ) type Syncer struct { + startOnce sync.Once + ctx context.Context cancel context.CancelFunc bridgeClient *BridgeClient @@ -75,32 +78,40 @@ func NewSyncer(ctx context.Context, db Database, config *Config, logger tmlog.Lo }, nil } +// Start begins the L1 message sync loop. Safe to call multiple times: the +// shared *Syncer is wired through main.go to both Derivation.Start (which +// always invokes it) and Executor.updateSequencerSet (which invokes it on +// sequencer-role transitions). Without the once-guard the second caller +// would spawn a duplicate poller racing on s.latestSynced and double-close +// s.stop on shutdown. func (s *Syncer) Start() { - if s.isFake { - return - } - // block node startup during initial sync and print some helpful logs - s.logger.Info("initial sync start", "msg", "Running initial sync of L1 messages before starting sequencer, this might take a while...") - s.fetchL1Messages() - s.logger.Info("initial sync completed", "latestSyncedBlock", s.latestSynced) - - go func() { - t := time.NewTicker(s.pollInterval) - defer t.Stop() - - for { - // don't wait for ticker during startup - s.fetchL1Messages() - - select { - case <-s.ctx.Done(): - close(s.stop) - return - case <-t.C: - continue - } + s.startOnce.Do(func() { + if s.isFake { + return } - }() + // block node startup during initial sync and print some helpful logs + s.logger.Info("initial sync start", "msg", "Running initial sync of L1 messages before starting sequencer, this might take a while...") + s.fetchL1Messages() + s.logger.Info("initial sync completed", "latestSyncedBlock", s.latestSynced) + + go func() { + t := time.NewTicker(s.pollInterval) + defer t.Stop() + + for { + // don't wait for ticker during startup + s.fetchL1Messages() + + select { + case <-s.ctx.Done(): + close(s.stop) + return + case <-t.C: + continue + } + } + }() + }) } func (s *Syncer) Stop() { diff --git a/node/types/retryable_client.go b/node/types/retryable_client.go index 8e26fcfb9..77dd641d6 100644 --- a/node/types/retryable_client.go +++ b/node/types/retryable_client.go @@ -2,6 +2,7 @@ package types import ( "context" + "errors" "math/big" "strings" "time" @@ -122,6 +123,44 @@ func (rc *RetryableClient) NewSafeL2Block(ctx context.Context, safeL2Data *catal return } +// NewL2BlockV2 wraps engine_newL2BlockV2 — the reorg-capable variant of +// NewSafeL2Block introduced by go-ethereum PR #325. Unlike NewSafeL2Block +// (which requires parent == currentHead), V2 only requires the parent to +// exist on-chain; SetCanonical detects parentHash != currentHead and +// triggers EL forkchoice reorg automatically. With isSafe=true the EL +// skips verifyBlock + ValidateState (used for L1-confirmed blocks where +// the caller already trusts the block's content). +// +// Used by SPEC-005 §4.3 local verify self-heal: when local-rebuild produces a +// versioned hash that disagrees with L1, we pull the real blob, derive +// the batch using the true sequencer payload, and rewrite the locally +// divergent unsafe blocks via this API. +// +// Temporary note: the upstream PR https://github.com/morph-l2/go-ethereum/pull/325 +// is still open. Once merged into main and the morph go-ethereum +// dependency is bumped to a release that contains the merged commit, +// no caller change is needed. +func (rc *RetryableClient) NewL2BlockV2(ctx context.Context, executableL2Data *catalyst.ExecutableL2Data, isSafe bool) (err error) { + if retryErr := backoff.Retry(func() error { + respErr := rc.authClient.NewL2BlockV2(ctx, executableL2Data, isSafe) + if respErr != nil { + rc.logger.Error("NewL2BlockV2 failed", + "block_number", executableL2Data.Number, + "parent_hash", executableL2Data.ParentHash, + "is_safe", isSafe, + "error", respErr) + if retryableError(respErr) { + return respErr + } + err = respErr + } + return nil + }, rc.b); retryErr != nil { + return retryErr + } + return +} + func (rc *RetryableClient) BlockNumber(ctx context.Context) (ret uint64, err error) { if retryErr := backoff.Retry(func() error { resp, respErr := rc.ethClient.BlockNumber(ctx) @@ -144,10 +183,11 @@ func (rc *RetryableClient) HeaderByNumber(ctx context.Context, blockNumber *big. if retryErr := backoff.Retry(func() error { resp, respErr := rc.ethClient.HeaderByNumber(ctx, blockNumber) if respErr != nil { - rc.logger.Info("failed to call HeaderByNumber", "error", respErr) if retryableError(respErr) { + rc.logger.Info("failed to call HeaderByNumber, will retry", "error", respErr) return respErr } + rc.logger.Error("failed to call HeaderByNumber, non-retryable", "error", respErr) err = respErr } ret = resp @@ -162,10 +202,11 @@ func (rc *RetryableClient) BlockByNumber(ctx context.Context, blockNumber *big.I if retryErr := backoff.Retry(func() error { resp, respErr := rc.ethClient.BlockByNumber(ctx, blockNumber) if respErr != nil { - rc.logger.Info("failed to call BlockByNumber", "error", respErr) if retryableError(respErr) { + rc.logger.Info("failed to call BlockByNumber, will retry", "error", respErr) return respErr } + rc.logger.Error("failed to call BlockByNumber, non-retryable", "error", respErr) err = respErr } ret = resp @@ -230,7 +271,25 @@ func (rc *RetryableClient) SetBlockTags(ctx context.Context, safeBlockHash commo } // currently we want every error retryable, except the DiscontinuousBlockError +// retryableError reports whether an RPC error should trigger an exponential +// backoff retry inside RetryableClient. Errors not classified as retryable +// escape immediately so callers see the failure on the first poll cycle +// rather than after the 30-minute MaxElapsedTime budget runs out. +// +// Permanent classifications (do NOT retry): +// - ethereum.NotFound: target block / header doesn't exist locally. With +// SPEC-005 local verify reading L2 blocks the sequencer hasn't yet sealed +// locally (snapshot too old, sync still catching up), this is a "wait +// for sync" condition, not a transient RPC blip; retrying every +// backoff tick for 30 minutes wastes the cycle and hides the gap from +// the operator. The caller (e.g. verify_local) surfaces the missing +// block, derivation logs an Error, and the next poll re-evaluates. +// - DiscontinuousBlockError: structurally invalid input that no amount +// of retry will fix. func retryableError(err error) bool { + if errors.Is(err, ethereum.NotFound) { + return false + } return !strings.Contains(err.Error(), DiscontinuousBlockError) } diff --git a/node/types/retryable_client_test.go b/node/types/retryable_client_test.go new file mode 100644 index 000000000..96703ea47 --- /dev/null +++ b/node/types/retryable_client_test.go @@ -0,0 +1,47 @@ +package types + +import ( + "errors" + "fmt" + "testing" + + "github.com/morph-l2/go-ethereum" +) + +// retryableError must classify ethereum.NotFound as permanent so that +// SPEC-005 local verify fails fast when a target L2 block has not yet been sealed +// locally (snapshot too old or P2P sync still catching up). Without this +// classification the caller blocks for the full 30-minute backoff budget +// before the gap is surfaced. +func TestRetryableError_NotFoundIsPermanent(t *testing.T) { + if retryableError(ethereum.NotFound) { + t.Fatal("ethereum.NotFound must be non-retryable") + } + // Wrapped errors must be unwrapped via errors.Is so go-ethereum's + // fmt.Errorf("...: %w", ethereum.NotFound) wrappers also classify. + wrapped := fmt.Errorf("BlockByNumber: %w", ethereum.NotFound) + if retryableError(wrapped) { + t.Fatal("wrapped ethereum.NotFound must be non-retryable") + } +} + +func TestRetryableError_DiscontinuousBlockIsPermanent(t *testing.T) { + err := errors.New("discontinuous block number: ...") + if retryableError(err) { + t.Fatal("DiscontinuousBlockError must be non-retryable") + } +} + +func TestRetryableError_GenericErrorIsRetryable(t *testing.T) { + cases := []error{ + errors.New("connection refused"), + errors.New("EOF"), + errors.New("i/o timeout"), + errors.New("502 Bad Gateway"), + } + for _, e := range cases { + if !retryableError(e) { + t.Errorf("expected retryable for %q", e) + } + } +} diff --git a/node/validator/config.go b/node/validator/config.go deleted file mode 100644 index 986fd16d5..000000000 --- a/node/validator/config.go +++ /dev/null @@ -1,46 +0,0 @@ -package validator - -import ( - "crypto/ecdsa" - "math/big" - "strings" - - "github.com/morph-l2/go-ethereum/common" - "github.com/morph-l2/go-ethereum/crypto" - "github.com/urfave/cli" - - "morph-l2/node/flags" -) - -type Config struct { - l1RPC string - PrivateKey *ecdsa.PrivateKey - L1ChainID *big.Int - rollupContract common.Address - challengeEnable bool -} - -func NewConfig() *Config { - return &Config{} -} - -func (c *Config) SetCliContext(ctx *cli.Context) error { - l1NodeAddr := ctx.GlobalString(flags.L1NodeAddr.Name) - l1ChainID := ctx.GlobalUint64(flags.L1ChainID.Name) - c.challengeEnable = ctx.GlobalBool(flags.ChallengeEnable.Name) - if c.challengeEnable { - hexPrvKey := ctx.GlobalString(flags.ValidatorPrivateKey.Name) - hex := strings.TrimPrefix(hexPrvKey, "0x") - privateKey, err := crypto.HexToECDSA(hex) - if err != nil { - return err - } - c.PrivateKey = privateKey - } - addrHex := ctx.GlobalString(flags.RollupContractAddress.Name) - rollupContract := common.HexToAddress(addrHex) - c.l1RPC = l1NodeAddr - c.L1ChainID = big.NewInt(int64(l1ChainID)) - c.rollupContract = rollupContract - return nil -} diff --git a/node/validator/validator.go b/node/validator/validator.go deleted file mode 100644 index 224c8c3d8..000000000 --- a/node/validator/validator.go +++ /dev/null @@ -1,118 +0,0 @@ -package validator - -import ( - "context" - "crypto/ecdsa" - "errors" - "fmt" - "math/big" - "time" - - "github.com/morph-l2/go-ethereum" - "github.com/morph-l2/go-ethereum/accounts/abi/bind" - ethtypes "github.com/morph-l2/go-ethereum/core/types" - "github.com/morph-l2/go-ethereum/ethclient" - "github.com/morph-l2/go-ethereum/log" - tmlog "github.com/tendermint/tendermint/libs/log" - - "morph-l2/bindings/bindings" -) - -type Validator struct { - cli DeployContractBackend - privateKey *ecdsa.PrivateKey - l1ChainID *big.Int - contract *bindings.Rollup - challengeEnable bool - logger tmlog.Logger -} - -type DeployContractBackend interface { - bind.DeployBackend - bind.ContractBackend -} - -func NewValidator(cfg *Config, rollup *bindings.Rollup, logger tmlog.Logger) (*Validator, error) { - cli, err := ethclient.Dial(cfg.l1RPC) - if err != nil { - return nil, fmt.Errorf("dial l1 node error:%v", err) - } - return &Validator{ - cli: cli, - contract: rollup, - privateKey: cfg.PrivateKey, - l1ChainID: cfg.L1ChainID, - challengeEnable: cfg.challengeEnable, - logger: logger, - }, nil -} - -func (v *Validator) SetLogger() { - v.logger = v.logger.With("module", "validator") -} - -func (v *Validator) ChallengeEnable() bool { - return v.challengeEnable -} - -func (v *Validator) ChallengeState(batchIndex uint64) error { - if !v.ChallengeEnable() { - return fmt.Errorf("the challenge is not enabled,please set challengeEnable is true") - } - opts, err := bind.NewKeyedTransactorWithChainID(v.privateKey, v.l1ChainID) - if err != nil { - return err - } - gasPrice, err := v.cli.SuggestGasPrice(opts.Context) - if err != nil { - return err - } - opts.GasPrice = gasPrice - opts.NoSend = true - batchHash, err := v.contract.CommittedBatches( - &bind.CallOpts{ - Pending: false, - Context: opts.Context, - }, - new(big.Int).SetUint64(batchIndex), - ) - if err != nil { - return err - } - tx, err := v.contract.ChallengeState(opts, batchIndex, batchHash) - if err != nil { - return err - } - log.Info("send ChallengeState transaction ", "txHash", tx.Hash().Hex()) - if err := v.cli.SendTransaction(context.Background(), tx); err != nil { - return err - } - // Wait for the receipt - receipt, err := waitForReceipt(v.cli, tx) - if err != nil { - return err - } - log.Info("Validator has already started the challenge", "hash", tx.Hash().Hex(), - "gas-used", receipt.GasUsed, "blocknumber", receipt.BlockNumber) - return nil -} - -func waitForReceipt(backend DeployContractBackend, tx *ethtypes.Transaction) (*ethtypes.Receipt, error) { - t := time.NewTicker(300 * time.Millisecond) - receipt := new(ethtypes.Receipt) - var err error - for range t.C { - receipt, err = backend.TransactionReceipt(context.Background(), tx.Hash()) - if errors.Is(err, ethereum.NotFound) { - continue - } - if err != nil { - return nil, err - } - if receipt != nil { - t.Stop() - break - } - } - return receipt, nil -} diff --git a/node/validator/validator_test.go b/node/validator/validator_test.go deleted file mode 100644 index 038a6f978..000000000 --- a/node/validator/validator_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package validator - -import ( - "crypto/ecdsa" - "math/big" - "testing" - - "github.com/morph-l2/go-ethereum/accounts/abi/bind" - "github.com/morph-l2/go-ethereum/accounts/abi/bind/backends" - "github.com/morph-l2/go-ethereum/core" - "github.com/morph-l2/go-ethereum/core/rawdb" - "github.com/morph-l2/go-ethereum/crypto" - "github.com/morph-l2/go-ethereum/ethdb" - "github.com/morph-l2/go-ethereum/log" - "github.com/stretchr/testify/require" - - "morph-l2/bindings/bindings" -) - -func TestValidator_ChallengeState(t *testing.T) { - key, _ := crypto.GenerateKey() - sim, _ := newSimulatedBackend(key) - opts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(1337)) - require.NoError(t, err) - addr, _, rollup, err := bindings.DeployRollup(opts, sim, 1337) - require.NoError(t, err) - sim.Commit() - v := Validator{ - cli: sim, - privateKey: key, - l1ChainID: big.NewInt(1), - contract: rollup, - challengeEnable: true, - } - err = v.ChallengeState(10) - log.Info("addr:", addr) - require.EqualError(t, err, "execution reverted: only challenger allowed") -} - -func newSimulatedBackend(key *ecdsa.PrivateKey) (*backends.SimulatedBackend, ethdb.Database) { - var gasLimit uint64 = 9_000_000 - auth, _ := bind.NewKeyedTransactorWithChainID(key, big.NewInt(1337)) - genAlloc := make(core.GenesisAlloc) - genAlloc[auth.From] = core.GenesisAccount{Balance: big.NewInt(9223372036854775807)} - db := rawdb.NewMemoryDatabase() - sim := backends.NewSimulatedBackendWithDatabase(db, genAlloc, gasLimit) - return sim, db -} diff --git a/ops/devnet-morph/devnet/setup_nodes.py b/ops/devnet-morph/devnet/setup_nodes.py index d3b968687..667e23f24 100644 --- a/ops/devnet-morph/devnet/setup_nodes.py +++ b/ops/devnet-morph/devnet/setup_nodes.py @@ -38,13 +38,14 @@ def setup_devnet_nodes(): # Run the Tendermint testnet command print("Setting up the devnet...") command = [ - "tendermint", "testnet", "--v", "4", "--n", "1", "--o", devnet_dir, + "tendermint", "testnet", "--v", "4", "--n", "2", "--o", devnet_dir, "--populate-persistent-peers", "--hostname", "node-0", "--hostname", "node-1", "--hostname", "node-2", "--hostname", "node-3", - "--hostname", "sentry-node-0" + "--hostname", "sentry-node-0", + "--hostname", "sentry-node-1", ] if subprocess.call(command) != 0: @@ -54,7 +55,7 @@ def setup_devnet_nodes(): # Modify config.toml files using toml library print("Modifying config.toml files...") config_files = [ - os.path.join(devnet_dir, f"node{i}/config/config.toml") for i in range(5) + os.path.join(devnet_dir, f"node{i}/config/config.toml") for i in range(6) ] persistent_peers_value = ( @@ -83,7 +84,7 @@ def setup_devnet_nodes(): content = content.replace('block_sync = false', 'block_sync = true') content = re.sub(r'persistent_peers\s*=\s*".*?"', f'persistent_peers = "{persistent_peers_value}"', content) - # Modify pex for nodes 0 to 3 + # Modify pex for validator nodes. if i < 4: content = content.replace('pex = true', 'pex = false') @@ -94,19 +95,23 @@ def setup_devnet_nodes(): # Copy key files to devnet node directories print("Copying key files...") - node_dirs = [f"node{i}" for i in range(5)] + node_dirs = [f"node{i}" for i in range(6)] for node in node_dirs: source_dir = os.path.join(docker_dir, node) dest_dir = os.path.join(devnet_dir, node, "config") - if not os.path.isdir(source_dir) or not os.path.isdir(dest_dir): + if not os.path.isdir(dest_dir): + print(f"Error: Missing destination directory for {node}. Exiting.") + sys.exit(1) + + if not os.path.isdir(source_dir): print(f"Error: Missing source or destination directory for {node}. Exiting.") sys.exit(1) shutil.copyfile(os.path.join(source_dir, "node_key.json"), os.path.join(dest_dir, "node_key.json")) - if node != "node4": + if node not in ("node4", "node5"): shutil.copyfile(os.path.join(source_dir, "priv_validator_key.json"), os.path.join(dest_dir, "priv_validator_key.json")) # Copy and rename genesis file diff --git a/ops/docker/docker-compose-4nodes.yml b/ops/docker/docker-compose-4nodes.yml index 8a97f0e09..9b3934d14 100644 --- a/ops/docker/docker-compose-4nodes.yml +++ b/ops/docker/docker-compose-4nodes.yml @@ -9,13 +9,12 @@ volumes: morph_data_2: morph_data_3: sentry_el_data: + sentry_el_data_1: node_data_0: node_data_1: node_data_2: node_data_3: sentry_node_data: - validator_el_data: - validator_node_data: layer1-el-data: layer1-cl-data: layer1-vc-data: @@ -236,6 +235,7 @@ services: - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-0:8551 - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} + - MORPH_NODE_L1_ETH_BEACON_RPC=${L1_BEACON_CHAIN_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} - MORPH_NODE_L1_CONFIRMATIONS=0 - MORPH_NODE_ROLLUP_ADDRESS=${MORPH_ROLLUP:-0x6900000000000000000000000000000000000010} @@ -265,6 +265,7 @@ services: - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-1:8551 - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} + - MORPH_NODE_L1_ETH_BEACON_RPC=${L1_BEACON_CHAIN_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} - MORPH_NODE_L1_CONFIRMATIONS=0 - MORPH_NODE_ROLLUP_ADDRESS=${MORPH_ROLLUP:-0x6900000000000000000000000000000000000010} @@ -295,6 +296,7 @@ services: - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-2:8551 - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} + - MORPH_NODE_L1_ETH_BEACON_RPC=${L1_BEACON_CHAIN_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} - MORPH_NODE_L1_CONFIRMATIONS=0 - MORPH_NODE_ROLLUP_ADDRESS=${MORPH_ROLLUP:-0x6900000000000000000000000000000000000010} @@ -325,6 +327,7 @@ services: - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-3:8551 - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} + - MORPH_NODE_L1_ETH_BEACON_RPC=${L1_BEACON_CHAIN_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} - MORPH_NODE_L1_CONFIRMATIONS=0 - MORPH_NODE_ROLLUP_ADDRESS=${MORPH_ROLLUP:-0x6900000000000000000000000000000000000010} @@ -380,6 +383,7 @@ services: - MORPH_NODE_L2_ENGINE_RPC=http://sentry-el-0:8551 - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} + - MORPH_NODE_L1_ETH_BEACON_RPC=${L1_BEACON_CHAIN_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} - MORPH_NODE_L1_CONFIRMATIONS=0 - MORPH_NODE_ROLLUP_ADDRESS=${MORPH_ROLLUP:-0x6900000000000000000000000000000000000010} @@ -390,67 +394,61 @@ services: command: > morphnode --home $NODE_DATA_DIR - - validator-el: - container_name: validator-el - image: morph-geth:latest - depends_on: - tx-submitter-0: - condition: service_started - ports: - - "7545:8545" - - "7546:8546" - - "7551:8551" - healthcheck: - test: ["CMD-SHELL", "wget -qO- --header='Content-Type: application/json' --post-data='{\"jsonrpc\":\"2.0\",\"method\":\"eth_chainId\",\"params\":[],\"id\":1}' http://localhost:8545 | grep -q '\"result\"'"] - interval: 30s - timeout: 5s - retries: 3 - volumes: - - "validator_el_data:${GETH_DATA_DIR}" - - "${PWD}/jwt-secret.txt:${JWT_SECRET_PATH}" - - "${PWD}/../l2-genesis/.devnet/genesis-l2.json:/genesis.json" - entrypoint: # pass the L2 specific flags by overriding the entry-point and adding extra arguments - - "/bin/bash" - - "/entrypoint.sh" + sentry-el-1: + container_name: sentry-el-1 + depends_on: + node-0: + condition: service_started + image: morph-geth:latest + build: + context: ../.. + dockerfile: ops/docker/Dockerfile.l2-geth + restart: unless-stopped + ports: + - "9045:8545" + - "9046:8546" + - "8551" + - "6060" + - "30303" + volumes: + - "sentry_el_data_1:/db" + - "${PWD}/jwt-secret.txt:/jwt-secret.txt" + - "${PWD}/../l2-genesis/.devnet/genesis-l2.json:/genesis.json" + entrypoint: + - "/bin/sh" + - "/entrypoint.sh" - validator_node: - container_name: validator_node - depends_on: - validator-el: - condition: service_started - node-0: - condition: service_started - image: morph-node:latest - ports: - - "26660" - environment: - - MORPH_NODE_L2_ETH_RPC=http://validator-el:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://validator-el:8551 - - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} - ## todo need to replace it to a public network - - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} - - MORPH_NODE_L1_ETH_BEACON_RPC=${L1_BEACON_CHAIN_RPC} - - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} - - MORPH_NODE_VALIDATOR_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - - MORPH_NODE_ROLLUP_ADDRESS=${MORPH_ROLLUP:-0x6900000000000000000000000000000000000010} - - MORPH_NODE_DERIVATION_START_HEIGHT=1 - - MORPH_NODE_SYNC_START_HEIGHT=1 - - MORPH_NODE_DERIVATION_FETCH_BLOCK_RANGE=5000 - - MORPH_NODE_L1_CHAIN_ID=900 - - MORPH_NODE_VALIDATOR=true - - MORPH_NODE_MOCK_SEQUENCER=false - - MORPH_NODE_L1_CONFIRMATIONS=1 - - MORPH_NODE_METRICS_SERVER_ENABLE=true - - MORPH_NODE_METRICS_PORT=26660 - volumes: - - "validator_node_data:${NODE_DATA_DIR}" - - "${PWD}/jwt-secret.txt:${JWT_SECRET_PATH}" - command: > - morphnode - --validator - --home $NODE_DATA_DIR + sentry-node-1: + container_name: sentry-node-1 + depends_on: + sentry-el-1: + condition: service_started + image: morph-node:latest + restart: unless-stopped + ports: + - "26656" + - "26657" + - "26658" + - "26660" + environment: + - EMPTY_BLOCK_DELAY=true + - MORPH_NODE_L2_ETH_RPC=http://sentry-el-1:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://sentry-el-1:8551 + - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} + - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} + - MORPH_NODE_L1_ETH_BEACON_RPC=${L1_BEACON_CHAIN_RPC} + - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} + - MORPH_NODE_L1_CONFIRMATIONS=0 + - MORPH_NODE_ROLLUP_ADDRESS=${MORPH_ROLLUP:-0x6900000000000000000000000000000000000010} + - MORPH_NODE_SYNC_START_HEIGHT=${MORPH_NODE_SYNC_START_HEIGHT:-1} + - MORPH_NODE_DERIVATION_VERIFY_MODE=layer1 + volumes: + - ".devnet/node5:${NODE_DATA_DIR}" + - "${PWD}/jwt-secret.txt:${JWT_SECRET_PATH}" + command: > + morphnode + --home $NODE_DATA_DIR tx-submitter-0: container_name: tx-submitter-0 diff --git a/ops/docker/docker-compose-reth.yml b/ops/docker/docker-compose-reth.yml index fecc42f89..f66b471e8 100644 --- a/ops/docker/docker-compose-reth.yml +++ b/ops/docker/docker-compose-reth.yml @@ -40,8 +40,3 @@ services: sentry-el-0: <<: *reth-service build: !reset null - - validator-el: - <<: *reth-service - healthcheck: - disable: true diff --git a/ops/docker/node5/node_key.json b/ops/docker/node5/node_key.json new file mode 100644 index 000000000..fea963ab9 --- /dev/null +++ b/ops/docker/node5/node_key.json @@ -0,0 +1 @@ +{"priv_key":{"type":"tendermint/PrivKeyEd25519","value":"m7GeJrAoa4sybMA0zflWueAlx9TUK8S6pHJQvW8k6oz6PiFvUQd9FDWg+qZdRcAXjeja/x6MO11kkhv8YAwLPQ=="}} \ No newline at end of file diff --git a/ops/l2-genesis/go.mod b/ops/l2-genesis/go.mod index f122db637..6c23e35cf 100644 --- a/ops/l2-genesis/go.mod +++ b/ops/l2-genesis/go.mod @@ -6,7 +6,7 @@ replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3. require ( github.com/holiman/uint256 v1.2.4 - github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 + github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 github.com/stretchr/testify v1.10.0 github.com/urfave/cli v1.22.17 ) diff --git a/ops/l2-genesis/go.sum b/ops/l2-genesis/go.sum index 13f22341b..66dd0f211 100644 --- a/ops/l2-genesis/go.sum +++ b/ops/l2-genesis/go.sum @@ -139,8 +139,8 @@ github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqky github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 h1:SSJRj6BFZ9uJm29WuVonClXeUE+lPD43i19J0uTuAFw= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 h1:RvKSy6ApUxDaA8gprbvYZVz/vpchwQStW34YdKxppHE= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= diff --git a/ops/tools/go.mod b/ops/tools/go.mod index 658c8a916..2fb1ffc82 100644 --- a/ops/tools/go.mod +++ b/ops/tools/go.mod @@ -5,7 +5,7 @@ go 1.24.0 replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.7 require ( - github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 + github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 github.com/tendermint/tendermint v0.35.9 ) diff --git a/ops/tools/go.sum b/ops/tools/go.sum index 6e0a8cc9a..717348a02 100644 --- a/ops/tools/go.sum +++ b/ops/tools/go.sum @@ -161,8 +161,8 @@ github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqky github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 h1:SSJRj6BFZ9uJm29WuVonClXeUE+lPD43i19J0uTuAFw= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 h1:RvKSy6ApUxDaA8gprbvYZVz/vpchwQStW34YdKxppHE= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/morph-l2/tendermint v0.3.7 h1:6dHC0GYGKxP2eHzC3e/l1NBtjuqE3H6S1N/RgM0LOBI= github.com/morph-l2/tendermint v0.3.7/go.mod h1:TtCzp9l6Z6yDUiwv3TbqKqw8Q8RKp3fSz5+adO1/Y8w= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= diff --git a/oracle/go.mod b/oracle/go.mod index 64fe44a3a..de4569ce2 100644 --- a/oracle/go.mod +++ b/oracle/go.mod @@ -7,7 +7,7 @@ replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3. require ( github.com/go-kit/kit v0.12.0 github.com/morph-l2/externalsign v0.3.1 - github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 + github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 github.com/prometheus/client_golang v1.17.0 github.com/stretchr/testify v1.10.0 github.com/tendermint/tendermint v0.35.9 diff --git a/oracle/go.sum b/oracle/go.sum index dc1799fc8..4ec841302 100644 --- a/oracle/go.sum +++ b/oracle/go.sum @@ -172,8 +172,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/morph-l2/externalsign v0.3.1 h1:UYFDZFB0L85A4rDvuwLNBiGEi0kSmg9AZ2v8Q5O4dQo= github.com/morph-l2/externalsign v0.3.1/go.mod h1:b6NJ4GUiiG/gcSJsp3p8ExsIs4ZdphlrVALASnVoGJE= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 h1:SSJRj6BFZ9uJm29WuVonClXeUE+lPD43i19J0uTuAFw= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 h1:RvKSy6ApUxDaA8gprbvYZVz/vpchwQStW34YdKxppHE= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/morph-l2/tendermint v0.3.7 h1:6dHC0GYGKxP2eHzC3e/l1NBtjuqE3H6S1N/RgM0LOBI= github.com/morph-l2/tendermint v0.3.7/go.mod h1:TtCzp9l6Z6yDUiwv3TbqKqw8Q8RKp3fSz5+adO1/Y8w= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= diff --git a/token-price-oracle/go.mod b/token-price-oracle/go.mod index 099aa244a..e9ae9d453 100644 --- a/token-price-oracle/go.mod +++ b/token-price-oracle/go.mod @@ -8,7 +8,7 @@ replace ( ) require ( - github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 + github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 github.com/morph-l2/remote-signer-client/go v0.0.0-20260312080033-d078d86ddbe9 github.com/prometheus/client_golang v1.17.0 github.com/sirupsen/logrus v1.9.3 diff --git a/token-price-oracle/go.sum b/token-price-oracle/go.sum index b2ac235e3..6a771247a 100644 --- a/token-price-oracle/go.sum +++ b/token-price-oracle/go.sum @@ -143,8 +143,8 @@ github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqky github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 h1:SSJRj6BFZ9uJm29WuVonClXeUE+lPD43i19J0uTuAFw= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 h1:RvKSy6ApUxDaA8gprbvYZVz/vpchwQStW34YdKxppHE= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/morph-l2/remote-signer-client/go v0.0.0-20260312080033-d078d86ddbe9 h1:d2nKLUgiEJsQmpSWEiGbsC+sZXQCM4y/3EzyXkoMM60= github.com/morph-l2/remote-signer-client/go v0.0.0-20260312080033-d078d86ddbe9/go.mod h1:slD6GmYEwLHn4Yj/kO8/1QF3iaYlVVAXg2ZnGr8SW/8= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= diff --git a/tx-submitter/go.mod b/tx-submitter/go.mod index 2d9ada3d3..25b8c929d 100644 --- a/tx-submitter/go.mod +++ b/tx-submitter/go.mod @@ -9,7 +9,7 @@ require ( github.com/crate-crypto/go-eth-kzg v1.4.0 github.com/holiman/uint256 v1.2.4 github.com/morph-l2/externalsign v0.3.1 - github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 + github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 github.com/prometheus/client_golang v1.17.0 github.com/stretchr/testify v1.10.0 github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a diff --git a/tx-submitter/go.sum b/tx-submitter/go.sum index 8e8331539..50c1bb4bb 100644 --- a/tx-submitter/go.sum +++ b/tx-submitter/go.sum @@ -158,8 +158,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/morph-l2/externalsign v0.3.1 h1:UYFDZFB0L85A4rDvuwLNBiGEi0kSmg9AZ2v8Q5O4dQo= github.com/morph-l2/externalsign v0.3.1/go.mod h1:b6NJ4GUiiG/gcSJsp3p8ExsIs4ZdphlrVALASnVoGJE= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88 h1:SSJRj6BFZ9uJm29WuVonClXeUE+lPD43i19J0uTuAFw= -github.com/morph-l2/go-ethereum v1.10.14-0.20260526091422-01e8a4291b88/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4 h1:RvKSy6ApUxDaA8gprbvYZVz/vpchwQStW34YdKxppHE= +github.com/morph-l2/go-ethereum v1.10.14-0.20260508105911-56deb7072ae4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=