Skip to content

Add freeze mode for historical EVM RPC - #3910

Open
codchen wants to merge 1 commit into
mainfrom
codex/freeze-rpc-mode
Open

Add freeze mode for historical EVM RPC#3910
codchen wants to merge 1 commit into
mainfrom
codex/freeze-rpc-mode

Conversation

@codchen

@codchen codchen commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add a freeze-height node mode that keeps the existing binary and RPC services running while stopping block sync and consensus at an upgrade boundary.

freeze-height = H treats H as the first block the node must not execute. The node commits and serves state through H-1, transitions consensus to H/NewHeight, and remains alive without proposing, signing, or advancing further.

Motivation

Historical RPC nodes need to retain the binary behavior that was active before an upgrade. Shutting the node down at the upgrade height also shuts down EVM HTTP and WebSocket RPC, while allowing the old binary to execute the upgrade block can panic or produce responses using code that does not match that historical state.

Changes

  • Add freeze-height to app configuration and the seid start CLI.
  • Stop block sync before the configured height and hand the resulting state to consensus.
  • Keep consensus and node services alive while draining inbound work without state transitions or WAL writes.
  • Reject startup if the application, state store, or block store already reached the freeze height.
  • Disable state sync in freeze mode so it cannot jump beyond the boundary.
  • Reject incompatible halt, gRPC-only, seed, and Autobahn/Giga configurations.
  • Skip block-sync auto-remediation after reaching the intentional freeze boundary.
  • Add configuration characterization, validation, block-sync, consensus, and node-option tests.

Operator impact

Operators can set either:

freeze-height = 123456

or:

seid start --freeze-height 123456

The node will continue serving EVM HTTP/WebSocket RPC against state at block 123455. Transaction submission remains available, but accepted transactions cannot be committed while the node is frozen.

Validation

  • go test -race ./sei-tendermint/node ./sei-tendermint/internal/consensus ./sei-tendermint/internal/blocksync -run 'Test(ValidateFreezeHeight|WithFreezeHeight|StateFreezesAfterTargetBlock|PoolRoutineHandsOffAtFreezeHeight)$' -count=1
  • go test --count=0 ./sei-cosmos/server/config ./sei-cosmos/server ./sei-tendermint/node ./sei-tendermint/internal/consensus ./sei-tendermint/internal/blocksync ./cmd/seid/cmd
  • go vet on the affected packages
  • gofmt, goimports, and git diff --check on all changed files

Historical backports

Each branch below is exactly one backport commit above its corresponding release tag. v6.5 and v6.6 map to the repository's v6.5.0 and v6.6.0 tags.

Version Branch Commit
v5.5.2 codex/freeze-rpc-v5.5.2 b6814c179
v5.5.5 codex/freeze-rpc-v5.5.5 b8f17db9e
v5.6.0 codex/freeze-rpc-v5.6.0 4472fa6a8
v5.6.2 codex/freeze-rpc-v5.6.2 e3f99ff9d
v5.7.0 codex/freeze-rpc-v5.7.0 29461d2d9
v5.7.1 codex/freeze-rpc-v5.7.1 ab7103415
v5.7.2 codex/freeze-rpc-v5.7.2 d26eecc99
v5.7.4 codex/freeze-rpc-v5.7.4 6f831399b
v5.7.5 codex/freeze-rpc-v5.7.5 f0939f828
v5.8.0 codex/freeze-rpc-v5.8.0 7e9a4785a
v5.9.0 codex/freeze-rpc-v5.9.0 85e0848fa
v6.0.0 codex/freeze-rpc-v6.0.0 4a074671d
v6.0.1 codex/freeze-rpc-v6.0.1 9ce41f1ee
v6.0.2 codex/freeze-rpc-v6.0.2 72c896adc
v6.0.3 codex/freeze-rpc-v6.0.3 48804fe29
v6.0.4 codex/freeze-rpc-v6.0.4 641a82159
v6.0.5 codex/freeze-rpc-v6.0.5 2a7774679
v6.0.6 codex/freeze-rpc-v6.0.6 46c28a3ac
v6.1.0 codex/freeze-rpc-v6.1.0 44c28c9f0
v6.1.4 codex/freeze-rpc-v6.1.4 1dfc51696
v6.2.0 codex/freeze-rpc-v6.2.0 647c607ae
v6.3.0 codex/freeze-rpc-v6.3.0 e2aa7be2f
v6.4.0 codex/freeze-rpc-v6.4.0 8161b645c
v6.5.0 codex/freeze-rpc-v6.5.0 0f17410ff
v6.6.0 codex/freeze-rpc-v6.6.0 228b35bd3

For tags before v6.3.0, sei-cosmos and sei-tendermint were external archived modules. Those backport commits are self-contained: they import the matching historical dependency trees and point the branch's Go replacements to those local copies.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 13, 2026, 7:46 AM

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.52174% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.47%. Comparing base (99c9c29) to head (0a8aaab).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
sei-tendermint/node/node.go 70.96% 5 Missing and 4 partials ⚠️
sei-cosmos/server/start.go 33.33% 6 Missing ⚠️
sei-tendermint/internal/consensus/state.go 82.35% 5 Missing and 1 partial ⚠️
sei-tendermint/internal/blocksync/reactor.go 81.81% 3 Missing and 1 partial ⚠️
sei-tendermint/node/public.go 81.81% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3910      +/-   ##
==========================================
- Coverage   59.47%   58.47%   -1.00%     
==========================================
  Files        2323     2227      -96     
  Lines      198389   187864   -10525     
==========================================
- Hits       117982   109847    -8135     
+ Misses      69198    67641    -1557     
+ Partials    11209    10376     -833     
Flag Coverage Δ
sei-chain-pr 68.76% <76.52%> (?)
sei-db 70.41% <ø> (-0.22%) ⬇️
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-cosmos/server/config/config.go 98.48% <100.00%> (+0.04%) ⬆️
sei-cosmos/server/config/toml.go 57.14% <ø> (ø)
sei-tendermint/node/public.go 68.75% <81.81%> (+3.88%) ⬆️
sei-tendermint/internal/blocksync/reactor.go 68.70% <81.81%> (+0.77%) ⬆️
sei-cosmos/server/start.go 35.92% <33.33%> (+0.04%) ⬆️
sei-tendermint/internal/consensus/state.go 74.86% <82.35%> (+0.33%) ⬆️
sei-tendermint/node/node.go 65.95% <70.96%> (+0.35%) ⬆️

... and 98 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codchen
codchen marked this pull request as ready for review August 14, 2026 06:01
@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes core consensus and blocksync behavior at a configured height and affects node startup validation; misconfiguration or edge cases could leave nodes stuck or serving stale state while appearing healthy.

Overview
Freeze mode lets operators pin a node to historical state at an upgrade boundary without shutting down EVM/JSON-RPC. Setting freeze-height = H (or seid start --freeze-height H) means H is the first height the node must not execute; state through H−1 is committed and served while the process stays alive.

Configuration adds freeze-height to app.toml, defaults, GetConfig, and ValidateFreeze (no mixing with halt-height/halt-time, cap at MaxInt64). seid start validates freeze settings, rejects grpc-only, and passes the height into Tendermint via node.WithFreezeHeight.

Block sync stops applying blocks before the freeze height and hands off without auto-remediation past the boundary. Consensus enters a frozen path: no new rounds, WAL catchup/double-sign checks skipped when frozen, receiveWhileFrozen drains queues without advancing state.

Node startup rejects freeze if app/state/block store already reached H, disables state sync when freeze is on, and blocks Autobahn and seed mode. Tests and config fuzz/golden coverage were extended for the new key.

Reviewed by Cursor Bugbot for commit 0a8aaab. Bugbot is set up for automated code reviews on this repo. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Freeze mode is implemented soundly: the boundary is enforced at both the blocksync handoff and the consensus updateToState transition, the startup guards (app/state/block-store height, initial height, MaxInt64) correctly prevent the handshaker from ever replaying the freeze block, and forcing stateSync = false before the blocksync reactor is constructed keeps BlockSync/waitSync consistent. No blocking defects found; the notes below are a dead-in-production eager freeze check, a silent state-sync downgrade, a stale characterization doc, and a gap in blocksync test coverage.

Findings: 0 blocking | 9 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Operator note is slightly optimistic: while frozen the mempool is never Updated, so gossiped and submitted txs accumulate and are never purged (this fork has no purgeExpiredTxs outside Update). Once mempool.size is reached, CheckTx rejects everything, so "transaction submission remains available" holds only until the mempool fills. Worth stating in the PR/operator docs.
  • No test covers the incompatible-configuration rejections that the PR description lists (grpc-only, seed mode, Autobahn). TestValidateFreezeHeight covers only the height guards, and TestWithFreezeHeight only the option plumbing.
  • While frozen, enterNewRound returns before the tracing bookkeeping, so cs.heightSpan for the last executed height is never End()ed. Only one dangling span, and only when tracing is enabled — noting for completeness rather than as something to fix.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.
  • 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.

Pre-existing issues

  • [suggestion] sei-cosmos/server/start.go:319 discards config.ValidateBasic's error and logs a fixed "minimum-gas-prices is empty" warning regardless of which check actually failed, so the pruning-everything + state-sync-snapshots rejection is effectively unenforced at startup. This is why the PR has to call ValidateFreeze() separately at line 311; the underlying swallowed error predates this PR.

// It must be called before the state starts.
func (cs *State) SetFreezeHeight(height uint64) {
cs.freezeHeight = height
cs.markFrozen(nextHeightForState(cs.state), cs.state.LastBlockHeight)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This eager markFrozen never fires at the only production call site. makeNode calls SetFreezeHeight immediately after NewState, and NewState leaves cs.state as the zero sm.State — so nextHeightForState(cs.state) is 0 (LastBlockHeight+1 == 1InitialHeight, which is 0), and markFrozen returns at the uint64(nextHeight) < cs.freezeHeight guard every time.

Startup freezing actually works through Run()updateStateFromStore()updateToState()markFrozen(), which is reached before the cs.frozen.Load() checks in Run, so behavior is correct. But the consensus test helper calls updateStateFromStore() before SetFreezeHeight (common_test.go:536), so TestStateFreezesAfterTargetBlock exercises this call under an initialization order production never uses — a regression here would not be caught.

Either drop the call and let updateToState be the single choke point (matching the guard-at-the-choke-point rule in AGENTS.md), or keep it and reword the godoc, which currently reads as though this is what arms the freeze at startup.


// Determine whether we should attempt state sync.
stateSync := cfg.StateSync.Enable && !onlyValidatorIsUs(state, pubKey)
if stateSync && opts.freezeHeight > 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Every other incompatible configuration in this PR is rejected (halt-height/halt-time, grpc-only, seed mode, Autobahn), but [statesync] enable = true is silently downgraded at Info level. Note this branch only matters for a node with no local state — an existing node is already caught by the state.LastBlockHeight > 0 check below — which is exactly the case where the fallback is most surprising: the operator gets a sync from genesis instead of a snapshot restore, discoverable only from one log line.

Suggest rejecting with an error for consistency with the other mode checks (or at minimum logging at warn/error), so freeze-height + state sync fails fast rather than after hours of syncing.


consensus.Global.RecordConsMetrics(first)
blocksSynced++
if handoff, frozen := s.frozenHandoff(pool, state, blocksSynced, stateSynced); frozen {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This is the call site that matters operationally — stopping an in-flight sync exactly one block before the boundary — and it is untested. TestPoolRoutineHandsOffAtFreezeHeight only reaches the entry-guard at line 471 (it passes a state already at freezeHeight-1, so poolRoutine returns before the loop and never touches blockExec/store).

A test that applies a block and asserts the handoff carries blocksSynced == 1 with state.LastBlockHeight == freezeHeight-1 would also pin the off-by-one that the whole feature rests on (freeze before executing freezeHeight, not after).

"cannot enable state sync snapshots with '%s' pruning setting", storetypes.PruningOptionEverything,
)
}
return c.ValidateFreeze()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This adds two more rejection conditions to ValidateBasic, which makes FuzzConfigValidateBasic's doc comment stale — it opens "pins the two conditions that reject an otherwise parseable app.toml" and enumerates only min-gas-prices and pruning-everything + snapshots (config_fuzz_test.go:693). That fuzz surface also never varies freeze-height, so the new conditions sit outside it; they are pinned only by the TestValidateBasic table rows.

In a package where these comments are the characterization record, worth updating the count/enumeration (and ideally threading freeze-height/halt-height through the fuzz inputs).

if err != nil {
return nil, fmt.Errorf("LoadStateFromDBOrGenesisDocProvider(): %w", err)
}
if err := validateFreezeHeight(opts.freezeHeight, genDoc.InitialHeight, state.LastBlockHeight, blockStore.Height(), proxyApp.Info().LastBlockHeight); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Consider swapping these two checks. In Autobahn/giga mode the CometBFT block and state stores stay at 0 while the app height is N, so a giga node configured with freeze-height <= N fails here with "application height N has already reached freeze height H" instead of the accurate "freeze height is not supported with Autobahn" on the next line. Putting the mode rejection first gives the operator the actionable message in every case.

@masih masih left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers but I highly recommend addressing the condition check inside auto restart if behind loop.

if r, ok := s.consReactor.Get(); ok {
logger.Info("switching to consensus reactor", "height", handoff.height, "blocks_synced", handoff.blocksSynced, "state_synced", handoff.stateSynced, "max_peer_height", handoff.maxPeerHeight)
r.SwitchToConsensus(handoff.state, handoff.blocksSynced > 0 || handoff.stateSynced)
if s.shouldFreeze(handoff.state) {

@masih masih Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the handoff state is not already in freeze boundary?

IIUC, that means:

  • the auto restart mechanism starts and runs for the lifetime of the process
  • then when we reach freeze height at H-1 because pool's max peer height keeps growing regardless (status request keeps broadcasting) we will end up restarting.

We can avoid this by checking the freeze boundary inside autoRestartIfBehind loop based on self height.

@@ -375,6 +378,9 @@ func (s *syncController) run(ctx context.Context) error {
if r, ok := s.consReactor.Get(); ok {
logger.Info("switching to consensus reactor", "height", handoff.height, "blocks_synced", handoff.blocksSynced, "state_synced", handoff.stateSynced, "max_peer_height", handoff.maxPeerHeight)
r.SwitchToConsensus(handoff.state, handoff.blocksSynced > 0 || handoff.stateSynced)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switch to consensus means we still write to wal right? not a huge issue but worth gating any state writes in freeze mode in case it results in hands on involvement to bring the node out of freeze mode.

Operationally, we want the freeze mode to basically be noop for consensus.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants