Skip to content

feat(gnoland): genesis tx metadata + chain replay at initial height#5489

Open
moul wants to merge 15 commits into
masterfrom
feat/genesis-tx-metadata
Open

feat(gnoland): genesis tx metadata + chain replay at initial height#5489
moul wants to merge 15 commits into
masterfrom
feat/genesis-tx-metadata

Conversation

@moul
Copy link
Copy Markdown
Member

@moul moul commented Apr 13, 2026

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 triggers
    full ante handler (real sig verification, account numbers, sequences)
  • Timestamp int64 — when non-zero, overrides the block header time
  • ChainID string — originating chain ID for per-tx chain ID override

GnoGenesisState:

  • PastChainIDs []string — allowlist of chain IDs accepted for sig
    verification override; each tx's metadata.ChainID must be in this slice
  • InitialHeight int64 — informational; actual enforcement is at the
    consensus layer via GenesisDoc.InitialHeight

GenesisDoc.InitialHeight (tm2):

  • When > 1, the Handshaker sets state.LastBlockHeight = InitialHeight - 1
    after InitChain so the chain starts producing blocks at the correct height

Why PastChainIDs []string instead of a single chain ID

A genesis spanning multiple upgrades may contain txs from several past chains.
With a slice + per-tx ChainID, each tx is verified against its own
originating 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

moul and others added 4 commits April 13, 2026 21:24
- 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
@github-actions github-actions Bot added 📦 🌐 tendermint v2 Issues or PRs tm2 related 📦 ⛰️ gno.land Issues or PRs gno.land package related labels Apr 13, 2026
@Gno2D2
Copy link
Copy Markdown
Collaborator

Gno2D2 commented Apr 13, 2026

🛠 PR Checks Summary

All Automated Checks passed. ✅

Manual Checks (for Reviewers):
  • IGNORE the bot requirements for this PR (force green CI check)
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:
  1. Fix any issues flagged by automated checks.
  2. Follow the Contributor Checklist to ensure your PR is ready for review.
    • Add new tests, or document why they are unnecessary.
    • Provide clear examples/screenshots, if necessary.
    • Update documentation, if required.
    • Ensure no breaking changes, or include BREAKING CHANGE notes.
    • Link related issues/PRs, where applicable.
☑️ Reviewer Actions:
  1. Complete manual checks for the PR, including the guidelines and additional checks if applicable.
📚 Resources:
Debug
Manual Checks
**IGNORE** the bot requirements for this PR (force green CI check)

If

🟢 Condition met
└── 🟢 On every pull request

Can be checked by

  • Any user with comment edit permission

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 13, 2026

Codecov Report

❌ Patch coverage is 91.66667% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
tm2/pkg/bft/blockchain/reactor.go 0.00% 0 Missing and 1 partial ⚠️
tm2/pkg/bft/state/validation.go 80.00% 1 Missing ⚠️
tm2/pkg/bft/store/store.go 75.00% 0 Missing and 1 partial ⚠️
tm2/pkg/sdk/baseapp.go 0.00% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

moul added 2 commits April 13, 2026 22:29
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.
@moul moul marked this pull request as ready for review April 13, 2026 20:41
@moul moul requested review from aeddi, jaekwon and tbruyelle April 13, 2026 20:41
moul added 4 commits April 13, 2026 22:43
- 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).
Comment thread gno.land/pkg/gnoland/app.go Outdated
// 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

@tbruyelle tbruyelle Apr 14, 2026

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yes, it's still wip.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

@ajnavarro ajnavarro left a comment

Choose a reason for hiding this comment

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

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.
moul added 2 commits April 14, 2026 17:33
…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
@nemanjantic nemanjantic moved this from Triage to In Review in 🧙‍♂️Gno.land development Apr 15, 2026
@nemanjantic nemanjantic added the a/gnops DevOps, Valopers, NetOps, Infra, Monitoring, Coordination team label Apr 15, 2026
@piux2
Copy link
Copy Markdown
Contributor

piux2 commented May 7, 2026

I like the direction of using PastChainIDs plus per-tx metadata.ChainID for multi-upgrade genesis replay.

One concern: the PR says:

A genesis spanning multiple upgrades may contain txs from several past chains.
Unrecognised chain IDs are never silently overridden.

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 &&
metadata.ChainID != "" &&
metadata.ChainID is in PastChainIDs

If metadata.ChainID is not in PastChainIDs, the tx simply continues under the current chain ID.

https://github.com/gnolang/gno/pull/5489/changes#diff-7b99cf1b76d029cb2ffdae96eceedcd2dc3891c58047f994aa5b41f262f778e8R429

For historical replay txs, I think metadata.ChainID should be treated as part of the genesis replay contract. If metadata.BlockHeight > 0 and metadata.ChainID is non-empty but not allowlisted, InitChain should reject the entire genesis with an explicit error. Otherwise a malformed genesis can silently ignore the tx’s declared origin chain ID.

The existing test "no chain ID override when metadata.ChainID not in PastChainIDs" also appears to encode the permissive behavior: it signs the tx with the current chain ID and expects InitChain to succeed.

https://github.com/gnolang/gno/pull/5489/changes#diff-8dab105610a1af4a116d300c6a51ac759df8a90c72358df6d7e218cd5c8db7e7R1571

I think that test should probably be inverted to assert fail-closed behavior instead:

  • historical tx has metadata.BlockHeight > 0
  • metadata.ChainID = "unknown-chain"
  • PastChainIDs does not include "unknown-chain"
  • InitChain fails with an explicit genesis validation error

More generally, I think the tests should be written against the replay invariant defined in the PR requirement, not just the current branch behavior.

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

Labels

a/gnops DevOps, Valopers, NetOps, Infra, Monitoring, Coordination team 📦 🌐 tendermint v2 Issues or PRs tm2 related 📦 ⛰️ gno.land Issues or PRs gno.land package related

Projects

Status: 📥 Inbox
Status: In Review
Status: In Progress

Development

Successfully merging this pull request may close these issues.

7 participants