diff --git a/blocksync/msgs.go b/blocksync/msgs.go index 676feea95f7..1a7fd2a663f 100644 --- a/blocksync/msgs.go +++ b/blocksync/msgs.go @@ -8,7 +8,6 @@ import ( bcproto "github.com/tendermint/tendermint/proto/tendermint/blocksync" "github.com/tendermint/tendermint/types" - "github.com/tendermint/tendermint/upgrade" ) const ( @@ -88,23 +87,13 @@ func ValidateMsg(pb proto.Message) error { return errors.New("negative Height") } case *bcproto.BlockResponse: - // V1 block format - block, err := types.BlockFromProto(msg.Block) - if err != nil { + if _, err := types.BlockFromProto(msg.Block); err != nil { return err } - if upgrade.IsUpgraded(block.Height) { - return fmt.Errorf("unexpected BlockResponse at upgraded height %d", block.Height) - } case *bcproto.BlockResponseV2: - // V2 block format (sequencer mode) - block, err := types.BlockV2FromProto(msg.Block) - if err != nil { + if _, err := types.BlockV2FromProto(msg.Block); err != nil { return err } - if !upgrade.IsUpgraded(block.GetHeight()) { - return fmt.Errorf("unexpected BlockResponseV2 before upgraded height %d", block.GetHeight()) - } case *bcproto.NoBlockResponse: if msg.Height < 0 { return errors.New("negative Height") diff --git a/blocksync/msgs_test.go b/blocksync/msgs_test.go index 8d904b97121..a38d06c388d 100644 --- a/blocksync/msgs_test.go +++ b/blocksync/msgs_test.go @@ -1,7 +1,6 @@ package blocksync_test import ( - "bytes" "encoding/hex" "math" "math/big" @@ -13,10 +12,8 @@ import ( "github.com/stretchr/testify/require" "github.com/tendermint/tendermint/blocksync" - "github.com/tendermint/tendermint/crypto" bcproto "github.com/tendermint/tendermint/proto/tendermint/blocksync" "github.com/tendermint/tendermint/types" - "github.com/tendermint/tendermint/upgrade" ) func TestBcBlockRequestMessageValidateBasic(t *testing.T) { @@ -84,65 +81,23 @@ func TestBcStatusResponseMessageValidateBasic(t *testing.T) { } } -func TestValidateMsgBlockResponseRejectsV1AtUpgradeHeight(t *testing.T) { - oldHeight := upgrade.UpgradeBlockHeight - upgrade.SetUpgradeBlockHeight(10) - defer upgrade.SetUpgradeBlockHeight(oldHeight) - - lastBlockID := types.BlockID{ - Hash: bytes.Repeat([]byte{1}, 32), - PartSetHeader: types.PartSetHeader{ - Total: 1, - Hash: bytes.Repeat([]byte{2}, 32), - }, - } - lastCommit := types.NewCommit(9, 0, lastBlockID, []types.CommitSig{{ - BlockIDFlag: types.BlockIDFlagCommit, - ValidatorAddress: bytes.Repeat([]byte{3}, crypto.AddressSize), - Signature: []byte{1}, - }}) - - block := types.MakeBlock(10, []types.Tx{types.Tx("Hello World")}, nil, nil, nil, lastCommit, nil) - block.ProposerAddress = bytes.Repeat([]byte{4}, crypto.AddressSize) - bpb, err := block.ToProto() - require.NoError(t, err) - - err = blocksync.ValidateMsg(&bcproto.BlockResponse{Block: bpb}) - require.Error(t, err) - assert.Contains(t, err.Error(), "unexpected BlockResponse") -} - -func TestValidateMsgBlockResponseV2RejectsPreUpgradeHeight(t *testing.T) { - oldHeight := upgrade.UpgradeBlockHeight - upgrade.SetUpgradeBlockHeight(10) - defer upgrade.SetUpgradeBlockHeight(oldHeight) - - blockV2 := &types.BlockV2{ - ParentHash: common.HexToHash("0x1"), - Hash: common.HexToHash("0x2"), - BaseFee: big.NewInt(1), - Number: 9, - } - - err := blocksync.ValidateMsg(&bcproto.BlockResponseV2{Block: types.BlockV2ToProto(blockV2)}) - require.Error(t, err) - assert.Contains(t, err.Error(), "unexpected BlockResponseV2") -} - -func TestValidateMsgBlockResponseV2AllowsUpgradeHeight(t *testing.T) { - oldHeight := upgrade.UpgradeBlockHeight - upgrade.SetUpgradeBlockHeight(10) - defer upgrade.SetUpgradeBlockHeight(oldHeight) - +// ValidateMsg validates block responses structurally only. The old "reject V1/V2 by upgrade +// height" checks were removed: the timestamp-driven boundary is unknown at message-validation +// time, so the blocksync reactor guards the V1/V2 type at consume time instead. A structurally +// valid block passes regardless of height; a malformed one is rejected. +func TestValidateMsgBlockResponseV2Structural(t *testing.T) { blockV2 := &types.BlockV2{ ParentHash: common.HexToHash("0x1"), Hash: common.HexToHash("0x2"), BaseFee: big.NewInt(1), Number: 10, } + require.NoError(t, blocksync.ValidateMsg(&bcproto.BlockResponseV2{Block: types.BlockV2ToProto(blockV2)})) - err := blocksync.ValidateMsg(&bcproto.BlockResponseV2{Block: types.BlockV2ToProto(blockV2)}) - require.NoError(t, err) + // Malformed V2 (bad hash length) -> rejected by structural validation. + bad := types.BlockV2ToProto(blockV2) + bad.Hash = []byte{0x1, 0x2} + require.Error(t, blocksync.ValidateMsg(&bcproto.BlockResponseV2{Block: bad})) } //nolint:lll // ignore line length in tests diff --git a/blocksync/reactor.go b/blocksync/reactor.go index b2ed8ec0519..6ae80f95e02 100644 --- a/blocksync/reactor.go +++ b/blocksync/reactor.go @@ -380,24 +380,44 @@ func (bcR *Reactor) L2Node() l2node.L2Node { return bcR.l2Node } -// syncBlockV2 handles syncing a single BlockV2 in sequencer mode. -// Verifies block signature using the history-aware verifier, then applies. -func (bcR *Reactor) syncBlockV2(block types.SyncableBlock, blocksSynced *uint64, lastRate *float64, lastHundred *time.Time) bool { +// verifyBlockV2 asserts block is a *types.BlockV2 and verifies its sequencer signature +// (history-aware lookup via IsSequencerAt). Pure check: no pool side effects — the caller +// decides what to redo/ban. Shared by the boundary check and syncBlockV2. +func (bcR *Reactor) verifyBlockV2(block types.SyncableBlock) (*types.BlockV2, error) { blockV2, ok := block.(*types.BlockV2) if !ok { - bcR.Logger.Error("Expected BlockV2 after upgrade", "height", block.GetHeight()) - bcR.pool.RedoRequest(block.GetHeight()) - return false + return nil, fmt.Errorf("expected BlockV2 at height %d, got V1", block.GetHeight()) } - - // Verify block signature (uses IsSequencerAt with history-aware lookup) if err := sequencer.VerifyBlockSignature(bcR.verifier, blockV2); err != nil { - bcR.Logger.Error("Block signature verification failed", "height", blockV2.Number, "err", err) - bcR.pool.RedoRequest(blockV2.GetHeight()) + return nil, fmt.Errorf("block signature verification failed at height %d: %w", blockV2.GetHeight(), err) + } + return blockV2, nil +} + +// redoAndBan re-queues the block's height and disconnects the peer that served it. +// Single-block granularity: the PBFT path bans only the mismatched block, while the +// one-time boundary check calls it for both blocks. +func (bcR *Reactor) redoAndBan(block types.SyncableBlock, reason string) { + bcR.Logger.Error("blocksync redo+ban", "height", block.GetHeight(), "reason", reason) + peerID := bcR.pool.RedoRequest(block.GetHeight()) + if peer := bcR.Switch.Peers().Get(peerID); peer != nil { + bcR.Switch.StopPeerForError(peer, fmt.Errorf("blocksync: %s", reason)) + } +} + +// syncBlockV2 handles syncing a single BlockV2 in sequencer mode. +// Verifies block signature using the history-aware verifier, then applies. +func (bcR *Reactor) syncBlockV2(block types.SyncableBlock, blocksSynced *uint64, lastRate *float64, lastHundred *time.Time) bool { + blockV2, err := bcR.verifyBlockV2(block) + if err != nil { + // Wrong type or bad signature == the peer served bad/forged data: redo + ban. + bcR.redoAndBan(block, fmt.Sprintf("BlockV2 verification failed: %v", err)) return false } - // Apply BlockV2 via stateV2 + // Apply BlockV2 via stateV2. The block is already signature-verified (authentic), so an + // apply failure is almost always a local issue (geth state), not the peer's fault — redo + // without banning to avoid dropping honest peers during a local hiccup. if err := bcR.stateV2.ApplyBlock(blockV2); err != nil { bcR.Logger.Error("Failed to apply BlockV2", "height", blockV2.Number, "err", err) bcR.pool.RedoRequest(blockV2.GetHeight()) @@ -612,18 +632,9 @@ FOR_LOOP: didProcessCh <- struct{}{} } - if firstSync.GetHeight()+1 == upgrade.UpgradeBlockHeight { - if err := bcR.handleTheLastTMBlock(state, firstSync); err != nil { - bcR.Logger.Error("Error in apply last tendermint block", "err", err) - peerID := bcR.pool.RedoRequest(firstSync.GetHeight()) - peer := bcR.Switch.Peers().Get(peerID) - if peer != nil { - bcR.Switch.StopPeerForError(peer, fmt.Errorf("handleTheLastTMBlock error: %v", err)) - } - continue FOR_LOOP - } - bcR.pool.PopRequest() - blocksSynced++ + // Detect and process the last PBFT block at the timestamp-driven upgrade + // boundary (runs at most once; no-op after the boundary height is recorded). + if bcR.checkAndHandleTheLastTMBlock(state, firstSync, secondSync, &blocksSynced) { continue FOR_LOOP } @@ -633,8 +644,20 @@ FOR_LOOP: continue FOR_LOOP } - // PBFT mode: type assert to *types.Block - first, second := firstSync.(*types.Block), secondSync.(*types.Block) + // PBFT mode: both blocks must be V1. A peer that serves a V2 block in this + // range is malicious or on a bad fork — ban only the mismatched one(s) (this + // is the hot path; avoid false-banning the peer that served a correct block). + first, ok1 := firstSync.(*types.Block) + second, ok2 := secondSync.(*types.Block) + if !ok1 || !ok2 { + if !ok1 { + bcR.redoAndBan(firstSync, "expected V1 block in PBFT range") + } + if !ok2 { + bcR.redoAndBan(secondSync, "expected V1 block in PBFT range") + } + continue FOR_LOOP + } firstParts, err := first.MakePartSet(types.BlockPartSizeBytes) if err != nil { @@ -727,10 +750,34 @@ func (bcR *Reactor) BroadcastStatusRequest() error { return nil } -// just skip the last tendermint block commit check. -// TODO: consider add the commit check in the future or using batch derivation reorg +func (bcR *Reactor) checkAndHandleTheLastTMBlock(state sm.State, first, second types.SyncableBlock, blocksSynced *uint64) bool { + if upgrade.UpgradeBlockHeight() >= 0 { + return false // boundary already discovered; only runs once + } + if first.GetBlockVersion() != types.Version1 || !upgrade.IsUpgradedByTs(first.GetTime()) { + return false // not the boundary; fall through to the normal sync paths + } + if _, err := bcR.verifyBlockV2(second); err != nil { + bcR.redoAndBan(first, fmt.Sprintf("boundary check failed: %v", err)) + bcR.redoAndBan(second, fmt.Sprintf("boundary: second must be a valid V2 block: %v", err)) + return true + } + if err := bcR.handleTheLastTMBlock(state, first); err != nil { + bcR.redoAndBan(first, fmt.Sprintf("apply last tendermint block: %v", err)) + bcR.redoAndBan(second, fmt.Sprintf("boundary rolled back after first failed: %v", err)) + return true + } + upgrade.SetUpgradeBlockHeight(first.GetHeight()) + bcR.pool.PopRequest() + *blocksSynced++ + return true +} + func (bcR *Reactor) handleTheLastTMBlock(state sm.State, lastSyncable types.SyncableBlock) error { - last := lastSyncable.(*types.Block) + last, ok := lastSyncable.(*types.Block) + if !ok { + return fmt.Errorf("handleTheLastTMBlock: expected V1 block at height %d", lastSyncable.GetHeight()) + } lastParts, err := last.MakePartSet(types.BlockPartSizeBytes) if err != nil { bcR.Logger.Error( diff --git a/consensus/reactor.go b/consensus/reactor.go index 18d260eaa43..531139640f8 100644 --- a/consensus/reactor.go +++ b/consensus/reactor.go @@ -81,7 +81,7 @@ func (conR *Reactor) OnStart() error { if upgrade.IsUpgraded(conR.conS.Height) { conR.Logger.Info("Already upgraded to sequencer mode, consensus reactor will not start", "height", conR.conS.Height, - "upgradeHeight", upgrade.UpgradeBlockHeight) + "upgradeHeight", upgrade.UpgradeBlockHeight()) return nil } diff --git a/consensus/state.go b/consensus/state.go index 08e9c76f8ce..da8562ab15c 100644 --- a/consensus/state.go +++ b/consensus/state.go @@ -1809,10 +1809,13 @@ func (cs *State) finalizeCommit(height int64) { } // Check for upgrade to sequencer mode - if upgrade.IsUpgraded(cs.Height) { - logger.Info("Upgrade height reached, switching to sequencer mode", - "height", cs.Height, - "upgradeHeight", upgrade.UpgradeBlockHeight) + if upgrade.IsUpgradedByTs(block.Time.UnixMilli()) { + // persistent the upgradeHeight, load it into upgradeHeight when startup + upgrade.SetUpgradeBlockHeight(height) + + logger.Info("Upgrade time reached, switching to sequencer mode", + "height", height, + "upgradeTime", upgrade.UpgradeBlockTime()) // Stop consensus state first. // diff --git a/node/node.go b/node/node.go index a6d0744aa36..8c3b2f509de 100644 --- a/node/node.go +++ b/node/node.go @@ -804,6 +804,14 @@ func NewNode( sm.StoreOptions{DiscardABCIResponses: config.Storage.DiscardABCIResponses}, ) + // Restore the persisted sequencer-upgrade boundary (if any) and wire the store so future + // SetUpgradeBlockHeight calls (finalizeCommit / blocksync) persist it automatically. + // A DB read failure here is fatal: booting with a reset boundary would let the node + // re-run PBFT past the upgrade. + if err := upgrade.SetStore(stateDB); err != nil { + return nil, err + } + state, genDoc, err := LoadStateFromDBOrGenesisDocProvider(stateDB, genesisDocProvider) if err != nil { return nil, err diff --git a/sequencer/interfaces.go b/sequencer/interfaces.go index b9192ff4a2c..542ff4b98b6 100644 --- a/sequencer/interfaces.go +++ b/sequencer/interfaces.go @@ -25,11 +25,6 @@ var ( type SequencerVerifier interface { // IsSequencerAt checks if addr was the valid sequencer at the given L2 block height. IsSequencerAt(addr common.Address, l2Height uint64) (bool, error) - - // VerificationStartHeight returns the L2 block height from which V2 signature - // verification is enforced (= upgradeBlockHeight). Blocks below this height are - // PBFT blocks and skip V2 verification. Returns math.MaxUint64 if not configured. - VerificationStartHeight() uint64 } // Signer interface for sequencer block signing diff --git a/sequencer/state_v2.go b/sequencer/state_v2.go index c5f2e13f501..4fb5ce139ae 100644 --- a/sequencer/state_v2.go +++ b/sequencer/state_v2.go @@ -12,7 +12,7 @@ import ( const ( // BlockInterval is the fallback interval for empty blocks (no txs). - BlockInterval = 3000 * time.Millisecond + BlockInterval = 5000 * time.Millisecond // FastBlockInterval is the txpool polling interval. // When pending txs are found, a block is produced immediately. FastBlockInterval = 300 * time.Millisecond diff --git a/sequencer/state_v2_test.go b/sequencer/state_v2_test.go index 054f57bc192..f95dfab315a 100644 --- a/sequencer/state_v2_test.go +++ b/sequencer/state_v2_test.go @@ -34,7 +34,6 @@ func (m *mockSignerImpl) Address() common.Address { // mockSequencerVerifier is a mock implementation of SequencerVerifier for testing. type mockSequencerVerifier struct { isSequencer bool - startHeight uint64 err error } @@ -45,10 +44,6 @@ func (m *mockSequencerVerifier) IsSequencerAt(addr common.Address, l2Height uint return m.isSequencer, nil } -func (m *mockSequencerVerifier) VerificationStartHeight() uint64 { - return m.startHeight -} - // mockSequencerHA is a mock implementation of SequencerHA for testing. type mockSequencerHA struct { leader bool diff --git a/types/block.go b/types/block.go index be6ae7806b2..1c8fb351f44 100644 --- a/types/block.go +++ b/types/block.go @@ -59,6 +59,10 @@ func (b *Block) GetHash() []byte { return b.Hash().Bytes() } +func (b *Block) GetTime() int64 { return b.Header.Time.UnixMilli() } + +func (b *Block) GetBlockVersion() BlockVersion { return Version1 } + // Ensure Block implements SyncableBlock var _ SyncableBlock = (*Block)(nil) diff --git a/types/block_v2.go b/types/block_v2.go index ce447a3b3be..70f22d613e6 100644 --- a/types/block_v2.go +++ b/types/block_v2.go @@ -2,10 +2,9 @@ package types import ( "errors" - "math/big" - "github.com/morph-l2/go-ethereum/common" seqproto "github.com/tendermint/tendermint/proto/tendermint/sequencer" + "math/big" ) // BlockV2 represents the block format after upgrade to centralized sequencer mode. @@ -49,11 +48,26 @@ func (b *BlockV2) GetHash() []byte { return b.Hash.Bytes() } +func (b *BlockV2) GetTime() int64 { return int64(b.Timestamp) * 1000 } + +func (b *BlockV2) GetBlockVersion() BlockVersion { return Version2 } + +// BlockVersion identifies the wire format of a SyncableBlock: Version1 is the +// PBFT-era tendermint block, Version2 is the post-upgrade sequencer block. +type BlockVersion uint8 + +const ( + Version1 BlockVersion = 1 + Version2 BlockVersion = 2 +) + // SyncableBlock is an interface that both old Block and new BlockV2 can implement // for compatibility in the block pool. type SyncableBlock interface { GetHeight() int64 GetHash() []byte + GetTime() int64 + GetBlockVersion() BlockVersion } // Ensure BlockV2 implements SyncableBlock diff --git a/upgrade/upgrade.go b/upgrade/upgrade.go index 80222b24e12..3037ef438bc 100644 --- a/upgrade/upgrade.go +++ b/upgrade/upgrade.go @@ -1,23 +1,114 @@ package upgrade -// Hardcoded upgrade parameters +import ( + "encoding/binary" + "fmt" + "sync/atomic" +) + +// Upgrade parameters. These are written at runtime by the consensus / blocksync paths and read +// concurrently by multiple reactors and the derivation loop, so all access goes through atomic +// load/store. The variables are unexported; callers use the accessor functions below rather than +// touching them directly, which keeps every read/write race-free. var ( - // UpgradeBlockHeight is the block height at which the consensus switches to sequencer mode. - // Default is -1 (upgrade disabled). Set via --consensus.switchHeight flag. - // When < 0, the upgrade never activates. - UpgradeBlockHeight int64 = -1 + // upgradeBlockHeight is the block height at which the consensus switches to sequencer mode. + // Default is -1 (upgrade disabled). It is discovered at runtime once upgradeBlockTime is + // crossed (see finalizeCommit / blocksync) and persisted via the wired store so the boundary + // survives restarts. When < 0, the height-based upgrade check is disabled. + upgradeBlockHeight int64 = -1 + + // upgradeBlockTime is the block timestamp (Unix milliseconds) at which the consensus switches + // to sequencer mode. Set once at node startup via SetUpgradeBlockTime. When <= 0, the + // timestamp-based upgrade check is disabled. + upgradeBlockTime int64 + + // store, when wired via SetStore, persists upgradeBlockHeight across restarts. Set once in + // SetStore at node startup (before the consensus/blocksync goroutines start), then only read. + store Store + + // upgradeHeightKey is the key under which upgradeBlockHeight is persisted. It is a plain key + // in the state DB and does not collide with state-store keys (stateKey, validatorsKey, ...). + upgradeHeightKey = []byte("upgrade/block_height") ) -// IsUpgraded returns true if the given height is at or after the upgrade height. -// Returns false when UpgradeBlockHeight < 0 (upgrade disabled). +// Store is the minimal key/value subset of the node's database that the upgrade package needs. +// Keeping it tiny lets this package stay dependency-light (no tm-db import) and trivially testable +// with an in-memory map. tm-db.DB satisfies it. +type Store interface { + Get([]byte) ([]byte, error) + Set([]byte, []byte) error +} + +// UpgradeBlockHeight returns the current upgrade block height (-1 when not yet discovered). +func UpgradeBlockHeight() int64 { return atomic.LoadInt64(&upgradeBlockHeight) } + +// UpgradeBlockTime returns the configured upgrade block time in Unix milliseconds (0 = disabled). +func UpgradeBlockTime() int64 { return atomic.LoadInt64(&upgradeBlockTime) } + +// SetStore wires persistence and restores any previously persisted upgrade height. +// Call once at node startup, before consensus / blocksync run. +// +// Fail-hard policy on the critical upgrade-height data: +// - a DB read error (IO) -> return error (treating it as "nothing persisted" would silently +// reset the boundary and let the node re-run PBFT past the upgrade after a restart); +// - a non-empty value of unexpected length -> corrupt -> return error rather than ignore it; +// - an absent key (len 0) is normal on first run and leaves the height at its current value. +func SetStore(s Store) error { + store = s + bz, err := s.Get(upgradeHeightKey) + if err != nil { + return fmt.Errorf("upgrade: read persisted block height: %w", err) + } + switch len(bz) { + case 0: + // absent: first run, nothing to restore. + case 8: + atomic.StoreInt64(&upgradeBlockHeight, int64(binary.BigEndian.Uint64(bz))) + default: + return fmt.Errorf("upgrade: corrupt persisted block height: got %d bytes, want 8", len(bz)) + } + return nil +} + +// IsUpgraded returns true if the given height is past the upgrade height. +// Returns false when the upgrade height is not yet set (< 0, upgrade disabled). func IsUpgraded(height int64) bool { - if UpgradeBlockHeight < 0 { + h := atomic.LoadInt64(&upgradeBlockHeight) + if h < 0 { + return false + } + return height > h +} + +// IsUpgradedByTs returns true if the given block timestamp (Unix ms) is at or after the +// upgrade time. Returns false when the upgrade time is not set (<= 0, upgrade disabled). +func IsUpgradedByTs(timestamp int64) bool { + t := atomic.LoadInt64(&upgradeBlockTime) + if t <= 0 { return false } - return height >= UpgradeBlockHeight + return timestamp >= t } -// SetUpgradeBlockHeight sets the upgrade block height. +// SetUpgradeBlockHeight sets the upgrade block height and, if a store is wired, persists it so the +// discovered boundary survives restarts. +// +// The height is critical consensus state: a node that loses it on restart re-runs PBFT past the +// upgrade or mishandles the boundary. If the write fails we therefore panic rather than continue +// with in-memory-only state that would silently diverge after a restart (same fail-hard policy as +// the signature store on a failed persist). func SetUpgradeBlockHeight(height int64) { - UpgradeBlockHeight = height + atomic.StoreInt64(&upgradeBlockHeight, height) + if store != nil { + var bz [8]byte + binary.BigEndian.PutUint64(bz[:], uint64(height)) + if err := store.Set(upgradeHeightKey, bz[:]); err != nil { + panic(fmt.Sprintf("upgrade: persist block height %d failed: %v", height, err)) + } + } +} + +// SetUpgradeBlockTime sets the upgrade block time (Unix milliseconds). +func SetUpgradeBlockTime(timestamp int64) { + atomic.StoreInt64(&upgradeBlockTime, timestamp) } diff --git a/upgrade/upgrade_test.go b/upgrade/upgrade_test.go new file mode 100644 index 00000000000..12de95f9303 --- /dev/null +++ b/upgrade/upgrade_test.go @@ -0,0 +1,162 @@ +package upgrade + +import ( + "errors" + "testing" +) + +// memStore is an in-memory Store for tests. +type memStore map[string][]byte + +func (m memStore) Get(k []byte) ([]byte, error) { return m[string(k)], nil } +func (m memStore) Set(k, v []byte) error { m[string(k)] = v; return nil } + +// errStore is a Store whose Get/Set can be made to fail, to exercise the +// fail-hard policy on the critical upgrade-height data. +type errStore struct { + data map[string][]byte + getErr error + setErr error +} + +func (s *errStore) Get(k []byte) ([]byte, error) { + if s.getErr != nil { + return nil, s.getErr + } + return s.data[string(k)], nil +} + +func (s *errStore) Set(k, v []byte) error { + if s.setErr != nil { + return s.setErr + } + s.data[string(k)] = v + return nil +} + +// reset restores package globals so tests do not leak state into each other. +func reset() { + upgradeBlockHeight = -1 + upgradeBlockTime = 0 + store = nil +} + +func TestIsUpgradedByTs(t *testing.T) { + reset() + defer reset() + + // Disabled when unset. + if IsUpgradedByTs(1) { + t.Fatal("expected disabled when upgrade time <= 0") + } + + SetUpgradeBlockTime(1000) + cases := []struct { + ts int64 + want bool + }{ + {999, false}, // before + {1000, true}, // exactly at boundary (>=) + {1001, true}, // after + } + for _, c := range cases { + if got := IsUpgradedByTs(c.ts); got != c.want { + t.Fatalf("IsUpgradedByTs(%d) = %v, want %v", c.ts, got, c.want) + } + } +} + +func TestSetUpgradeBlockHeightPersists(t *testing.T) { + reset() + defer reset() + + db := memStore{} + + // Fresh store, nothing persisted: load leaves the -1 sentinel. + if err := SetStore(db); err != nil { + t.Fatalf("SetStore: %v", err) + } + if UpgradeBlockHeight() != -1 { + t.Fatalf("expected -1 after empty load, got %d", UpgradeBlockHeight()) + } + + // Discovering the boundary persists it. + SetUpgradeBlockHeight(42) + if UpgradeBlockHeight() != 42 { + t.Fatalf("expected 42, got %d", UpgradeBlockHeight()) + } + + // Simulate a restart: clear globals, re-wire the same DB, expect the height restored. + upgradeBlockHeight = -1 + store = nil + if err := SetStore(db); err != nil { + t.Fatalf("SetStore: %v", err) + } + if UpgradeBlockHeight() != 42 { + t.Fatalf("expected 42 restored after restart, got %d", UpgradeBlockHeight()) + } + if !IsUpgraded(43) || IsUpgraded(42) { + t.Fatalf("IsUpgraded boundary wrong after restore: 42=%v 43=%v", IsUpgraded(42), IsUpgraded(43)) + } +} + +func TestSetUpgradeBlockHeightNoStore(t *testing.T) { + reset() + defer reset() + + // No store wired: must not panic, just sets the global. + SetUpgradeBlockHeight(7) + if UpgradeBlockHeight() != 7 { + t.Fatalf("expected 7, got %d", UpgradeBlockHeight()) + } +} + +// A DB read failure must propagate (not be swallowed as "nothing persisted"), +// otherwise a restart would silently reset the boundary. +func TestSetStoreReadErrorPropagates(t *testing.T) { + reset() + defer reset() + + s := &errStore{getErr: errors.New("db read failed")} + if err := SetStore(s); err == nil { + t.Fatal("expected error when DB read fails") + } + if UpgradeBlockHeight() != -1 { + t.Fatalf("expected -1 unchanged on read error, got %d", UpgradeBlockHeight()) + } +} + +// A persisted value of unexpected (non-zero, non-8) length is corruption and must error, +// not be silently ignored (which would leave the boundary at the wrong value). +func TestSetStoreCorruptLengthErrors(t *testing.T) { + reset() + defer reset() + + db := memStore{} + db[string(upgradeHeightKey)] = []byte{0x1, 0x2, 0x3, 0x4} // 4 bytes -> corrupt + if err := SetStore(db); err == nil { + t.Fatal("expected error on corrupt (non-8-byte) persisted height") + } + if UpgradeBlockHeight() != -1 { + t.Fatalf("expected -1 unchanged on corrupt value, got %d", UpgradeBlockHeight()) + } +} + +// A failed persist of the critical upgrade height must stop the node (panic), +// not continue with in-memory-only state that diverges after a restart. +func TestSetUpgradeBlockHeightPanicsOnWriteFailure(t *testing.T) { + reset() + defer reset() + + s := &errStore{data: map[string][]byte{}, setErr: errors.New("disk full")} + if err := SetStore(s); err != nil { + t.Fatalf("SetStore: %v", err) + } + + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic when persisting block height fails") + } + }() + SetUpgradeBlockHeight(99) +}