diff --git a/CHANGELOG.md b/CHANGELOG.md index c24987da88..fad75f3fef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ### Improvements * [#3818](https://github.com/sei-protocol/sei-chain/pull/3818) feat(evmrpc): extend HTTP admission control (`max_request_body_bytes`, `max_concurrent_request_bytes`, `ws_admission_timeout`) to the WebSocket plane (:8546). WS oversize frames close with WebSocket close code 1009; budget-wait timeouts return JSON-RPC error `-32005` before the connection closes. `evmrpc_requests_rejected_total` gains a `protocol` label (`http` / `ws`). * [#3984](https://github.com/sei-protocol/sei-chain/pull/3984) feat(query): origin-aware pagination limits for ABCI queries. Untrusted callers on the ABCI/gRPC query path get configurable `max-limit`, `max-offset`, and flat `max-iterations` (defaults: 1000 / 10000 / 11000); requests above the caps are rejected upfront, and an exhausted iteration budget returns a partial page with `next_key` instead of failing. Trusted origins (new `[query] trusted-cidrs`) and the `[query] disable-limits` kill switch bypass the caps; the consensus/EVM precompile path is unaffected. +* [#3990](https://github.com/sei-protocol/sei-chain/pull/3990) Freeze mode is limited to full nodes and disables transaction and evidence submission, mempool gossip, and state sync from startup while preserving query RPC and mempool-backed reads. Frozen and Autobahn nodes no longer advertise the unused mempool P2P channel. ### Upgrade guide * **IBC transfer removal.** Removes ICS-20 execution, module APIs, CLI commands, CosmWasm transfer messages, transfer codecs, and transfer keeper integration, including the IBC EVM precompile's keeper injection. The transfer store and module account remain materialized for state compatibility. Transfer queries, historical transfer transaction decoding, and pre-v6.7 IBC precompile tracing must be served by v6.6 freeze nodes; v6.7 nodes do not provide them. diff --git a/sei-cosmos/server/config/config.go b/sei-cosmos/server/config/config.go index 3cecd080c6..ee732b85c9 100644 --- a/sei-cosmos/server/config/config.go +++ b/sei-cosmos/server/config/config.go @@ -114,8 +114,9 @@ type BaseConfig struct { // Note: Commitment of state will be attempted on the corresponding block. HaltHeight uint64 `mapstructure:"halt-height"` - // FreezeHeight contains a non-zero block height at which the node stops - // before executing the block while continuing to serve RPC. + // FreezeHeight contains the first block height a full node must not execute. + // Query RPC remains available, while transaction and evidence submission, + // mempool gossip, and state sync are disabled from startup. FreezeHeight uint64 `mapstructure:"freeze-height"` // HaltTime contains a non-zero minimum block time (in Unix seconds) at which diff --git a/sei-cosmos/server/config/toml.go b/sei-cosmos/server/config/toml.go index 5b6a4165e8..eb7d7f4ea0 100644 --- a/sei-cosmos/server/config/toml.go +++ b/sei-cosmos/server/config/toml.go @@ -41,8 +41,9 @@ occ-enabled = {{ .BaseConfig.OccEnabled }} # Note: Commitment of state will be attempted on the corresponding block. halt-height = {{ .BaseConfig.HaltHeight }} -# FreezeHeight contains a non-zero block height at which the node stops before -# executing the block while continuing to serve RPC. +# FreezeHeight contains the first block height a full node must not execute. +# Query RPC remains available, while transaction and evidence submission, +# mempool gossip, and state sync are disabled from startup. freeze-height = {{ .BaseConfig.FreezeHeight }} # HaltTime contains a non-zero minimum block time (in Unix seconds) at which diff --git a/sei-cosmos/server/start.go b/sei-cosmos/server/start.go index 98cae88149..ff9e416b75 100644 --- a/sei-cosmos/server/start.go +++ b/sei-cosmos/server/start.go @@ -103,8 +103,9 @@ the ABCI Commit phase, the node will check if the current block height is greate the halt-height or if the current block time is greater than or equal to the halt-time. If so, the node will attempt to gracefully shutdown and the block will not be committed. In addition, the node will not be able to commit subsequent blocks. -The '--freeze-height' flag instead keeps the process and RPC servers running while preventing block -sync and consensus from executing the block at the configured height or advancing beyond it. +The '--freeze-height' flag puts a full node in read-only freeze mode. Query RPC remains available, +but transaction and evidence submission, mempool gossip, and state sync are disabled from startup. +Block sync and consensus stop before executing the configured height. For profiling and benchmarking purposes, CPU profiling can be enabled via the '--cpu-profile' flag which accepts a path for the resulting pprof file. The node may be started in a 'query only' mode where only the gRPC and JSON HTTP @@ -211,7 +212,7 @@ func addStartNodeFlags(cmd *cobra.Command, defaultNodeHome string) { cmd.Flags().String(FlagMinGasPrices, "", "Minimum gas prices to accept for transactions; Any fee in a tx must meet this minimum (e.g. 0.01photino;0.0001stake)") cmd.Flags().IntSlice(FlagUnsafeSkipUpgrades, []int{}, "Skip a set of upgrade heights to continue the old binary") cmd.Flags().Uint64(FlagHaltHeight, 0, "Block height at which to gracefully halt the chain and shutdown the node") - cmd.Flags().Uint64(FlagFreezeHeight, 0, "Block height to stop before executing while continuing to serve RPC") + cmd.Flags().Uint64(FlagFreezeHeight, 0, "Block height at which a full node stops executing while continuing to serve query RPC") cmd.Flags().Uint64(FlagHaltTime, 0, "Minimum block time (in Unix seconds) at which to gracefully halt the chain and shutdown the node") cmd.Flags().Bool(FlagInterBlockCache, true, "Enable inter-block caching") cmd.Flags().String(flagCPUProfile, "", "Enable CPU profiling and write to the provided file") diff --git a/sei-tendermint/internal/rpc/core/env.go b/sei-tendermint/internal/rpc/core/env.go index 5a2cc310db..0b6e2560d9 100644 --- a/sei-tendermint/internal/rpc/core/env.go +++ b/sei-tendermint/internal/rpc/core/env.go @@ -3,6 +3,7 @@ package core import ( "context" "encoding/base64" + "errors" "fmt" "net" "net/http" @@ -48,7 +49,12 @@ const ( genesisChunkSize = 16 * 1024 * 1024 // 16 ) -var logger = seilog.NewLogger("tendermint", "internal", "rpc", "core") +var ( + logger = seilog.NewLogger("tendermint", "internal", "rpc", "core") + + // ErrReadOnly indicates that an RPC operation would mutate a read-only node. + ErrReadOnly = errors.New("RPC writes are disabled in freeze mode") +) //---------------------------------------------- // These interfaces are used by RPC and must be thread safe @@ -93,6 +99,9 @@ type Environment struct { Config config.RPCConfig + // ReadOnly rejects RPC writes while preserving query and mempool reads. + ReadOnly bool + // cache of chunked genesis data. genChunks []string } @@ -230,6 +239,13 @@ func (env *Environment) requireMempool() (*mempool.TxMempool, error) { return nil, fmt.Errorf("mempool is not available") } +func (env *Environment) requireWritable() error { + if env.ReadOnly { + return ErrReadOnly + } + return nil +} + func (env *Environment) requireEventLog() (*eventlog.Log, error) { if lg, ok := env.EventLog.Get(); ok { return lg, nil diff --git a/sei-tendermint/internal/rpc/core/evidence.go b/sei-tendermint/internal/rpc/core/evidence.go index 66015b110f..4de2928eb6 100644 --- a/sei-tendermint/internal/rpc/core/evidence.go +++ b/sei-tendermint/internal/rpc/core/evidence.go @@ -10,6 +10,9 @@ import ( // BroadcastEvidence broadcasts evidence of the misbehavior. // More: https://docs.tendermint.com/master/rpc/#/Evidence/broadcast_evidence func (env *Environment) BroadcastEvidence(ctx context.Context, req *coretypes.RequestBroadcastEvidence) (*coretypes.ResultBroadcastEvidence, error) { + if err := env.requireWritable(); err != nil { + return nil, err + } if req.Evidence == nil { return nil, fmt.Errorf("%w: no evidence was provided", coretypes.ErrInvalidRequest) } diff --git a/sei-tendermint/internal/rpc/core/mempool.go b/sei-tendermint/internal/rpc/core/mempool.go index 13a7cc82e4..aa31bd5950 100644 --- a/sei-tendermint/internal/rpc/core/mempool.go +++ b/sei-tendermint/internal/rpc/core/mempool.go @@ -50,6 +50,9 @@ func (env *Environment) EvmTxByHash(hash common.Hash) (types.Tx, bool) { // https://docs.tendermint.com/master/rpc/#/Tx/broadcast_tx_async // Deprecated and should be removed in 0.37 func (env *Environment) BroadcastTxAsync(ctx context.Context, req *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) { + if err := env.requireWritable(); err != nil { + return nil, err + } if giga, ok := env.gigaRouter().Get(); ok { v, ok := giga.Mempool().Get() if !ok { @@ -76,6 +79,9 @@ func (env *Environment) BroadcastTxSync(ctx context.Context, req *coretypes.Requ // DeliverTx result. // More: https://docs.tendermint.com/master/rpc/#/Tx/broadcast_tx_sync func (env *Environment) BroadcastTx(ctx context.Context, req *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) { + if err := env.requireWritable(); err != nil { + return nil, err + } if giga, ok := env.gigaRouter().Get(); ok { v, ok := giga.Mempool().Get() if !ok { @@ -113,6 +119,9 @@ func (env *Environment) BroadcastTx(ctx context.Context, req *coretypes.RequestB // BroadcastTxCommit returns with the responses from CheckTx and DeliverTx. // More: https://docs.tendermint.com/master/rpc/#/Tx/broadcast_tx_commit func (env *Environment) BroadcastTxCommit(ctx context.Context, req *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTxCommit, error) { + if err := env.requireWritable(); err != nil { + return nil, err + } if timeout := env.Config.TimeoutBroadcastTxCommit; timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, timeout) diff --git a/sei-tendermint/node/freeze_test.go b/sei-tendermint/node/freeze_test.go index 12125881cf..587549a9fd 100644 --- a/sei-tendermint/node/freeze_test.go +++ b/sei-tendermint/node/freeze_test.go @@ -1,8 +1,18 @@ package node import ( + "errors" + "fmt" "math" + "slices" "testing" + + "github.com/sei-protocol/sei-chain/sei-tendermint/config" + mempoolreactor "github.com/sei-protocol/sei-chain/sei-tendermint/internal/mempool/reactor" + rpccore "github.com/sei-protocol/sei-chain/sei-tendermint/internal/rpc/core" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/tcp" + "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/coretypes" + "github.com/sei-protocol/sei-chain/sei-tendermint/types" ) func TestValidateFreezeHeight(t *testing.T) { @@ -39,3 +49,95 @@ func TestWithFreezeHeight(t *testing.T) { t.Fatalf("freeze height = %d, want %d", got, height) } } + +func TestValidateFreezeMode(t *testing.T) { + for _, tc := range []struct { + name string + mode string + freezeHeight uint64 + wantErr bool + }{ + {name: "disabled validator", mode: config.ModeValidator}, + {name: "full node", mode: config.ModeFull, freezeHeight: 10}, + {name: "validator", mode: config.ModeValidator, freezeHeight: 10, wantErr: true}, + {name: "seed", mode: config.ModeSeed, freezeHeight: 10, wantErr: true}, + {name: "unknown mode handled by node mode validation", mode: "unknown", freezeHeight: 10}, + } { + t.Run(tc.name, func(t *testing.T) { + err := validateFreezeMode(tc.mode, tc.freezeHeight) + if (err != nil) != tc.wantErr { + t.Fatalf("validateFreezeMode() error = %v, wantErr %t", err, tc.wantErr) + } + }) + } +} + +func TestFreezeModeDisablesMempoolTraffic(t *testing.T) { + cfg, err := config.ResetTestRoot(t.TempDir(), "freeze_mempool_test") + if err != nil { + t.Fatal(err) + } + cfg.Mode = config.ModeFull + cfg.RPC.ListenAddress = fmt.Sprintf("tcp://%s", tcp.TestReserveAddr()) + nodeService, err := newLocalNodeService(t.Context(), cfg, WithFreezeHeight(2)) + if err != nil { + t.Fatal(err) + } + node := nodeService.(*nodeImpl) + + if !node.mempool.IsPresent() { + t.Fatal("internal mempool is unavailable") + } + if !node.rpcEnv.Mempool.IsPresent() { + t.Fatal("RPC mempool reads are unavailable in freeze mode") + } + if !node.rpcEnv.ReadOnly { + t.Fatal("RPC writes are enabled in freeze mode") + } + txRequest := &coretypes.RequestBroadcastTx{Tx: types.Tx{1}} + for name, broadcast := range map[string]func() error{ + "async": func() error { + _, err := node.rpcEnv.BroadcastTxAsync(t.Context(), txRequest) + return err + }, + "sync": func() error { + _, err := node.rpcEnv.BroadcastTxSync(t.Context(), txRequest) + return err + }, + "default": func() error { + _, err := node.rpcEnv.BroadcastTx(t.Context(), txRequest) + return err + }, + "commit": func() error { + _, err := node.rpcEnv.BroadcastTxCommit(t.Context(), txRequest) + return err + }, + } { + if err := broadcast(); !errors.Is(err, rpccore.ErrReadOnly) { + t.Fatalf("%s RPC transaction broadcast error = %v, want ErrReadOnly", name, err) + } + } + if _, err := node.rpcEnv.BroadcastEvidence(t.Context(), &coretypes.RequestBroadcastEvidence{}); !errors.Is(err, rpccore.ErrReadOnly) { + t.Fatalf("RPC evidence broadcast error = %v, want ErrReadOnly", err) + } + if pending, err := node.rpcEnv.UnconfirmedTxs(t.Context(), &coretypes.RequestUnconfirmedTxs{}); err != nil { + t.Fatalf("reading unconfirmed transactions: %v", err) + } else if pending.Total != 0 { + t.Fatalf("unconfirmed transaction total = %d, want 0", pending.Total) + } + for _, nodeService := range node.services { + if _, ok := nodeService.(*mempoolreactor.Reactor); ok { + t.Fatal("mempool reactor is enabled in freeze mode") + } + } + if err := node.Start(t.Context()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + node.Stop() + node.Wait() + }) + if slices.Contains(node.NodeInfo().Channels, byte(mempoolreactor.MempoolChannel)) { + t.Fatal("mempool channel is advertised in freeze mode") + } +} diff --git a/sei-tendermint/node/node.go b/sei-tendermint/node/node.go index 0ac292db3a..bfc6ba3f86 100644 --- a/sei-tendermint/node/node.go +++ b/sei-tendermint/node/node.go @@ -133,18 +133,19 @@ type nodeImpl struct { nodeKey types.NodeKey // our node privkey // services - eventSinks []indexer.EventSink - initialState sm.State - stateStore sm.Store - blockStore *store.BlockStore // store the blockchain to disk - mempool utils.Option[*mempool.TxMempool] - evPool utils.Option[*evidence.Pool] - indexerService *indexer.Service - services []service.Service - rpcListeners []net.Listener // rpc servers - shutdownOps closer - rpcEnv *rpccore.Environment - prometheusSrv utils.Option[*http.Server] + eventSinks []indexer.EventSink + initialState sm.State + stateStore sm.Store + blockStore *store.BlockStore // store the blockchain to disk + mempool utils.Option[*mempool.TxMempool] + mempoolP2PEnabled bool + evPool utils.Option[*evidence.Pool] + indexerService *indexer.Service + services []service.Service + rpcListeners []net.Listener // rpc servers + shutdownOps closer + rpcEnv *rpccore.Environment + prometheusSrv utils.Option[*http.Server] } // makeNode returns a new, ready to go, Tendermint Node. @@ -273,6 +274,8 @@ func makeNode( EventBus: eventBus, EventLog: eventLogOpt, Config: *cfg.RPC, + + ReadOnly: opts.freezeHeight > 0, }, } @@ -333,12 +336,15 @@ func makeNode( mp := mempool.NewTxMempool(cfg.Mempool.ToMempoolConfig(), proxyApp, sm.TxConstraintsFetcherFromStore(stateStore)) node.mempool = utils.Some(mp) node.rpcEnv.Mempool = utils.Some(mp) - mpReactor, err := mempoolreactor.NewReactor(cfg.Mempool, mp, router) - if err != nil { - return nil, fmt.Errorf("mempoolreactor.NewReactor(): %w", err) + if opts.freezeHeight == 0 { + mpReactor, err := mempoolreactor.NewReactor(cfg.Mempool, mp, router) + if err != nil { + return nil, fmt.Errorf("mempoolreactor.NewReactor(): %w", err) + } + mpReactor.MarkReadyToStart() + node.services = append(node.services, mpReactor) + node.mempoolP2PEnabled = true } - mpReactor.MarkReadyToStart() - node.services = append(node.services, mpReactor) // make block executor for consensus and blockchain reactors to execute blocks blockExec := sm.NewBlockExecutor( @@ -562,7 +568,7 @@ func (n *nodeImpl) OnStart(ctx context.Context) (err error) { // TODO: Fetch and provide real options and do proper p2p bootstrapping. // TODO: Use a persistent peer database. - n.nodeInfo, err = makeNodeInfo(n.config, n.nodeKey, n.eventSinks, n.genesisDoc, state.Version.Consensus) + n.nodeInfo, err = makeNodeInfo(n.config, n.nodeKey, n.eventSinks, n.genesisDoc, state.Version.Consensus, n.mempoolP2PEnabled) if err != nil { return err } diff --git a/sei-tendermint/node/node_test.go b/sei-tendermint/node/node_test.go index db652a4bef..55aaa7eb63 100644 --- a/sei-tendermint/node/node_test.go +++ b/sei-tendermint/node/node_test.go @@ -39,7 +39,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/types" ) -func newLocalNodeService(ctx context.Context, cfg *config.Config) (service.Service, error) { +func newLocalNodeService(ctx context.Context, cfg *config.Config, nodeOptions ...Option) (service.Service, error) { app := kvstore.NewApplication() app.SetValidators(utils.OrPanic1(types.GenesisDocFromFile(cfg.GenesisFile())).ValidatorUpdates()) return New( @@ -50,6 +50,7 @@ func newLocalNodeService(ctx context.Context, cfg *config.Config) (service.Servi nil, nil, types.DefaultConsensusPolicy(), + nodeOptions..., ) } diff --git a/sei-tendermint/node/public.go b/sei-tendermint/node/public.go index 6529b654db..7f48ab460f 100644 --- a/sei-tendermint/node/public.go +++ b/sei-tendermint/node/public.go @@ -54,6 +54,10 @@ func New( if err := validateNodeSetupConfig(conf); err != nil { return nil, err } + opts := resolveOptions(nodeOptions...) + if err := validateFreezeMode(conf.Mode, opts.freezeHeight); err != nil { + return nil, err + } app = prepareApplication(conf, app) proxyApp := proxy.New(app) nodeKey, err := tmtypes.LoadOrGenNodeKey(conf.NodeKeyFile()) @@ -90,9 +94,6 @@ func New( nodeOptions..., ) case config.ModeSeed: - if resolveOptions(nodeOptions...).freezeHeight > 0 { - return nil, fmt.Errorf("freeze height is not supported in seed mode") - } return makeSeedNode( conf, config.DefaultDBProvider, @@ -104,6 +105,18 @@ func New( } } +func validateFreezeMode(mode string, freezeHeight uint64) error { + if freezeHeight == 0 || mode == config.ModeFull { + return nil + } + switch mode { + case config.ModeValidator, config.ModeSeed: + return fmt.Errorf("freeze height is not supported in %s mode", mode) + default: + return nil + } +} + func validateNodeSetupConfig(conf *config.Config) error { if conf.MockApp && conf.AutobahnConfigFile == "" { return fmt.Errorf("mock-app requires autobahn-config-file") diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index 092f4c3e16..fba83ea8dc 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -633,6 +633,7 @@ func makeNodeInfo( eventSinks []indexer.EventSink, genDoc *types.GenesisDoc, versionInfo version.Consensus, + mempoolP2PEnabled bool, ) (types.NodeInfo, error) { txIndexerStatus := "off" @@ -641,29 +642,35 @@ func makeNodeInfo( txIndexerStatus = "on" } + channels := []byte{ + byte(blocksync.BlockSyncChannel), + byte(consensus.StateChannel), + byte(consensus.DataChannel), + byte(consensus.VoteChannel), + byte(consensus.VoteSetBitsChannel), + } + if mempoolP2PEnabled { + channels = append(channels, byte(mempoolreactor.MempoolChannel)) + } + channels = append(channels, + byte(evidence.EvidenceChannel), + byte(statesync.SnapshotChannel), + byte(statesync.ChunkChannel), + byte(statesync.LightBlockChannel), + byte(statesync.ParamsChannel), + ) + nodeInfo := types.NodeInfo{ ProtocolVersion: types.ProtocolVersion{ P2P: version.P2PProtocol, // global Block: versionInfo.Block, App: versionInfo.App, }, - NodeID: nodeKey.ID(), - Network: genDoc.ChainID, - Version: version.TMVersion, - Channels: []byte{ - byte(blocksync.BlockSyncChannel), - byte(consensus.StateChannel), - byte(consensus.DataChannel), - byte(consensus.VoteChannel), - byte(consensus.VoteSetBitsChannel), - byte(mempoolreactor.MempoolChannel), - byte(evidence.EvidenceChannel), - byte(statesync.SnapshotChannel), - byte(statesync.ChunkChannel), - byte(statesync.LightBlockChannel), - byte(statesync.ParamsChannel), - }, - Moniker: cfg.Moniker, + NodeID: nodeKey.ID(), + Network: genDoc.ChainID, + Version: version.TMVersion, + Channels: channels, + Moniker: cfg.Moniker, Other: types.NodeInfoOther{ TxIndex: txIndexerStatus, RPCAddress: cfg.RPC.ListenAddress,