diff --git a/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md b/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md new file mode 100644 index 00000000000..c7a1dd0e82b --- /dev/null +++ b/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md @@ -0,0 +1,100 @@ +# ADR: Genesis TX Metadata and Initial Height for Chain Upgrades + +## Context + +Chain hard forks require replaying historical transactions in a new chain's +genesis. Historical transactions were signed with the old chain's ID; during +genesis replay the ante handler must verify signatures against the chain ID +that was in effect when the tx was originally executed. + +A chain may go through multiple upgrades — a genesis could contain transactions +originating from several past chains (e.g. `gnoland1` and `gnoland-1`). Using +a single `OriginalChainID` field is fragile: it assumes all historical txs come +from one chain. Instead, we use a `PastChainIDs` allowlist and a per-tx +`ChainID` so each transaction is verified against its own originating chain ID. + +## Decision + +### `GnoTxMetadata` extensions + +Three fields on `GnoTxMetadata` (populated by tx-archive export): + +- **`Timestamp`** (`int64`): Unix timestamp of the original block. When + non-zero, overrides the block header time during replay. Zero means "use + the genesis block time" — it never clobbers the header with Unix epoch. +- **`BlockHeight`** (`int64`): Original block height of the tx. When > 0, + the context's block header height is set to this value during replay, and + the tx goes through the full ante handler (real sig verification, account + numbers, sequences). +- **`ChainID`** (`string`): Originating chain ID. Used for per-tx chain ID + override during replay if `ChainID` is in `GnoGenesisState.PastChainIDs`. + +### `GnoGenesisState` extensions + +Two new fields on `GnoGenesisState`: + +- **`PastChainIDs`** (`[]string`): Allowlist of chain IDs from which + historical transactions originated. Only chain IDs present in this slice + can override the context chain ID during replay. Empty = no overrides. +- **`InitialHeight`** (`int64`): Informational field for tooling. Records the + block height the new chain should start from. The actual enforcement is at + the consensus layer via `GenesisDoc.InitialHeight`; this field is not read + by the app during InitChain. + +### `GenesisDoc.InitialHeight` (tm2) + +Added to `tm2/pkg/bft/types.GenesisDoc`. When > 1, the consensus `Handshaker` +sets `state.LastBlockHeight = InitialHeight - 1` after `InitChain`, so the +first produced block has height `InitialHeight`. Validated to be non-negative. + +### How genesis replay works + +1. Genesis txs **without** metadata (or `BlockHeight = 0`) → current genesis + mode: package deploys, infinite gas, auto-account creation, no sig + verification. +2. Genesis txs **with** `metadata.BlockHeight > 0` → normal mode: full sig + verification, real account numbers and sequences. +3. Chain ID override applies only when all three conditions hold: + `BlockHeight > 0` AND `metadata.ChainID != ""` AND + `metadata.ChainID ∈ PastChainIDs`. +4. Timestamp override applies when `metadata.Timestamp != 0`. +5. After `InitChain`, the consensus layer reads `GenesisDoc.InitialHeight` and + advances `state.LastBlockHeight` so blocks start at the correct height. + +### Key properties + +- Standard genesis txs (package deployments, etc.) are unaffected. +- Unrecognised chain IDs are never silently overridden — they fail as expected. +- A genesis spanning multiple past chains works: each tx uses its own chain ID. +- All new fields use `omitempty`; existing genesis files are unaffected. + +## Alternatives considered + +1. **Re-sign all transactions**: Requires access to all private keys. Not + feasible. +2. **Skip sig verification entirely**: Reduces security guarantees. +3. **Single `OriginalChainID string`**: Simpler but fragile — assumes all + historical txs come from one chain. Breaks for multi-hop upgrades + (chain A → chain B → chain C). +4. **State-level override**: `OriginalChainID` applied uniformly to all + historical txs. `PastChainIDs` + per-tx `ChainID` is more precise: each tx + is verified against its own origin. + +## Consequences + +- Genesis files for chain upgrades will be larger (all historical txs with + metadata). +- `InitialHeight` is enforced at the consensus layer (`GenesisDoc.InitialHeight` + → `Handshaker` → `state.LastBlockHeight`). `GnoGenesisState.InitialHeight` + is informational only — it is not read during `InitChain`. +- Future upgrades from `gnoland-1` to `gnoland-2` can set + `PastChainIDs: ["gnoland1", "gnoland-1"]` to replay the full history. + +## Open items + +- Account number preservation: accounts are auto-assigned during balance + initialization. If the old chain had different account numbers, some txs + may fail replay. Workaround: order genesis balances so account numbers + align with the original chain. +- End-to-end test with a real chain halt → export → genesis assembly → + new chain start. diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index 8264b33e1f2..114245a1e13 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -356,6 +356,13 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci return nil, fmt.Errorf("invalid AppState of type %T", appState) } + if len(state.PastChainIDs) > 0 { + ctx.Logger().Info("Chain upgrade genesis replay", + "past_chain_ids", state.PastChainIDs, + "initial_height", state.InitialHeight, + ) + } + cfg.bankk.InitGenesis(ctx, state.Bank) // Apply genesis balances. for _, bal := range state.Balances { @@ -404,15 +411,26 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci // Check if there is metadata associated with the tx if metadata != nil { - // Create a custom context modifier ctxFn = func(ctx sdk.Context) sdk.Context { - // Create a copy of the header, in - // which only the timestamp information is modified header := ctx.BlockHeader().(*bft.Header).Copy() - header.Time = time.Unix(metadata.Timestamp, 0) + if metadata.Timestamp != 0 { + header.Time = time.Unix(metadata.Timestamp, 0) + } + if metadata.BlockHeight > 0 { + header.Height = metadata.BlockHeight + } + + ctx = ctx.WithBlockHeader(header) + + // For historical txs (BlockHeight > 0), override the chain ID + // for signature verification using the per-tx ChainID, provided + // it is in the genesis allowlist. This allows replaying txs from + // multiple past chains during a hard fork. + if metadata.BlockHeight > 0 && metadata.ChainID != "" && isPastChainID(state.PastChainIDs, metadata.ChainID) { + ctx = ctx.WithChainID(metadata.ChainID) + } - // Save the modified header - return ctx.WithBlockHeader(header) + return ctx } } @@ -434,6 +452,13 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci cfg.GenesisTxResultHandler(ctx, stdTx, res) } + + if state.InitialHeight > 0 { + ctx.Logger().Info("Genesis replay complete, chain will start from initial height", + "initial_height", state.InitialHeight, + ) + } + return txResponses, nil } @@ -446,6 +471,11 @@ type endBlockerApp interface { Logger() *slog.Logger } +// isPastChainID reports whether chainID is present in the pastChainIDs allowlist. +func isPastChainID(pastChainIDs []string, chainID string) bool { + return slices.Contains(pastChainIDs, chainID) +} + // EndBlocker defines the logic executed after every block. // Currently, it parses events that happened during execution to calculate // validator set changes diff --git a/gno.land/pkg/gnoland/app_test.go b/gno.land/pkg/gnoland/app_test.go index 8ccfb0de6c3..9eafe435ee6 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -358,6 +358,24 @@ func TestInitChainer_MetadataTxs(t *testing.T) { VM: vm.DefaultGenesisState(), } } + + getZeroTimestampMetadataState = func(tx std.Tx, balances []Balance) GnoGenesisState { + return GnoGenesisState{ + // Metadata present but Timestamp=0 — genesis block time should be preserved + Txs: []TxWithMetadata{ + { + Tx: tx, + Metadata: &GnoTxMetadata{ + Timestamp: 0, // zero — must not override to Unix epoch + }, + }, + }, + Balances: balances, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + } + } ) testTable := []struct { @@ -378,6 +396,12 @@ func TestInitChainer_MetadataTxs(t *testing.T) { laterTimestamp, getMetadataState, }, + { + "metadata transaction with zero timestamp uses genesis block time", + currentTimestamp, + currentTimestamp, // zero Timestamp → falls back to genesis block time + getZeroTimestampMetadataState, + }, } for _, testCase := range testTable { @@ -1315,3 +1339,422 @@ func TestPruneStrategyNothing(t *testing.T) { err = db.Close() require.NoError(t, err) } + +func TestChainUpgradeGenesisReplay(t *testing.T) { + t.Parallel() + + t.Run("fields serialize correctly", func(t *testing.T) { + t.Parallel() + + state := GnoGenesisState{ + Balances: []Balance{}, + Txs: []TxWithMetadata{}, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"old-chain-1", "old-chain-2"}, + InitialHeight: 100, + } + + // Serialize and deserialize + data, err := amino.MarshalJSON(state) + require.NoError(t, err) + + var decoded GnoGenesisState + require.NoError(t, amino.UnmarshalJSON(data, &decoded)) + + assert.Equal(t, []string{"old-chain-1", "old-chain-2"}, decoded.PastChainIDs) + assert.Equal(t, int64(100), decoded.InitialHeight) + }) + + t.Run("historical tx replays with correct block height", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "new-chain" + + path = "gno.land/r/demo/upgradetest" + body = `package upgradetest + +import "chain/runtime" + +var height int64 = runtime.ChainHeight() + +func GetHeight(cur realm) int64 { return height } +` + ) + + // Create a fresh app instance + app, err := NewAppWithOptions(TestAppOptions(db)) + require.NoError(t, err) + + // Prepare the deploy transaction + msg := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "upgradetest", + Path: path, + Files: []*std.MemFile{ + { + Name: "file.gno", + Body: body, + }, + { + Name: "gnomod.toml", + Body: gnolang.GenGnoModLatest(path), + }, + }, + }, + MaxDeposit: nil, + } + + // Sign with the old chain ID — metadata.BlockHeight > 0 and metadata.ChainID + // in PastChainIDs will cause the ctxFn to override the chain ID for sig verification. + // Account number=0 and sequence=0 because the account is created from balances + // but hasn't processed any transactions yet. + tx := createAndSignTx(t, []std.Msg{msg}, "old-chain", key) + + // Run InitChain with PastChainIDs and InitialHeight set, + // and the deploy tx using metadata with BlockHeight=42 and ChainID="old-chain" + app.InitChain(abci.RequestInitChain{ + ChainID: chainID, + Time: time.Now(), + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{}, + }, + }, + AppState: GnoGenesisState{ + Txs: []TxWithMetadata{ + { + Tx: tx, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 42, + ChainID: "old-chain", // must be in PastChainIDs for override + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"old-chain"}, + InitialHeight: 100, + }, + }) + + // Call GetHeight to verify the realm captured height=42 + callMsg := vm.MsgCall{ + Caller: key.PubKey().Address(), + PkgPath: path, + Func: "GetHeight", + } + + callTx := createAndSignTx(t, []std.Msg{callMsg}, chainID, key) + + marshalledTx, err := amino.Marshal(callTx) + require.NoError(t, err) + + resp := app.DeliverTx(abci.RequestDeliverTx{ + Tx: marshalledTx, + }) + + require.True(t, resp.IsOK(), "DeliverTx failed: %s", resp.Log) + + // The realm should have captured block height 42 + assert.Contains(t, string(resp.Data), "(42 int64)") + }) + + t.Run("metadata block height in GnoTxMetadata serializes correctly", func(t *testing.T) { + t.Parallel() + + txm := TxWithMetadata{ + Tx: std.Tx{}, + Metadata: &GnoTxMetadata{ + Timestamp: 1234567890, + BlockHeight: 42, + ChainID: "gnoland1", + }, + } + + data, err := amino.MarshalJSON(txm) + require.NoError(t, err) + + var decoded TxWithMetadata + require.NoError(t, amino.UnmarshalJSON(data, &decoded)) + + require.NotNil(t, decoded.Metadata) + assert.Equal(t, int64(1234567890), decoded.Metadata.Timestamp) + assert.Equal(t, int64(42), decoded.Metadata.BlockHeight) + assert.Equal(t, "gnoland1", decoded.Metadata.ChainID) + }) + + t.Run("chain ID not overridden when BlockHeight is zero in metadata", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "new-chain" + + path = "gno.land/r/demo/chainidtest" + body = `package chainidtest + +var Deployed = true + +func IsDeployed(cur realm) bool { return Deployed } +` + ) + + app, err := NewAppWithOptions(TestAppOptions(db)) + require.NoError(t, err) + + msg := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "chainidtest", + Path: path, + Files: []*std.MemFile{ + {Name: "file.gno", Body: body}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path)}, + }, + }, + } + + // When metadata.BlockHeight == 0, the chain ID override must NOT happen. + // So the tx must be signed with the current chain ID (chainID), not any past chain ID. + tx := createAndSignTx(t, []std.Msg{msg}, chainID, key) + + app.InitChain(abci.RequestInitChain{ + ChainID: chainID, + Time: time.Now(), + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{}, + }, + }, + AppState: GnoGenesisState{ + Txs: []TxWithMetadata{ + { + Tx: tx, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 0, // zero — no chain ID override + ChainID: "old-chain", // present but ignored since BlockHeight == 0 + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"old-chain"}, // set, but should NOT be used since BlockHeight == 0 + }, + }) + }) + + t.Run("no chain ID override when metadata.ChainID not in PastChainIDs", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "new-chain" + + path = "gno.land/r/demo/nooverride" + body = `package nooverride + +var Deployed = true +` + ) + + app, err := NewAppWithOptions(TestAppOptions(db)) + require.NoError(t, err) + + msg := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "nooverride", + Path: path, + Files: []*std.MemFile{ + {Name: "file.gno", Body: body}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path)}, + }, + }, + } + + // BlockHeight > 0 and metadata.ChainID is set, but the chain ID is NOT in + // PastChainIDs — no chain ID override should happen. The tx is signed with + // chainID so it verifies correctly without the override. + tx := createAndSignTx(t, []std.Msg{msg}, chainID, key) + + app.InitChain(abci.RequestInitChain{ + ChainID: chainID, + Time: time.Now(), + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{}, + }, + }, + AppState: GnoGenesisState{ + Txs: []TxWithMetadata{ + { + Tx: tx, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 10, + ChainID: "unknown-chain", // not in PastChainIDs — no override + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + // PastChainIDs intentionally empty — no chain ID override allowed + }, + }) + }) + + t.Run("txs from multiple past chains replay correctly", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "new-chain" + + path1 = "gno.land/r/demo/multichain1" + path2 = "gno.land/r/demo/multichain2" + body = `package %s +var Deployed = true +` + ) + + app, err := NewAppWithOptions(TestAppOptions(db)) + require.NoError(t, err) + + // Both txs come from the same account (accNum=0) but different past chains. + // tx1: seq=0, chain-a; tx2: seq=1, chain-b (sequence incremented by tx1). + msg1 := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "multichain1", + Path: path1, + Files: []*std.MemFile{ + {Name: "file.gno", Body: fmt.Sprintf(body, "multichain1")}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path1)}, + }, + }, + } + msg2 := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "multichain2", + Path: path2, + Files: []*std.MemFile{ + {Name: "file.gno", Body: fmt.Sprintf(body, "multichain2")}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path2)}, + }, + }, + } + + tx1 := createAndSignTx(t, []std.Msg{msg1}, "chain-a", key) // accNum=0, seq=0 + + // tx2 must use seq=1 because tx1 already incremented the sequence. + tx2Raw := std.Tx{ + Msgs: []std.Msg{msg2}, + Fee: std.Fee{GasFee: std.NewCoin("ugnot", 2_000_000), GasWanted: 10_000_000}, + } + signBytes2, err := tx2Raw.GetSignBytes("chain-b", 0, 1) // accNum=0, seq=1 + require.NoError(t, err) + sig2, err := key.Sign(signBytes2) + require.NoError(t, err) + tx2Raw.Signatures = []std.Signature{{PubKey: key.PubKey(), Signature: sig2}} + + // Both chain IDs in the allowlist; each tx carries its own ChainID + app.InitChain(abci.RequestInitChain{ + ChainID: chainID, + Time: time.Now(), + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{}, + }, + }, + AppState: GnoGenesisState{ + Txs: []TxWithMetadata{ + { + Tx: tx1, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 10, + ChainID: "chain-a", + }, + }, + { + Tx: tx2Raw, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 20, + ChainID: "chain-b", + }, + }, + }, + Balances: []Balance{ + {Address: key.PubKey().Address(), Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000))}, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"chain-a", "chain-b"}, + }, + }) + }) +} + +func TestIsPastChainID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pastChainIDs []string + chainID string + expected bool + }{ + {"empty allowlist", []string{}, "chain-a", false}, + {"nil allowlist", nil, "chain-a", false}, + {"single match", []string{"chain-a"}, "chain-a", true}, + {"no match in list", []string{"chain-a", "chain-b"}, "chain-c", false}, + {"match second element", []string{"chain-a", "chain-b"}, "chain-b", true}, + {"empty chain ID", []string{"chain-a"}, "", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, isPastChainID(tc.pastChainIDs, tc.chainID)) + }) + } +} diff --git a/gno.land/pkg/gnoland/node_initial_height_test.go b/gno.land/pkg/gnoland/node_initial_height_test.go new file mode 100644 index 00000000000..8512ff71481 --- /dev/null +++ b/gno.land/pkg/gnoland/node_initial_height_test.go @@ -0,0 +1,80 @@ +package gnoland + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" + bft "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/db/memdb" + "github.com/gnolang/gno/tm2/pkg/log" + + "github.com/gnolang/gno/gnovm/pkg/gnoenv" +) + +// TestNodeBootWithInitialHeight boots a full in-memory node whose genesis doc +// has InitialHeight = 100. It verifies that: +// +// - The node starts without panicking (exercises all the InitialHeight paths +// through Handshaker → ConsensusState.reconstructLastCommit → +// BlockchainReactor.NewBlockchainReactor). +// - The first committed block is at height 100, not 1. +func TestNodeBootWithInitialHeight(t *testing.T) { + const initialHeight = int64(100) + + td := t.TempDir() + tmcfg := NewDefaultTMConfig(td) + + pv := bft.NewMockPV() + pk := pv.PubKey() + + genesis := &bft.GenesisDoc{ + GenesisTime: time.Now(), + ChainID: tmcfg.ChainID(), + InitialHeight: initialHeight, + ConsensusParams: abci.ConsensusParams{ + Block: defaultBlockParams(), + }, + Validators: []bft.GenesisValidator{ + { + Address: pk.Address(), + PubKey: pk, + Power: 10, + Name: "self", + }, + }, + AppState: DefaultGenState(), + } + + cfg := &InMemoryNodeConfig{ + PrivValidator: pv, + Genesis: genesis, + TMConfig: tmcfg, + DB: memdb.NewMemDB(), + InitChainerConfig: InitChainerConfig{ + GenesisTxResultHandler: PanicOnFailingTxResultHandler, + StdlibDir: filepath.Join(gnoenv.RootDir(), "gnovm", "stdlibs"), + CacheStdlibLoad: true, + }, + } + + n, err := NewInMemoryNode(log.NewTestingLogger(t), cfg) + require.NoError(t, err) + + require.NoError(t, n.Start()) + t.Cleanup(func() { require.NoError(t, n.Stop()) }) + + select { + case <-n.Ready(): + // first block committed + case <-time.After(30 * time.Second): + t.Fatal("timeout waiting for node to produce first block") + } + + height := n.BlockStore().Height() + require.Equal(t, initialHeight, height, + "first committed block should be at InitialHeight (%d), got %d", initialHeight, height) +} diff --git a/gno.land/pkg/gnoland/types.go b/gno.land/pkg/gnoland/types.go index 050eda60c92..485597e2c26 100644 --- a/gno.land/pkg/gnoland/types.go +++ b/gno.land/pkg/gnoland/types.go @@ -126,6 +126,9 @@ type GnoGenesisState struct { Auth auth.GenesisState `json:"auth"` Bank bank.GenesisState `json:"bank"` VM vm.GenesisState `json:"vm"` + // Chain upgrade fields + PastChainIDs []string `json:"past_chain_ids,omitempty"` // Allowlist of chain IDs valid for historical tx signature verification + InitialHeight int64 `json:"initial_height,omitempty"` // Block height to start from after genesis replay } type TxWithMetadata struct { @@ -134,7 +137,9 @@ type TxWithMetadata struct { } type GnoTxMetadata struct { - Timestamp int64 `json:"timestamp"` + Timestamp int64 `json:"timestamp"` + BlockHeight int64 `json:"block_height,omitempty"` // Original block height for historical tx replay + ChainID string `json:"chain_id,omitempty"` // Originating chain ID, populated by tx-archive export } // ReadGenesisTxs reads the genesis txs from the given file path diff --git a/tm2/pkg/bft/blockchain/reactor.go b/tm2/pkg/bft/blockchain/reactor.go index 02714a7619e..abc4921f06b 100644 --- a/tm2/pkg/bft/blockchain/reactor.go +++ b/tm2/pkg/bft/blockchain/reactor.go @@ -78,7 +78,10 @@ func NewBlockchainReactor( fastSync bool, switchToConsensusFn SwitchToConsensusFn, ) *BlockchainReactor { - if state.LastBlockHeight != store.Height() { + // Allow the case where InitialHeight > 1: after InitChain, the Handshaker sets + // state.LastBlockHeight = InitialHeight - 1, but the block store is still empty + // (Height() == 0). A non-empty store must always match state. + if store.Height() != 0 && state.LastBlockHeight != store.Height() { panic(fmt.Sprintf("state (%v) and store (%v) height mismatch", state.LastBlockHeight, store.Height())) } diff --git a/tm2/pkg/bft/blockchain/reactor_test.go b/tm2/pkg/bft/blockchain/reactor_test.go index c3e55fce3ca..5402faa7f6c 100644 --- a/tm2/pkg/bft/blockchain/reactor_test.go +++ b/tm2/pkg/bft/blockchain/reactor_test.go @@ -390,6 +390,36 @@ func TestBcStatusResponseMessageValidateBasic(t *testing.T) { } } +// TestNewBlockchainReactor_InitialHeight verifies that NewBlockchainReactor does +// not panic when InitialHeight > 1 causes state.LastBlockHeight > 0 on a fresh +// chain where the block store is still empty (Height() == 0). +func TestNewBlockchainReactor_InitialHeight(t *testing.T) { + t.Parallel() + + config, _ = cfg.ResetTestRoot("blockchain_reactor_initial_height_test") + defer os.RemoveAll(config.RootDir) + + genDoc, _ := randGenesisDoc(1, false, 30) + state, err := sm.MakeGenesisState(genDoc) + assert.NoError(t, err) + + // Simulate the Handshaker setting LastBlockHeight = InitialHeight - 1 + // after InitChain with InitialHeight = 100. + state.LastBlockHeight = 99 + + db := memdb.NewMemDB() + sm.SaveState(db, state) + blockExec := sm.NewBlockExecutor(db, log.NewNoopLogger(), nil, mock.Mempool{}) + + // Empty block store: Height() == 0, no blocks committed yet. + blockStore := store.NewBlockStore(memdb.NewMemDB()) + + // Must not panic even though state.LastBlockHeight (99) != store.Height() (0). + assert.NotPanics(t, func() { + _ = NewBlockchainReactor(state.Copy(), blockExec, blockStore, false, nil) + }) +} + // ---------------------------------------------- // utility funcs diff --git a/tm2/pkg/bft/consensus/replay.go b/tm2/pkg/bft/consensus/replay.go index f2d7f15e51a..35257905b35 100644 --- a/tm2/pkg/bft/consensus/replay.go +++ b/tm2/pkg/bft/consensus/replay.go @@ -329,6 +329,18 @@ func (h *Handshaker) ReplayBlocks( if res.ConsensusParams != nil { state.ConsensusParams = state.ConsensusParams.Update(*res.ConsensusParams) } + + // If InitialHeight is set, the chain starts at that height. + // This is used for chain upgrades where historical txs are replayed + // during genesis and the chain should continue from the halted height. + if h.genDoc.InitialHeight > 1 { + state.LastBlockHeight = h.genDoc.InitialHeight - 1 + h.logger.Info("Setting initial height from genesis", + "initial_height", h.genDoc.InitialHeight, + "last_block_height", state.LastBlockHeight, + ) + } + sm.SaveState(h.stateDB, state) } } diff --git a/tm2/pkg/bft/consensus/replay_test.go b/tm2/pkg/bft/consensus/replay_test.go index 37e28720d4d..5fde4de44a6 100644 --- a/tm2/pkg/bft/consensus/replay_test.go +++ b/tm2/pkg/bft/consensus/replay_test.go @@ -1304,3 +1304,158 @@ func TestReplayNilGuards(t *testing.T) { }) } } + +// TestReconstructLastCommit_InitialHeight verifies that reconstructLastCommit +// does not panic when LastBlockHeight > 0 but the block store is empty +// (the scenario that arises when InitialHeight > 1 is set after InitChain). +func TestReconstructLastCommit_InitialHeight(t *testing.T) { + t.Parallel() + + testCfg, genesisFile := ResetConfig("reconstruct_last_commit_initial_height_test") + t.Cleanup(func() { os.RemoveAll(testCfg.RootDir) }) + + state, err := sm.MakeGenesisStateFromFile(genesisFile) + require.NoError(t, err) + + // Simulate what the Handshaker does after InitChain with InitialHeight=100: + // LastBlockHeight is set to InitialHeight-1 but the block store is empty. + state.LastBlockHeight = 99 + + stateDB := memdb.NewMemDB() + sm.SaveState(stateDB, state) + + mempool := mock.Mempool{} + blockExec := sm.NewBlockExecutor(stateDB, log.NewNoopLogger(), nil, mempool) + + // Empty block store — Height() returns 0, LoadSeenCommit returns nil. + store := &nilReturningBlockStore{height: 0} + + // NewConsensusState calls reconstructLastCommit; this must not panic. + require.NotPanics(t, func() { + _ = NewConsensusState(testCfg.Consensus, state, blockExec, store, mempool) + }) +} + +// TestCreateProposalBlock_InitialHeight verifies that createProposalBlock uses +// an empty commit (genesis behaviour) when the block store is empty and height > 1, +// rather than falling through to the "No commit for previous block" error branch. +func TestCreateProposalBlock_InitialHeight(t *testing.T) { + t.Parallel() + + testCfg, genesisFile := ResetConfig("create_proposal_block_initial_height_test") + t.Cleanup(func() { os.RemoveAll(testCfg.RootDir) }) + + state, err := sm.MakeGenesisStateFromFile(genesisFile) + require.NoError(t, err) + + state.LastBlockHeight = 99 // simulates InitialHeight=100 + + stateDB := memdb.NewMemDB() + sm.SaveState(stateDB, state) + + mempool := mock.Mempool{} + blockExec := sm.NewBlockExecutor(stateDB, log.NewNoopLogger(), nil, mempool) + + store := &nilReturningBlockStore{height: 0, nilBlockMetaAt: 99} + cs := NewConsensusState(testCfg.Consensus, state, blockExec, store, mempool) + + // Supply a private validator so createProposalBlock can proceed past the + // commit-selection logic and reach the actual block creation. + pv := loadPrivValidator(testCfg) + cs.SetPrivValidator(pv) + + // cs.Height == 100, cs.blockStore.Height() == 0, cs.LastCommit == nil. + // createProposalBlock used to fall through to the "No commit" default branch + // and return nil without creating a block. After the fix it must return a + // non-nil block at height 100. + block, parts := cs.createProposalBlock() + require.NotNil(t, block, "createProposalBlock should return a valid block at InitialHeight") + require.NotNil(t, parts) + assert.Equal(t, int64(100), block.Height) +} + +// TestNeedProofBlock_InitialHeight verifies that needProofBlock returns true +// when the block store is empty and height > 1 (InitialHeight > 1 scenario), +// rather than panicking from a nil LoadBlockMeta result. +func TestNeedProofBlock_InitialHeight(t *testing.T) { + t.Parallel() + + testCfg, genesisFile := ResetConfig("need_proof_block_initial_height_test") + t.Cleanup(func() { os.RemoveAll(testCfg.RootDir) }) + + state, err := sm.MakeGenesisStateFromFile(genesisFile) + require.NoError(t, err) + + // Simulate InitialHeight=100: LastBlockHeight is set to 99, block store empty. + state.LastBlockHeight = 99 + + stateDB := memdb.NewMemDB() + sm.SaveState(stateDB, state) + + mempool := mock.Mempool{} + blockExec := sm.NewBlockExecutor(stateDB, log.NewNoopLogger(), nil, mempool) + + // Empty block store: Height() == 0, and LoadBlockMeta returns nil for all heights + // (no blocks exist yet — this is what a real empty store does). + store := &nilReturningBlockStore{height: 0, nilBlockMetaAt: 99} + + cs := NewConsensusState(testCfg.Consensus, state, blockExec, store, mempool) + + // cs.Height is 100 (LastBlockHeight+1). + // needProofBlock(100) used to panic: LoadBlockMeta(99) == nil on empty store. + // After the fix it should return true (genesis-equivalent block). + var result bool + require.NotPanics(t, func() { + result = cs.needProofBlock(cs.Height) + }) + assert.True(t, result, "needProofBlock should return true at InitialHeight with empty block store") +} + +// TestHandshaker_InitialHeight verifies that when GenesisDoc.InitialHeight > 1, +// the handshaker sets state.LastBlockHeight = InitialHeight - 1 after InitChain, +// so the chain starts producing blocks at the correct height. +func TestHandshaker_InitialHeight(t *testing.T) { + t.Parallel() + + testCfg, genesisFile := ResetConfig("initial_height_test") + t.Cleanup(func() { os.RemoveAll(testCfg.RootDir) }) + + state, err := sm.MakeGenesisStateFromFile(genesisFile) + require.NoError(t, err) + + genDoc, err := sm.MakeGenesisDocFromFile(genesisFile) + require.NoError(t, err) + + // Simulate a chain upgrade: new chain starts at a height > 1 + const initialHeight = int64(100) + genDoc.InitialHeight = initialHeight + + stateDB := memdb.NewMemDB() + sm.SaveState(stateDB, state) + + // App that echoes back validators from InitChain (required for state update) + app := initChainApp{ + initChain: func(req abci.RequestInitChain) abci.ResponseInitChain { + return abci.ResponseInitChain{Validators: req.Validators} + }, + } + + // Empty block store: no blocks committed yet (fresh genesis scenario) + store := &nilReturningBlockStore{height: 0} + + handshaker := NewHandshaker(stateDB, state, store, genDoc) + handshaker.SetLogger(log.NewNoopLogger()) + + proxyApp := appconn.NewAppConns(proxy.NewLocalClientCreator(app)) + require.NoError(t, proxyApp.Start()) + t.Cleanup(func() { require.NoError(t, proxyApp.Stop()) }) + + // appBlockHeight=0 triggers InitChain; storeBlockHeight=0 so no block replay + _, err = handshaker.ReplayBlocks(state, nil, 0, proxyApp) + require.NoError(t, err) + + // After InitChain with InitialHeight=100, the saved state must have LastBlockHeight=99 + // so the first produced block will be at height 100. + savedState := sm.LoadState(stateDB) + assert.Equal(t, initialHeight-1, savedState.LastBlockHeight) +} diff --git a/tm2/pkg/bft/consensus/state.go b/tm2/pkg/bft/consensus/state.go index 6dac45654dd..2cf53f81e95 100644 --- a/tm2/pkg/bft/consensus/state.go +++ b/tm2/pkg/bft/consensus/state.go @@ -494,6 +494,12 @@ func (cs *ConsensusState) reconstructLastCommit(state sm.State) { } seenCommit := cs.blockStore.LoadSeenCommit(state.LastBlockHeight) if seenCommit == nil { + // Fresh genesis with InitialHeight > 1: the block store has no history yet. + // The handshaker sets LastBlockHeight = InitialHeight - 1 before the first + // block is produced, so there is no SeenCommit to reconstruct. + if cs.blockStore.Height() == 0 { + return + } panic(fmt.Sprintf("Failed to reconstruct LastCommit: SeenCommit not found for height %d", state.LastBlockHeight)) } lastPrecommits := types.CommitToVoteSet(state.ChainID, seenCommit, state.LastValidators) @@ -849,9 +855,11 @@ func (cs *ConsensusState) enterNewRound(height int64, round int) { } // needProofBlock returns true on the first height (so the genesis app hash is signed right away) -// and where the last block (height-1) caused the app hash to change +// and where the last block (height-1) caused the app hash to change. +// When InitialHeight > 1, the block store is empty at the genesis height, so we +// treat it the same as height == 1. func (cs *ConsensusState) needProofBlock(height int64) bool { - if height == 1 { + if height == 1 || cs.blockStore.Height() == 0 { return true } @@ -988,9 +996,9 @@ func (cs *ConsensusState) isProposalComplete() bool { func (cs *ConsensusState) createProposalBlock() (block *types.Block, blockParts *types.PartSet) { var commit *types.Commit switch { - case cs.Height == 1: - // We're creating a proposal for the first block. - // The commit is empty, but not nil. + case cs.Height == 1 || cs.blockStore.Height() == 0: + // We're creating a proposal for the genesis block (height 1, or InitialHeight > 1 + // where the block store is still empty). The commit is empty, but not nil. commit = types.NewCommit(types.BlockID{}, nil) case cs.LastCommit.HasTwoThirdsMajority(): // Make the commit from LastCommit diff --git a/tm2/pkg/bft/state/execution.go b/tm2/pkg/bft/state/execution.go index 50d1f6a8279..0ced3fd47f7 100644 --- a/tm2/pkg/bft/state/execution.go +++ b/tm2/pkg/bft/state/execution.go @@ -268,7 +268,12 @@ func getBeginBlockLastCommitInfo(block *types.Block, stateDB dbm.DB) abci.LastCo voteInfos := make([]abci.VoteInfo, block.LastCommit.Size()) var lastValSet *types.ValidatorSet var err error - if block.Height > 1 { + // For a genesis block (standard height-1 or InitialHeight > 1) the commit + // has no precommits, so there are no previous validators to attribute votes + // to. We detect this by checking whether the commit is empty: when the chain + // has just started the last-commit passed to the genesis block is always + // types.NewCommit(BlockID{}, nil) which has Size() == 0. + if block.LastCommit.Size() > 0 { lastValSet, err = LoadValidators(stateDB, block.Height-1) if err != nil { panic(err) // shouldn't happen diff --git a/tm2/pkg/bft/state/execution_test.go b/tm2/pkg/bft/state/execution_test.go index 5e9c0083c56..d8b1fbb8d66 100644 --- a/tm2/pkg/bft/state/execution_test.go +++ b/tm2/pkg/bft/state/execution_test.go @@ -355,3 +355,31 @@ func TestEndBlockValidatorUpdatesResultingInEmptySet(t *testing.T) { assert.NotNil(t, err) assert.NotEmpty(t, state.NextValidators.Validators) } + +// TestGetBeginBlockLastCommitInfo_InitialHeight verifies that +// getBeginBlockLastCommitInfo does not panic when the chain starts at +// InitialHeight > 1. In that case the genesis block (e.g. height 100) has +// an empty LastBlockID (no real previous block) and the stateDB contains no +// validator-set entry for height 99 — because the chain never had a block at +// that height. +func TestGetBeginBlockLastCommitInfo_InitialHeight(t *testing.T) { + t.Parallel() + + const initialHeight = int64(100) + + // Build a genesis block at initialHeight with zero LastBlockID (no prev block). + state, stateDB, _ := makeState(1, 1) + state.LastBlockHeight = initialHeight - 1 + + emptyCommit := types.NewCommit(types.BlockID{}, nil) + block, _ := state.MakeBlock(initialHeight, nil, emptyCommit, state.Validators.GetProposer().Address) + + // The stateDB has no validator set saved at height initialHeight-1 + // because the chain never produced blocks before initialHeight. + // Before the fix this panics with "Could not find validator set for height #99". + assert.NotPanics(t, func() { + info := sm.GetBeginBlockLastCommitInfo(block, stateDB) + // Genesis block: no votes + assert.Empty(t, info.Votes) + }) +} diff --git a/tm2/pkg/bft/state/export_test.go b/tm2/pkg/bft/state/export_test.go index 0935236ed92..8491330715f 100644 --- a/tm2/pkg/bft/state/export_test.go +++ b/tm2/pkg/bft/state/export_test.go @@ -53,3 +53,10 @@ func SaveConsensusParamsInfo(db dbm.DB, nextHeight, changeHeight int64, params a func SaveValidatorsInfo(db dbm.DB, height, lastHeightChanged int64, valSet *types.ValidatorSet) { saveValidatorsInfo(db, height, lastHeightChanged, valSet) } + +// GetBeginBlockLastCommitInfo is an alias for the private +// getBeginBlockLastCommitInfo function in execution.go, exported exclusively +// and explicitly for testing. +func GetBeginBlockLastCommitInfo(block *types.Block, stateDB dbm.DB) abci.LastCommitInfo { + return getBeginBlockLastCommitInfo(block, stateDB) +} diff --git a/tm2/pkg/bft/state/state.go b/tm2/pkg/bft/state/state.go index e0de2b2ad34..60b094dd786 100644 --- a/tm2/pkg/bft/state/state.go +++ b/tm2/pkg/bft/state/state.go @@ -127,8 +127,11 @@ func (state State) MakeBlock( block := types.MakeBlock(height, txs, commit) // Set time. + // The genesis block (height 1, or the first block after InitialHeight > 1) + // uses the genesis time from state. We detect the genesis case by whether the + // commit references a real previous block (non-zero BlockID). var timestamp time.Time - if height == 1 { + if commit.BlockID.IsZero() { timestamp = state.LastBlockTime // genesis time } else { timestamp = MedianTime(commit, state.LastValidators) diff --git a/tm2/pkg/bft/state/validation.go b/tm2/pkg/bft/state/validation.go index c08d2b23a66..9b2c9b773f1 100644 --- a/tm2/pkg/bft/state/validation.go +++ b/tm2/pkg/bft/state/validation.go @@ -92,9 +92,14 @@ func (state State) ValidateBlock(block *types.Block) error { } // Validate block LastCommit. - if block.Height == 1 { + // The genesis block (height 1, or InitialHeight > 1 where there is no real + // previous block) has an empty LastCommit; all other blocks must have a valid + // commit from the previous round. We detect the genesis case by checking + // whether state.LastBlockID is zero (no previous block exists). + isGenesisBlock := state.LastBlockID.IsZero() + if isGenesisBlock { if len(block.LastCommit.Precommits) != 0 { - return errors.New("block at height 1 can't have LastCommit precommits") + return errors.New("genesis block can't have LastCommit precommits") } } else { if len(block.LastCommit.Precommits) != state.LastValidators.Size() { @@ -108,7 +113,7 @@ func (state State) ValidateBlock(block *types.Block) error { } // Validate block Time - if block.Height > 1 { + if !isGenesisBlock { if !block.Time.After(state.LastBlockTime) { return fmt.Errorf("block time %v not greater than last block time %v", block.Time, @@ -123,7 +128,7 @@ func (state State) ValidateBlock(block *types.Block) error { block.Time, ) } - } else if block.Height == 1 { + } else { genesisTime := state.LastBlockTime if !block.Time.Equal(genesisTime) { return fmt.Errorf("block time %v is not equal to genesis time %v", diff --git a/tm2/pkg/bft/store/store.go b/tm2/pkg/bft/store/store.go index 8cce3ef8a3f..3242976fe70 100644 --- a/tm2/pkg/bft/store/store.go +++ b/tm2/pkg/bft/store/store.go @@ -165,8 +165,13 @@ func (bs *BlockStore) SaveBlock(block *types.Block, blockParts *types.PartSet, s panic("BlockStore can only save a non-nil block") } height := block.Height - if g, w := height, bs.Height()+1; g != w { - panic(fmt.Sprintf("BlockStore can only save contiguous blocks. Wanted %v, got %v", w, g)) + // When the store is empty (Height() == 0) the chain may start at InitialHeight > 1, + // so any height is valid for the first block. Once the store has blocks, saves + // must be strictly contiguous. + if bs.Height() != 0 { + if g, w := height, bs.Height()+1; g != w { + panic(fmt.Sprintf("BlockStore can only save contiguous blocks. Wanted %v, got %v", w, g)) + } } if !blockParts.IsComplete() { panic("BlockStore can only save complete block part sets") @@ -205,7 +210,8 @@ func (bs *BlockStore) SaveBlock(block *types.Block, blockParts *types.PartSet, s } func (bs *BlockStore) saveBlockPart(height int64, index int, part *types.Part) { - if height != bs.Height()+1 { + // Allow the genesis block at any height when the store is empty (InitialHeight > 1). + if bs.Height() != 0 && height != bs.Height()+1 { panic(fmt.Sprintf("BlockStore can only save contiguous blocks. Wanted %v, got %v", bs.Height()+1, height)) } partBytes := amino.MustMarshal(part) diff --git a/tm2/pkg/bft/store/store_test.go b/tm2/pkg/bft/store/store_test.go index 6f2e413e671..da8464f5793 100644 --- a/tm2/pkg/bft/store/store_test.go +++ b/tm2/pkg/bft/store/store_test.go @@ -197,9 +197,13 @@ func TestBlockStoreSaveLoadBlock(t *testing.T) { }, { + // An empty block store now accepts any height as the first block + // (supporting InitialHeight > 1 for chain upgrades). The panic + // here is therefore NOT about contiguous blocks but about the nil + // seenCommit that gets marshaled. block: newBlock(header2, commitAtH10), parts: uncontiguousPartSet, - wantPanic: "only save contiguous blocks", // and incomplete and uncontiguous parts + wantPanic: "nil pointer", }, { @@ -472,3 +476,55 @@ func newBlock(hdr types.Header, lastCommit *types.Commit) *types.Block { LastCommit: lastCommit, } } + +// TestBlockStore_InitialHeight verifies that an empty BlockStore accepts a +// block at any height >= 1. This is required for chains that start at +// InitialHeight > 1 (e.g. a chain upgraded from an older chain ID that +// replays historical transactions at genesis). +func TestBlockStore_InitialHeight(t *testing.T) { + t.Parallel() + + state, _, cleanup := makeStateAndBlockStore(log.NewNoopLogger()) + defer cleanup() + + const initialHeight = int64(50) + + bs, _ := freshBlockStore() + require.Equal(t, int64(0), bs.Height()) + + genesisBlock := makeBlock(initialHeight, state, new(types.Commit)) + parts := genesisBlock.MakePartSet(2) + sc := makeTestCommit(initialHeight, tmtime.Now()) + + assert.NotPanics(t, func() { + bs.SaveBlock(genesisBlock, parts, sc) + }, "empty store should accept first block at any height") + require.Equal(t, initialHeight, bs.Height()) +} + +// TestBlockStore_ContiguousAfterInitialHeight verifies that after the first +// block is saved (possibly at InitialHeight > 1), subsequent saves must be +// strictly contiguous. +func TestBlockStore_ContiguousAfterInitialHeight(t *testing.T) { + t.Parallel() + + state, _, cleanup := makeStateAndBlockStore(log.NewNoopLogger()) + defer cleanup() + + const initialHeight = int64(50) + + bs, _ := freshBlockStore() + genesisBlock := makeBlock(initialHeight, state, new(types.Commit)) + parts := genesisBlock.MakePartSet(2) + sc := makeTestCommit(initialHeight, tmtime.Now()) + bs.SaveBlock(genesisBlock, parts, sc) + + // Trying to save at a non-contiguous height (50+2=52, not 51) must panic. + skippedBlock := makeBlock(initialHeight+2, state, new(types.Commit)) + skippedParts := skippedBlock.MakePartSet(2) + skippedSC := makeTestCommit(initialHeight+2, tmtime.Now()) + + assert.Panics(t, func() { + bs.SaveBlock(skippedBlock, skippedParts, skippedSC) + }, "non-contiguous height must still panic after InitialHeight is set") +} diff --git a/tm2/pkg/bft/types/block.go b/tm2/pkg/bft/types/block.go index 2aff30da43c..c823e5140d9 100644 --- a/tm2/pkg/bft/types/block.go +++ b/tm2/pkg/bft/types/block.go @@ -65,7 +65,14 @@ func (b *Block) ValidateBasic() error { } // Validate the last commit and its hash. - if b.Header.Height > 1 { + // The genesis block has an empty last commit and skips this validation. + // We detect genesis by Height == 1 (standard) OR by a zero LastBlockID + // (chain starting at InitialHeight > 1 with no real previous block). + // Using both conditions preserves backward compatibility: a height-1 block + // is always treated as genesis regardless of a potentially malleated + // LastBlockID, while a block at InitialHeight > 1 is correctly skipped too. + isGenesisBlock := b.Height == 1 || b.Header.LastBlockID.IsZero() + if !isGenesisBlock { if b.LastCommit == nil { return errors.New("nil LastCommit") } diff --git a/tm2/pkg/bft/types/genesis.go b/tm2/pkg/bft/types/genesis.go index b09cdfc101b..e4fbabb224e 100644 --- a/tm2/pkg/bft/types/genesis.go +++ b/tm2/pkg/bft/types/genesis.go @@ -22,6 +22,7 @@ var ( ErrEmptyChainID = errors.New("chain ID is empty") ErrLongChainID = fmt.Errorf("chain ID cannot be longer than %d chars", MaxChainIDLen) ErrInvalidGenesisTime = errors.New("invalid genesis time") + ErrInvalidInitialHeight = errors.New("initial height must be non-negative") ErrNoValidators = errors.New("no validators in set") ErrInvalidValidatorVotingPower = errors.New("validator has no voting power") ErrInvalidValidatorAddress = errors.New("invalid validator address") @@ -46,6 +47,7 @@ type GenesisValidator struct { type GenesisDoc struct { GenesisTime time.Time `json:"genesis_time"` ChainID string `json:"chain_id"` + InitialHeight int64 `json:"initial_height,omitempty"` ConsensusParams abci.ConsensusParams `json:"consensus_params,omitempty"` Validators []GenesisValidator `json:"validators,omitempty"` AppHash []byte `json:"app_hash"` @@ -88,6 +90,11 @@ func (genDoc *GenesisDoc) Validate() error { return ErrInvalidGenesisTime } + // Make sure the initial height is non-negative + if genDoc.InitialHeight < 0 { + return ErrInvalidInitialHeight + } + // Validate the consensus params if consensusParamsErr := ValidateConsensusParams(genDoc.ConsensusParams); consensusParamsErr != nil { return consensusParamsErr @@ -129,6 +136,10 @@ func (genDoc *GenesisDoc) ValidateAndComplete() error { return errors.New("chain_id in genesis doc is too long (max: %d)", MaxChainIDLen) } + if genDoc.InitialHeight < 0 { + return errors.New("initial_height in genesis doc must be non-negative") + } + // Start from defaults and fill in consensus params from GenesisDoc. genDoc.ConsensusParams = DefaultConsensusParams().Update(genDoc.ConsensusParams) if err := ValidateConsensusParams(genDoc.ConsensusParams); err != nil { diff --git a/tm2/pkg/bft/types/genesis_test.go b/tm2/pkg/bft/types/genesis_test.go index 24c69c6a28e..6c676b5afe5 100644 --- a/tm2/pkg/bft/types/genesis_test.go +++ b/tm2/pkg/bft/types/genesis_test.go @@ -251,4 +251,58 @@ func TestGenesis_Validate(t *testing.T) { assert.ErrorIs(t, g.Validate(), ErrValidatorPubKeyMismatch) }) + + t.Run("valid initial height", func(t *testing.T) { + t.Parallel() + + g := getValidTestGenesis() + g.InitialHeight = 1000 + + require.NoError(t, g.Validate()) + }) + + t.Run("invalid initial height (negative)", func(t *testing.T) { + t.Parallel() + + g := getValidTestGenesis() + g.InitialHeight = -1 + + assert.ErrorIs(t, g.Validate(), ErrInvalidInitialHeight) + }) +} + +func TestGenesisDoc_ValidateAndComplete_InitialHeight(t *testing.T) { + t.Parallel() + + pubkey := ed25519.GenPrivKey().PubKey() + newDoc := func() *GenesisDoc { + return &GenesisDoc{ + ChainID: "test-chain", + Validators: []GenesisValidator{{pubkey.Address(), pubkey, 10, "val"}}, + } + } + + t.Run("negative initial height rejected", func(t *testing.T) { + t.Parallel() + + g := newDoc() + g.InitialHeight = -1 + assert.Error(t, g.ValidateAndComplete()) + }) + + t.Run("zero initial height accepted", func(t *testing.T) { + t.Parallel() + + g := newDoc() + g.InitialHeight = 0 + assert.NoError(t, g.ValidateAndComplete()) + }) + + t.Run("positive initial height accepted", func(t *testing.T) { + t.Parallel() + + g := newDoc() + g.InitialHeight = 100 + assert.NoError(t, g.ValidateAndComplete()) + }) } diff --git a/tm2/pkg/sdk/baseapp.go b/tm2/pkg/sdk/baseapp.go index 8084e834a82..c17caaf037c 100644 --- a/tm2/pkg/sdk/baseapp.go +++ b/tm2/pkg/sdk/baseapp.go @@ -508,7 +508,10 @@ func (app *BaseApp) validateHeight(req abci.RequestBeginBlock) error { } prevHeight := app.LastBlockHeight() - if req.Header.GetHeight() != prevHeight+1 { + // When prevHeight == 0 the app has no committed blocks yet. The first block + // may arrive at any height >= 1, including InitialHeight > 1 for chains + // that replay historical transactions during genesis. + if prevHeight != 0 && req.Header.GetHeight() != prevHeight+1 { return fmt.Errorf("invalid height: %d; expected: %d", req.Header.GetHeight(), prevHeight+1) } diff --git a/tm2/pkg/sdk/baseapp_test.go b/tm2/pkg/sdk/baseapp_test.go index 257ce45b3b2..33ed560cc86 100644 --- a/tm2/pkg/sdk/baseapp_test.go +++ b/tm2/pkg/sdk/baseapp_test.go @@ -1351,3 +1351,24 @@ func TestSetHaltHeight(t *testing.T) { app.SetHaltHeight(0) require.Equal(t, uint64(0), app.haltHeight) } + +// TestBeginBlock_InitialHeight verifies that BeginBlock does not panic when +// the chain starts at InitialHeight > 1. After InitChain the app's commit +// store has no committed blocks (LastBlockHeight == 0), so the first +// BeginBlock must be accepted at any height >= 1, not only height 1. +func TestBeginBlock_InitialHeight(t *testing.T) { + t.Parallel() + + const initialHeight = int64(100) + + app := setupBaseApp(t) + app.InitChain(abci.RequestInitChain{ChainID: "test-chain"}) + + // Before the fix, validateHeight panics: + // "invalid height: 100; expected: 1" + assert.NotPanics(t, func() { + app.BeginBlock(abci.RequestBeginBlock{ + Header: &bft.Header{ChainID: "test-chain", Height: initialHeight}, + }) + }) +}