diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index af8b35e4179..bdd084568af 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -410,6 +410,9 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci // 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 + } // Save the modified header return ctx.WithBlockHeader(header) diff --git a/gno.land/pkg/gnoland/app_test.go b/gno.land/pkg/gnoland/app_test.go index 8ccfb0de6c3..41db1d69673 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -1,9 +1,11 @@ package gnoland import ( + "bytes" "context" "errors" "fmt" + "os" "path/filepath" "strings" "testing" @@ -481,6 +483,269 @@ func TestInitChainer_MetadataTxs(t *testing.T) { } } +func TestInitChainer_MetadataBlockHeight(t *testing.T) { + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "test" + path = "gno.land/r/demo/heighttx" + body = `package heighttx + +import "chain/runtime" + +// Height is initialized on deployment (genesis) +var h int64 = runtime.ChainHeight() + +// GetH returns the height that was saved from genesis +func GetH(cur realm) int64 { return h } +` + ) + + // Create a fresh app instance + app, err := NewAppWithOptions(TestAppOptions(db)) + require.NoError(t, err) + + msg := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "heighttx", + Path: path, + Files: []*std.MemFile{ + { + Name: "file.gno", + Body: body, + }, + { + Name: "gnomod.toml", + Body: gnolang.GenGnoModLatest(path), + }, + }, + }, + MaxDeposit: nil, + } + + tx := createAndSignTx(t, []std.Msg{msg}, chainID, key) + + var expectedHeight int64 = 42 + + // Run InitChain with metadata containing BlockHeight + 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: expectedHeight, + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + }, + }) + + // Call GetH to verify the block height was set during genesis + callMsg := vm.MsgCall{ + Caller: key.PubKey().Address(), + PkgPath: path, + Func: "GetH", + } + + tx = createAndSignTx(t, []std.Msg{callMsg}, chainID, key) + marshalledTx, err := amino.Marshal(tx) + require.NoError(t, err) + + resp := app.DeliverTx(abci.RequestDeliverTx{ + Tx: marshalledTx, + }) + + require.True(t, resp.IsOK()) + assert.Contains( + t, + string(resp.Data), + fmt.Sprintf("(%d int64)", expectedHeight), + ) +} + +func TestGenesisMetadataRoundTrip(t *testing.T) { + t.Parallel() + + var ( + key = getDummyKey(t) + chainID = "test" + + originalTimestamp = time.Now().Unix() + originalBlockHeight = int64(99) + originalChainID = "test-chain-42" + ) + + // Build two TxWithMetadata entries: one with all fields, one with only Timestamp (backward compat) + path1 := "gno.land/r/demo/roundtrip1" + body1 := `package roundtrip1 + +import ( + "time" + "chain/runtime" +) + +var t time.Time = time.Now() +var h int64 = runtime.ChainHeight() + +func GetT(cur realm) int64 { return t.Unix() } +func GetH(cur realm) int64 { return h } +` + msg1 := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "roundtrip1", + Path: path1, + Files: []*std.MemFile{ + {Name: "file.gno", Body: body1}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path1)}, + }, + }, + MaxDeposit: nil, + } + tx1 := createAndSignTx(t, []std.Msg{msg1}, chainID, key) + + path2 := "gno.land/r/demo/roundtrip2" + body2 := `package roundtrip2 + +import "time" + +var t time.Time = time.Now() + +func GetT(cur realm) int64 { return t.Unix() } +` + msg2 := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "roundtrip2", + Path: path2, + Files: []*std.MemFile{ + {Name: "file.gno", Body: body2}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path2)}, + }, + }, + MaxDeposit: nil, + } + tx2 := createAndSignTx(t, []std.Msg{msg2}, chainID, key) + + fullMetadataTx := TxWithMetadata{ + Tx: tx1, + Metadata: &GnoTxMetadata{ + Timestamp: originalTimestamp, + BlockHeight: originalBlockHeight, + ChainID: originalChainID, + }, + } + + // Backward compat: only Timestamp, no BlockHeight or ChainID + partialMetadataTx := TxWithMetadata{ + Tx: tx2, + Metadata: &GnoTxMetadata{ + Timestamp: originalTimestamp, + }, + } + + // --- Part 1: JSONL round-trip --- + + var buf bytes.Buffer + for _, txm := range []TxWithMetadata{fullMetadataTx, partialMetadataTx} { + line, err := amino.MarshalJSON(txm) + require.NoError(t, err) + buf.Write(line) + buf.WriteByte('\n') + } + + tmpFile := filepath.Join(t.TempDir(), "txs.jsonl") + require.NoError(t, os.WriteFile(tmpFile, buf.Bytes(), 0o644)) + + readTxs, err := ReadGenesisTxs(context.Background(), tmpFile) + require.NoError(t, err) + require.Len(t, readTxs, 2) + + // Verify full metadata preserved + require.NotNil(t, readTxs[0].Metadata) + require.Equal(t, originalTimestamp, readTxs[0].Metadata.Timestamp) + require.Equal(t, originalBlockHeight, readTxs[0].Metadata.BlockHeight) + require.Equal(t, originalChainID, readTxs[0].Metadata.ChainID) + + // Verify backward compat: partial metadata has zero-value BlockHeight and empty ChainID + require.NotNil(t, readTxs[1].Metadata) + require.Equal(t, originalTimestamp, readTxs[1].Metadata.Timestamp) + require.Equal(t, int64(0), readTxs[1].Metadata.BlockHeight) + require.Equal(t, "", readTxs[1].Metadata.ChainID) + + // --- Part 2: Genesis replay with full metadata --- + + db := memdb.NewMemDB() + app, err := NewAppWithOptions(TestAppOptions(db)) + require.NoError(t, err) + + app.InitChain(abci.RequestInitChain{ + ChainID: chainID, + Time: time.Now(), + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{}, + }, + }, + AppState: GnoGenesisState{ + Txs: readTxs, + Balances: []Balance{{Address: key.PubKey().Address(), Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000))}}, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + }, + }) + + // Verify timestamp was applied (via realm 1) + callMsg1 := vm.MsgCall{ + Caller: key.PubKey().Address(), + PkgPath: path1, + Func: "GetT", + } + callTx1 := createAndSignTx(t, []std.Msg{callMsg1}, chainID, key) + marshalledTx1, err := amino.Marshal(callTx1) + require.NoError(t, err) + + resp1 := app.DeliverTx(abci.RequestDeliverTx{Tx: marshalledTx1}) + require.True(t, resp1.IsOK(), "DeliverTx failed: %s", resp1.Log) + assert.Contains(t, string(resp1.Data), fmt.Sprintf("(%d int64)", originalTimestamp)) + + // Verify block height was applied (via realm 1, which was deployed with full metadata) + callMsg2 := vm.MsgCall{ + Caller: key.PubKey().Address(), + PkgPath: path1, + Func: "GetH", + } + callTx2 := createAndSignTx(t, []std.Msg{callMsg2}, chainID, key) + marshalledTx2, err := amino.Marshal(callTx2) + require.NoError(t, err) + + resp2 := app.DeliverTx(abci.RequestDeliverTx{Tx: marshalledTx2}) + require.True(t, resp2.IsOK(), "DeliverTx failed: %s", resp2.Log) + assert.Contains(t, string(resp2.Data), fmt.Sprintf("(%d int64)", originalBlockHeight)) +} + func TestEndBlocker(t *testing.T) { t.Parallel() diff --git a/gno.land/pkg/gnoland/types.go b/gno.land/pkg/gnoland/types.go index 050eda60c92..8bdc534908a 100644 --- a/gno.land/pkg/gnoland/types.go +++ b/gno.land/pkg/gnoland/types.go @@ -134,7 +134,9 @@ type TxWithMetadata struct { } type GnoTxMetadata struct { - Timestamp int64 `json:"timestamp"` + Timestamp int64 `json:"timestamp"` + BlockHeight int64 `json:"block_height,omitempty"` + ChainID string `json:"chain_id,omitempty"` } // ReadGenesisTxs reads the genesis txs from the given file path