diff --git a/.dockerignore b/.dockerignore index 0ec45acbf..e4d93ad6c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ prover/ .git/ +.claude/ contracts/node_modules/ gas-oracle/app/target/ node/build/ diff --git a/Makefile b/Makefile index 26f593412..baa590a39 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ ################## update dependencies #################### -ETHEREUM_SUBMODULE_COMMIT_OR_TAG := morph-v2.2.6 -ETHEREUM_TARGET_VERSION := morph-v2.2.6 -TENDERMINT_TARGET_VERSION := v0.3.9 +ETHEREUM_SUBMODULE_COMMIT_OR_TAG := 4012f174b967153a31926f370d77af389b2968c8 +ETHEREUM_TARGET_VERSION := v1.10.14-0.20260908092055-4012f174b967 +TENDERMINT_TARGET_VERSION := v0.3.10-0.20260916055518-560fb469b16d ETHEREUM_MODULE_NAME := github.com/morph-l2/go-ethereum @@ -161,12 +161,25 @@ export MORPH_RETH_RUSTFLAGS export MORPH_RETH_DOCKER_TARGET export MORPH_RETH_ENTRYPOINT DEVNET_COMPOSE_FILES := -f docker-compose-devnet.yml -DEVNET_CLEAN_COMPOSE_FILES := -f docker-compose-devnet.yml -f docker-compose-reth.yml -f docker-compose-cluster.yml +DEVNET_CLEAN_COMPOSE_FILES := -f docker-compose-devnet.yml -f docker-compose-cluster.yml -f docker-compose-reth.yml -f docker-compose-cluster-reth.yml + +# The cluster topology is layered before the execution-client override, so that +# the reth files have the last word on the ha-el-* image, entrypoint and +# command. Swapping these two leaves the cluster nodes running geth even when +# reth was asked for, because later -f files win. +ifneq ($(DEVNET_CLUSTER_ENABLED),) +DEVNET_COMPOSE_FILES += -f docker-compose-cluster.yml +endif ifeq ($(EXECUTION_CLIENT),geth) DEVNET_EXECUTION_DEPS := submodules else ifeq ($(EXECUTION_CLIENT),reth) DEVNET_COMPOSE_FILES += -f docker-compose-reth.yml +# The ha-el-* reth overrides are kept out of docker-compose-reth.yml because +# compose starts any service a later -f file introduces, cluster or not. +ifneq ($(DEVNET_CLUSTER_ENABLED),) +DEVNET_COMPOSE_FILES += -f docker-compose-cluster-reth.yml +endif ifeq ($(MORPH_RETH_BUILD_FROM_SOURCE),true) DEVNET_EXECUTION_DEPS := reth else @@ -175,9 +188,6 @@ endif else $(error unsupported EXECUTION_CLIENT "$(EXECUTION_CLIENT)", expected "geth" or "reth") endif -ifneq ($(DEVNET_CLUSTER_ENABLED),) -DEVNET_COMPOSE_FILES += -f docker-compose-cluster.yml -endif devnet-up: $(DEVNET_EXECUTION_DEPS) go-ubuntu-builder python3 ops/devnet-morph/main.py --polyrepo-dir=. --execution-client=$(EXECUTION_CLIENT) \ diff --git a/bindings/go.mod b/bindings/go.mod index 6dbb1a82a..e5751d4a4 100644 --- a/bindings/go.mod +++ b/bindings/go.mod @@ -2,9 +2,9 @@ module morph-l2/bindings go 1.24.0 -replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.9 +replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.10-0.20260916055518-560fb469b16d -require github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4 +require github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 require ( github.com/VictoriaMetrics/fastcache v1.12.2 // indirect diff --git a/bindings/go.sum b/bindings/go.sum index 6352d3284..4b0fb2573 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.20260824023452-5744b8f66ec4 h1:HCXegM088SPDt3wITDEBM+Osx06I0CDLv2jmsaPA/LU= -github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 h1:lgAgQYT34XY/l7+auFRHdqt7HsnCcAMNBGxd5DKiiHw= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967/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/go.mod b/common/go.mod index 1b67b1876..ab44dd85f 100644 --- a/common/go.mod +++ b/common/go.mod @@ -2,11 +2,11 @@ module morph-l2/common go 1.24.0 -replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.9 +replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.10-0.20260916055518-560fb469b16d require ( github.com/holiman/uint256 v1.2.4 - github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4 + github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 github.com/morph-l2/morph-da-codec/bindings/codec v0.0.0-20260727095527-681727561e38 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 0406a85c0..4c0a02f8e 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.20260824023452-5744b8f66ec4 h1:HCXegM088SPDt3wITDEBM+Osx06I0CDLv2jmsaPA/LU= -github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 h1:lgAgQYT34XY/l7+auFRHdqt7HsnCcAMNBGxd5DKiiHw= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= github.com/morph-l2/morph-da-codec/bindings/codec v0.0.0-20260727095527-681727561e38 h1:2OIgRP51TPMlpw9E82toxu/SQzN1hlYcxpJKC9jB0cs= github.com/morph-l2/morph-da-codec/bindings/codec v0.0.0-20260727095527-681727561e38/go.mod h1:f6+BHrrHbRmEYplC1nmNjGdatQtMSa2CIK7CX8T1Nfc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= diff --git a/contracts/deploy/022-SequencerInit.ts b/contracts/deploy/022-SequencerInit.ts index be82cd1e9..607e28e2d 100644 --- a/contracts/deploy/022-SequencerInit.ts +++ b/contracts/deploy/022-SequencerInit.ts @@ -66,6 +66,59 @@ export const SequencerInit = async ( console.log('L1SequencerProxy upgrade success') } + + return '' +} + +// SetFirstSequencer must run *after* the proxy admin has been handed over to +// ProxyAdmin. While the deployer is still the proxy admin, any call that falls +// through to the implementation reverts with +// "TransparentUpgradeableProxy: admin cannot fallback to proxy target". +export const SetFirstSequencer = async ( + hre: HardhatRuntimeEnvironment, + path: string, + deployer: any, + configTmp: any +): Promise => { + const L1SequencerProxyAddress = getContractAddressByName(path, ProxyStorageName.L1SequencerProxyStorageName) + const L1SequencerFactory = await hre.ethers.getContractFactory(ContractFactoryName.L1Sequencer) + + const L1Sequencer = new ethers.Contract( + L1SequencerProxyAddress, + L1SequencerFactory.interface, + deployer, + ) + + // deployConfig is a Proxy that throws on unknown keys, so probe with `in` + // before reading. + const firstSequencer: string = process.env.firstSequencerAddress || + ('firstSequencerAddress' in configTmp ? configTmp.firstSequencerAddress : '') + if (!firstSequencer) { + // Networks that register the first sequencer out-of-band (devnet does it + // from ops/devnet-morph) leave this unset. + console.log('firstSequencerAddress not configured, skipping setFirstSequencer') + return '' + } + if (!ethers.utils.isAddress(firstSequencer)) { + return `invalid firstSequencerAddress: ${firstSequencer}` + } + + if ((await L1Sequencer.getSequencerHistoryLength()).gt(0)) { + console.log('First sequencer already set:', await L1Sequencer.getSequencer()) + return '' + } + + console.log('Setting first sequencer:', firstSequencer) + const tx = await L1Sequencer.setFirstSequencer(firstSequencer) + await tx.wait() + + await assertContractVariable( + L1Sequencer, + 'getSequencer', + firstSequencer, + ) + + console.log('setFirstSequencer success') return '' } diff --git a/contracts/deploy/index.ts b/contracts/deploy/index.ts index 18dde9082..b1921cb80 100644 --- a/contracts/deploy/index.ts +++ b/contracts/deploy/index.ts @@ -10,7 +10,7 @@ import GatewayInit from './017-GatewayInit' import {AdminTransfer,AdminTransferByProxyStorageName, AdminTransferConcurrently} from './019-AdminTransfer' import ContractInit from './020-ContractInit' import SubmitterRegister from './021-SubmitterRegister' -import SequencerInit from './022-SequencerInit' +import SequencerInit, { SetFirstSequencer } from './022-SequencerInit' export { @@ -30,5 +30,6 @@ export { AdminTransferConcurrently, ContractInit, SubmitterRegister, - SequencerInit + SequencerInit, + SetFirstSequencer } diff --git a/contracts/go.mod b/contracts/go.mod index ee02e8ff0..3d3da7cce 100644 --- a/contracts/go.mod +++ b/contracts/go.mod @@ -2,11 +2,11 @@ module morph-l2/contract go 1.24.0 -replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.9 +replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.10-0.20260916055518-560fb469b16d require ( github.com/iden3/go-iden3-crypto v0.0.16 - github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4 + github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 github.com/stretchr/testify v1.10.0 ) diff --git a/contracts/go.sum b/contracts/go.sum index 1c5f50533..8a0917f3c 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.20260824023452-5744b8f66ec4 h1:HCXegM088SPDt3wITDEBM+Osx06I0CDLv2jmsaPA/LU= -github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 h1:lgAgQYT34XY/l7+auFRHdqt7HsnCcAMNBGxd5DKiiHw= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967/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/src/deploy-config/qanetl1.ts b/contracts/src/deploy-config/qanetl1.ts index b869fa25c..5c76700ec 100644 --- a/contracts/src/deploy-config/qanetl1.ts +++ b/contracts/src/deploy-config/qanetl1.ts @@ -31,6 +31,11 @@ const config = { submitterChallengeDeposit: 1, // 1 ether submitterMinimumStake: 1, // 1 ether submitterRewardPercentage: 50, + // L1Sequencer initialize config + // First single-mode sequencer, active from L2 block 0. Leave empty to skip + // setFirstSequencer; can also be overridden by the firstSequencerAddress env var. + firstSequencerAddress: '', + // register sequencers l2SequencerAddresses: [ "0x25d36d94b64165f72a817ae0ff9bad2ce6914c70", diff --git a/contracts/tasks/deploy.ts b/contracts/tasks/deploy.ts index 6b6d3ddaf..3d784ad7d 100644 --- a/contracts/tasks/deploy.ts +++ b/contracts/tasks/deploy.ts @@ -22,6 +22,7 @@ import { ContractInit, SubmitterRegister, SequencerInit, + SetFirstSequencer, } from '../deploy/index' import { ethers } from "ethers"; @@ -140,7 +141,14 @@ task("initialize") return } - + // Must come after AdminTransfer: while the deployer is still the proxy + // admin, calls that fall through to the implementation revert. + console.log('\n---------------------------------- Set first sequencer ----------------------------------') + err = await SetFirstSequencer(hre, storagePath, deployer, config) + if (err != '') { + console.log('SetFirstSequencer failed, err: ', err) + return + } console.log('\n---------------------------------- Contract Init ----------------------------------') err = await ContractInit(hre, storagePath, deployer, config) diff --git a/go-ethereum b/go-ethereum index 5744b8f66..4012f174b 160000 --- a/go-ethereum +++ b/go-ethereum @@ -1 +1 @@ -Subproject commit 5744b8f66ec4070a535ed12200db48028df29d93 +Subproject commit 4012f174b967153a31926f370d77af389b2968c8 diff --git a/node/cmd/node/main.go b/node/cmd/node/main.go index bc20875df..235fedc2d 100644 --- a/node/cmd/node/main.go +++ b/node/cmd/node/main.go @@ -140,6 +140,14 @@ func L2NodeMain(ctx *cli.Context) error { return fmt.Errorf("failed to init L1 sequencer components: %w", err) } + // Nodes that can produce blocks must fail fast on a stuck geth. Set before the + // executor and derivation clients are built, since both read this at construction. + if signer != nil { + types.GethRetryMaxElapsedTime = types.GethRetryMaxElapsedTimeSequencer + } + nodeConfig.Logger.Info("geth retry window configured", + "maxElapsed", types.GethRetryMaxElapsedTime, "isSequencer", signer != nil) + // ========== Executor + sequencer / mock ========== tmCfg, err := sequencer.LoadTmConfig(ctx, home) if err != nil { diff --git a/node/core/executor.go b/node/core/executor.go index 0c581973e..a271906a9 100644 --- a/node/core/executor.go +++ b/node/core/executor.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math/big" + "strings" syncos "sync" "github.com/morph-l2/go-ethereum/accounts/abi/bind" @@ -475,7 +476,8 @@ func (e *Executor) ApplyBlockV2(block *l2node.BlockV2) (applied bool, err error) } } - if _, err := e.l2Client.NewL2BlockV2(context.Background(), execBlock); err != nil { + if _, err := e.l2Client.NewL2BlockV2(context.Background(), execBlock); err != nil && + !strings.Contains(err.Error(), types.BlockAlreadyKnownError) { e.logger.Error("failed to apply block v2", "number", execBlock.Number, "hash", execBlock.Hash.Hex(), diff --git a/node/go.mod b/node/go.mod index 58d149343..c0e8107b4 100644 --- a/node/go.mod +++ b/node/go.mod @@ -2,7 +2,7 @@ module morph-l2/node go 1.24.0 -replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.9 +replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.10-0.20260916055518-560fb469b16d require ( github.com/cenkalti/backoff/v4 v4.1.3 @@ -13,7 +13,7 @@ require ( github.com/hashicorp/raft v1.7.3 github.com/hashicorp/raft-boltdb/v2 v2.3.1 github.com/mdlayher/vsock v1.2.1 - github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4 + github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.17.0 github.com/spf13/viper v1.13.0 diff --git a/node/go.sum b/node/go.sum index 10fe62c49..a607b5d06 100644 --- a/node/go.sum +++ b/node/go.sum @@ -414,10 +414,10 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4 h1:HCXegM088SPDt3wITDEBM+Osx06I0CDLv2jmsaPA/LU= -github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= -github.com/morph-l2/tendermint v0.3.9 h1:0jiFtMpVmVImCAFrMFTIvTX7jfJwzaQFyLQoLccP5A8= -github.com/morph-l2/tendermint v0.3.9/go.mod h1:qpiwqfcCB89dBYfqVJOc/HjGxDp3OdDlthgttJJYyRs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 h1:lgAgQYT34XY/l7+auFRHdqt7HsnCcAMNBGxd5DKiiHw= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/tendermint v0.3.10-0.20260916055518-560fb469b16d h1:7vfVAK5eeHgdLxXG/1hMJLvgvE5QiYIfRA2nNYj6jPU= +github.com/morph-l2/tendermint v0.3.10-0.20260916055518-560fb469b16d/go.mod h1:qpiwqfcCB89dBYfqVJOc/HjGxDp3OdDlthgttJJYyRs= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= diff --git a/node/hakeeper/config.go b/node/hakeeper/config.go index f81e8d62b..20e9b4779 100644 --- a/node/hakeeper/config.go +++ b/node/hakeeper/config.go @@ -67,8 +67,8 @@ func DefaultConfig() *Config { ListenPort: 9400, }, Snapshot: SnapshotConfig{ - Interval: 120 * time.Second, - Threshold: 8192, + Interval: 30 * time.Second, + Threshold: 200, TrailingLogs: 1200, }, Timeout: TimeoutConfig{ diff --git a/node/types/retryable_client.go b/node/types/retryable_client.go index ffe9f7f3f..7ec3bb487 100644 --- a/node/types/retryable_client.go +++ b/node/types/retryable_client.go @@ -36,13 +36,24 @@ const ( // always fail and only delay error surfacing to the consensus layer. BlockHashMismatchError = "block hash mismatch" InvalidNextL1MsgIndexError = "invalid block.NextL1MsgIndex" + BlockAlreadyKnownError = "block already known" // Geth connection retry settings - GethRetryAttempts = 60 // max retry attempts - GethRetryInterval = 5 * time.Second // interval between retries - GethRetryMaxElapsedTime = 30 * time.Minute + GethRetryAttempts = 60 // max retry attempts + GethRetryInterval = 5 * time.Second // interval between retries ) +// GethRetryMaxElapsedTime bounds how long a geth call is retried before giving up. +// Sequencer nodes lower it to GethRetryMaxElapsedTimeSequencer at boot (see +// cmd/node/main.go) so a stuck geth surfaces to consensus quickly instead of +// stalling block production; fullnodes keep the long window because they only +// need to catch up eventually. +var GethRetryMaxElapsedTime = 30 * time.Minute + +// GethRetryMaxElapsedTimeSequencer is the window used by nodes that can produce +// blocks (i.e. a signer is configured). +const GethRetryMaxElapsedTimeSequencer = 1 * time.Minute + type RetryableClient struct { authClient *authclient.Client ethClient *ethclient.Client @@ -296,7 +307,8 @@ func retryableError(err error) bool { !strings.Contains(msg, WrongBlockNumberError) && !strings.Contains(msg, ParentNotFoundError) && !strings.Contains(msg, BlockHashMismatchError) && - !strings.Contains(msg, InvalidNextL1MsgIndexError) + !strings.Contains(msg, InvalidNextL1MsgIndexError) && + !strings.Contains(msg, BlockAlreadyKnownError) } // ============================================================================ diff --git a/ops/README.md b/ops/README.md index b286c19eb..0994c9e0b 100644 --- a/ops/README.md +++ b/ops/README.md @@ -84,10 +84,64 @@ looking anywhere else. | L1 | `9545` | `9546` | beacon `4000` | | `morph-el-0` | `8545` | `8546` | `node-0` → `26657` | | `morph-el-1` | `8645` | `8646` | — | -| `ha-geth-0/1/2` | `9145` / `9245` / `9345` | `9146` / `9246` / `9346` | `27657` / `27757` / `27857` | +| `ha-el-0/1/2` | `9145` / `9245` / `9345` | `9146` / `9246` / `9346` | `27657` / `27757` / `27857` | `ha-node` admin API: `9501` / `9601` / `9701`. +The execution-layer services are named `el` rather than `geth` because either +client can back them: `docker-compose-cluster.yml` defines `ha-el-*` as geth, +and `docker-compose-cluster-reth.yml` overrides them to reth. That override only +works because the cluster file is layered *before* the reth files — later `-f` +files win, so the reverse order silently leaves the cluster on geth. + +The `ha-el-*` reth overrides need their own file rather than a section of +`docker-compose-reth.yml`: compose starts every service a later `-f` file +introduces, whether or not an earlier file declared it, so overrides living in +the shared reth file also came up in the non-cluster devnet — without the +`/genesis.json` and `/jwt-secret.txt` mounts that only the cluster file +provides, which made them exit with `Invalid value '/genesis.json' for --chain`. + +## Execution-layer peering + +Discovery is off everywhere (`--nodiscover` / `--disable-discovery`), so peers +are configured explicitly and the topology is fixed: + +- geth reads `static-nodes.json` (mounted into `morph-el-1`) and, for the + cluster, `static-nodes-cluster.json` (mounted into all three `ha-el-*`). +- reth ignores those files and takes `--trusted-peers` on the command line. + +Both clients derive their identity from the same `nodekey*` / `ha-nodekey*` +files, so a node's enode is the same whichever client is running. reth needs +`--p2p-secret-key` for this; without it, it invents a random identity per +datadir and no peer list can be written in advance. The key files must not have +a trailing newline — reth rejects those with `malformed or out-of-range secret +key`, while geth tolerates them either way. + +`morph-el-0` and `morph-el-1` only know each other. The `ha-el-*` nodes dial +both of those plus each other, which keeps `ha-el-*` names out of the +non-cluster setup, where they would not resolve. + +## Consensus-layer peering + +`setup_nodes.py` writes `persistent_peers` for every tendermint home, deriving +each node ID from the `node_key.json` that ends up installed — which is why the +key files are copied before the peer list is built. Overwriting a +`node_key.json` changes the node's identity, so a hardcoded ID silently goes +stale. + +The list contains only the nodes that actually run tendermint: `node-0` and the +three `ha-node-*`. `node-1` runs with +`MORPH_NODE_DERIVATION_VERIFY_MODE=layer1` and never starts tendermint, and +`node-2` has no compose service at all; listing either just produces endless +reconnect and DNS failures. + +The `ha-node-*` reaching each other matters: the sequencer hand-over waits for +the block pool to report caught up, and a pool whose only peers are unreachable +never does. That is the silent-stall-at-height-0 failure described above. + +RPC is served on `0.0.0.0:26657` inside each container so the published ports in +the table above are actually reachable from the host. + Each geth serves metrics on `6060` inside its container, with `--metrics.expensive` on. Without that flag every counter behind `metrics.EnabledExpensive` stays zero, which blanks `chain/account/*` and diff --git a/ops/devnet-morph/devnet/__init__.py b/ops/devnet-morph/devnet/__init__.py index 530e52c59..ef4d056ad 100644 --- a/ops/devnet-morph/devnet/__init__.py +++ b/ops/devnet-morph/devnet/__init__.py @@ -66,10 +66,20 @@ def compose_file_args(execution_client, cluster=False): """Return docker-compose -f flags for the chosen L2 execution client.""" args = ['-f', 'docker-compose-devnet.yml'] - if execution_client == 'reth': - args.extend(['-f', 'docker-compose-reth.yml']) + # The cluster topology comes before the execution-client override so that + # the reth files get the last word on the ha-el-* image, entrypoint and + # command. Later -f files win, so reversing these two leaves the cluster + # nodes on geth even when reth was requested. if cluster: args.extend(['-f', 'docker-compose-cluster.yml']) + if execution_client == 'reth': + args.extend(['-f', 'docker-compose-reth.yml']) + # The ha-el-* reth overrides sit in their own file: compose starts any + # service a later -f file introduces, so parking them in + # docker-compose-reth.yml started them in the non-cluster devnet too, + # without the mounts that only the cluster file supplies. + if cluster: + args.extend(['-f', 'docker-compose-cluster-reth.yml']) return args diff --git a/ops/devnet-morph/devnet/setup_nodes.py b/ops/devnet-morph/devnet/setup_nodes.py index d2f2f4a54..827394f48 100644 --- a/ops/devnet-morph/devnet/setup_nodes.py +++ b/ops/devnet-morph/devnet/setup_nodes.py @@ -1,9 +1,109 @@ +import base64 +import hashlib +import json import os import shutil import subprocess import sys import re +# Directories that hold a node's tendermint home, in the order their config +# files are processed. The first entry is the genesis validator. +NODE_DIRS = ("node0", "node1", "node2", "ha-node0", "ha-node1", "ha-node2") + +# Directory name -> compose service hostname, used to build peer addresses. +SERVICE_HOSTNAMES = { + "node0": "node-0", + "node1": "node-1", + "node2": "node-2", + "ha-node0": "ha-node-0", + "ha-node1": "ha-node-1", + "ha-node2": "ha-node-2", +} + +# Nodes that actually run tendermint, and so can be dialed as peers. node-1 +# runs with MORPH_NODE_DERIVATION_VERIFY_MODE=layer1 and never starts +# tendermint; node-2 has no compose service at all. Listing either as a peer +# only produces endless reconnect and DNS lookup failures. +TENDERMINT_PEERS = ("node0", "ha-node0", "ha-node1", "ha-node2") + + +def tendermint_node_id(node_key_path): + """Derive a tendermint node ID from a node_key.json file. + + The ID is the hex encoding of the first 20 bytes of sha256(pubkey). An + ed25519 private key is stored as seed(32) || pubkey(32), so the public half + is the tail of the decoded value. + + IDs must be derived from the key files that are actually in place, which is + why this runs after the key files have been copied: overwriting a + node_key.json changes the node's identity. + """ + with open(node_key_path) as f: + priv_key = json.load(f)["priv_key"]["value"] + pubkey = base64.b64decode(priv_key)[32:] + return hashlib.sha256(pubkey).hexdigest()[:40] + + +def copy_key_files(docker_dir, devnet_dir): + """Install the fixed node keys and the shared genesis into each node home. + + Only node0 gets a genesis validator key. The others must not have one: a + node holding the sole genesis validator key gets block sync disabled and + never hands over to the sequencer routines. + """ + print("Copying key files...") + + 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(dest_dir): + print(f"Error: Missing destination directory for {node}. Exiting.") + sys.exit(1) + + if os.path.isdir(source_dir): + shutil.copyfile(os.path.join(source_dir, "node_key.json"), os.path.join(dest_dir, "node_key.json")) + + if node == "node0" and os.path.isdir(source_dir): + shutil.copyfile(os.path.join(source_dir, "priv_validator_key.json"), os.path.join(dest_dir, "priv_validator_key.json")) + else: + priv_validator_key = os.path.join(dest_dir, "priv_validator_key.json") + priv_validator_state = os.path.join(devnet_dir, node, "data", "priv_validator_state.json") + for validator_file in (priv_validator_key, priv_validator_state): + if os.path.exists(validator_file): + os.remove(validator_file) + + # Copy and rename genesis file + shutil.copyfile(os.path.join(docker_dir, "tendermint-devnet-genesis.json"), os.path.join(dest_dir, "genesis.json")) + + print(f"Files copied successfully for {node}.") + + print("All key files have been copied successfully.") + + +def build_persistent_peers(devnet_dir): + """Map each node directory to the peer list it should dial. + + Every tendermint-running node is given all the others, so the HA nodes + reach each other rather than only node-0. Without that the HA block pool + never reports caught up, the sequencer hand-over never runs, and the + cluster silently stalls at height 0. + """ + addresses = {} + for node in TENDERMINT_PEERS: + node_key = os.path.join(devnet_dir, node, "config", "node_key.json") + node_id = tendermint_node_id(node_key) + addresses[node] = f"{node_id}@{SERVICE_HOSTNAMES[node]}:26656" + + peers = {} + for node in NODE_DIRS: + peers[node] = ",".join( + address for peer, address in addresses.items() if peer != node + ) + return peers + + def setup_devnet_nodes(): """ Set up the devnet nodes, modify configuration files using toml library, and copy key files. @@ -33,14 +133,7 @@ def setup_devnet_nodes(): devnet_dir = os.path.join(docker_dir, ".devnet") if os.path.exists(devnet_dir): old_topology_paths = [os.path.join(devnet_dir, f"node{i}") for i in range(3, 6)] - expected_paths = [ - os.path.join(devnet_dir, "node0"), - os.path.join(devnet_dir, "node1"), - os.path.join(devnet_dir, "node2"), - os.path.join(devnet_dir, "ha-node0"), - os.path.join(devnet_dir, "ha-node1"), - os.path.join(devnet_dir, "ha-node2"), - ] + expected_paths = [os.path.join(devnet_dir, node) for node in NODE_DIRS] if any(os.path.exists(path) for path in old_topology_paths) or any( not os.path.exists(path) for path in expected_paths): print("Existing stale devnet detected. Regenerating single-sequencer config.") @@ -73,20 +166,17 @@ def setup_devnet_nodes(): if os.path.exists(generated_path): os.rename(generated_path, desired_path) + # Install the key files first: node IDs are derived from node_key.json, so + # the peer addresses below must be computed from the final keys. + copy_key_files(docker_dir, devnet_dir) + + persistent_peers = build_persistent_peers(devnet_dir) + # Modify config.toml files. print("Modifying config.toml files...") - config_files = [ - os.path.join(devnet_dir, node, "config", "config.toml") - for node in ("node0", "node1", "node2", "ha-node0", "ha-node1", "ha-node2") - ] - - persistent_peers_value = ( - "93e27ea2306e158a8146d5f44caaab97496797d2@node-0:26656," - "7f78b7d7a7e6bad4faf68d5731d437f4288d96d0@node-1:26656," - "06c699be2f9aeb9f7ec79f508a95ff80576deb12@node-2:26656" - ) - for i, config_file in enumerate(config_files): + for i, node in enumerate(NODE_DIRS): + config_file = os.path.join(devnet_dir, node, "config", "config.toml") if not os.path.isfile(config_file): print(f"Error: {config_file} not found. Exiting.") sys.exit(1) @@ -102,7 +192,11 @@ def setup_devnet_nodes(): content = content.replace('send_rate = 5120000', 'send_rate = 52428800') content = content.replace('recv_rate = 5120000', 'recv_rate = 102428800') content = content.replace('block_sync = false', 'block_sync = true') - content = re.sub(r'persistent_peers\s*=\s*".*?"', f'persistent_peers = "{persistent_peers_value}"', content) + content = re.sub(r'persistent_peers\s*=\s*".*?"', f'persistent_peers = "{persistent_peers[node]}"', content) + + # Serve the RPC on all interfaces so the published container ports + # (26657, 27657, 27757, 27857) are reachable from the host. + content = content.replace('laddr = "tcp://127.0.0.1:26657"', 'laddr = "tcp://0.0.0.0:26657"') # Modify pex for the sequencer validator node. if i == 0: @@ -115,35 +209,4 @@ def setup_devnet_nodes(): f.write(content) print("All config.toml files have been updated successfully.") - - # Copy key files to devnet node directories - print("Copying key files...") - node_dirs = ["node0", "node1", "node2", "ha-node0", "ha-node1", "ha-node2"] - - 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(dest_dir): - print(f"Error: Missing destination directory for {node}. Exiting.") - sys.exit(1) - - if os.path.isdir(source_dir): - shutil.copyfile(os.path.join(source_dir, "node_key.json"), os.path.join(dest_dir, "node_key.json")) - - if node == "node0" and os.path.isdir(source_dir): - shutil.copyfile(os.path.join(source_dir, "priv_validator_key.json"), os.path.join(dest_dir, "priv_validator_key.json")) - else: - priv_validator_key = os.path.join(dest_dir, "priv_validator_key.json") - priv_validator_state = os.path.join(devnet_dir, node, "data", "priv_validator_state.json") - for validator_file in (priv_validator_key, priv_validator_state): - if os.path.exists(validator_file): - os.remove(validator_file) - - # Copy and rename genesis file - shutil.copyfile(os.path.join(docker_dir, "tendermint-devnet-genesis.json"), os.path.join(dest_dir, "genesis.json")) - - print(f"Files copied successfully for {node}.") - - print("All key files have been copied successfully.") print("Devnet nodes setup completed successfully.") diff --git a/ops/devnet-morph/tests/test_devnet_config.py b/ops/devnet-morph/tests/test_devnet_config.py index 7881fe7b9..64f9d6cee 100644 --- a/ops/devnet-morph/tests/test_devnet_config.py +++ b/ops/devnet-morph/tests/test_devnet_config.py @@ -17,6 +17,10 @@ def test_root_dockerignore_excludes_generated_build_outputs(self): "gas-oracle/app/target/", "node/build/", "ops/docker/.devnet/", + # Claude Code keeps its worktrees here; on a machine that has used + # one, this is the single largest directory in the repo and every + # image build would ship it as build context. + ".claude/", ): self.assertIn(generated_path, dockerignore) @@ -43,7 +47,9 @@ def test_devnet_clean_removes_compose_project_volumes(self): makefile = (REPO_ROOT / "Makefile").read_text() self.assertIn( - "DEVNET_CLEAN_COMPOSE_FILES := -f docker-compose-devnet.yml -f docker-compose-reth.yml -f docker-compose-cluster.yml", + "DEVNET_CLEAN_COMPOSE_FILES := -f docker-compose-devnet.yml " + "-f docker-compose-cluster.yml -f docker-compose-reth.yml " + "-f docker-compose-cluster-reth.yml", makefile, ) self.assertIn("docker compose $(DEVNET_CLEAN_COMPOSE_FILES) down --volumes --remove-orphans", makefile) @@ -118,11 +124,120 @@ def test_cluster_compose_defines_ha_services(self): self.assertTrue(cluster_compose.exists()) compose = cluster_compose.read_text() - for service in ("ha-geth-0:", "ha-geth-1:", "ha-geth-2:", "ha-node-0:", "ha-node-1:", "ha-node-2:"): + for service in ("ha-el-0:", "ha-el-1:", "ha-el-2:", "ha-node-0:", "ha-node-1:", "ha-node-2:"): self.assertIn(service, compose) + self.assertNotIn("ha-geth", compose) self.assertIn("MORPH_NODE_HA_ENABLED=true", compose) self.assertIn("MORPH_NODE_HA_BOOTSTRAP=true", compose) self.assertIn("MORPH_NODE_HA_JOIN=ha-node-0:9401", compose) + for index in (0, 1, 2): + self.assertIn(f"MORPH_NODE_L2_ETH_RPC=http://ha-el-{index}:8545", compose) + self.assertIn(f"MORPH_NODE_L2_ENGINE_RPC=http://ha-el-{index}:8551", compose) + + def test_cluster_geth_nodes_peer_with_each_other(self): + """The shared static-nodes.json lists only morph-el-*, so the cluster + needs its own file or the ha-el nodes never dial each other.""" + cluster_static_nodes = (DOCKER_DIR / "static-nodes-cluster.json").read_text() + + for host in ("ha-el-0", "ha-el-1", "ha-el-2", "morph-el-0", "morph-el-1"): + self.assertIn(f"@{host}:30303", cluster_static_nodes) + + compose = (DOCKER_DIR / "docker-compose-cluster.yml").read_text() + # geth only reads the fixed filename, so the mount has to be renamed on + # the way in. + self.assertIn( + '"${PWD}/static-nodes-cluster.json:/db/geth/static-nodes.json"', + compose, + ) + + def test_reth_compose_configures_deterministic_peering(self): + compose = (DOCKER_DIR / "docker-compose-reth.yml").read_text() + + # reth cannot read geth's static-nodes.json, so peers are passed as + # flags, and a fixed key file is what makes the enodes predictable. + for service, key in ( + ("morph-el-0", "nodekey0"), + ("morph-el-1", "nodekey1"), + ): + self.assertIn(f"{service}:", compose) + self.assertIn(f'"${{PWD}}/{key}:/p2p-secret.key"', compose) + self.assertEqual(compose.count("--p2p-secret-key=/p2p-secret.key"), 2) + self.assertEqual(compose.count("--trusted-peers="), 2) + + def test_reth_compose_leaves_cluster_services_to_the_cluster_reth_file(self): + """compose starts every service a later -f file introduces, even one no + earlier file declared. ha-el-* overrides parked in the shared reth file + therefore came up in the non-cluster devnet as well, missing the + /genesis.json and /jwt-secret.txt mounts that only + docker-compose-cluster.yml supplies, and exited with + "Invalid value '/genesis.json' for --chain".""" + compose = (DOCKER_DIR / "docker-compose-reth.yml").read_text() + + for service in ("ha-el-0", "ha-el-1", "ha-el-2"): + self.assertNotIn(f"{service}:", compose) + for key in ("ha-nodekey0", "ha-nodekey1", "ha-nodekey2"): + self.assertNotIn(key, compose) + + def test_cluster_reth_compose_overrides_the_ha_execution_clients(self): + cluster_reth = DOCKER_DIR / "docker-compose-cluster-reth.yml" + + self.assertTrue(cluster_reth.exists()) + compose = cluster_reth.read_text() + + self.assertIn("${MORPH_RETH_IMAGE:-ghcr.io/morph-l2/morph-reth:latest}", compose) + self.assertIn("${MORPH_RETH_ENTRYPOINT:-/usr/local/bin/morph-reth}", compose) + for service, key in ( + ("ha-el-0", "ha-nodekey0"), + ("ha-el-1", "ha-nodekey1"), + ("ha-el-2", "ha-nodekey2"), + ): + self.assertIn(f"{service}:", compose) + self.assertIn(f'"${{PWD}}/{key}:/p2p-secret.key"', compose) + self.assertEqual(compose.count("--p2p-secret-key=/p2p-secret.key"), 3) + self.assertEqual(compose.count("--trusted-peers="), 3) + # Nothing here may redefine the plain devnet's execution clients, or + # they would be reconfigured only in cluster mode. + for service in ("morph-el-0:", "morph-el-1:"): + self.assertNotIn(f" {service}", compose) + + def test_execution_client_keys_have_no_trailing_newline(self): + """reth rejects a key file with a trailing newline ("malformed or + out-of-range secret key"); geth accepts it either way.""" + for key in ("nodekey0", "nodekey1", "nodekey2", + "ha-nodekey0", "ha-nodekey1", "ha-nodekey2"): + contents = (DOCKER_DIR / key).read_bytes() + self.assertEqual(len(contents), 64, f"{key} should be 64 hex characters") + self.assertFalse(contents.endswith(b"\n"), f"{key} must not end with a newline") + + def test_tendermint_peers_are_derived_from_installed_node_keys(self): + sys.path.insert(0, str(DEVNET_PACKAGE)) + try: + setup_nodes = importlib.import_module("devnet.setup_nodes") + importlib.reload(setup_nodes) + finally: + sys.path.remove(str(DEVNET_PACKAGE)) + + # Anchored on the ID that used to be hardcoded in persistent_peers. + self.assertEqual( + setup_nodes.tendermint_node_id(DOCKER_DIR / "node0" / "node_key.json"), + "93e27ea2306e158a8146d5f44caaab97496797d2", + ) + + # node-1 never starts tendermint and node-2 has no compose service, so + # neither may appear as a peer. + self.assertEqual( + setup_nodes.TENDERMINT_PEERS, + ("node0", "ha-node0", "ha-node1", "ha-node2"), + ) + + source = (DEVNET_PACKAGE / "devnet" / "setup_nodes.py").read_text() + # Node IDs come from the keys that end up installed, so the copy step + # must run before the peer list is built. + self.assertLess( + source.index("copy_key_files(docker_dir, devnet_dir)"), + source.index("persistent_peers = build_persistent_peers(devnet_dir)"), + ) + self.assertIn('laddr = "tcp://0.0.0.0:26657"', source) def test_compose_file_args_can_enable_cluster_mode(self): sys.path.insert(0, str(DEVNET_PACKAGE)) @@ -133,6 +248,8 @@ def test_compose_file_args_can_enable_cluster_mode(self): sys.path.remove(str(DEVNET_PACKAGE)) self.assertEqual(devnet.compose_file_args("geth"), ["-f", "docker-compose-devnet.yml"]) + # The ha-el-* reth overrides must not reach the non-cluster devnet: + # compose would start those services without the cluster's mounts. self.assertEqual( devnet.compose_file_args("reth"), ["-f", "docker-compose-devnet.yml", "-f", "docker-compose-reth.yml"], @@ -141,15 +258,20 @@ def test_compose_file_args_can_enable_cluster_mode(self): devnet.compose_file_args("geth", cluster=True), ["-f", "docker-compose-devnet.yml", "-f", "docker-compose-cluster.yml"], ) + # The cluster file must come first so the reth override wins on the + # ha-el-* image, entrypoint and command; reversed, the cluster silently + # stays on geth. self.assertEqual( devnet.compose_file_args("reth", cluster=True), [ "-f", "docker-compose-devnet.yml", "-f", + "docker-compose-cluster.yml", + "-f", "docker-compose-reth.yml", "-f", - "docker-compose-cluster.yml", + "docker-compose-cluster-reth.yml", ], ) diff --git a/ops/docker/docker-compose-cluster-reth.yml b/ops/docker/docker-compose-cluster-reth.yml new file mode 100644 index 000000000..354058ef7 --- /dev/null +++ b/ops/docker/docker-compose-cluster-reth.yml @@ -0,0 +1,97 @@ +# reth overrides for the HA cluster's execution clients, layered on top of +# docker-compose-cluster.yml, which defines ha-el-* as geth. +# +# These live in their own file rather than in docker-compose-reth.yml because +# compose creates any service a later -f file introduces, whether or not an +# earlier file declared it. Keeping the ha-el-* overrides in the reth file made +# them appear in the non-cluster devnet too, where they started without the +# /genesis.json and /jwt-secret.txt mounts that only the cluster file provides +# and died with "Invalid value '/genesis.json' for --chain". So this file is +# passed only when both --cluster and reth are asked for. +# +# See docker-compose-reth.yml for why discovery is off, why every node is handed +# a fixed --p2p-secret-key, and how the enodes below were derived. Peering here +# mirrors static-nodes-cluster.json: each ha-el-* dials the other two plus both +# morph-el-*. +# +# The command list is spelled out per service because --trusted-peers takes one +# comma separated value and YAML cannot concatenate an anchor with extra items. + +x-reth-service: &reth-service + image: ${MORPH_RETH_IMAGE:-ghcr.io/morph-l2/morph-reth:latest} + user: "0:0" + entrypoint: + - ${MORPH_RETH_ENTRYPOINT:-/usr/local/bin/morph-reth} + +services: + ha-el-0: + <<: *reth-service + command: + - node + - --chain=/genesis.json + - --datadir=/db + - --http + - --http.addr=0.0.0.0 + - --http.port=8545 + - --http.api=web3,debug,eth,txpool,net,trace,admin,reth + - --ws + - --ws.addr=0.0.0.0 + - --ws.port=8546 + - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth + - --authrpc.addr=0.0.0.0 + - --authrpc.port=8551 + - --authrpc.jwtsecret=/jwt-secret.txt + - --nat=none + - --disable-discovery + - --p2p-secret-key=/p2p-secret.key + - --trusted-peers=enode://73edbabab8727eba67dd5c2964c610decf28ec73e72d5bc395cc5be3c61ebd76cf72ada700b10926c3d2f9e86d64fafb92c6d02deb48c16439040b7c37f86c2d@ha-el-1:30303,enode://4a1a481e53b57ea351a08d945443cdff9cb7911e9121e8240efa99e4214582c0e383a30706349ce1be88b81d0f6344c401823a6624569e20d26880774279c41c@ha-el-2:30303,enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303,enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303 + volumes: + - "${PWD}/ha-nodekey0:/p2p-secret.key" + + ha-el-1: + <<: *reth-service + command: + - node + - --chain=/genesis.json + - --datadir=/db + - --http + - --http.addr=0.0.0.0 + - --http.port=8545 + - --http.api=web3,debug,eth,txpool,net,trace,admin,reth + - --ws + - --ws.addr=0.0.0.0 + - --ws.port=8546 + - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth + - --authrpc.addr=0.0.0.0 + - --authrpc.port=8551 + - --authrpc.jwtsecret=/jwt-secret.txt + - --nat=none + - --disable-discovery + - --p2p-secret-key=/p2p-secret.key + - --trusted-peers=enode://343bbe0507fd72946e8d57d5b42630dc905aed7e512945eb2dfd99a0d27bb1c7e84ba621c3408242d713a972a581374ea83a4d134651c373c4f2e8425ba99857@ha-el-0:30303,enode://4a1a481e53b57ea351a08d945443cdff9cb7911e9121e8240efa99e4214582c0e383a30706349ce1be88b81d0f6344c401823a6624569e20d26880774279c41c@ha-el-2:30303,enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303,enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303 + volumes: + - "${PWD}/ha-nodekey1:/p2p-secret.key" + + ha-el-2: + <<: *reth-service + command: + - node + - --chain=/genesis.json + - --datadir=/db + - --http + - --http.addr=0.0.0.0 + - --http.port=8545 + - --http.api=web3,debug,eth,txpool,net,trace,admin,reth + - --ws + - --ws.addr=0.0.0.0 + - --ws.port=8546 + - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth + - --authrpc.addr=0.0.0.0 + - --authrpc.port=8551 + - --authrpc.jwtsecret=/jwt-secret.txt + - --nat=none + - --disable-discovery + - --p2p-secret-key=/p2p-secret.key + - --trusted-peers=enode://343bbe0507fd72946e8d57d5b42630dc905aed7e512945eb2dfd99a0d27bb1c7e84ba621c3408242d713a972a581374ea83a4d134651c373c4f2e8425ba99857@ha-el-0:30303,enode://73edbabab8727eba67dd5c2964c610decf28ec73e72d5bc395cc5be3c61ebd76cf72ada700b10926c3d2f9e86d64fafb92c6d02deb48c16439040b7c37f86c2d@ha-el-1:30303,enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303,enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303 + volumes: + - "${PWD}/ha-nodekey2:/p2p-secret.key" diff --git a/ops/docker/docker-compose-cluster.yml b/ops/docker/docker-compose-cluster.yml index f5c75e6fb..c4fd14fe4 100644 --- a/ops/docker/docker-compose-cluster.yml +++ b/ops/docker/docker-compose-cluster.yml @@ -1,6 +1,6 @@ services: - ha-geth-0: - container_name: ha-geth-0 + ha-el-0: + container_name: ha-el-0 depends_on: morph-el-0: condition: service_started @@ -17,15 +17,15 @@ services: - "${PWD}/jwt-secret.txt:/jwt-secret.txt" - "${PWD}/../l2-genesis/.devnet/genesis-l2.json:/genesis.json" - "${PWD}/ha-nodekey0:/db/geth/nodekey" - - "${PWD}/static-nodes.json:/db/geth/static-nodes.json" + - "${PWD}/static-nodes-cluster.json:/db/geth/static-nodes.json" environment: - RUST_LOG=${RUST_LOG} entrypoint: - "/bin/bash" - "/entrypoint.sh" - ha-geth-1: - container_name: ha-geth-1 + ha-el-1: + container_name: ha-el-1 depends_on: morph-el-0: condition: service_started @@ -42,15 +42,15 @@ services: - "${PWD}/jwt-secret.txt:/jwt-secret.txt" - "${PWD}/../l2-genesis/.devnet/genesis-l2.json:/genesis.json" - "${PWD}/ha-nodekey1:/db/geth/nodekey" - - "${PWD}/static-nodes.json:/db/geth/static-nodes.json" + - "${PWD}/static-nodes-cluster.json:/db/geth/static-nodes.json" environment: - RUST_LOG=${RUST_LOG} entrypoint: - "/bin/bash" - "/entrypoint.sh" - ha-geth-2: - container_name: ha-geth-2 + ha-el-2: + container_name: ha-el-2 depends_on: morph-el-0: condition: service_started @@ -67,7 +67,7 @@ services: - "${PWD}/jwt-secret.txt:/jwt-secret.txt" - "${PWD}/../l2-genesis/.devnet/genesis-l2.json:/genesis.json" - "${PWD}/ha-nodekey2:/db/geth/nodekey" - - "${PWD}/static-nodes.json:/db/geth/static-nodes.json" + - "${PWD}/static-nodes-cluster.json:/db/geth/static-nodes.json" environment: - RUST_LOG=${RUST_LOG} entrypoint: @@ -77,7 +77,7 @@ services: ha-node-0: container_name: ha-node-0 depends_on: - ha-geth-0: + ha-el-0: condition: service_started node-0: condition: service_started @@ -92,8 +92,8 @@ services: environment: - MORPH_NODE_SEQUENCER_PRIVATE_KEY=${SEQUENCER_PRIVATE_KEY} - MORPH_NODE_SEQUENCER_UPGRADE_TIME=${SEQUENCER_UPGRADE_TIME:-0} - - MORPH_NODE_L2_ETH_RPC=http://ha-geth-0:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://ha-geth-0:8551 + - MORPH_NODE_L2_ETH_RPC=http://ha-el-0:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://ha-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} @@ -119,7 +119,7 @@ services: depends_on: ha-node-0: condition: service_started - ha-geth-1: + ha-el-1: condition: service_started image: morph-node:latest restart: unless-stopped @@ -132,8 +132,8 @@ services: environment: - MORPH_NODE_SEQUENCER_PRIVATE_KEY=${SEQUENCER_PRIVATE_KEY} - MORPH_NODE_SEQUENCER_UPGRADE_TIME=${SEQUENCER_UPGRADE_TIME:-0} - - MORPH_NODE_L2_ETH_RPC=http://ha-geth-1:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://ha-geth-1:8551 + - MORPH_NODE_L2_ETH_RPC=http://ha-el-1:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://ha-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} @@ -159,7 +159,7 @@ services: depends_on: ha-node-0: condition: service_started - ha-geth-2: + ha-el-2: condition: service_started image: morph-node:latest restart: unless-stopped @@ -172,8 +172,8 @@ services: environment: - MORPH_NODE_SEQUENCER_PRIVATE_KEY=${SEQUENCER_PRIVATE_KEY} - MORPH_NODE_SEQUENCER_UPGRADE_TIME=${SEQUENCER_UPGRADE_TIME:-0} - - MORPH_NODE_L2_ETH_RPC=http://ha-geth-2:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://ha-geth-2:8551 + - MORPH_NODE_L2_ETH_RPC=http://ha-el-2:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://ha-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} diff --git a/ops/docker/docker-compose-reth.yml b/ops/docker/docker-compose-reth.yml index 6d18823f4..fbd83cb20 100644 --- a/ops/docker/docker-compose-reth.yml +++ b/ops/docker/docker-compose-reth.yml @@ -1,32 +1,74 @@ -x-reth-command: &reth-command - - node - - --chain=/genesis.json - - --datadir=/db - - --http - - --http.addr=0.0.0.0 - - --http.port=8545 - - --http.api=web3,debug,eth,txpool,net,trace,admin,reth - - --ws - - --ws.addr=0.0.0.0 - - --ws.port=8546 - - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth - - --authrpc.addr=0.0.0.0 - - --authrpc.port=8551 - - --authrpc.jwtsecret=/jwt-secret.txt - - --nat=none - - --disable-discovery +# reth does not read geth's static-nodes.json, so each execution client is +# handed its peers on the command line. Discovery is disabled, so --trusted-peers +# is the only thing wiring these nodes together. +# +# Every node is pointed at the same nodekey file geth mounts, via +# --p2p-secret-key, which makes its peer ID deterministic. Keep the two in sync: +# without the key file reth generates a fresh random identity for every new +# datadir, and no peer list could be written ahead of time. The enode public +# keys below are derived from those same files. +# +# Peering mirrors the geth static-nodes layout: morph-el-0 and morph-el-1 know +# only each other. The ha-el-* overrides live in docker-compose-cluster-reth.yml +# instead of here, because compose creates every service a later -f file names, +# so overrides parked in this file would also start in the non-cluster devnet, +# where the cluster's mounts are absent and those hostnames do not resolve. +# +# The command list is spelled out per service because --trusted-peers takes one +# comma separated value and YAML cannot concatenate an anchor with extra items. x-reth-service: &reth-service image: ${MORPH_RETH_IMAGE:-ghcr.io/morph-l2/morph-reth:latest} user: "0:0" entrypoint: - ${MORPH_RETH_ENTRYPOINT:-/usr/local/bin/morph-reth} - command: *reth-command services: morph-el-0: <<: *reth-service build: !reset null + command: + - node + - --chain=/genesis.json + - --datadir=/db + - --http + - --http.addr=0.0.0.0 + - --http.port=8545 + - --http.api=web3,debug,eth,txpool,net,trace,admin,reth + - --ws + - --ws.addr=0.0.0.0 + - --ws.port=8546 + - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth + - --authrpc.addr=0.0.0.0 + - --authrpc.port=8551 + - --authrpc.jwtsecret=/jwt-secret.txt + - --nat=none + - --disable-discovery + - --p2p-secret-key=/p2p-secret.key + - --trusted-peers=enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303 + volumes: + - "${PWD}/nodekey0:/p2p-secret.key" morph-el-1: <<: *reth-service + command: + - node + - --chain=/genesis.json + - --datadir=/db + - --http + - --http.addr=0.0.0.0 + - --http.port=8545 + - --http.api=web3,debug,eth,txpool,net,trace,admin,reth + - --ws + - --ws.addr=0.0.0.0 + - --ws.port=8546 + - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth + - --authrpc.addr=0.0.0.0 + - --authrpc.port=8551 + - --authrpc.jwtsecret=/jwt-secret.txt + - --nat=none + - --disable-discovery + - --p2p-secret-key=/p2p-secret.key + - --trusted-peers=enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303 + volumes: + - "${PWD}/nodekey1:/p2p-secret.key" diff --git a/ops/docker/ha-nodekey0 b/ops/docker/ha-nodekey0 index b2235d998..3608fda3f 100644 --- a/ops/docker/ha-nodekey0 +++ b/ops/docker/ha-nodekey0 @@ -1 +1 @@ -543b2702353da96f244f35fb73a3495dd8a94b5628eafab0ca1bb85e9986d83c +543b2702353da96f244f35fb73a3495dd8a94b5628eafab0ca1bb85e9986d83c \ No newline at end of file diff --git a/ops/docker/ha-nodekey1 b/ops/docker/ha-nodekey1 index 75bbfcceb..986df5a90 100644 --- a/ops/docker/ha-nodekey1 +++ b/ops/docker/ha-nodekey1 @@ -1 +1 @@ -1ff441a6f43f26057e63fc92859d3fa36f49942e280c9a518ee11b42b56c1457 +1ff441a6f43f26057e63fc92859d3fa36f49942e280c9a518ee11b42b56c1457 \ No newline at end of file diff --git a/ops/docker/ha-nodekey2 b/ops/docker/ha-nodekey2 index 206c5a341..5bfcb857b 100644 --- a/ops/docker/ha-nodekey2 +++ b/ops/docker/ha-nodekey2 @@ -1 +1 @@ -5146627e8b60d6dca6666c4996083e5bd551fdbe56c6c4a7a53791274a574bd4 +5146627e8b60d6dca6666c4996083e5bd551fdbe56c6c4a7a53791274a574bd4 \ No newline at end of file diff --git a/ops/docker/nodekey2 b/ops/docker/nodekey2 index e8f14ad4a..90952052f 100644 --- a/ops/docker/nodekey2 +++ b/ops/docker/nodekey2 @@ -1 +1 @@ -b9b15801462f01ba61c1f6921c6214c0a4a87f010022086c1544b794fde6fc7e +b9b15801462f01ba61c1f6921c6214c0a4a87f010022086c1544b794fde6fc7e \ No newline at end of file diff --git a/ops/docker/static-nodes-cluster.json b/ops/docker/static-nodes-cluster.json new file mode 100644 index 000000000..34725bddb --- /dev/null +++ b/ops/docker/static-nodes-cluster.json @@ -0,0 +1,6 @@ +["enode://343bbe0507fd72946e8d57d5b42630dc905aed7e512945eb2dfd99a0d27bb1c7e84ba621c3408242d713a972a581374ea83a4d134651c373c4f2e8425ba99857@ha-el-0:30303", + "enode://73edbabab8727eba67dd5c2964c610decf28ec73e72d5bc395cc5be3c61ebd76cf72ada700b10926c3d2f9e86d64fafb92c6d02deb48c16439040b7c37f86c2d@ha-el-1:30303", + "enode://4a1a481e53b57ea351a08d945443cdff9cb7911e9121e8240efa99e4214582c0e383a30706349ce1be88b81d0f6344c401823a6624569e20d26880774279c41c@ha-el-2:30303", + "enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303", + "enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303" +] diff --git a/ops/l2-genesis/deploy-config/devnet-deploy-config.json b/ops/l2-genesis/deploy-config/devnet-deploy-config.json index 7b8200198..9a16a5369 100644 --- a/ops/l2-genesis/deploy-config/devnet-deploy-config.json +++ b/ops/l2-genesis/deploy-config/devnet-deploy-config.json @@ -11,6 +11,7 @@ "fundDevAccounts": true, "l1StartingBlockTag": "earliest", "maxTxPerBlock": 1000, + "maxTxPayloadBytesPerBlock": 737280, "gasPriceOracleOverhead": 2500, "gasPriceOracleScalar": 1000000000, "gasPriceOracleOwner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", diff --git a/ops/l2-genesis/deploy-config/qanet-deploy-config.json b/ops/l2-genesis/deploy-config/qanet-deploy-config.json index 530a442b7..f1948472b 100644 --- a/ops/l2-genesis/deploy-config/qanet-deploy-config.json +++ b/ops/l2-genesis/deploy-config/qanet-deploy-config.json @@ -11,7 +11,7 @@ "fundDevAccounts": false, "l1StartingBlockTag": "earliest", "maxTxPerBlock": 1000, - "maxTxPayloadBytesPerBlock": 122880, + "maxTxPayloadBytesPerBlock": 737280, "gasPriceOracleOverhead": 2500, "gasPriceOracleScalar": 1000000000, "gasPriceOracleOwner": "0x71612D2b36859fbad5865085be7c9dEB026bBCbC", diff --git a/ops/l2-genesis/deploy-config/testnet-deploy-config.json b/ops/l2-genesis/deploy-config/testnet-deploy-config.json index 27cd48bc3..92b808bf8 100644 --- a/ops/l2-genesis/deploy-config/testnet-deploy-config.json +++ b/ops/l2-genesis/deploy-config/testnet-deploy-config.json @@ -15,6 +15,7 @@ "l1StartingBlockTag": "earliest", "fundDevAccounts": false, "maxTxPerBlock": 100, + "maxTxPayloadBytesPerBlock": 737280, "stakingSequencerSize": 7, "stakingLockNumber": 3, diff --git a/ops/l2-genesis/go.mod b/ops/l2-genesis/go.mod index 92f652ca3..02a705444 100644 --- a/ops/l2-genesis/go.mod +++ b/ops/l2-genesis/go.mod @@ -2,11 +2,11 @@ module morph-l2/morph-deployer go 1.24.0 -replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.9 +replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.10-0.20260916055518-560fb469b16d require ( github.com/holiman/uint256 v1.2.4 - github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4 + github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 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 f0979324c..6d3f57e8c 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.20260824023452-5744b8f66ec4 h1:HCXegM088SPDt3wITDEBM+Osx06I0CDLv2jmsaPA/LU= -github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 h1:lgAgQYT34XY/l7+auFRHdqt7HsnCcAMNBGxd5DKiiHw= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967/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 71959b6be..bc36026e2 100644 --- a/ops/tools/go.mod +++ b/ops/tools/go.mod @@ -2,10 +2,10 @@ module morph-l2/tools go 1.24.0 -replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.9 +replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.10-0.20260916055518-560fb469b16d require ( - github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4 + github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 github.com/tendermint/tendermint v0.35.9 ) diff --git a/ops/tools/go.sum b/ops/tools/go.sum index b08bc073c..6bde6fe22 100644 --- a/ops/tools/go.sum +++ b/ops/tools/go.sum @@ -161,10 +161,10 @@ 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.20260824023452-5744b8f66ec4 h1:HCXegM088SPDt3wITDEBM+Osx06I0CDLv2jmsaPA/LU= -github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= -github.com/morph-l2/tendermint v0.3.9 h1:0jiFtMpVmVImCAFrMFTIvTX7jfJwzaQFyLQoLccP5A8= -github.com/morph-l2/tendermint v0.3.9/go.mod h1:qpiwqfcCB89dBYfqVJOc/HjGxDp3OdDlthgttJJYyRs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 h1:lgAgQYT34XY/l7+auFRHdqt7HsnCcAMNBGxd5DKiiHw= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/tendermint v0.3.10-0.20260916055518-560fb469b16d h1:7vfVAK5eeHgdLxXG/1hMJLvgvE5QiYIfRA2nNYj6jPU= +github.com/morph-l2/tendermint v0.3.10-0.20260916055518-560fb469b16d/go.mod h1:qpiwqfcCB89dBYfqVJOc/HjGxDp3OdDlthgttJJYyRs= 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/token-price-oracle/go.mod b/token-price-oracle/go.mod index d74e33cf7..66cf834a7 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.20260824023452-5744b8f66ec4 + github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 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 98189dc61..e21579afc 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.20260824023452-5744b8f66ec4 h1:HCXegM088SPDt3wITDEBM+Osx06I0CDLv2jmsaPA/LU= -github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 h1:lgAgQYT34XY/l7+auFRHdqt7HsnCcAMNBGxd5DKiiHw= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967/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 32d563c34..57fbe4d7b 100644 --- a/tx-submitter/go.mod +++ b/tx-submitter/go.mod @@ -2,14 +2,14 @@ module morph-l2/tx-submitter go 1.24.0 -replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.9 +replace github.com/tendermint/tendermint => github.com/morph-l2/tendermint v0.3.10-0.20260916055518-560fb469b16d require ( github.com/consensys/gnark-crypto v0.16.0 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.20260824023452-5744b8f66ec4 + github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 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 359c2c19b..f96d02b4b 100644 --- a/tx-submitter/go.sum +++ b/tx-submitter/go.sum @@ -161,8 +161,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.20260824023452-5744b8f66ec4 h1:HCXegM088SPDt3wITDEBM+Osx06I0CDLv2jmsaPA/LU= -github.com/morph-l2/go-ethereum v1.10.14-0.20260824023452-5744b8f66ec4/go.mod h1:nkVzHjQWCOjvukQW8ittlwX+Xz9gmVHrP7mUi7zoHTs= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967 h1:lgAgQYT34XY/l7+auFRHdqt7HsnCcAMNBGxd5DKiiHw= +github.com/morph-l2/go-ethereum v1.10.14-0.20260908092055-4012f174b967/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/tx-submitter/services/pendingtx.go b/tx-submitter/services/pendingtx.go index b94735b94..cae7bd6b9 100644 --- a/tx-submitter/services/pendingtx.go +++ b/tx-submitter/services/pendingtx.go @@ -210,7 +210,7 @@ func (pt *PendingTxs) GetPFinalize() uint64 { return atomic.LoadUint64(&pt.pfinalize) } -// ExistedIndex checks if a batch index exists +// ExistedIndex checks if a commit-like tx for the batch index is already pending. func (pt *PendingTxs) ExistedIndex(index uint64) bool { txs := pt.GetAll() // snapshot taken under RLock inside GetAll; caller does not hold the mutex abi, err := bindings.RollupMetaData.GetAbi() @@ -231,6 +231,26 @@ func (pt *PendingTxs) ExistedIndex(index uint64) bool { return false } +// ExistedFinalizeIndex reports whether a finalizeBatch tx for index is already pending. +func (pt *PendingTxs) ExistedFinalizeIndex(index uint64) bool { + txs := pt.GetAll() + abi, err := bindings.RollupMetaData.GetAbi() + if err != nil { + log.Error("Failed to get ABI", "err", err) + return false + } + + for i := len(txs) - 1; i >= 0; i-- { + tx := txs[i].Tx + if utils.ParseMethod(tx, abi) == constants.MethodFinalizeBatch { + if utils.ParseFBatchIndex(tx.Data()) == index { + return true + } + } + } + return false +} + // Recover recovers transactions from the journal func (pt *PendingTxs) Recover(txs []*ethtypes.Transaction, abi *abi.ABI) error { if len(txs) == 0 { diff --git a/tx-submitter/services/rollup.go b/tx-submitter/services/rollup.go index 6639fe2bb..62f1cbc08 100644 --- a/tx-submitter/services/rollup.go +++ b/tx-submitter/services/rollup.go @@ -764,16 +764,29 @@ func (r *Rollup) finalize() error { return fmt.Errorf("get last committed error:%v", err) } - target := big.NewInt(int64(r.pendingTxs.GetPFinalize() + 1)) - if target.Cmp(lastFinalized) <= 0 { - target = new(big.Int).Add(lastFinalized, big.NewInt(1)) - } + // Contract requires consecutive finalize (lastFinalized+1). Do not advance + // from pfinalize: a dropped/failed finalize tx leaves pfinalize ahead and + // would skip the next required batch (incorrect previous state root). + target := new(big.Int).Add(lastFinalized, big.NewInt(1)) if target.Cmp(lastCommitted) > 0 { log.Info("no need to finalize", "last_finalized", lastFinalized.Uint64(), "last_committed", lastCommitted.Uint64()) return nil } + if pf := r.pendingTxs.GetPFinalize(); pf > lastFinalized.Uint64() { + log.Warn("pfinalize ahead of lastFinalized, targeting lastFinalized+1", + "pfinalize", pf, + "last_finalized", lastFinalized, + "finalize_index", target, + ) + } + + if r.pendingTxs.ExistedFinalizeIndex(target.Uint64()) { + log.Info("finalize tx already pending", "batch_index", target) + return nil + } + log.Info("finalize info", "last_finalized", lastFinalized, "last_committed", lastCommitted, diff --git a/tx-submitter/services/rollup_submitter_activity_test.go b/tx-submitter/services/rollup_submitter_activity_test.go index 6c87252fd..ac072d891 100644 --- a/tx-submitter/services/rollup_submitter_activity_test.go +++ b/tx-submitter/services/rollup_submitter_activity_test.go @@ -1,6 +1,7 @@ package services import ( + "encoding/binary" "errors" "math/big" "testing" @@ -10,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "morph-l2/tx-submitter/mock" + "morph-l2/tx-submitter/utils" ) // A submitter can be removed, slashed, priced out by a raised minimum stake, or @@ -69,3 +71,37 @@ func TestFinalizeDoesNotRoughEstimatePastRevert(t *testing.T) { require.NoError(t, r.finalize()) require.Equal(t, 1, r.pendingTxs.Len()) } + +func headerWithBatchIndex(index uint64) hexutil.Bytes { + h := make([]byte, 9) + h[0] = 1 + binary.BigEndian.PutUint64(h[1:9], index) + return h +} + +// A dropped finalize tx can leave pfinalize ahead of lastFinalized. The contract +// still requires lastFinalized+1; skipping that batch reverts with +// "incorrect previous state root". +func TestFinalizeDoesNotSkipAheadOfLastFinalized(t *testing.T) { + r, l1Mock, _, rollupContract := setupTestRollup(t) + r.cfg.RoughEstimateGas = true + l1Mock.EstimateGasErr = errors.New("connection refused") + + lastFinalized := uint64(27036) + next := lastFinalized + 1 + rollupContract.SetLastFinalizedBatchIndex(new(big.Int).SetUint64(lastFinalized)) + rollupContract.SetLastCommittedBatchIndex(big.NewInt(27076)) + rollupContract.SetBatchExists(true) + rollupContract.SetBatchInsideChallengeWindow(false) + r.pendingTxs.SetPFinalize(next) // stale: previous finalize(27037) was sent then dropped + r.batchCacheLegacy.Set(next+1, ð.RPCRollupBatch{ + ParentBatchHeader: headerWithBatchIndex(next), + }) + + require.NoError(t, r.finalize()) + require.Equal(t, 1, r.pendingTxs.Len()) + require.Equal(t, next, utils.ParseFBatchIndex(r.pendingTxs.GetAll()[0].Tx.Data())) + + require.NoError(t, r.finalize()) + require.Equal(t, 1, r.pendingTxs.Len(), "must not send a second finalize while one is pending") +}