feat(gnoland): genesis tx metadata + chain replay at initial height#5489
feat(gnoland): genesis tx metadata + chain replay at initial height#5489moul wants to merge 15 commits into
Conversation
- 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
…lay and ValidateAndComplete
🛠 PR Checks SummaryAll Automated Checks passed. ✅ Manual Checks (for Reviewers):
Read More🤖 This bot helps streamline PR reviews by verifying automated checks and providing guidance for contributors and reviewers. ✅ Automated Checks (for Contributors):No automated checks match this pull request. ☑️ Contributor Actions:
☑️ Reviewer Actions:
📚 Resources:Debug
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
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.
…amp != 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.
- 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
… 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).
| // 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 |
There was a problem hiding this comment.
This triggers a panic Failed to reconstruct LastCommit: SeenCommit not found for height 99 in tm2/pkg/bft/consensus/state.go:498 when I test the feature with a InitialHeight of 100, because it tries to load the commit associated with state.LastBlockHeight.
There was a problem hiding this comment.
After commit 28502eb, panic is now panic: state (99) and store (0) height mismatch at tm2/pkg/bft/blockchain/reactor.go:82
Please test on your side.
There was a problem hiding this comment.
OK ping me when I can retest on my side 🙏
- 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
There was a problem hiding this comment.
LGTM with a concern (not blocking).
The chain replay model puts significant pressure on every tool that reads the genesis file. Today, the load path is buffer-based end-to-end: GenesisDocFromFile does a full os.ReadFile then amino.UnmarshalJSON(jsonBlob, &genDoc) (tm2/pkg/bft/types/genesis.go:175), and amino's JSON codec has no streaming API, only []byte-based MarshalJSON/UnmarshalJSON. So swapping to a streaming parser isn't a drop-in json.Decoder change; amino's JSON layer itself needs a streaming path.
The replay loop in loadAppState (gno.land/pkg/gnoland/app.go:397) is a simple for _, tx := range state.Txs. That side could be made streaming relatively cheaply once the parse is, since each tx can be processed and discarded. But the GenesisDoc isn't just a startup spike, it's held on the Node struct for the lifetime of the process (tm2/pkg/bft/node/node.go:167, assigned at :552), handed to the RPC layer via rpccore.SetGenesisDoc (:727) so it stays queryable, and persisted into the state DB under genesisDocKey on first run (:1002) and reloaded from there on subsequent starts (:994).
So a large genesis ends up resident in memory for the entire node lifetime and duplicated into the state DB. Every consumer (node, gnogenesis, gnodev, tx-archive, explorers) goes through the same buffered load.
Once a genesis file with full historical tx replay exceeds available memory, all of those tools break. Worth tracking as a follow-up before the first real hard fork.
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.
…d 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
|
I like the direction of using One concern: the PR says:
But the current implementation seems weaker than the replay invariant in the description. The code only applies the chain ID override when: metadata.BlockHeight > 0 && If For historical replay txs, I think The existing test I think that test should probably be inverted to assert fail-closed behavior instead:
More generally, I think the tests should be written against the replay invariant defined in the PR requirement, not just the current branch behavior. |
Adds protocol-level support for replaying historical transactions during
genesis, enabling hard forks where a new chain resumes from where the old
one halted.
New fields
GnoTxMetadata:BlockHeight int64— when > 0, sets the block header height and triggersfull ante handler (real sig verification, account numbers, sequences)
Timestamp int64— when non-zero, overrides the block header timeChainID string— originating chain ID for per-tx chain ID overrideGnoGenesisState:PastChainIDs []string— allowlist of chain IDs accepted for sigverification override; each tx's
metadata.ChainIDmust be in this sliceInitialHeight int64— informational; actual enforcement is at theconsensus layer via
GenesisDoc.InitialHeightGenesisDoc.InitialHeight(tm2):state.LastBlockHeight = InitialHeight - 1after InitChain so the chain starts producing blocks at the correct height
Why
PastChainIDs []stringinstead of a single chain IDA genesis spanning multiple upgrades may contain txs from several past chains.
With a slice + per-tx
ChainID, each tx is verified against its ownoriginating chain ID. Unrecognised chain IDs are never silently overridden.
What this does NOT include
The hardfork tooling (
misc/hardfork/) and deployment scripts live in #5411.This PR is only the protocol mechanism — independently mergeable.
Related
halt_heightconfig (merged ✅)