From ccc49cb7bc698ef47788df7744f320fbec16de22 Mon Sep 17 00:00:00 2001 From: moul Date: Wed, 1 Apr 2026 15:20:32 +0000 Subject: [PATCH 01/13] feat(gnoland): support chain upgrade genesis replay with original chain ID --- .../prxxxx_chain_upgrade_genesis_replay.md | 44 +++++ gno.land/pkg/gnoland/app.go | 32 +++- gno.land/pkg/gnoland/app_test.go | 156 ++++++++++++++++++ gno.land/pkg/gnoland/types.go | 6 +- 4 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 gno.land/adr/prxxxx_chain_upgrade_genesis_replay.md diff --git a/gno.land/adr/prxxxx_chain_upgrade_genesis_replay.md b/gno.land/adr/prxxxx_chain_upgrade_genesis_replay.md new file mode 100644 index 00000000000..8b3e0163304 --- /dev/null +++ b/gno.land/adr/prxxxx_chain_upgrade_genesis_replay.md @@ -0,0 +1,44 @@ +# ADR: Chain Upgrade Genesis Replay + +## Context + +We need to support a hard fork from `gnoland1` to `gnoland-1`. The approach is to export all historical transactions from the old chain, include them in the new chain's genesis with metadata, and replay them during `InitChain`. The new chain then starts at the halted height of the old chain. + +Historical transactions were signed with the old chain's ID (`gnoland1`). During genesis replay, the ante handler needs to verify these signatures using the original chain ID, not the new one. + +## Decision + +Extend `GnoGenesisState` with two new fields: + +- **`OriginalChainID`** (`string`): The chain ID of the source chain. When set, historical transactions (those with `metadata.BlockHeight > 0`) are replayed with this chain ID in the context, allowing signature verification to succeed. +- **`InitialHeight`** (`int64`): The block height the new chain should start from after genesis replay. This corresponds to the halt height of the old chain. + +Extend `GnoTxMetadata` with: + +- **`BlockHeight`** (`int64`): The original block height at which the transaction was included. When greater than zero, the context's block header height is set to this value during replay. + +### How it works + +1. Historical txs are exported from the old chain with metadata (timestamp, block height). +2. The new genesis includes these txs along with `OriginalChainID` and `InitialHeight`. +3. During `InitChain`, the genesis tx replay loop checks each tx's metadata: + - If `metadata.BlockHeight > 0`, the block header height is set accordingly. + - If `metadata.BlockHeight > 0` AND `state.OriginalChainID` is set, the context's chain ID is overridden to the original chain ID. +4. The ante handler sees `BlockHeight > 0` as non-genesis, so it performs full signature verification using account numbers, sequences, and the (overridden) chain ID. This means historical tx signatures verify correctly without modification. + +### Key insight + +When `header.Height` is set to a non-zero value, the ante handler treats transactions as normal (not genesis), using actual account numbers/sequences and verifying signatures with `ctx.ChainID()`. By setting `ctx.WithChainID(originalChainID)`, the original signatures verify correctly. + +## Alternatives considered + +1. **Re-sign all transactions**: Would require access to all private keys. Not feasible. +2. **Skip signature verification entirely**: Reduces security guarantees during genesis replay. +3. **Patch the ante handler**: More invasive and harder to maintain. + +## Consequences + +- Genesis files for chain upgrades will be larger (containing all historical txs with metadata). +- The `InitialHeight` field is informational in the current implementation; full support requires `GenesisDoc.InitialHeight` to be propagated to the consensus layer. +- The `OriginalChainID` override only applies to txs with `BlockHeight > 0`, so standard genesis txs (package deployments, etc.) continue to work normally. +- Both new fields use `omitempty`, so existing genesis files are unaffected. diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index 8264b33e1f2..6aedc631ee0 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 state.OriginalChainID != "" { + ctx.Logger().Info("Chain upgrade genesis replay", + "original_chain_id", state.OriginalChainID, + "initial_height", state.InitialHeight, + ) + } + cfg.bankk.InitGenesis(ctx, state.Bank) // Apply genesis balances. for _, bal := range state.Balances { @@ -404,15 +411,23 @@ 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.BlockHeight > 0 { + header.Height = metadata.BlockHeight + } + + ctx = ctx.WithBlockHeader(header) - // Save the modified header - return ctx.WithBlockHeader(header) + // For historical txs (BlockHeight > 0), use the original chain ID + // for signature verification. This allows replaying txs that were + // signed with the old chain ID during a hard fork. + if metadata.BlockHeight > 0 && state.OriginalChainID != "" { + ctx = ctx.WithChainID(state.OriginalChainID) + } + + return ctx } } @@ -434,6 +449,13 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci cfg.GenesisTxResultHandler(ctx, stdTx, res) } + + if state.InitialHeight > 0 { + ctx.Logger().Info("Chain will start from initial height (requires GenesisDoc.InitialHeight support)", + "initial_height", state.InitialHeight, + ) + } + return txResponses, nil } diff --git a/gno.land/pkg/gnoland/app_test.go b/gno.land/pkg/gnoland/app_test.go index 8ccfb0de6c3..fd1876132b0 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -1315,3 +1315,159 @@ 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(), + OriginalChainID: "old-chain", + 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, "old-chain", decoded.OriginalChainID) + 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 original chain ID since metadata.BlockHeight > 0 will cause + // the ctxFn to override the chain ID to OriginalChainID for signature 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 OriginalChainID and InitialHeight set, + // and the deploy tx using metadata with BlockHeight=42 + 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, + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + OriginalChainID: "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, + }, + } + + 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) + }) +} diff --git a/gno.land/pkg/gnoland/types.go b/gno.land/pkg/gnoland/types.go index 050eda60c92..ec6ec4f834c 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 + OriginalChainID string `json:"original_chain_id,omitempty"` // Chain ID for verifying historical tx signatures + InitialHeight int64 `json:"initial_height,omitempty"` // Block height to start from after genesis replay } type TxWithMetadata struct { @@ -134,7 +137,8 @@ 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 } // ReadGenesisTxs reads the genesis txs from the given file path From 33b17e1baaa9a1501dcce908bab590501c95e6eb Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:37:58 +0000 Subject: [PATCH 02/13] feat(tm2): add InitialHeight to GenesisDoc for chain upgrades --- gno.land/pkg/gnoland/app.go | 2 +- tm2/pkg/bft/consensus/replay.go | 12 ++++++++++++ tm2/pkg/bft/types/genesis.go | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index 6aedc631ee0..1f0f74c7f13 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -451,7 +451,7 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci } if state.InitialHeight > 0 { - ctx.Logger().Info("Chain will start from initial height (requires GenesisDoc.InitialHeight support)", + ctx.Logger().Info("Genesis replay complete, chain will start from initial height", "initial_height", state.InitialHeight, ) } 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/types/genesis.go b/tm2/pkg/bft/types/genesis.go index b09cdfc101b..6d580eaaa06 100644 --- a/tm2/pkg/bft/types/genesis.go +++ b/tm2/pkg/bft/types/genesis.go @@ -46,6 +46,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"` From 9e6dd7f7205d7959d90c438b88711800dbd2d9b0 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:16:04 +0200 Subject: [PATCH 03/13] feat(gnoland): improve hardfork genesis replay mechanism - Add ChainID field to GnoTxMetadata for tx provenance recording - Add InitialHeight validation (non-negative) to GenesisDoc.Validate and ValidateAndComplete - Add test cases: no chain ID override when BlockHeight=0, no override when OriginalChainID unset - Update ADR: document per-tx vs state-level design choice, mark InitialHeight as implemented end-to-end --- .../prxxxx_chain_upgrade_genesis_replay.md | 35 ++++- gno.land/pkg/gnoland/app_test.go | 140 ++++++++++++++++++ gno.land/pkg/gnoland/types.go | 5 +- tm2/pkg/bft/types/genesis.go | 10 ++ tm2/pkg/bft/types/genesis_test.go | 18 +++ 5 files changed, 200 insertions(+), 8 deletions(-) diff --git a/gno.land/adr/prxxxx_chain_upgrade_genesis_replay.md b/gno.land/adr/prxxxx_chain_upgrade_genesis_replay.md index 8b3e0163304..713045be290 100644 --- a/gno.land/adr/prxxxx_chain_upgrade_genesis_replay.md +++ b/gno.land/adr/prxxxx_chain_upgrade_genesis_replay.md @@ -8,37 +8,60 @@ Historical transactions were signed with the old chain's ID (`gnoland1`). During ## Decision -Extend `GnoGenesisState` with two new fields: +### `GnoGenesisState` extensions + +Two new fields on `GnoGenesisState`: - **`OriginalChainID`** (`string`): The chain ID of the source chain. When set, historical transactions (those with `metadata.BlockHeight > 0`) are replayed with this chain ID in the context, allowing signature verification to succeed. -- **`InitialHeight`** (`int64`): The block height the new chain should start from after genesis replay. This corresponds to the halt height of the old chain. +- **`InitialHeight`** (`int64`): The block height the new chain should start from after genesis replay. This corresponds to the halt height of the old chain + 1. + +### `GnoTxMetadata` extensions -Extend `GnoTxMetadata` with: +Three fields on `GnoTxMetadata` (populated by tx-archive export): +- **`Timestamp`** (`int64`): Unix timestamp of the original block (pre-existing field). - **`BlockHeight`** (`int64`): The original block height at which the transaction was included. When greater than zero, the context's block header height is set to this value during replay. +- **`ChainID`** (`string`): The originating chain ID for this transaction. Informational — used by tx-archive to record provenance; the actual chain ID override during replay uses `GnoGenesisState.OriginalChainID`. + +### `GenesisDoc` extension + +- **`InitialHeight`** (`int64`): Added to `tm2/pkg/bft/types.GenesisDoc`. When greater than 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 it works -1. Historical txs are exported from the old chain with metadata (timestamp, block height). +1. Historical txs are exported from the old chain with metadata (timestamp, block height, chain ID). 2. The new genesis includes these txs along with `OriginalChainID` and `InitialHeight`. 3. During `InitChain`, the genesis tx replay loop checks each tx's metadata: - If `metadata.BlockHeight > 0`, the block header height is set accordingly. - If `metadata.BlockHeight > 0` AND `state.OriginalChainID` is set, the context's chain ID is overridden to the original chain ID. + - If `metadata.BlockHeight == 0` (or no metadata), normal genesis mode applies (no chain ID override, no sig verification for package deploys). 4. The ante handler sees `BlockHeight > 0` as non-genesis, so it performs full signature verification using account numbers, sequences, and the (overridden) chain ID. This means historical tx signatures verify correctly without modification. +5. After `InitChain`, the consensus layer reads `GenesisDoc.InitialHeight` and advances `state.LastBlockHeight` so the chain starts producing blocks at the correct height. ### Key insight When `header.Height` is set to a non-zero value, the ante handler treats transactions as normal (not genesis), using actual account numbers/sequences and verifying signatures with `ctx.ChainID()`. By setting `ctx.WithChainID(originalChainID)`, the original signatures verify correctly. +The chain ID override is intentionally guarded by both conditions (`BlockHeight > 0` AND `OriginalChainID != ""`), so: +- Standard genesis txs (package deployments, setup) are unaffected. +- Historical txs without an original chain ID use the new chain's context. + ## Alternatives considered 1. **Re-sign all transactions**: Would require access to all private keys. Not feasible. 2. **Skip signature verification entirely**: Reduces security guarantees during genesis replay. 3. **Patch the ante handler**: More invasive and harder to maintain. +4. **Per-tx chain ID override** (using `GnoTxMetadata.ChainID`): We chose a state-level `OriginalChainID` instead. All historical txs in a hard fork come from the same source chain, so a single override is simpler and less error-prone. ## Consequences - Genesis files for chain upgrades will be larger (containing all historical txs with metadata). -- The `InitialHeight` field is informational in the current implementation; full support requires `GenesisDoc.InitialHeight` to be propagated to the consensus layer. +- `InitialHeight` is implemented end-to-end: `GenesisDoc.InitialHeight` → consensus `Handshaker` → `state.LastBlockHeight`. The chain starts producing blocks at `InitialHeight` after genesis replay. - The `OriginalChainID` override only applies to txs with `BlockHeight > 0`, so standard genesis txs (package deployments, etc.) continue to work normally. -- Both new fields use `omitempty`, so existing genesis files are unaffected. +- All new fields use `omitempty`, so existing genesis files are unaffected. +- `GenesisDoc.InitialHeight` is validated to be non-negative. + +## Open items + +- Account number preservation: accounts are currently auto-assigned during balance initialization. If the old chain had different account numbers, some txs may fail replay. Workaround: ensure genesis balances are ordered so account numbers align. +- End-to-end test with a real chain halt → export → genesis assembly → new chain start. diff --git a/gno.land/pkg/gnoland/app_test.go b/gno.land/pkg/gnoland/app_test.go index fd1876132b0..393ccc2d08e 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -1457,6 +1457,7 @@ func GetHeight(cur realm) int64 { return height } Metadata: &GnoTxMetadata{ Timestamp: 1234567890, BlockHeight: 42, + ChainID: "gnoland1", }, } @@ -1469,5 +1470,144 @@ func GetHeight(cur realm) int64 { return height } 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 the OriginalChainID. + 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 + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + OriginalChainID: "old-chain", // set, but should NOT be used since BlockHeight == 0 + }, + }) + }) + + t.Run("no chain ID override when OriginalChainID is unset", 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 but OriginalChainID is not set — no chain ID override. + // The tx is signed with the current chainID (genesis mode skips sig verification + // when BlockHeight == 0, but BlockHeight > 0 triggers ante handler normally, + // so we sign with chainID). + 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, // non-zero, but OriginalChainID is unset + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + // OriginalChainID intentionally not set + }, + }) }) } diff --git a/gno.land/pkg/gnoland/types.go b/gno.land/pkg/gnoland/types.go index ec6ec4f834c..96353276ca7 100644 --- a/gno.land/pkg/gnoland/types.go +++ b/gno.land/pkg/gnoland/types.go @@ -137,8 +137,9 @@ type TxWithMetadata struct { } type GnoTxMetadata struct { - Timestamp int64 `json:"timestamp"` - BlockHeight int64 `json:"block_height,omitempty"` // Original block height for historical tx replay + 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/types/genesis.go b/tm2/pkg/bft/types/genesis.go index 6d580eaaa06..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") @@ -89,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 @@ -130,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..e37b82dc6d8 100644 --- a/tm2/pkg/bft/types/genesis_test.go +++ b/tm2/pkg/bft/types/genesis_test.go @@ -251,4 +251,22 @@ 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) + }) } From 773a01cb6ffca0b72bc7b16659819034067b30aa Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:28:42 +0200 Subject: [PATCH 04/13] test(tm2): add coverage for GenesisDoc.InitialHeight in consensus replay and ValidateAndComplete --- tm2/pkg/bft/consensus/replay_test.go | 49 ++++++++++++++++++++++++++++ tm2/pkg/bft/types/genesis_test.go | 36 ++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/tm2/pkg/bft/consensus/replay_test.go b/tm2/pkg/bft/consensus/replay_test.go index 070218142b4..364a173ee11 100644 --- a/tm2/pkg/bft/consensus/replay_test.go +++ b/tm2/pkg/bft/consensus/replay_test.go @@ -1300,3 +1300,52 @@ func TestReplayNilGuards(t *testing.T) { }) } } + +// 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/types/genesis_test.go b/tm2/pkg/bft/types/genesis_test.go index e37b82dc6d8..6c676b5afe5 100644 --- a/tm2/pkg/bft/types/genesis_test.go +++ b/tm2/pkg/bft/types/genesis_test.go @@ -270,3 +270,39 @@ func TestGenesis_Validate(t *testing.T) { 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()) + }) +} From f67f3cad5b847b13ee81e2b8b16275f2cca5e72f Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:27:13 +0200 Subject: [PATCH 05/13] chore(adr): rename ADR to pr5489 --- ...sis_replay.md => pr5489_genesis_tx_metadata_initial_height.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename gno.land/adr/{prxxxx_chain_upgrade_genesis_replay.md => pr5489_genesis_tx_metadata_initial_height.md} (100%) diff --git a/gno.land/adr/prxxxx_chain_upgrade_genesis_replay.md b/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md similarity index 100% rename from gno.land/adr/prxxxx_chain_upgrade_genesis_replay.md rename to gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md From 58017819609f65aa7f564f2f917b9bb696f33c60 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:29:03 +0200 Subject: [PATCH 06/13] feat(gnoland): replace OriginalChainID with PastChainIDs allowlist Use a []string PastChainIDs on GnoGenesisState instead of a single OriginalChainID. The chain ID override during genesis replay now uses the per-tx metadata.ChainID, verified against the allowlist. This supports multi-hop upgrades where a genesis contains txs from multiple past chains, and prevents silent overrides for unrecognised chain IDs. --- ...5489_genesis_tx_metadata_initial_height.md | 34 +++++----- gno.land/pkg/gnoland/app.go | 25 +++++-- gno.land/pkg/gnoland/app_test.go | 68 ++++++++++--------- gno.land/pkg/gnoland/types.go | 4 +- 4 files changed, 72 insertions(+), 59 deletions(-) diff --git a/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md b/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md index 713045be290..2330cc20b4e 100644 --- a/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md +++ b/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md @@ -1,10 +1,10 @@ -# ADR: Chain Upgrade Genesis Replay +# ADR: Genesis TX Metadata and Initial Height for Chain Upgrades ## Context -We need to support a hard fork from `gnoland1` to `gnoland-1`. The approach is to export all historical transactions from the old chain, include them in the new chain's genesis with metadata, and replay them during `InitChain`. The new chain then starts at the halted height of the old chain. +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. -Historical transactions were signed with the old chain's ID (`gnoland1`). During genesis replay, the ante handler needs to verify these signatures using the original chain ID, not the new one. +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 @@ -12,7 +12,7 @@ Historical transactions were signed with the old chain's ID (`gnoland1`). During Two new fields on `GnoGenesisState`: -- **`OriginalChainID`** (`string`): The chain ID of the source chain. When set, historical transactions (those with `metadata.BlockHeight > 0`) are replayed with this chain ID in the context, allowing signature verification to succeed. +- **`PastChainIDs`** (`[]string`): Allowlist of chain IDs from which historical transactions in this genesis originated. Only chain IDs present in this slice can be used for the chain ID override during replay. - **`InitialHeight`** (`int64`): The block height the new chain should start from after genesis replay. This corresponds to the halt height of the old chain + 1. ### `GnoTxMetadata` extensions @@ -20,8 +20,8 @@ Two new fields on `GnoGenesisState`: Three fields on `GnoTxMetadata` (populated by tx-archive export): - **`Timestamp`** (`int64`): Unix timestamp of the original block (pre-existing field). -- **`BlockHeight`** (`int64`): The original block height at which the transaction was included. When greater than zero, the context's block header height is set to this value during replay. -- **`ChainID`** (`string`): The originating chain ID for this transaction. Informational — used by tx-archive to record provenance; the actual chain ID override during replay uses `GnoGenesisState.OriginalChainID`. +- **`BlockHeight`** (`int64`): The original block height at which the transaction was included. When greater than zero, the context's block header height is set to this value during replay, and the tx goes through the normal ante handler (full sig verification). +- **`ChainID`** (`string`): The originating chain ID for this transaction. Used for the per-tx chain ID override during replay if `ChainID` is in `GnoGenesisState.PastChainIDs`. ### `GenesisDoc` extension @@ -30,36 +30,36 @@ Three fields on `GnoTxMetadata` (populated by tx-archive export): ### How it works 1. Historical txs are exported from the old chain with metadata (timestamp, block height, chain ID). -2. The new genesis includes these txs along with `OriginalChainID` and `InitialHeight`. +2. The new genesis includes these txs along with `PastChainIDs` (the allowlist) and `InitialHeight`. 3. During `InitChain`, the genesis tx replay loop checks each tx's metadata: - - If `metadata.BlockHeight > 0`, the block header height is set accordingly. - - If `metadata.BlockHeight > 0` AND `state.OriginalChainID` is set, the context's chain ID is overridden to the original chain ID. + - If `metadata.BlockHeight > 0`, the block header height is set to `metadata.BlockHeight`. + - If `metadata.BlockHeight > 0` AND `metadata.ChainID != ""` AND `metadata.ChainID` is in `state.PastChainIDs`, the context's chain ID is overridden to `metadata.ChainID` for that tx's sig verification. - If `metadata.BlockHeight == 0` (or no metadata), normal genesis mode applies (no chain ID override, no sig verification for package deploys). -4. The ante handler sees `BlockHeight > 0` as non-genesis, so it performs full signature verification using account numbers, sequences, and the (overridden) chain ID. This means historical tx signatures verify correctly without modification. +4. The ante handler sees `BlockHeight > 0` as non-genesis, performing full signature verification using account numbers, sequences, and the (possibly overridden) chain ID. 5. After `InitChain`, the consensus layer reads `GenesisDoc.InitialHeight` and advances `state.LastBlockHeight` so the chain starts producing blocks at the correct height. ### Key insight -When `header.Height` is set to a non-zero value, the ante handler treats transactions as normal (not genesis), using actual account numbers/sequences and verifying signatures with `ctx.ChainID()`. By setting `ctx.WithChainID(originalChainID)`, the original signatures verify correctly. - -The chain ID override is intentionally guarded by both conditions (`BlockHeight > 0` AND `OriginalChainID != ""`), so: +The override is guarded by three conditions: `BlockHeight > 0` AND `metadata.ChainID != ""` AND `metadata.ChainID ∈ PastChainIDs`. This means: - Standard genesis txs (package deployments, setup) are unaffected. -- Historical txs without an original chain ID use the new chain's context. +- Historical txs with an unrecognised chain ID are not silently overridden — they fail as expected. +- A genesis spanning multiple past chains works correctly: each tx uses its own chain ID. ## Alternatives considered 1. **Re-sign all transactions**: Would require access to all private keys. Not feasible. 2. **Skip signature verification entirely**: Reduces security guarantees during genesis replay. -3. **Patch the ante handler**: More invasive and harder to maintain. -4. **Per-tx chain ID override** (using `GnoTxMetadata.ChainID`): We chose a state-level `OriginalChainID` instead. All historical txs in a hard fork come from the same source chain, so a single override is simpler and less error-prone. +3. **Single `OriginalChainID` field**: 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 (old design)**: `OriginalChainID` applied to all historical txs regardless of their actual origin. `PastChainIDs` + per-tx `ChainID` is more precise and more extensible. ## Consequences - Genesis files for chain upgrades will be larger (containing all historical txs with metadata). - `InitialHeight` is implemented end-to-end: `GenesisDoc.InitialHeight` → consensus `Handshaker` → `state.LastBlockHeight`. The chain starts producing blocks at `InitialHeight` after genesis replay. -- The `OriginalChainID` override only applies to txs with `BlockHeight > 0`, so standard genesis txs (package deployments, etc.) continue to work normally. +- The chain ID override only applies to txs satisfying all three conditions, so standard genesis txs continue to work normally. - All new fields use `omitempty`, so existing genesis files are unaffected. - `GenesisDoc.InitialHeight` is validated to be non-negative. +- Future upgrades from `gnoland-1` to `gnoland-2` can include `PastChainIDs: ["gnoland1", "gnoland-1"]` to replay the full history. ## Open items diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index 1f0f74c7f13..bef5a31c00b 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -356,9 +356,9 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci return nil, fmt.Errorf("invalid AppState of type %T", appState) } - if state.OriginalChainID != "" { + if len(state.PastChainIDs) > 0 { ctx.Logger().Info("Chain upgrade genesis replay", - "original_chain_id", state.OriginalChainID, + "past_chain_ids", state.PastChainIDs, "initial_height", state.InitialHeight, ) } @@ -420,11 +420,12 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci ctx = ctx.WithBlockHeader(header) - // For historical txs (BlockHeight > 0), use the original chain ID - // for signature verification. This allows replaying txs that were - // signed with the old chain ID during a hard fork. - if metadata.BlockHeight > 0 && state.OriginalChainID != "" { - ctx = ctx.WithChainID(state.OriginalChainID) + // 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) } return ctx @@ -468,6 +469,16 @@ type endBlockerApp interface { Logger() *slog.Logger } +// isPastChainID reports whether chainID is present in the pastChainIDs allowlist. +func isPastChainID(pastChainIDs []string, chainID string) bool { + for _, id := range pastChainIDs { + if id == chainID { + return true + } + } + return false +} + // 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 393ccc2d08e..0b584be86fc 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -1323,13 +1323,13 @@ func TestChainUpgradeGenesisReplay(t *testing.T) { t.Parallel() state := GnoGenesisState{ - Balances: []Balance{}, - Txs: []TxWithMetadata{}, - Auth: auth.DefaultGenesisState(), - Bank: bank.DefaultGenesisState(), - VM: vm.DefaultGenesisState(), - OriginalChainID: "old-chain", - InitialHeight: 100, + 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 @@ -1339,7 +1339,7 @@ func TestChainUpgradeGenesisReplay(t *testing.T) { var decoded GnoGenesisState require.NoError(t, amino.UnmarshalJSON(data, &decoded)) - assert.Equal(t, "old-chain", decoded.OriginalChainID) + assert.Equal(t, []string{"old-chain-1", "old-chain-2"}, decoded.PastChainIDs) assert.Equal(t, int64(100), decoded.InitialHeight) }) @@ -1386,14 +1386,14 @@ func GetHeight(cur realm) int64 { return height } MaxDeposit: nil, } - // Sign with original chain ID since metadata.BlockHeight > 0 will cause - // the ctxFn to override the chain ID to OriginalChainID for signature verification. + // 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 OriginalChainID and InitialHeight set, - // and the deploy tx using metadata with BlockHeight=42 + // 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(), @@ -1410,6 +1410,7 @@ func GetHeight(cur realm) int64 { return height } Metadata: &GnoTxMetadata{ Timestamp: time.Now().Unix(), BlockHeight: 42, + ChainID: "old-chain", // must be in PastChainIDs for override }, }, }, @@ -1419,11 +1420,11 @@ func GetHeight(cur realm) int64 { return height } Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), }, }, - Auth: auth.DefaultGenesisState(), - Bank: bank.DefaultGenesisState(), - VM: vm.DefaultGenesisState(), - OriginalChainID: "old-chain", - InitialHeight: 100, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"old-chain"}, + InitialHeight: 100, }, }) @@ -1506,7 +1507,7 @@ func IsDeployed(cur realm) bool { return Deployed } } // When metadata.BlockHeight == 0, the chain ID override must NOT happen. - // So the tx must be signed with the current chain ID (chainID), not the OriginalChainID. + // 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{ @@ -1524,7 +1525,8 @@ func IsDeployed(cur realm) bool { return Deployed } Tx: tx, Metadata: &GnoTxMetadata{ Timestamp: time.Now().Unix(), - BlockHeight: 0, // zero — no chain ID override + BlockHeight: 0, // zero — no chain ID override + ChainID: "old-chain", // present but ignored since BlockHeight == 0 }, }, }, @@ -1534,15 +1536,15 @@ func IsDeployed(cur realm) bool { return Deployed } Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), }, }, - Auth: auth.DefaultGenesisState(), - Bank: bank.DefaultGenesisState(), - VM: vm.DefaultGenesisState(), - OriginalChainID: "old-chain", // set, but should NOT be used since BlockHeight == 0 + 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 OriginalChainID is unset", func(t *testing.T) { + t.Run("no chain ID override when metadata.ChainID not in PastChainIDs", func(t *testing.T) { t.Parallel() var ( @@ -1572,10 +1574,9 @@ var Deployed = true }, } - // BlockHeight > 0 but OriginalChainID is not set — no chain ID override. - // The tx is signed with the current chainID (genesis mode skips sig verification - // when BlockHeight == 0, but BlockHeight > 0 triggers ante handler normally, - // so we sign with chainID). + // 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{ @@ -1593,7 +1594,8 @@ var Deployed = true Tx: tx, Metadata: &GnoTxMetadata{ Timestamp: time.Now().Unix(), - BlockHeight: 10, // non-zero, but OriginalChainID is unset + BlockHeight: 10, + ChainID: "unknown-chain", // not in PastChainIDs — no override }, }, }, @@ -1603,10 +1605,10 @@ var Deployed = true Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), }, }, - Auth: auth.DefaultGenesisState(), - Bank: bank.DefaultGenesisState(), - VM: vm.DefaultGenesisState(), - // OriginalChainID intentionally not set + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + // PastChainIDs intentionally empty — no chain ID override allowed }, }) }) diff --git a/gno.land/pkg/gnoland/types.go b/gno.land/pkg/gnoland/types.go index 96353276ca7..485597e2c26 100644 --- a/gno.land/pkg/gnoland/types.go +++ b/gno.land/pkg/gnoland/types.go @@ -127,8 +127,8 @@ type GnoGenesisState struct { Bank bank.GenesisState `json:"bank"` VM vm.GenesisState `json:"vm"` // Chain upgrade fields - OriginalChainID string `json:"original_chain_id,omitempty"` // Chain ID for verifying historical tx signatures - InitialHeight int64 `json:"initial_height,omitempty"` // Block height to start from after genesis replay + 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 { From 0ae7333fa1343588736439f3bf6d021fe51b3532 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:39:24 +0200 Subject: [PATCH 07/13] fix(gnoland): only override genesis tx timestamp when metadata.Timestamp != 0 Timestamp=0 in metadata should not clobber the genesis block time with Unix epoch. Guard the override the same way BlockHeight is guarded. Also adds a test case covering the zero-timestamp fallback. --- gno.land/pkg/gnoland/app.go | 4 +++- gno.land/pkg/gnoland/app_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index bef5a31c00b..bb9e6f9182f 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -413,7 +413,9 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci if metadata != nil { ctxFn = func(ctx sdk.Context) sdk.Context { 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 } diff --git a/gno.land/pkg/gnoland/app_test.go b/gno.land/pkg/gnoland/app_test.go index 0b584be86fc..fa1c5d5608a 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 { From beda29e72a33d477820bd00083860c6ab4cabc09 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:43:05 +0200 Subject: [PATCH 08/13] test(gnoland): add isPastChainID unit tests + multi-chain replay test - TestIsPastChainID: table-driven test for the allowlist helper - multi-chain replay: two txs each signed with a different past chain ID, validating the core PastChainIDs use case - ADR: clarify GnoGenesisState.InitialHeight is informational only, reflow to 80 cols --- ...5489_genesis_tx_metadata_initial_height.md | 119 +++++++++++------- gno.land/pkg/gnoland/app_test.go | 112 +++++++++++++++++ 2 files changed, 188 insertions(+), 43 deletions(-) diff --git a/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md b/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md index 2330cc20b4e..c7a1dd0e82b 100644 --- a/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md +++ b/gno.land/adr/pr5489_genesis_tx_metadata_initial_height.md @@ -2,66 +2,99 @@ ## 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. +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. +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 -### `GnoGenesisState` extensions - -Two new fields on `GnoGenesisState`: - -- **`PastChainIDs`** (`[]string`): Allowlist of chain IDs from which historical transactions in this genesis originated. Only chain IDs present in this slice can be used for the chain ID override during replay. -- **`InitialHeight`** (`int64`): The block height the new chain should start from after genesis replay. This corresponds to the halt height of the old chain + 1. - ### `GnoTxMetadata` extensions Three fields on `GnoTxMetadata` (populated by tx-archive export): -- **`Timestamp`** (`int64`): Unix timestamp of the original block (pre-existing field). -- **`BlockHeight`** (`int64`): The original block height at which the transaction was included. When greater than zero, the context's block header height is set to this value during replay, and the tx goes through the normal ante handler (full sig verification). -- **`ChainID`** (`string`): The originating chain ID for this transaction. Used for the per-tx chain ID override during replay if `ChainID` is in `GnoGenesisState.PastChainIDs`. +- **`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`. -### `GenesisDoc` extension - -- **`InitialHeight`** (`int64`): Added to `tm2/pkg/bft/types.GenesisDoc`. When greater than 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 it works - -1. Historical txs are exported from the old chain with metadata (timestamp, block height, chain ID). -2. The new genesis includes these txs along with `PastChainIDs` (the allowlist) and `InitialHeight`. -3. During `InitChain`, the genesis tx replay loop checks each tx's metadata: - - If `metadata.BlockHeight > 0`, the block header height is set to `metadata.BlockHeight`. - - If `metadata.BlockHeight > 0` AND `metadata.ChainID != ""` AND `metadata.ChainID` is in `state.PastChainIDs`, the context's chain ID is overridden to `metadata.ChainID` for that tx's sig verification. - - If `metadata.BlockHeight == 0` (or no metadata), normal genesis mode applies (no chain ID override, no sig verification for package deploys). -4. The ante handler sees `BlockHeight > 0` as non-genesis, performing full signature verification using account numbers, sequences, and the (possibly overridden) chain ID. -5. After `InitChain`, the consensus layer reads `GenesisDoc.InitialHeight` and advances `state.LastBlockHeight` so the chain starts producing blocks at the correct height. +### `GnoGenesisState` extensions -### Key insight +Two new fields on `GnoGenesisState`: -The override is guarded by three conditions: `BlockHeight > 0` AND `metadata.ChainID != ""` AND `metadata.ChainID ∈ PastChainIDs`. This means: -- Standard genesis txs (package deployments, setup) are unaffected. -- Historical txs with an unrecognised chain ID are not silently overridden — they fail as expected. -- A genesis spanning multiple past chains works correctly: each tx uses its own chain ID. +- **`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**: Would require access to all private keys. Not feasible. -2. **Skip signature verification entirely**: Reduces security guarantees during genesis replay. -3. **Single `OriginalChainID` field**: 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 (old design)**: `OriginalChainID` applied to all historical txs regardless of their actual origin. `PastChainIDs` + per-tx `ChainID` is more precise and more extensible. +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 (containing all historical txs with metadata). -- `InitialHeight` is implemented end-to-end: `GenesisDoc.InitialHeight` → consensus `Handshaker` → `state.LastBlockHeight`. The chain starts producing blocks at `InitialHeight` after genesis replay. -- The chain ID override only applies to txs satisfying all three conditions, so standard genesis txs continue to work normally. -- All new fields use `omitempty`, so existing genesis files are unaffected. -- `GenesisDoc.InitialHeight` is validated to be non-negative. -- Future upgrades from `gnoland-1` to `gnoland-2` can include `PastChainIDs: ["gnoland1", "gnoland-1"]` to replay the full history. +- 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 currently auto-assigned during balance initialization. If the old chain had different account numbers, some txs may fail replay. Workaround: ensure genesis balances are ordered so account numbers align. -- End-to-end test with a real chain halt → export → genesis assembly → new chain start. +- 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_test.go b/gno.land/pkg/gnoland/app_test.go index fa1c5d5608a..5fbd6c1c5d4 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -1636,4 +1636,116 @@ var Deployed = true }, }) }) + + t.Run("txs from multiple past chains replay correctly", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key1 = getDummyKey(t) + key2 = 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) + + // tx1 was originally on "chain-a", tx2 on "chain-b" + msg1 := vm.MsgAddPackage{ + Creator: key1.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: key2.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", key1) + tx2 := createAndSignTx(t, []std.Msg{msg2}, "chain-b", key2) + + // 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: tx2, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 20, + ChainID: "chain-b", + }, + }, + }, + Balances: []Balance{ + {Address: key1.PubKey().Address(), Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000))}, + {Address: key2.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)) + }) + } } From a19bae563b63248b0b3af2b11545eda1d666d4ae Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:49:29 +0200 Subject: [PATCH 09/13] fix(gnoland): gofmt/goimports formatting in app_test.go --- gno.land/pkg/gnoland/app_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gno.land/pkg/gnoland/app_test.go b/gno.land/pkg/gnoland/app_test.go index 5fbd6c1c5d4..7f86a2d8d68 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -1549,7 +1549,7 @@ func IsDeployed(cur realm) bool { return Deployed } Tx: tx, Metadata: &GnoTxMetadata{ Timestamp: time.Now().Unix(), - BlockHeight: 0, // zero — no chain ID override + BlockHeight: 0, // zero — no chain ID override ChainID: "old-chain", // present but ignored since BlockHeight == 0 }, }, From b4e87a78748cede3e385346ceb34b8efd8e74094 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:15:27 +0200 Subject: [PATCH 10/13] fix(test): use single account with correct seq for multi-chain replay test Two keys with BlockHeight>0 caused UnauthorizedError because the second key got accNum=1 but was signed with accNum=0 (createAndSignTx hardcodes 0,0). Use one key for both txs; sign tx2 with seq=1 (incremented by tx1). --- gno.land/pkg/gnoland/app_test.go | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/gno.land/pkg/gnoland/app_test.go b/gno.land/pkg/gnoland/app_test.go index 7f86a2d8d68..9eafe435ee6 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -1642,8 +1642,7 @@ var Deployed = true var ( db = memdb.NewMemDB() - key1 = getDummyKey(t) - key2 = getDummyKey(t) + key = getDummyKey(t) chainID = "new-chain" path1 = "gno.land/r/demo/multichain1" @@ -1656,9 +1655,10 @@ var Deployed = true app, err := NewAppWithOptions(TestAppOptions(db)) require.NoError(t, err) - // tx1 was originally on "chain-a", tx2 on "chain-b" + // 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: key1.PubKey().Address(), + Creator: key.PubKey().Address(), Package: &std.MemPackage{ Name: "multichain1", Path: path1, @@ -1669,7 +1669,7 @@ var Deployed = true }, } msg2 := vm.MsgAddPackage{ - Creator: key2.PubKey().Address(), + Creator: key.PubKey().Address(), Package: &std.MemPackage{ Name: "multichain2", Path: path2, @@ -1680,8 +1680,18 @@ var Deployed = true }, } - tx1 := createAndSignTx(t, []std.Msg{msg1}, "chain-a", key1) - tx2 := createAndSignTx(t, []std.Msg{msg2}, "chain-b", key2) + 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{ @@ -1704,7 +1714,7 @@ var Deployed = true }, }, { - Tx: tx2, + Tx: tx2Raw, Metadata: &GnoTxMetadata{ Timestamp: time.Now().Unix(), BlockHeight: 20, @@ -1713,8 +1723,7 @@ var Deployed = true }, }, Balances: []Balance{ - {Address: key1.PubKey().Address(), Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000))}, - {Address: key2.PubKey().Address(), Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000))}, + {Address: key.PubKey().Address(), Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000))}, }, Auth: auth.DefaultGenesisState(), Bank: bank.DefaultGenesisState(), From 28502ebef5bda05f27f88659c476a87853abe082 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:13:05 +0200 Subject: [PATCH 11/13] fix: address tbruyelle review comments on PR #5489 - Simplify isPastChainID to use slices.Contains - Fix panic in reconstructLastCommit when InitialHeight > 1 and block store is empty (fresh genesis with InitialHeight set by Handshaker) - Add TestReconstructLastCommit_InitialHeight to cover the fixed path --- gno.land/pkg/gnoland/app.go | 7 +------ tm2/pkg/bft/consensus/replay_test.go | 31 ++++++++++++++++++++++++++++ tm2/pkg/bft/consensus/state.go | 6 ++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index bb9e6f9182f..114245a1e13 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -473,12 +473,7 @@ type endBlockerApp interface { // isPastChainID reports whether chainID is present in the pastChainIDs allowlist. func isPastChainID(pastChainIDs []string, chainID string) bool { - for _, id := range pastChainIDs { - if id == chainID { - return true - } - } - return false + return slices.Contains(pastChainIDs, chainID) } // EndBlocker defines the logic executed after every block. diff --git a/tm2/pkg/bft/consensus/replay_test.go b/tm2/pkg/bft/consensus/replay_test.go index 364a173ee11..2973f19bcc3 100644 --- a/tm2/pkg/bft/consensus/replay_test.go +++ b/tm2/pkg/bft/consensus/replay_test.go @@ -1301,6 +1301,37 @@ 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) + }) +} + // 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. diff --git a/tm2/pkg/bft/consensus/state.go b/tm2/pkg/bft/consensus/state.go index 8c1e0a456ba..c1fddeefd18 100644 --- a/tm2/pkg/bft/consensus/state.go +++ b/tm2/pkg/bft/consensus/state.go @@ -495,6 +495,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) From 9fca49a1e0eb67b508f990b953536e124eabad9a Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:52:39 +0200 Subject: [PATCH 12/13] fix(blockchain): allow empty block store when InitialHeight > 1 When GenesisDoc.InitialHeight > 1, the Handshaker sets state.LastBlockHeight = InitialHeight - 1 after InitChain, but the block store is still empty (Height() == 0). NewBlockchainReactor was panicking with "state (N) and store (0) height mismatch" in this case. Fix: skip the mismatch check when the store is empty. A non-empty store must still match state exactly. Add TestNewBlockchainReactor_InitialHeight that reproduces the panic before the fix and passes after. --- tm2/pkg/bft/blockchain/reactor.go | 5 ++++- tm2/pkg/bft/blockchain/reactor_test.go | 30 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) 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 From 5d0e278dd1a0d7b45c0693a3db391c6ce827f71a Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:33:01 +0200 Subject: [PATCH 13/13] fix(tm2): support InitialHeight > 1 across consensus, state, store and SDK Replace all `height == 1` / `height > 1` genesis-detection heuristics with semantically-correct checks so that a chain starting at InitialHeight > 1 (replaying historical transactions at genesis) behaves identically to a standard height-1 chain. Locations fixed (test written first for each new panic found): consensus/state.go - reconstructLastCommit: skip when block store is empty (InitialHeight > 1) - needProofBlock: treat empty store as genesis - createProposalBlock: use empty commit when store is empty blockchain/reactor.go - NewBlockchainReactor: allow state.LastBlockHeight != store.Height when store is still empty (Handshaker sets LastBlockHeight = InitialHeight-1) bft/types/block.go - ValidateBasic: detect genesis via `Height == 1 || LastBlockID.IsZero()` instead of `Height > 1`; preserves backward compat for malleated height-1 blocks while correctly handling InitialHeight > 1 state/state.go - MakeBlock: use commit.BlockID.IsZero() to select genesis time state/validation.go - ValidateBlock: replace Height == 1 checks with isGenesisBlock derived from state.LastBlockID.IsZero() state/execution.go - getBeginBlockLastCommitInfo: skip validator load when LastCommit has no precommits (genesis block has an empty commit regardless of height) store/store.go - SaveBlock / saveBlockPart: allow first save at any height when store is empty (contiguity still enforced once store is non-empty) sdk/baseapp.go - validateHeight: allow first BeginBlock at any height when prevHeight == 0 (InitialHeight > 1 sends height=InitialHeight, not height=1) Tests added: - gno.land/pkg/gnoland: TestNodeBootWithInitialHeight (full in-memory node) - consensus/replay_test: TestReconstructLastCommit_InitialHeight, TestNeedProofBlock_InitialHeight, TestCreateProposalBlock_InitialHeight - blockchain/reactor_test: TestNewBlockchainReactor_InitialHeight - state/execution_test: TestGetBeginBlockLastCommitInfo_InitialHeight - store/store_test: TestBlockStore_InitialHeight, TestBlockStore_ContiguousAfterInitialHeight - sdk/baseapp_test: TestBeginBlock_InitialHeight --- .../pkg/gnoland/node_initial_height_test.go | 80 +++++++++++++++++++ tm2/pkg/bft/consensus/replay_test.go | 75 +++++++++++++++++ tm2/pkg/bft/consensus/state.go | 12 +-- tm2/pkg/bft/state/execution.go | 7 +- tm2/pkg/bft/state/execution_test.go | 28 +++++++ tm2/pkg/bft/state/export_test.go | 7 ++ tm2/pkg/bft/state/state.go | 5 +- tm2/pkg/bft/state/validation.go | 13 ++- tm2/pkg/bft/store/store.go | 12 ++- tm2/pkg/bft/store/store_test.go | 58 +++++++++++++- tm2/pkg/bft/types/block.go | 9 ++- tm2/pkg/sdk/baseapp.go | 5 +- tm2/pkg/sdk/baseapp_test.go | 21 +++++ 13 files changed, 315 insertions(+), 17 deletions(-) create mode 100644 gno.land/pkg/gnoland/node_initial_height_test.go 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/tm2/pkg/bft/consensus/replay_test.go b/tm2/pkg/bft/consensus/replay_test.go index 2973f19bcc3..bd9bdc4a5e8 100644 --- a/tm2/pkg/bft/consensus/replay_test.go +++ b/tm2/pkg/bft/consensus/replay_test.go @@ -1332,6 +1332,81 @@ func TestReconstructLastCommit_InitialHeight(t *testing.T) { }) } +// 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. diff --git a/tm2/pkg/bft/consensus/state.go b/tm2/pkg/bft/consensus/state.go index c1fddeefd18..b315db90f30 100644 --- a/tm2/pkg/bft/consensus/state.go +++ b/tm2/pkg/bft/consensus/state.go @@ -864,9 +864,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 } @@ -1003,9 +1005,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 a58a50c1877..d7d4adb5788 100644 --- a/tm2/pkg/bft/state/execution.go +++ b/tm2/pkg/bft/state/execution.go @@ -277,7 +277,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/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}, + }) + }) +}