From 95981f2252ff0b6d46c1f36f3804be65da77b48e Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 21 Apr 2026 12:41:16 +0200 Subject: [PATCH 01/92] import: PR #5486 (hf-glue testbed) squashed diff --- contribs/gnogenesis/genesis.go | 2 + contribs/gnogenesis/internal/fork/fork.go | 48 + contribs/gnogenesis/internal/fork/generate.go | 501 ++++++ .../gnogenesis/internal/fork/generate_test.go | 104 ++ contribs/gnogenesis/internal/fork/source.go | 75 + .../gnogenesis/internal/fork/source_dir.go | 191 +++ .../gnogenesis/internal/fork/source_rpc.go | 430 ++++++ .../internal/fork/source_rpc_test.go | 164 ++ contribs/gnogenesis/internal/fork/test.go | 284 ++++ .../gnogenesis/internal/fork/test_test.go | 174 +++ contribs/tx-archive/backup/backup.go | 108 +- contribs/tx-archive/backup/backup_test.go | 3 + contribs/tx-archive/backup/client/client.go | 10 + contribs/tx-archive/backup/client/rpc/rpc.go | 45 + contribs/tx-archive/backup/mock_test.go | 21 + contribs/tx-archive/backup/options.go | 10 + contribs/tx-archive/backup/signerinfo.go | 246 +++ contribs/tx-archive/cmd/backup.go | 24 +- docs/resources/gnoland-networks.md | 17 +- examples/gno.land/r/sys/params/halt.gno | 51 + .../gno.land/r/sys/params/params_test.gno | 21 + .../pr5511_chain_upgrade_genesis_replay.md | 243 +++ gno.land/cmd/gnoland/start.go | 1 + gno.land/pkg/gnoland/app.go | 162 +- gno.land/pkg/gnoland/app_test.go | 1365 ++++++++++++++++- gno.land/pkg/gnoland/mock_test.go | 50 + .../pkg/gnoland/node_initial_height_test.go | 80 + gno.land/pkg/gnoland/node_params.go | 152 ++ gno.land/pkg/gnoland/package.go | 1 + gno.land/pkg/gnoland/replay_report.go | 150 ++ gno.land/pkg/gnoland/replay_report_test.go | 89 ++ gno.land/pkg/gnoland/types.go | 29 +- misc/deployments/gnoland-1/.gitignore | 1 + misc/deployments/gnoland-1/README.md | 99 ++ misc/deployments/gnoland-1/config.toml | 256 ++++ .../deployments/gnoland-1/generate-genesis.sh | 87 ++ .../add-validator-from-valopers.sh | 73 + .../gnoland-1/govdao-scripts/add-validator.sh | 78 + .../gnoland-1/govdao-scripts/rm-validator.sh | 75 + .../govdao-scripts/unrestrict-account.sh | 72 + .../gnoland-1/migrate-from-gnoland1.sh | 120 ++ .../migrations/01_reset_valset.gno.tmpl | 44 + .../deployments/gnoland-1/migrations/build.sh | 158 ++ misc/hf-glue/.gitignore | 1 + misc/hf-glue/Makefile | 128 ++ misc/hf-glue/README.md | 216 +++ misc/hf-glue/docker-compose.yml | 55 + misc/hf-glue/fixvalidator/main.go | 84 + misc/hf-glue/scripts/check-state.sh | 162 ++ misc/hf-glue/scripts/fetch-from-dir.sh | 107 ++ misc/hf-glue/scripts/gen-local-genesis.sh | 42 + misc/hf-glue/scripts/init-node.sh | 66 + misc/hf-glue/scripts/lib/hf.sh | 244 +++ misc/hf-glue/scripts/migrate.sh | 97 ++ misc/hf-glue/scripts/replay-log.sh | 53 + misc/hf-glue/scripts/report-replay.sh | 134 ++ tm2/adr/pr5511_initial_height.md | 100 ++ tm2/pkg/bft/abci/types/abci.proto | 1 + tm2/pkg/bft/abci/types/types.go | 1 + tm2/pkg/bft/blockchain/reactor.go | 16 +- tm2/pkg/bft/blockchain/reactor_test.go | 61 + tm2/pkg/bft/config/config.go | 4 + tm2/pkg/bft/consensus/replay.go | 13 + tm2/pkg/bft/consensus/replay_test.go | 253 ++- tm2/pkg/bft/consensus/state.go | 47 +- 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/store.go | 19 +- tm2/pkg/bft/state/store_test.go | 77 + 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 | 18 +- tm2/pkg/bft/types/block_test.go | 46 + tm2/pkg/bft/types/genesis.go | 11 + tm2/pkg/bft/types/genesis_test.go | 54 + tm2/pkg/sdk/auth/ante.go | 13 + tm2/pkg/sdk/auth/ante_test.go | 51 + tm2/pkg/sdk/auth/keeper.go | 33 + tm2/pkg/sdk/auth/keeper_test.go | 83 + tm2/pkg/sdk/auth/types.go | 1 + tm2/pkg/sdk/baseapp.go | 44 +- tm2/pkg/sdk/baseapp_test.go | 21 + 85 files changed, 8306 insertions(+), 94 deletions(-) create mode 100644 contribs/gnogenesis/internal/fork/fork.go create mode 100644 contribs/gnogenesis/internal/fork/generate.go create mode 100644 contribs/gnogenesis/internal/fork/generate_test.go create mode 100644 contribs/gnogenesis/internal/fork/source.go create mode 100644 contribs/gnogenesis/internal/fork/source_dir.go create mode 100644 contribs/gnogenesis/internal/fork/source_rpc.go create mode 100644 contribs/gnogenesis/internal/fork/source_rpc_test.go create mode 100644 contribs/gnogenesis/internal/fork/test.go create mode 100644 contribs/gnogenesis/internal/fork/test_test.go create mode 100644 contribs/tx-archive/backup/signerinfo.go create mode 100644 examples/gno.land/r/sys/params/halt.gno create mode 100644 gno.land/adr/pr5511_chain_upgrade_genesis_replay.md create mode 100644 gno.land/pkg/gnoland/node_initial_height_test.go create mode 100644 gno.land/pkg/gnoland/node_params.go create mode 100644 gno.land/pkg/gnoland/replay_report.go create mode 100644 gno.land/pkg/gnoland/replay_report_test.go create mode 100644 misc/deployments/gnoland-1/.gitignore create mode 100644 misc/deployments/gnoland-1/README.md create mode 100644 misc/deployments/gnoland-1/config.toml create mode 100755 misc/deployments/gnoland-1/generate-genesis.sh create mode 100755 misc/deployments/gnoland-1/govdao-scripts/add-validator-from-valopers.sh create mode 100755 misc/deployments/gnoland-1/govdao-scripts/add-validator.sh create mode 100755 misc/deployments/gnoland-1/govdao-scripts/rm-validator.sh create mode 100755 misc/deployments/gnoland-1/govdao-scripts/unrestrict-account.sh create mode 100755 misc/deployments/gnoland-1/migrate-from-gnoland1.sh create mode 100644 misc/deployments/gnoland-1/migrations/01_reset_valset.gno.tmpl create mode 100755 misc/deployments/gnoland-1/migrations/build.sh create mode 100644 misc/hf-glue/.gitignore create mode 100644 misc/hf-glue/Makefile create mode 100644 misc/hf-glue/README.md create mode 100644 misc/hf-glue/docker-compose.yml create mode 100644 misc/hf-glue/fixvalidator/main.go create mode 100755 misc/hf-glue/scripts/check-state.sh create mode 100755 misc/hf-glue/scripts/fetch-from-dir.sh create mode 100755 misc/hf-glue/scripts/gen-local-genesis.sh create mode 100755 misc/hf-glue/scripts/init-node.sh create mode 100755 misc/hf-glue/scripts/lib/hf.sh create mode 100755 misc/hf-glue/scripts/migrate.sh create mode 100755 misc/hf-glue/scripts/replay-log.sh create mode 100755 misc/hf-glue/scripts/report-replay.sh create mode 100644 tm2/adr/pr5511_initial_height.md diff --git a/contribs/gnogenesis/genesis.go b/contribs/gnogenesis/genesis.go index 2bf27b32c85..a43a277e414 100644 --- a/contribs/gnogenesis/genesis.go +++ b/contribs/gnogenesis/genesis.go @@ -2,6 +2,7 @@ package main import ( "github.com/gnolang/contribs/gnogenesis/internal/balances" + "github.com/gnolang/contribs/gnogenesis/internal/fork" "github.com/gnolang/contribs/gnogenesis/internal/generate" "github.com/gnolang/contribs/gnogenesis/internal/params" "github.com/gnolang/contribs/gnogenesis/internal/txs" @@ -28,6 +29,7 @@ func newGenesisCmd(io commands.IO) *commands.Command { balances.NewBalancesCmd(io), txs.NewTxsCmd(io), params.NewParamsCmd(io), + fork.NewForkCmd(io), ) return cmd diff --git a/contribs/gnogenesis/internal/fork/fork.go b/contribs/gnogenesis/internal/fork/fork.go new file mode 100644 index 00000000000..a97b71f1a81 --- /dev/null +++ b/contribs/gnogenesis/internal/fork/fork.go @@ -0,0 +1,48 @@ +// Package fork provides the `gnogenesis fork` subcommands for building and +// smoke-testing hardfork genesis files. +// +// A hardfork genesis is built from: +// 1. SOURCE CHAIN — provides historical state (genesis + tx history) +// 2. NEW BINARY — the updated gnoland built from this repo +// +// Source modes (auto-detected from --source): +// +// http(s)://... RPC of a running or recently-halted node +// /path/to/dir local node data directory (must contain config/genesis.json) +// /path/to/file exported file: genesis.json (no txs) or .jsonl (txs) or .tar.gz +package fork + +import ( + "github.com/gnolang/gno/tm2/pkg/commands" +) + +// NewForkCmd returns the `gnogenesis fork` parent command with its +// subcommands (`generate`, `test`) attached. +func NewForkCmd(io commands.IO) *commands.Command { + cmd := commands.NewCommand( + commands.Metadata{ + Name: "fork", + ShortUsage: " [flags] [...]", + ShortHelp: "build and smoke-test hardfork genesis files", + LongHelp: `Build a hardfork genesis from a source chain and smoke-test it locally. + +Subcommands: + generate Assemble a new-chain genesis.json from a source chain's state + tx history. + test Run an in-memory InitChain replay against a genesis.json (fast smoke-test). + +Source modes (auto-detected from --source): + http(s)://... RPC of a running or recently-halted node + /path/to/dir local node data directory (must contain config/genesis.json) + /path/to/file exported file: genesis.json (no txs) or .jsonl (txs) or .tar.gz`, + }, + commands.NewEmptyConfig(), + commands.HelpExec, + ) + + cmd.AddSubCommands( + newGenerateCmd(io), + newTestCmd(io), + ) + + return cmd +} diff --git a/contribs/gnogenesis/internal/fork/generate.go b/contribs/gnogenesis/internal/fork/generate.go new file mode 100644 index 00000000000..8028d821525 --- /dev/null +++ b/contribs/gnogenesis/internal/fork/generate.go @@ -0,0 +1,501 @@ +package fork + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/tm2/pkg/amino" + bftypes "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/std" +) + +type generateCfg struct { + source string + chainID string + originalChainID string + haltHeight int64 + output string + txsOutput string + patchRealms patchRealmList + migrationTxs stringList + skipTxs bool + noVerify bool +} + +// patchRealmList accepts repeated --patch-realm flags. Each value is +// "pkgpath=srcdir"; the tool rewrites the matching genesis-mode addpkg +// tx's Package.Files with the contents of srcdir. +type patchRealmList []string + +func (p *patchRealmList) String() string { return strings.Join(*p, ",") } +func (p *patchRealmList) Set(v string) error { + *p = append(*p, v) + return nil +} + +// stringList accepts repeated string flags. +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ",") } +func (s *stringList) Set(v string) error { + *s = append(*s, v) + return nil +} + +func newGenerateCmd(io commands.IO) *commands.Command { + cfg := &generateCfg{} + + return commands.NewCommand( + commands.Metadata{ + Name: "generate", + ShortUsage: "generate [flags]", + ShortHelp: "assemble a hardfork genesis from a source chain", + LongHelp: `Generates a hardfork genesis.json by extracting state from a source chain +and assembling it with the hardfork parameters (original_chain_id, initial_height). + +The source chain provides the base genesis (balances, validators, auth state) +and the historical transaction history. Both are embedded in the new genesis +so the new chain can replay all historical activity starting from the halt height. + +Examples: + + # From a running or recently-halted node via RPC: + gnogenesis fork generate --source http://rpc.gno.land:26657 --chain-id gnoland-1 + + # From a local node data directory (offline, reads block store): + gnogenesis fork generate --source /var/lib/gnoland --chain-id gnoland-1 + + # From a pre-exported tarball (genesis.json + txs.jsonl): + gnogenesis fork generate --source /tmp/gnoland1-export.tar.gz --chain-id gnoland-1 + + # Preview only (skip tx export — fast summary of genesis structure): + gnogenesis fork generate --source http://rpc.gno.land:26657 --skip-txs`, + }, + cfg, + func(ctx context.Context, args []string) error { + return execGenerate(ctx, cfg, io) + }, + ) +} + +func (c *generateCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar(&c.source, "source", "", "source: RPC URL, local data dir, or exported file (.json/.jsonl/.tar.gz)") + fs.StringVar(&c.chainID, "chain-id", "gnoland-1", "new chain ID") + fs.StringVar(&c.originalChainID, "original-chain-id", "", "source chain ID for signature verification (auto-detected from source genesis if empty)") + fs.Int64Var(&c.haltHeight, "halt-height", 0, "block height at which source chain halted (auto-detected from source if 0)") + fs.StringVar(&c.output, "output", "genesis.json", "output genesis file path") + fs.StringVar(&c.txsOutput, "txs-output", "", "also write extracted txs to this .jsonl file (optional)") + fs.Var(&c.migrationTxs, "migration-tx", "append migration txs at the END of appState.Txs "+ + "(after historical replay). Repeatable. FILE is a .jsonl where each "+ + "line is an amino-JSON gnoland.TxWithMetadata. These are genesis-mode "+ + "txs (BlockHeight==0) that run through the same --skip-genesis-sig-"+ + "verification code path as original genesis-mode txs, but are placed "+ + "after the historical stream so they can mutate replayed state "+ + "(e.g. govDAO prop to update r/sys/validators/v2 to the new valset).") + fs.Var(&c.patchRealms, "patch-realm", "patch a genesis-mode addpkg tx in place: repeatable, PKGPATH=SRCDIR. "+ + "Replaces Package.Files with the *.gno + gnomod.toml files found in SRCDIR. "+ + "Source genesis on disk is NOT modified; the patch is applied in memory "+ + "before writing the hardfork genesis. Use this to deliver realm upgrades "+ + "as part of the fork (e.g. adding a new .gno file to an existing realm).") + fs.BoolVar(&c.skipTxs, "skip-txs", false, "skip tx export (only copy genesis structure — useful for quick preview)") + fs.BoolVar(&c.noVerify, "no-verify", false, "skip genesis verification after assembly") +} + +func execGenerate(ctx context.Context, cfg *generateCfg, io commands.IO) error { + if cfg.source == "" { + return errors.New("--source is required (RPC URL, local data dir, or exported file)") + } + + src, err := openSource(cfg.source) + if err != nil { + return fmt.Errorf("opening source %q: %w", cfg.source, err) + } + defer src.Close() + + io.Printf("Source: %s (%s)\n", src.Description(), cfg.source) + + // ------------------------------------------------------------------------- + // Step 1: Fetch base genesis from source + // ------------------------------------------------------------------------- + io.Println("Step 1/4: Fetching base genesis...") + + baseGenDoc, err := src.FetchGenesis(ctx) + if err != nil { + return fmt.Errorf("fetching genesis: %w", err) + } + + sourceChainID := baseGenDoc.ChainID + io.Printf(" Source chain ID: %s\n", sourceChainID) + io.Printf(" Source genesis time: %s\n", baseGenDoc.GenesisTime) + + // Use auto-detected chain ID if not explicitly provided + if cfg.originalChainID == "" { + cfg.originalChainID = sourceChainID + io.Printf(" Original chain ID (auto-detected): %s\n", cfg.originalChainID) + } + + // Auto-detect halt height from source + if cfg.haltHeight == 0 { + h, err := src.LatestHeight(ctx) + if err != nil { + return fmt.Errorf("detecting halt height: %w", err) + } + cfg.haltHeight = h + io.Printf(" Halt height (auto-detected): %d\n", cfg.haltHeight) + } else { + io.Printf(" Halt height: %d\n", cfg.haltHeight) + } + + // ------------------------------------------------------------------------- + // Step 2: Fetch historical transactions + // ------------------------------------------------------------------------- + var txs []gnoland.TxWithMetadata + + if !cfg.skipTxs { + io.Printf("Step 2/4: Fetching historical transactions (height 1..%d)...\n", cfg.haltHeight) + + txs, err = src.FetchTxs(ctx, 1, cfg.haltHeight, io) + if err != nil { + return fmt.Errorf("fetching transactions: %w", err) + } + + io.Printf(" Fetched %d successful transactions\n", len(txs)) + + // Write txs to separate file if requested + if cfg.txsOutput != "" { + if err := writeTxsJSONL(cfg.txsOutput, txs); err != nil { + return fmt.Errorf("writing txs output: %w", err) + } + io.Printf(" Txs written to: %s\n", cfg.txsOutput) + } + } else { + io.Println("Step 2/4: Skipping tx export (--skip-txs)") + } + + // ------------------------------------------------------------------------- + // Step 3: Assemble hardfork genesis + // ------------------------------------------------------------------------- + io.Println("Step 3/4: Assembling hardfork genesis...") + + initialHeight := cfg.haltHeight + 1 + + newGenDoc, appState, err := buildHardforkGenesis(baseGenDoc, txs, cfg.chainID, cfg.originalChainID, initialHeight) + if err != nil { + return fmt.Errorf("building hardfork genesis: %w", err) + } + + // Apply --patch-realm rewrites on genesis-mode addpkg txs (in-memory only). + for _, spec := range cfg.patchRealms { + parts := strings.SplitN(spec, "=", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("--patch-realm needs PKGPATH=SRCDIR, got %q", spec) + } + pkgPath, srcDir := parts[0], parts[1] + n, err := patchGenesisModeAddPkg(appState, pkgPath, srcDir) + if err != nil { + return fmt.Errorf("patch %s: %w", pkgPath, err) + } + if n == 0 { + io.Printf(" WARNING: --patch-realm %s did not match any genesis-mode addpkg tx\n", pkgPath) + } else { + io.Printf(" patched %s from %s (%d tx rewritten)\n", pkgPath, srcDir, n) + } + } + // Append --migration-tx files at the END of appState.Txs (post-history). + // Each file is a .jsonl of gnoland.TxWithMetadata. We force BlockHeight=0 + // so they go through the genesis-mode path (chain-id via PastChainIDs[0], + // sig verify skipped under --skip-genesis-sig-verification). + for _, path := range cfg.migrationTxs { + migTxs, err := readMigrationTxs(path) + if err != nil { + return fmt.Errorf("migration-tx %s: %w", path, err) + } + appState.Txs = append(appState.Txs, migTxs...) + io.Printf(" appended %d migration tx(s) from %s\n", len(migTxs), path) + } + newGenDoc.AppState = *appState + + // ------------------------------------------------------------------------- + // Step 4: Write and verify output + // ------------------------------------------------------------------------- + io.Println("Step 4/4: Writing genesis...") + + if err := writeGenesis(cfg.output, newGenDoc, appState); err != nil { + return fmt.Errorf("writing genesis: %w", err) + } + + stat, _ := os.Stat(cfg.output) + io.Printf(" Written: %s", cfg.output) + if stat != nil { + io.Printf(" (%.1f MB)", float64(stat.Size())/(1024*1024)) + } + io.Println() + + if !cfg.noVerify { + if err := verifyGenesisFile(cfg.output); err != nil { + return fmt.Errorf("genesis verification failed: %w (use --no-verify to skip)", err) + } + io.Println(" Verification: OK") + } + + // Summary + io.Println() + io.Println("=== Hardfork Genesis Summary ===") + io.Printf(" New chain ID: %s\n", cfg.chainID) + io.Printf(" Original chain ID: %s\n", cfg.originalChainID) + io.Printf(" Initial height: %d\n", initialHeight) + io.Printf(" Halt height: %d\n", cfg.haltHeight) + io.Printf(" Genesis-mode txs: %d (from source genesis, no metadata)\n", len(baseGenesisModeTxs(appState))) + io.Printf(" Historical txs: %d (with block_height metadata)\n", len(txs)) + io.Printf(" Total txs: %d\n", len(appState.Txs)) + io.Printf(" Output: %s\n", cfg.output) + io.Println() + io.Println("Next steps:") + io.Printf(" 1. Test locally (in-process replay):\n") + io.Printf(" hardfork test --genesis %s\n", cfg.output) + io.Printf(" 2. Verify with other validators (share SHA-256):\n") + io.Printf(" sha256: $(sha256sum %s | cut -d' ' -f1)\n", cfg.output) + + _ = appState // suppress unused warning (used in summary above) + return nil +} + +// buildHardforkGenesis constructs the new genesis document. +// It takes the source chain's genesis as the base, injects the hardfork +// parameters, and appends historical txs (with block_height metadata). +func buildHardforkGenesis( + srcGenDoc *bftypes.GenesisDoc, + historicalTxs []gnoland.TxWithMetadata, + newChainID string, + originalChainID string, + initialHeight int64, +) (*bftypes.GenesisDoc, *gnoland.GnoGenesisState, error) { + // Extract app state from source genesis + appState, ok := srcGenDoc.AppState.(gnoland.GnoGenesisState) + if !ok { + // Try amino JSON round-trip if the app state is a raw json.RawMessage + raw, err := amino.MarshalJSON(srcGenDoc.AppState) + if err != nil { + return nil, nil, fmt.Errorf("marshalling source app state: %w", err) + } + if err := amino.UnmarshalJSON(raw, &appState); err != nil { + return nil, nil, fmt.Errorf("unmarshalling source app state as GnoGenesisState: %w", err) + } + } + + // Inject hardfork fields + appState.PastChainIDs = []string{originalChainID} + appState.InitialHeight = initialHeight + + // Append historical txs after existing genesis-mode txs + // Genesis-mode txs (no metadata or BlockHeight==0): package deploys, setup + // Historical txs (BlockHeight > 0): replayed with original chain ID context + appState.Txs = append(appState.Txs, historicalTxs...) + + // Build the new genesis doc + newGenDoc := *srcGenDoc // copy + newGenDoc.ChainID = newChainID + newGenDoc.InitialHeight = initialHeight + newGenDoc.AppState = appState + + return &newGenDoc, &appState, nil +} + +// writeGenesis serializes and writes the genesis to a file. +func writeGenesis(path string, genDoc *bftypes.GenesisDoc, _ *gnoland.GnoGenesisState) error { + data, err := amino.MarshalJSONIndent(genDoc, "", " ") + if err != nil { + return fmt.Errorf("marshalling genesis: %w", err) + } + return os.WriteFile(path, data, 0o644) +} + +// readMigrationTxs reads a .jsonl file of gnoland.TxWithMetadata entries. +// BlockHeight is forced to 0 so each line is treated as a genesis-mode tx +// when replayed (uses PastChainIDs[0] for chain-id; sig verify skipped under +// --skip-genesis-sig-verification). Blank lines and # comments are ignored. +func readMigrationTxs(path string) ([]gnoland.TxWithMetadata, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var out []gnoland.TxWithMetadata + for i, line := range strings.Split(string(data), "\n") { + trim := strings.TrimSpace(line) + if trim == "" || strings.HasPrefix(trim, "#") { + continue + } + var tx gnoland.TxWithMetadata + if err := amino.UnmarshalJSON([]byte(line), &tx); err != nil { + return nil, fmt.Errorf("line %d: %w", i+1, err) + } + if tx.Metadata == nil { + tx.Metadata = &gnoland.GnoTxMetadata{} + } + tx.Metadata.BlockHeight = 0 // always genesis-mode + out = append(out, tx) + } + return out, nil +} + +// writeTxsJSONL writes transactions to a file, one amino JSON per line. +// Uses amino.MarshalJSON to preserve interface type information (e.g. std.Msg). +func writeTxsJSONL(path string, txs []gnoland.TxWithMetadata) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + for _, tx := range txs { + data, err := amino.MarshalJSON(tx) + if err != nil { + return err + } + data = append(data, '\n') + if _, err := f.Write(data); err != nil { + return err + } + } + return nil +} + +// verifyGenesisFile runs basic validation on the written genesis file. +func verifyGenesisFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + var genDoc bftypes.GenesisDoc + if err := amino.UnmarshalJSON(data, &genDoc); err != nil { + return fmt.Errorf("parse: %w", err) + } + + return genDoc.ValidateAndComplete() +} + +// baseGenesisModeTxs returns only the genesis-mode txs (BlockHeight == 0) from app state. +func baseGenesisModeTxs(appState *gnoland.GnoGenesisState) []gnoland.TxWithMetadata { + var out []gnoland.TxWithMetadata + for _, tx := range appState.Txs { + if tx.Metadata == nil || tx.Metadata.BlockHeight == 0 { + out = append(out, tx) + } + } + return out +} + +// patchGenesisModeAddPkg rewrites every genesis-mode addpkg tx whose package +// path matches `pkgPath` in-place — replacing its Package.Files slice with +// the *.gno + gnomod.toml files read from `srcDir`. +// +// This is how realm upgrades ride along in a hardfork: instead of adding a +// new tx (which would run with a different caller + account state and may +// collide with existing state), we rewrite the tx that originally deployed +// the realm so the forked chain initialises it with the new source. +// +// The source genesis on disk is NOT touched — this operates on the in-memory +// GnoGenesisState that we assembled for the output. +// +// Returns the number of txs rewritten. +func patchGenesisModeAddPkg(appState *gnoland.GnoGenesisState, pkgPath, srcDir string) (int, error) { + files, err := loadGnoPackageFiles(srcDir) + if err != nil { + return 0, fmt.Errorf("load %s: %w", srcDir, err) + } + if len(files) == 0 { + return 0, fmt.Errorf("no .gno/.toml files in %s", srcDir) + } + + patched := 0 + for i := range appState.Txs { + txm := &appState.Txs[i] + if txm.Metadata != nil && txm.Metadata.BlockHeight > 0 { + continue // historical tx, leave alone + } + for mi, msg := range txm.Tx.Msgs { + addpkg, ok := msg.(vm.MsgAddPackage) + if !ok { + continue + } + if addpkg.Package == nil || addpkg.Package.Path != pkgPath { + continue + } + addpkg.Package.Files = files + // Refresh package name in case a .gno's `package ...` declaration + // matters downstream. + for _, f := range files { + if strings.HasSuffix(f.Name, ".gno") { + if name := gnoPackageNameFromFileBody(f.Name, f.Body); name != "" { + addpkg.Package.Name = name + } + break + } + } + txm.Tx.Msgs[mi] = addpkg + patched++ + } + } + return patched, nil +} + +// loadGnoPackageFiles reads *.gno and gnomod.toml files from srcDir +// (non-recursive) and returns them as ordered std.MemFile entries. +// Skips _test.gno, _filetest.gno, and hidden files. +func loadGnoPackageFiles(srcDir string) ([]*std.MemFile, error) { + entries, err := os.ReadDir(srcDir) + if err != nil { + return nil, err + } + var names []string + for _, e := range entries { + if e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + n := e.Name() + if strings.HasSuffix(n, "_test.gno") || strings.HasSuffix(n, "_filetest.gno") { + continue + } + if strings.HasSuffix(n, ".gno") || n == "gnomod.toml" { + names = append(names, n) + } + } + sort.Strings(names) + + files := make([]*std.MemFile, 0, len(names)) + for _, n := range names { + body, err := os.ReadFile(filepath.Join(srcDir, n)) + if err != nil { + return nil, err + } + files = append(files, &std.MemFile{Name: n, Body: string(body)}) + } + return files, nil +} + +// gnoPackageNameFromFileBody extracts `package NAME` from the top of a .gno +// file. Returns "" if not found. (Intentionally lightweight — avoids pulling +// in the gnovm parser.) +func gnoPackageNameFromFileBody(_ string, body string) string { + for _, line := range strings.Split(body, "\n") { + l := strings.TrimSpace(line) + if strings.HasPrefix(l, "package ") { + rest := strings.TrimPrefix(l, "package ") + if i := strings.IndexAny(rest, " \t/"); i >= 0 { + rest = rest[:i] + } + return rest + } + } + return "" +} diff --git a/contribs/gnogenesis/internal/fork/generate_test.go b/contribs/gnogenesis/internal/fork/generate_test.go new file mode 100644 index 00000000000..5852adb7dcb --- /dev/null +++ b/contribs/gnogenesis/internal/fork/generate_test.go @@ -0,0 +1,104 @@ +package fork + +import ( + "bufio" + "os" + "path/filepath" + "testing" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/tm2/pkg/amino" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/sdk/bank" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWriteTxsJSONL_RoundTrip verifies that writeTxsJSONL produces output +// that can be read back by the dir source's JSONL reader. +// BUG: writeTxsJSONL uses encoding/json instead of amino, which loses type +// information for interface fields (std.Msg). The round-trip fails because +// the Msg type cannot be recovered from plain JSON. +func TestWriteTxsJSONL_RoundTrip(t *testing.T) { + t.Parallel() + + // Create a tx with a concrete Msg (bank.MsgSend). + msg := bank.MsgSend{ + FromAddress: crypto.AddressFromPreimage([]byte("sender")), + ToAddress: crypto.AddressFromPreimage([]byte("receiver")), + Amount: std.NewCoins(std.NewCoin("ugnot", 1000)), + } + tx := std.Tx{ + Msgs: []std.Msg{msg}, + Fee: std.NewFee(50000, std.NewCoin("ugnot", 1000)), + } + original := []gnoland.TxWithMetadata{ + { + Tx: tx, + Metadata: &gnoland.GnoTxMetadata{ + Timestamp: 1234567890, + BlockHeight: 42, + ChainID: "test-chain", + }, + }, + } + + // Write to JSONL. + dir := t.TempDir() + path := filepath.Join(dir, "txs.jsonl") + require.NoError(t, writeTxsJSONL(path, original)) + + // Read back line-by-line using amino.UnmarshalJSON (the correct decoder + // for amino-registered interfaces like std.Msg). + f, err := os.Open(path) + require.NoError(t, err) + defer f.Close() + + var decoded []gnoland.TxWithMetadata + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var tx gnoland.TxWithMetadata + require.NoError(t, amino.UnmarshalJSON(line, &tx), "amino should unmarshal JSONL line") + decoded = append(decoded, tx) + } + require.NoError(t, scanner.Err()) + + require.Len(t, decoded, 1, "should decode exactly one tx") + + // The Msg should round-trip correctly with its type preserved. + require.Len(t, decoded[0].Tx.Msgs, 1, "should have one msg") + _, ok := decoded[0].Tx.Msgs[0].(bank.MsgSend) + require.True(t, ok, "Msg should be bank.MsgSend after round-trip, got %T", decoded[0].Tx.Msgs[0]) + + // Metadata should survive. + require.NotNil(t, decoded[0].Metadata) + assert.Equal(t, int64(42), decoded[0].Metadata.BlockHeight) + assert.Equal(t, "test-chain", decoded[0].Metadata.ChainID) +} + +// TestVerifyGenesisFile_Invalid verifies that verifyGenesisFile returns an +// error for a malformed genesis file (so the calling tool can abort). +func TestVerifyGenesisFile_Invalid(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + t.Run("missing file", func(t *testing.T) { + t.Parallel() + err := verifyGenesisFile(filepath.Join(dir, "does-not-exist.json")) + require.Error(t, err) + }) + + t.Run("malformed json", func(t *testing.T) { + t.Parallel() + path := filepath.Join(dir, "bad.json") + require.NoError(t, os.WriteFile(path, []byte(`{"not_valid": `), 0o644)) + err := verifyGenesisFile(path) + require.Error(t, err) + }) +} diff --git a/contribs/gnogenesis/internal/fork/source.go b/contribs/gnogenesis/internal/fork/source.go new file mode 100644 index 00000000000..967ae698a2b --- /dev/null +++ b/contribs/gnogenesis/internal/fork/source.go @@ -0,0 +1,75 @@ +package fork + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + bftypes "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/commands" +) + +// Source is a provider of chain state for hardfork genesis assembly. +type Source interface { + // Description returns a human-readable source type label. + Description() string + + // FetchGenesis returns the source chain's genesis document. + FetchGenesis(ctx context.Context) (*bftypes.GenesisDoc, error) + + // LatestHeight returns the latest committed block height. + // Used to auto-detect halt height when --halt-height is not specified. + LatestHeight(ctx context.Context) (int64, error) + + // FetchTxs fetches all successful transactions in [fromHeight, toHeight] + // with metadata (BlockHeight, Timestamp, ChainID populated). + // Progress is reported via io. + FetchTxs(ctx context.Context, fromHeight, toHeight int64, io commands.IO) ([]gnoland.TxWithMetadata, error) + + // Close releases any resources held by the source. + Close() error +} + +// openSource auto-detects the source type from the provided string and +// returns the appropriate Source implementation. +// +// Detection order: +// 1. http:// or https:// prefix → RPC source +// 2. directory path that exists → local directory source +// 3. file ending in .json → single genesis file source +// 4. file ending in .tar.gz/.tgz → tarball source (future) +func openSource(s string) (Source, error) { + // RPC source + if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { + u, err := url.Parse(s) + if err != nil { + return nil, fmt.Errorf("invalid RPC URL: %w", err) + } + return newRPCSource(u.String()) + } + + // Local path + fi, err := os.Stat(s) + if err != nil { + return nil, fmt.Errorf("source path %q: %w", s, err) + } + + if fi.IsDir() { + return newDirSource(s) + } + + // Single genesis file + if strings.HasSuffix(s, ".json") { + return newFileSource(s) + } + + // Tarball (not yet implemented) + if strings.HasSuffix(s, ".tar.gz") || strings.HasSuffix(s, ".tgz") { + return nil, fmt.Errorf("tarball source not yet implemented; extract first and use --source /path/to/dir") + } + + return nil, fmt.Errorf("unrecognised source %q: expected http(s) URL, directory, .json file, or .tar.gz", s) +} diff --git a/contribs/gnogenesis/internal/fork/source_dir.go b/contribs/gnogenesis/internal/fork/source_dir.go new file mode 100644 index 00000000000..9deb76244cb --- /dev/null +++ b/contribs/gnogenesis/internal/fork/source_dir.go @@ -0,0 +1,191 @@ +package fork + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/tm2/pkg/amino" + bftypes "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/commands" +) + +// dirSource reads chain state from a local directory. +// +// Expected directory layouts (tries both): +// +// /path/to/dir/ +// config/genesis.json ← gnoland default node layout +// genesis.json ← flat layout (e.g. manual export) +// txs.jsonl ← optional: pre-exported txs with metadata +// +// If txs.jsonl is present it is used directly (no block store access needed). +// If txs.jsonl is absent, FetchTxs returns an empty slice with a warning — +// reading directly from the block store will be added in a future version. +type dirSource struct { + dir string + genesisPath string // resolved path to genesis.json + txsPath string // resolved path to txs.jsonl (empty if not found) +} + +func newDirSource(dir string) (*dirSource, error) { + s := &dirSource{dir: dir} + + // Find genesis.json + candidates := []string{ + filepath.Join(dir, "config", "genesis.json"), + filepath.Join(dir, "genesis.json"), + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + s.genesisPath = c + break + } + } + if s.genesisPath == "" { + return nil, fmt.Errorf("genesis.json not found in %s (tried config/genesis.json and genesis.json)", dir) + } + + // Find txs.jsonl (optional) + txsCandidates := []string{ + filepath.Join(dir, "txs.jsonl"), + filepath.Join(dir, "historical-txs.jsonl"), + } + for _, c := range txsCandidates { + if _, err := os.Stat(c); err == nil { + s.txsPath = c + break + } + } + + return s, nil +} + +func (s *dirSource) Description() string { return "local directory" } +func (s *dirSource) Close() error { return nil } + +func (s *dirSource) FetchGenesis(ctx context.Context) (*bftypes.GenesisDoc, error) { + data, err := os.ReadFile(s.genesisPath) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", s.genesisPath, err) + } + + var genDoc bftypes.GenesisDoc + if err := amino.UnmarshalJSON(data, &genDoc); err != nil { + return nil, fmt.Errorf("parsing genesis: %w", err) + } + + return &genDoc, nil +} + +// LatestHeight returns the halt height from the genesis InitialHeight if set, +// otherwise falls back to -1 (user must specify --halt-height). +// +// For a proper auto-detect from a local node directory, reading the block store +// would be needed. That is tracked as a future enhancement. +func (s *dirSource) LatestHeight(_ context.Context) (int64, error) { + data, err := os.ReadFile(s.genesisPath) + if err != nil { + return 0, fmt.Errorf("reading genesis: %w", err) + } + + // Try to extract a height hint from the genesis file itself + var raw struct { + InitialHeight int64 `json:"initial_height"` + } + _ = amino.UnmarshalJSON(data, &raw) + if raw.InitialHeight > 1 { + // This is already a hardfork genesis — use InitialHeight-1 as the halt height + return raw.InitialHeight - 1, nil + } + + return 0, fmt.Errorf( + "cannot auto-detect halt height from local directory %s; " + + "please specify --halt-height explicitly, or point --source to a running node RPC", + s.dir, + ) +} + +// FetchTxs reads transactions from txs.jsonl if present. +// If no txs file is found, returns an empty slice with a warning. +// Full block-store reading will be added in a future version. +func (s *dirSource) FetchTxs(_ context.Context, fromHeight, toHeight int64, io commands.IO) ([]gnoland.TxWithMetadata, error) { + if s.txsPath == "" { + io.Println(" WARNING: no txs.jsonl found in source directory.") + io.Println(" Historical tx replay will be empty — only genesis-mode txs will be included.") + io.Println(" To include historical txs, provide a txs.jsonl file alongside genesis.json,") + io.Println(" or use --source with an RPC URL instead.") + return nil, nil + } + + io.Printf(" Reading txs from: %s\n", s.txsPath) + + f, err := os.Open(s.txsPath) + if err != nil { + return nil, fmt.Errorf("opening %s: %w", s.txsPath, err) + } + defer f.Close() + + var txs []gnoland.TxWithMetadata + scanner := bufio.NewScanner(f) + // Increase buffer for large tx lines. + scanner.Buffer(make([]byte, 0, 4096), 10*1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var tx gnoland.TxWithMetadata + if err := amino.UnmarshalJSON(line, &tx); err != nil { + return nil, fmt.Errorf("decoding tx: %w", err) + } + + // Filter to requested height range + if tx.Metadata != nil && tx.Metadata.BlockHeight > 0 { + if tx.Metadata.BlockHeight < fromHeight || tx.Metadata.BlockHeight > toHeight { + continue + } + } + + txs = append(txs, tx) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading txs: %w", err) + } + + return txs, nil +} + +// fileSource handles a single genesis.json file (no txs). +type fileSource struct { + path string +} + +func newFileSource(path string) (*fileSource, error) { + if _, err := os.Stat(path); err != nil { + return nil, fmt.Errorf("file %q: %w", path, err) + } + return &fileSource{path: path}, nil +} + +func (s *fileSource) Description() string { return "genesis file" } +func (s *fileSource) Close() error { return nil } + +func (s *fileSource) FetchGenesis(ctx context.Context) (*bftypes.GenesisDoc, error) { + d := &dirSource{dir: filepath.Dir(s.path), genesisPath: s.path} + return d.FetchGenesis(ctx) +} + +func (s *fileSource) LatestHeight(ctx context.Context) (int64, error) { + d := &dirSource{dir: filepath.Dir(s.path), genesisPath: s.path} + return d.LatestHeight(ctx) +} + +func (s *fileSource) FetchTxs(_ context.Context, _, _ int64, io commands.IO) ([]gnoland.TxWithMetadata, error) { + io.Println(" WARNING: single genesis.json source — no historical txs available.") + io.Println(" Use --source with a directory (containing txs.jsonl) or an RPC URL.") + return nil, nil +} diff --git a/contribs/gnogenesis/internal/fork/source_rpc.go b/contribs/gnogenesis/internal/fork/source_rpc.go new file mode 100644 index 00000000000..901ffce906c --- /dev/null +++ b/contribs/gnogenesis/internal/fork/source_rpc.go @@ -0,0 +1,430 @@ +package fork + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/tm2/pkg/amino" + rpcclient "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + bftypes "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/std" +) + +// rpcSource fetches chain state from a live (or recently-halted) node via RPC. +type rpcSource struct { + rpcURL string + client *rpcclient.RPCClient +} + +func newRPCSource(rpcURL string) (*rpcSource, error) { + client, err := rpcclient.NewHTTPClient(rpcURL) + if err != nil { + return nil, fmt.Errorf("creating RPC client for %s: %w", rpcURL, err) + } + return &rpcSource{rpcURL: rpcURL, client: client}, nil +} + +func (s *rpcSource) Description() string { return "RPC" } +func (s *rpcSource) Close() error { return s.client.Close() } + +func (s *rpcSource) FetchGenesis(ctx context.Context) (*bftypes.GenesisDoc, error) { + res, err := s.client.Genesis(ctx) + if err == nil { + return res.Genesis, nil + } + + // Fallback: large genesis docs can exceed the JSON-RPC client's response + // buffer. Fetch via raw HTTP with streaming decode instead. + genDoc, rawErr := s.fetchGenesisRawHTTP(ctx) + if rawErr != nil { + return nil, fmt.Errorf("RPC genesis call: %w (raw HTTP fallback also failed: %v)", err, rawErr) + } + return genDoc, nil +} + +// fetchGenesisRawHTTP fetches the genesis doc directly via HTTP, streaming the +// response body to handle arbitrarily large genesis documents (e.g. betanet +// with hundreds of thousands of genesis txs). +func (s *rpcSource) fetchGenesisRawHTTP(ctx context.Context) (*bftypes.GenesisDoc, error) { + // Build the HTTP URL from the RPC URL. + baseURL := strings.TrimRight(s.rpcURL, "/") + url := baseURL + "/genesis" + + // Force HTTP/1.1 — large responses (100+ MB) can trigger HTTP/2 stream + // errors with some reverse proxies / CDNs. + transport := &http.Transport{ + ForceAttemptHTTP2: false, + TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), + } + httpClient := &http.Client{ + Timeout: 10 * time.Minute, + Transport: transport, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP GET %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP GET %s: status %d", url, resp.StatusCode) + } + + // The response is a JSON-RPC envelope: {"jsonrpc":"2.0","id":"","result":{"genesis":{...}}} + // Stream-decode to avoid buffering the entire response. + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + var envelope struct { + Result struct { + Genesis json.RawMessage `json:"genesis"` + } `json:"result"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("decoding JSON-RPC envelope: %w", err) + } + + var genDoc bftypes.GenesisDoc + if err := amino.UnmarshalJSON(envelope.Result.Genesis, &genDoc); err != nil { + return nil, fmt.Errorf("decoding genesis doc: %w", err) + } + + return &genDoc, nil +} + +func (s *rpcSource) LatestHeight(ctx context.Context) (int64, error) { + res, err := s.client.Status(ctx, nil) + if err != nil { + return 0, fmt.Errorf("RPC status call: %w", err) + } + return res.SyncInfo.LatestBlockHeight, nil +} + +// signerState tracks per-signer sequence resolution during export. +type signerState struct { + accNum uint64 + finalSeq uint64 // from RPC query at halt_height + seq uint64 // current pre-tx sequence counter + initialized bool // true after first brute-force resolves starting seq + pendingFails []*pendingFailedTx +} + +type pendingFailedTx struct { + txIndex int // index in the output txs slice, for back-patching SignerInfo + signerI int // index of this signer within the tx's signers +} + +// FetchTxs fetches all transactions in [fromHeight, toHeight] with metadata. +// Includes both successful and failed txs. Failed txs are marked with +// Failed: true and are not re-executed during replay, but their sequence +// impact is tracked. +func (s *rpcSource) FetchTxs(ctx context.Context, fromHeight, toHeight int64, io commands.IO) ([]gnoland.TxWithMetadata, error) { + var txs []gnoland.TxWithMetadata + + // Get chain ID from genesis (needed for metadata) + genesis, err := s.FetchGenesis(ctx) + if err != nil { + return nil, err + } + chainID := genesis.ChainID + + // Per-signer state for sequence tracking + signerStates := map[crypto.Address]*signerState{} + + getOrCreateSignerState := func(addr crypto.Address) *signerState { + if ss, ok := signerStates[addr]; ok { + return ss + } + // Query account at halt_height + acc := s.queryAccountAtHeight(ctx, addr, toHeight, io) + ss := &signerState{} + if acc != nil { + ss.accNum = acc.GetAccountNumber() + ss.finalSeq = acc.GetSequence() + } + signerStates[addr] = ss + return ss + } + + total := toHeight - fromHeight + 1 + var processed, txCount int64 + + for h := fromHeight; h <= toHeight; h++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + processed++ + if processed%1000 == 0 || processed == total { + io.Printf("\r Blocks: %d/%d Txs: %d", processed, total, txCount) + } + + // Fetch block + block, err := s.client.Block(ctx, &h) + if err != nil { + return nil, fmt.Errorf("fetching block %d: %w", h, err) + } + + if len(block.Block.Data.Txs) == 0 { + continue + } + + // Fetch block results to check success/failure + results, err := s.client.BlockResults(ctx, &h) + if err != nil { + return nil, fmt.Errorf("fetching block results %d: %w", h, err) + } + + timestamp := block.Block.Header.Time.Unix() + + for i, rawTx := range block.Block.Data.Txs { + // Decode the raw transaction bytes + var stdTx std.Tx + if err := amino.Unmarshal(rawTx, &stdTx); err != nil { + io.Printf("\n WARNING: could not decode tx at height %d index %d: %v\n", h, i, err) + continue + } + + failed := false + if i < len(results.Results.DeliverTxs) && results.Results.DeliverTxs[i].IsErr() { + failed = true + } + + signers := stdTx.GetSigners() + sigs := stdTx.GetSignatures() + + txIdx := len(txs) // index in output slice + + // Build signer info + signerInfos := make([]gnoland.SignerAccountInfo, len(signers)) + for j, signer := range signers { + ss := getOrCreateSignerState(signer) + signerInfos[j] = gnoland.SignerAccountInfo{ + Address: signer, + AccountNum: ss.accNum, + Sequence: 0, // filled below + } + } + + if !failed { + // Successful tx: resolve sequences + for j, signer := range signers { + ss := signerStates[signer] + + if !ss.initialized || len(ss.pendingFails) > 0 { + // Brute-force to find this tx's pre-tx sequence. + lo := ss.seq + hi := ss.seq + uint64(len(ss.pendingFails)) + if !ss.initialized { + lo = 0 + hi = ss.finalSeq + } + + var sig std.Signature + if j < len(sigs) { + sig = sigs[j] + } + + resolvedSeq, err := bruteForceSignerSequence( + stdTx, sig, ss.accNum, lo, hi, chainID) + if err != nil { + io.Printf("\n WARNING: brute-force failed for signer %s at height %d: %v (using counter %d)\n", + signer, h, err, ss.seq) + resolvedSeq = ss.seq + } + + // Back-patch buffered failed txs (cosmetic/audit-only) + assignFailedTxSequences(txs, ss.pendingFails, ss.seq, resolvedSeq) + ss.pendingFails = nil + ss.seq = resolvedSeq + ss.initialized = true + } + + signerInfos[j].Sequence = ss.seq + ss.seq++ + } + } else { + // Failed tx: buffer for each signer + for j, signer := range signers { + ss := signerStates[signer] + ss.pendingFails = append(ss.pendingFails, &pendingFailedTx{ + txIndex: txIdx, + signerI: j, + }) + // Assign current counter as placeholder (will be back-patched) + signerInfos[j].Sequence = ss.seq + } + } + + txs = append(txs, gnoland.TxWithMetadata{ + Tx: stdTx, + Metadata: &gnoland.GnoTxMetadata{ + Timestamp: timestamp, + BlockHeight: h, + ChainID: chainID, + Failed: failed, + SignerInfo: signerInfos, + }, + }) + txCount++ + } + } + + // Resolve trailing failures + for _, ss := range signerStates { + if len(ss.pendingFails) == 0 { + continue + } + + if !ss.initialized { + // Never had a successful tx. Cap consumed at len(pendingFails). + var consumed uint64 + if ss.finalSeq > ss.seq { + consumed = ss.finalSeq - ss.seq + } + if consumed > uint64(len(ss.pendingFails)) { + ss.seq = ss.finalSeq - uint64(len(ss.pendingFails)) + consumed = uint64(len(ss.pendingFails)) + } + assignTrailingFailedTxSequences(txs, ss.pendingFails, ss.seq, consumed) + } else { + var consumed uint64 + if ss.finalSeq > ss.seq { + consumed = ss.finalSeq - ss.seq + } + assignTrailingFailedTxSequences(txs, ss.pendingFails, ss.seq, consumed) + } + } + + io.Printf("\r Blocks: %d/%d Txs: %d\n", processed, total, txCount) + return txs, nil +} + +// queryAccountAtHeight queries an account's state at a specific block height. +func (s *rpcSource) queryAccountAtHeight( + ctx context.Context, addr crypto.Address, height int64, io commands.IO, +) std.Account { + path := fmt.Sprintf("auth/accounts/%s", addr) + res, err := s.client.ABCIQueryWithOptions(ctx, path, nil, rpcclient.ABCIQueryOptions{ + Height: height, + }) + if err != nil { + return nil + } + if res.Response.Error != nil { + return nil + } + if len(res.Response.Data) == 0 { + return nil + } + + // Response data is amino JSON (the auth query handler returns JSON). + // Try wrapped form first {"BaseAccount": {...}}, then direct. + var wrapper struct { + BaseAccount std.BaseAccount `json:"BaseAccount"` + } + if err := amino.UnmarshalJSON(res.Response.Data, &wrapper); err == nil { + return &wrapper.BaseAccount + } + + var acc std.BaseAccount + if err := amino.UnmarshalJSON(res.Response.Data, &acc); err != nil { + io.Printf("\n WARNING: could not decode account %s at height %d: %v\n", + addr, height, err) + return nil + } + return &acc +} + +// bruteForceSignerSequence tries sequences in [lo, hi] to find which makes +// the signature verify. Returns the pre-tx sequence (the value used in sign bytes). +func bruteForceSignerSequence( + tx std.Tx, sig std.Signature, accNum uint64, + lo, hi uint64, chainID string, +) (uint64, error) { + pubKey := sig.PubKey + if pubKey == nil { + return lo, fmt.Errorf("no pubkey in signature") + } + + for seq := lo; seq <= hi; seq++ { + signBytes, err := std.GetSignaturePayload(std.SignDoc{ + ChainID: chainID, + AccountNumber: accNum, + Sequence: seq, + Fee: tx.Fee, + Msgs: tx.Msgs, + Memo: tx.Memo, + }) + if err != nil { + continue + } + if pubKey.VerifyBytes(signBytes, sig.Signature) { + return seq, nil + } + } + + return lo, fmt.Errorf("no sequence in [%d, %d] verified for account %d", lo, hi, accNum) +} + +// assignFailedTxSequences back-patches sequence values on buffered failed txs. +// This is cosmetic/audit-only — failed txs are skipped during replay and the +// replay loop does not depend on their SignerInfo.Sequence values. +// +// Ordering within the gap is ambiguous: we cannot determine whether a failed tx +// was ante-fail (no sequence consumed) or msg-fail (sequence consumed) without +// re-verifying its signature, which may not be possible if the pubkey was not +// on-chain yet. We approximate by assuming msg-fails (consuming) come first in +// the gap, then ante-fails. +func assignFailedTxSequences( + txs []gnoland.TxWithMetadata, + pending []*pendingFailedTx, + startSeq, resolvedSeq uint64, +) { + consumed := resolvedSeq - startSeq + seq := startSeq + for i, pf := range pending { + if pf.txIndex < len(txs) && pf.signerI < len(txs[pf.txIndex].Metadata.SignerInfo) { + txs[pf.txIndex].Metadata.SignerInfo[pf.signerI].Sequence = seq + } + if uint64(i) < consumed { + seq++ + } + } +} + +// assignTrailingFailedTxSequences handles failed txs at the end of the chain +// with no subsequent success to anchor against. +func assignTrailingFailedTxSequences( + txs []gnoland.TxWithMetadata, + pending []*pendingFailedTx, + startSeq, consumed uint64, +) { + seq := startSeq + for i, pf := range pending { + if pf.txIndex < len(txs) && pf.signerI < len(txs[pf.txIndex].Metadata.SignerInfo) { + txs[pf.txIndex].Metadata.SignerInfo[pf.signerI].Sequence = seq + } + if uint64(i) < consumed { + seq++ + } + } +} diff --git a/contribs/gnogenesis/internal/fork/source_rpc_test.go b/contribs/gnogenesis/internal/fork/source_rpc_test.go new file mode 100644 index 00000000000..8b9e34a1505 --- /dev/null +++ b/contribs/gnogenesis/internal/fork/source_rpc_test.go @@ -0,0 +1,164 @@ +package fork + +import ( + "testing" + + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/crypto/ed25519" + "github.com/gnolang/gno/tm2/pkg/crypto/secp256k1" + "github.com/gnolang/gno/tm2/pkg/sdk/bank" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// signTxAt signs a tx as-if the signer had (accNum, seq) at sign time and +// returns the Signature. The returned Signature embeds the pubkey so +// bruteForceSignerSequence can verify it. +func signTxAt(t *testing.T, priv crypto.PrivKey, tx std.Tx, chainID string, accNum, seq uint64) std.Signature { + t.Helper() + payload, err := std.GetSignaturePayload(std.SignDoc{ + ChainID: chainID, + AccountNumber: accNum, + Sequence: seq, + Fee: tx.Fee, + Msgs: tx.Msgs, + Memo: tx.Memo, + }) + require.NoError(t, err) + + sig, err := priv.Sign(payload) + require.NoError(t, err) + + return std.Signature{ + PubKey: priv.PubKey(), + Signature: sig, + } +} + +func makeTestTx(t *testing.T, priv crypto.PrivKey) std.Tx { + t.Helper() + msg := bank.MsgSend{ + FromAddress: priv.PubKey().Address(), + ToAddress: priv.PubKey().Address(), // doesn't matter for sig test + Amount: std.NewCoins(std.NewCoin("ugnot", 100)), + } + return std.Tx{ + Msgs: []std.Msg{msg}, + Fee: std.NewFee(50000, std.NewCoin("ugnot", 1000)), + Memo: "test", + } +} + +func TestBruteForceSignerSequence(t *testing.T) { + t.Parallel() + + chainID := "test-chain" + priv := ed25519.GenPrivKey() + accNum := uint64(42) + + t.Run("finds correct sequence in range", func(t *testing.T) { + t.Parallel() + tx := makeTestTx(t, priv) + actualSeq := uint64(7) + sig := signTxAt(t, priv, tx, chainID, accNum, actualSeq) + + resolved, err := bruteForceSignerSequence(tx, sig, accNum, 0, 20, chainID) + require.NoError(t, err) + assert.Equal(t, actualSeq, resolved) + }) + + t.Run("finds sequence at lo boundary", func(t *testing.T) { + t.Parallel() + tx := makeTestTx(t, priv) + sig := signTxAt(t, priv, tx, chainID, accNum, 5) + + resolved, err := bruteForceSignerSequence(tx, sig, accNum, 5, 10, chainID) + require.NoError(t, err) + assert.Equal(t, uint64(5), resolved) + }) + + t.Run("finds sequence at hi boundary", func(t *testing.T) { + t.Parallel() + tx := makeTestTx(t, priv) + sig := signTxAt(t, priv, tx, chainID, accNum, 10) + + resolved, err := bruteForceSignerSequence(tx, sig, accNum, 5, 10, chainID) + require.NoError(t, err) + assert.Equal(t, uint64(10), resolved) + }) + + t.Run("lo==hi with correct value", func(t *testing.T) { + t.Parallel() + tx := makeTestTx(t, priv) + sig := signTxAt(t, priv, tx, chainID, accNum, 3) + + resolved, err := bruteForceSignerSequence(tx, sig, accNum, 3, 3, chainID) + require.NoError(t, err) + assert.Equal(t, uint64(3), resolved) + }) + + t.Run("sequence outside range returns error", func(t *testing.T) { + t.Parallel() + tx := makeTestTx(t, priv) + sig := signTxAt(t, priv, tx, chainID, accNum, 100) + + _, err := bruteForceSignerSequence(tx, sig, accNum, 0, 20, chainID) + require.Error(t, err) + assert.Contains(t, err.Error(), "no sequence in") + }) + + t.Run("wrong account number returns error", func(t *testing.T) { + t.Parallel() + tx := makeTestTx(t, priv) + sig := signTxAt(t, priv, tx, chainID, accNum, 5) + + // Sign says accNum=42 but we search assuming 99. + _, err := bruteForceSignerSequence(tx, sig, 99, 0, 20, chainID) + require.Error(t, err) + }) + + t.Run("wrong chain ID returns error", func(t *testing.T) { + t.Parallel() + tx := makeTestTx(t, priv) + sig := signTxAt(t, priv, tx, chainID, accNum, 5) + + // Sign says chainID="test-chain" but we search with "other-chain". + _, err := bruteForceSignerSequence(tx, sig, accNum, 0, 20, "other-chain") + require.Error(t, err) + }) + + t.Run("nil pubkey returns error", func(t *testing.T) { + t.Parallel() + tx := makeTestTx(t, priv) + sig := std.Signature{PubKey: nil, Signature: []byte("dummy")} + + _, err := bruteForceSignerSequence(tx, sig, accNum, 0, 20, chainID) + require.Error(t, err) + assert.Contains(t, err.Error(), "no pubkey") + }) + + t.Run("secp256k1 key also works", func(t *testing.T) { + t.Parallel() + sPriv := secp256k1.GenPrivKey() + tx := makeTestTx(t, sPriv) + sig := signTxAt(t, sPriv, tx, chainID, accNum, 12) + + resolved, err := bruteForceSignerSequence(tx, sig, accNum, 0, 20, chainID) + require.NoError(t, err) + assert.Equal(t, uint64(12), resolved) + }) + + t.Run("tampered tx fee rejects all sequences", func(t *testing.T) { + t.Parallel() + tx := makeTestTx(t, priv) + sig := signTxAt(t, priv, tx, chainID, accNum, 5) + + // Tamper with the tx after signing. + tampered := tx + tampered.Fee = std.NewFee(99999, std.NewCoin("ugnot", 9999)) + + _, err := bruteForceSignerSequence(tampered, sig, accNum, 0, 20, chainID) + require.Error(t, err) + }) +} diff --git a/contribs/gnogenesis/internal/fork/test.go b/contribs/gnogenesis/internal/fork/test.go new file mode 100644 index 00000000000..08acde42a1f --- /dev/null +++ b/contribs/gnogenesis/internal/fork/test.go @@ -0,0 +1,284 @@ +package fork + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync/atomic" + "time" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + "github.com/gnolang/gno/tm2/pkg/amino" + tmcfg "github.com/gnolang/gno/tm2/pkg/bft/config" + bft "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/db/memdb" + "github.com/gnolang/gno/tm2/pkg/log" + "github.com/gnolang/gno/tm2/pkg/sdk" + "github.com/gnolang/gno/tm2/pkg/std" +) + +type testCfg struct { + genesis string + timeout time.Duration + verbose bool + keepRunning bool +} + +func newTestCmd(io commands.IO) *commands.Command { + cfg := &testCfg{} + + return commands.NewCommand( + commands.Metadata{ + Name: "test", + ShortUsage: "test [flags]", + ShortHelp: "smoke-test a hardfork genesis by replaying it in-process", + LongHelp: `Smoke-tests a hardfork genesis by loading it into an in-memory gnoland node +and replaying all transactions (genesis-mode and historical). + +A fresh single-validator identity is generated for the test — it replaces the +real validators in the genesis so the node can produce blocks without requiring +the actual validator keys. The app state (txs, balances, packages) is kept +exactly as-is. + +SkipGenesisSigVerification is enabled for genesis-mode txs. Historical txs +(those with block_height > 0) go through the normal ante handler using the +original_chain_id from the genesis to verify signatures. + +Exit code: 0 on success (all txs replayed, first block produced), non-zero on failure. + +Examples: + + # Smoke-test the default output of hardfork genesis: + hardfork test --genesis genesis.json + + # With a longer timeout and verbose tx logging: + hardfork test --genesis genesis.json --timeout 2h --verbose + + # Keep the node running after replay for manual inspection via RPC: + hardfork test --genesis genesis.json --keep-running`, + }, + cfg, + func(ctx context.Context, args []string) error { + return execTest(ctx, cfg, io) + }, + ) +} + +func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar(&c.genesis, "genesis", "genesis.json", "path to the hardfork genesis.json to test") + fs.DurationVar(&c.timeout, "timeout", 30*time.Minute, "maximum time to wait for genesis replay to complete") + fs.BoolVar(&c.verbose, "verbose", false, "print each tx result during replay") + fs.BoolVar(&c.keepRunning, "keep-running", false, "keep the node running after genesis replay (for manual RPC inspection)") +} + +func execTest(ctx context.Context, cfg *testCfg, io commands.IO) error { + // ------------------------------------------------------------------------- + // Step 1: Load and parse the genesis file + // ------------------------------------------------------------------------- + io.Printf("Loading genesis: %s\n", cfg.genesis) + + data, err := os.ReadFile(cfg.genesis) + if err != nil { + return fmt.Errorf("reading genesis file: %w", err) + } + + var genDoc bft.GenesisDoc + if err := amino.UnmarshalJSON(data, &genDoc); err != nil { + return fmt.Errorf("parsing genesis: %w", err) + } + + if err := genDoc.ValidateAndComplete(); err != nil { + return fmt.Errorf("genesis validation failed: %w", err) + } + + // Extract app state for summary + appState, ok := genDoc.AppState.(gnoland.GnoGenesisState) + if !ok { + raw, err := amino.MarshalJSON(genDoc.AppState) + if err != nil { + return fmt.Errorf("marshalling app state: %w", err) + } + if err := amino.UnmarshalJSON(raw, &appState); err != nil { + return fmt.Errorf("unmarshalling app state: %w", err) + } + } + + genesisModeTxs := baseGenesisModeTxs(&appState) + historicalTxs := len(appState.Txs) - len(genesisModeTxs) + + io.Printf(" Past chain IDs: %v\n", appState.PastChainIDs) + io.Printf(" New chain ID: %s\n", genDoc.ChainID) + io.Printf(" Initial height: %d\n", genDoc.InitialHeight) + io.Printf(" Genesis-mode txs: %d\n", len(genesisModeTxs)) + io.Printf(" Historical txs: %d\n", historicalTxs) + io.Printf(" Total txs: %d\n", len(appState.Txs)) + + if len(appState.PastChainIDs) == 0 && historicalTxs > 0 { + io.Println(" WARNING: past_chain_ids is empty — historical tx signatures cannot be verified.") + } + + // ------------------------------------------------------------------------- + // Step 2: Replace validators with a local test identity + // ------------------------------------------------------------------------- + pv := bft.NewMockPV() + pk := pv.PubKey() + genDoc.Validators = []bft.GenesisValidator{ + { + Address: pk.Address(), + PubKey: pk, + Power: 10, + Name: "hardfork-test-node", + }, + } + + // ------------------------------------------------------------------------- + // Step 3: Find GNOROOT (needed for stdlibs) + // ------------------------------------------------------------------------- + gnoroot, err := gnoenv.GuessRootDir() + if err != nil { + return fmt.Errorf("cannot locate GNOROOT (set the GNOROOT env var): %w", err) + } + + stdlibDir := filepath.Join(gnoroot, "gnovm", "stdlibs") + if _, err := os.Stat(stdlibDir); err != nil { + return fmt.Errorf("stdlibs directory not found at %s (is GNOROOT correct?): %w", stdlibDir, err) + } + + // ------------------------------------------------------------------------- + // Step 4: Set up tx result tracking + // ------------------------------------------------------------------------- + var txFailures atomic.Int64 + var txProcessed atomic.Int64 + + txResultHandler := func(ctx sdk.Context, tx std.Tx, res sdk.Result) { + txProcessed.Add(1) + if res.IsErr() { + txFailures.Add(1) + if cfg.verbose { + io.Printf(" [FAIL] height=%d error=%s\n", ctx.BlockHeight(), res.Log) + } + } else if cfg.verbose { + msgs := make([]string, len(tx.Msgs)) + for i, m := range tx.Msgs { + msgs[i] = m.Type() + } + io.Printf(" [OK] height=%d msgs=%v\n", ctx.BlockHeight(), msgs) + } + } + + // ------------------------------------------------------------------------- + // Step 5: Configure in-memory node + // ------------------------------------------------------------------------- + tmConfig := tmcfg.TestConfig().SetRootDir(gnoroot) + tmConfig.Consensus.WALDisabled = true + tmConfig.Consensus.SkipTimeoutCommit = true + tmConfig.Consensus.CreateEmptyBlocks = false + tmConfig.RPC.ListenAddress = "tcp://127.0.0.1:0" // random port, avoids conflicts + tmConfig.P2P.ListenAddress = "tcp://127.0.0.1:0" + + nodeCfg := &gnoland.InMemoryNodeConfig{ + PrivValidator: pv, + Genesis: &genDoc, + TMConfig: tmConfig, + DB: memdb.NewMemDB(), + SkipGenesisSigVerification: true, + InitChainerConfig: gnoland.InitChainerConfig{ + GenesisTxResultHandler: txResultHandler, + StdlibDir: stdlibDir, + CacheStdlibLoad: false, + }, + } + + // Choose logger: quiet by default, real output when verbose + var nodeLogger *slog.Logger + if cfg.verbose { + nodeLogger = slog.Default() + } else { + nodeLogger = log.NewNoopLogger() + } + + // ------------------------------------------------------------------------- + // Step 6: Start the node + // ------------------------------------------------------------------------- + io.Println() + io.Println("Starting in-memory node for genesis replay...") + + n, err := gnoland.NewInMemoryNode(nodeLogger, nodeCfg) + if err != nil { + return fmt.Errorf("creating in-memory node: %w", err) + } + + start := time.Now() + + if err := n.Start(); err != nil { + return fmt.Errorf("starting node: %w", err) + } + + defer func() { + if stopErr := n.Stop(); stopErr != nil { + io.Printf("WARNING: error stopping node: %v\n", stopErr) + } + }() + + // ------------------------------------------------------------------------- + // Step 7: Wait for genesis replay to complete (first block produced) + // ------------------------------------------------------------------------- + io.Printf("Replaying %d txs (timeout: %s)...\n", len(appState.Txs), cfg.timeout) + + // Progress ticker: print elapsed time every 30s so the user knows it's alive + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + timeoutCtx, cancel := context.WithTimeout(ctx, cfg.timeout) + defer cancel() + + for { + select { + case <-n.Ready(): + elapsed := time.Since(start) + failures := txFailures.Load() + processed := txProcessed.Load() + + io.Println() + io.Println("=== Test Results ===") + io.Printf(" Elapsed: %s\n", elapsed.Round(time.Second)) + io.Printf(" Txs processed: %d / %d\n", processed, len(appState.Txs)) + io.Printf(" Failures: %d\n", failures) + + if failures > 0 { + io.Println() + io.Printf("FAIL: %d transaction(s) failed during genesis replay.\n", failures) + io.Println("Run with --verbose to see individual failures.") + return fmt.Errorf("genesis replay completed with %d failures", failures) + } + + io.Println() + io.Println("PASS: genesis replay completed successfully.") + + if cfg.keepRunning { + io.Println() + io.Printf("Node is running at: %s\n", tmConfig.RPC.ListenAddress) + io.Println("Press Ctrl+C to stop.") + <-ctx.Done() + } + + return nil + + case <-ticker.C: + elapsed := time.Since(start) + processed := txProcessed.Load() + io.Printf(" ... still replaying: %d/%d txs, %s elapsed\n", + processed, len(appState.Txs), elapsed.Round(time.Second)) + + case <-timeoutCtx.Done(): + processed := txProcessed.Load() + return fmt.Errorf("genesis replay timed out after %s (%d/%d txs processed)", + cfg.timeout, processed, len(appState.Txs)) + } + } +} diff --git a/contribs/gnogenesis/internal/fork/test_test.go b/contribs/gnogenesis/internal/fork/test_test.go new file mode 100644 index 00000000000..fa53c40655f --- /dev/null +++ b/contribs/gnogenesis/internal/fork/test_test.go @@ -0,0 +1,174 @@ +package fork + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/tm2/pkg/amino" + 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/commands" + "github.com/gnolang/gno/tm2/pkg/sdk/auth" + "github.com/gnolang/gno/tm2/pkg/sdk/bank" + "github.com/stretchr/testify/require" + + vmm "github.com/gnolang/gno/gno.land/pkg/sdk/vm" +) + +// writeTestGenesis writes a minimal but valid genesis.json to a temp file. +// It uses a fresh private validator so the genesis is self-contained. +func writeTestGenesis(t *testing.T, appState gnoland.GnoGenesisState) string { + t.Helper() + + pv := bft.NewMockPV() + pk := pv.PubKey() + + genDoc := bft.GenesisDoc{ + GenesisTime: time.Now(), + ChainID: "test-hardfork-1", + ConsensusParams: abci.ConsensusParams{ + Block: &abci.BlockParams{ + MaxTxBytes: 1_000_000, + MaxDataBytes: 2_000_000, + MaxGas: 3_000_000_000, + TimeIotaMS: 100, + }, + }, + Validators: []bft.GenesisValidator{ + { + Address: pk.Address(), + PubKey: pk, + Power: 10, + Name: "test-validator", + }, + }, + AppState: appState, + } + + data, err := amino.MarshalJSONIndent(genDoc, "", " ") + require.NoError(t, err) + + dir := t.TempDir() + path := filepath.Join(dir, "genesis.json") + require.NoError(t, os.WriteFile(path, data, 0o644)) + return path +} + +func minimalAppState() gnoland.GnoGenesisState { + return gnoland.GnoGenesisState{ + Balances: []gnoland.Balance{}, + Txs: []gnoland.TxWithMetadata{}, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vmm.DefaultGenesisState(), + } +} + +// TestExecTest_MissingGenesis verifies that a missing genesis file is caught. +func TestExecTest_MissingGenesis(t *testing.T) { + io := commands.NewTestIO() + cfg := &testCfg{ + genesis: "/nonexistent/path/genesis.json", + timeout: 5 * time.Second, + } + err := execTest(context.Background(), cfg, io) + require.ErrorContains(t, err, "reading genesis file") +} + +// TestExecTest_InvalidGenesis verifies that a malformed genesis file is caught. +func TestExecTest_InvalidGenesis(t *testing.T) { + dir := t.TempDir() + bad := filepath.Join(dir, "bad.json") + require.NoError(t, os.WriteFile(bad, []byte(`{"not_valid": "json"`), 0o644)) + + io := commands.NewTestIO() + cfg := &testCfg{ + genesis: bad, + timeout: 5 * time.Second, + } + err := execTest(context.Background(), cfg, io) + require.ErrorContains(t, err, "parsing genesis") +} + +// TestExecTest_EmptyGenesis runs a full in-process replay with an empty genesis +// (no transactions). This verifies the happy path without requiring network access. +// +// This test is skipped in short mode (-short) because loading stdlibs takes ~30s. +func TestExecTest_EmptyGenesis(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode — requires loading stdlibs (~30s)") + } + + // Ensure GNOROOT is set (required for stdlibs). + // If running from the repo root, gnoenv.GuessRootDir() will find it via go list. + path := writeTestGenesis(t, minimalAppState()) + + io := commands.NewTestIO() + cfg := &testCfg{ + genesis: path, + timeout: 3 * time.Minute, + } + + err := execTest(context.Background(), cfg, io) + require.NoError(t, err, "empty genesis replay should succeed") +} + +// TestExecTest_HardforkGenesis builds a minimal hardfork genesis (with +// PastChainIDs and InitialHeight set) and verifies it can be replayed. +// +// This test is skipped in short mode (-short) because loading stdlibs takes ~30s. +func TestExecTest_HardforkGenesis(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode — requires loading stdlibs (~30s)") + } + + appState := minimalAppState() + appState.PastChainIDs = []string{"test-hardfork-source"} + + pv := bft.NewMockPV() + pk := pv.PubKey() + + genDoc := bft.GenesisDoc{ + GenesisTime: time.Now(), + ChainID: "test-hardfork-1", + InitialHeight: 100, // hardfork starts at block 100 + ConsensusParams: abci.ConsensusParams{ + Block: &abci.BlockParams{ + MaxTxBytes: 1_000_000, + MaxDataBytes: 2_000_000, + MaxGas: 3_000_000_000, + TimeIotaMS: 100, + }, + }, + Validators: []bft.GenesisValidator{ + { + Address: pk.Address(), + PubKey: pk, + Power: 10, + Name: "test-validator", + }, + }, + AppState: appState, + } + + data, err := amino.MarshalJSONIndent(genDoc, "", " ") + require.NoError(t, err) + + dir := t.TempDir() + path := filepath.Join(dir, "genesis.json") + require.NoError(t, os.WriteFile(path, data, 0o644)) + + io := commands.NewTestIO() + cfg := &testCfg{ + genesis: path, + timeout: 3 * time.Minute, + } + + err = execTest(context.Background(), cfg, io) + require.NoError(t, err, "hardfork genesis replay should succeed") +} + diff --git a/contribs/tx-archive/backup/backup.go b/contribs/tx-archive/backup/backup.go index 48acfe1c389..d3df9573589 100644 --- a/contribs/tx-archive/backup/backup.go +++ b/contribs/tx-archive/backup/backup.go @@ -23,18 +23,20 @@ type Service struct { writer writer.Writer logger log.Logger - batchSize uint - watchInterval time.Duration // interval for the watch routine - skipFailedTxs bool + batchSize uint + watchInterval time.Duration // interval for the watch routine + skipFailedTxs bool + populateSignerInfo bool // populate per-tx SignerInfo (default true for bounded backups) } // NewService creates a new backup service func NewService(client client.Client, writer writer.Writer, opts ...Option) *Service { s := &Service{ - client: client, - writer: writer, - logger: noop.New(), - watchInterval: 1 * time.Second, + client: client, + writer: writer, + logger: noop.New(), + watchInterval: 1 * time.Second, + populateSignerInfo: true, } for _, opt := range opts { @@ -62,12 +64,39 @@ func (s *Service) ExecuteBackup(ctx context.Context, cfg Config) error { return fmt.Errorf("unable to determine right bound, %w", boundErr) } + // Fetch source chain ID once — used to tag every tx so a hardfork replay + // (see gno.land/pkg/gnoland.GnoGenesisState.PastChainIDs) can verify the + // signature against the chain that produced it. + chainID, chainIDErr := s.client.GetChainID() + if chainIDErr != nil { + return fmt.Errorf("unable to fetch source chain id, %w", chainIDErr) + } + + // SignerInfo resolver: fills per-signer (account_num, sequence) metadata + // so hardfork replay can force-set account state before signature + // verification. Only enabled for non-watch, bounded backups — it needs + // a fixed halt height to anchor the brute-force sequence search. + // + // Because the resolver back-patches failed-tx SignerInfo retroactively + // (at the next success per signer), enabling it switches the writer + // from streaming to buffered mode: all txs are held in memory until the + // final Finalize pass, then flushed to the writer in one pass. + var ( + resolver *signerResolver + bufferedTxs []*gnoland.TxWithMetadata + ) + if s.populateSignerInfo && !cfg.Watch { + resolver = newSignerResolver(s.client, chainID, toBlock) + } + // Log info about what will be backed up s.logger.Info( "Existing blocks to backup", + "chain id", chainID, "from block", cfg.FromBlock, "to block", toBlock, "total", toBlock-cfg.FromBlock+1, + "populate signer info", resolver != nil, ) // Keep track of what has been backed up @@ -89,6 +118,13 @@ func (s *Service) ExecuteBackup(ctx context.Context, cfg Config) error { // Internal function that fetches and writes a range of blocks fetchAndWrite := func(fromBlock, toBlock uint64) error { + // Progress pacing: print one status line every ~5s, not per batch. + var ( + progressStart = time.Now() + totalRange = toBlock - fromBlock + 1 + nextProgress = progressStart.Add(5 * time.Second) + ) + // Fetch by batches for batchStart := fromBlock; batchStart <= toBlock; { // Determine batch stop block @@ -117,6 +153,33 @@ func (s *Service) ExecuteBackup(ctx context.Context, cfg Config) error { results.blocksFetched += batchSize results.blocksWithTxs += uint64(len(blocks)) + // Pace progress output at ~5s intervals + always on last batch. + if now := time.Now(); now.After(nextProgress) || batchStop == toBlock { + nextProgress = now.Add(5 * time.Second) + + elapsed := now.Sub(progressStart) + done := batchStop - fromBlock + 1 + var blocksPerSec float64 + if secs := elapsed.Seconds(); secs > 0 { + blocksPerSec = float64(done) / secs + } + eta := time.Duration(0) + if blocksPerSec > 0 && done < totalRange { + remaining := totalRange - done + eta = time.Duration(float64(remaining)/blocksPerSec) * time.Second + } + pct := float64(done) / float64(totalRange) * 100 + s.logger.Info( + "Progress", + "blocks", fmt.Sprintf("%d/%d", done, totalRange), + "pct", fmt.Sprintf("%.1f%%", pct), + "rate", fmt.Sprintf("%.0f blocks/s", blocksPerSec), + "txs", results.txsBackedUp, + "elapsed", elapsed.Round(time.Second), + "eta", eta.Round(time.Second), + ) + } + // Verbose log for blocks containing transactions s.logger.Debug( "Batch fetched successfully", @@ -142,8 +205,9 @@ func (s *Service) ExecuteBackup(ctx context.Context, cfg Config) error { for i, tx := range block.Txs { txResult := txResults[i] + failed := !txResult.IsOK() - if !txResult.IsOK() && s.skipFailedTxs { + if failed && s.skipFailedTxs { // Skip saving failed transaction s.logger.Debug( "Skipping failed tx", @@ -158,11 +222,22 @@ func (s *Service) ExecuteBackup(ctx context.Context, cfg Config) error { txData := &gnoland.TxWithMetadata{ Tx: tx, Metadata: &gnoland.GnoTxMetadata{ - Timestamp: block.Timestamp, + Timestamp: block.Timestamp, + BlockHeight: int64(block.Height), + ChainID: chainID, + Failed: failed, }, } - if writeErr := s.writer.WriteTxData(txData); writeErr != nil { + if resolver != nil { + // Buffer — SignerInfo is populated & back-patched, + // then the whole batch is flushed after Finalize. + if pErr := resolver.Populate(txData); pErr != nil { + return fmt.Errorf("populate signer info @ h=%d idx=%d: %w", + block.Height, i, pErr) + } + bufferedTxs = append(bufferedTxs, txData) + } else if writeErr := s.writer.WriteTxData(txData); writeErr != nil { return fmt.Errorf("unable to write tx data, %w", writeErr) } @@ -190,6 +265,19 @@ func (s *Service) ExecuteBackup(ctx context.Context, cfg Config) error { return fetchErr } + // Flush the resolver's buffered output (if enabled). Finalize back-patches + // any trailing failed-tx SignerInfo entries, then we stream the whole + // ordered batch to the writer. + if resolver != nil { + resolver.Finalize() + for _, txData := range bufferedTxs { + if writeErr := s.writer.WriteTxData(txData); writeErr != nil { + return fmt.Errorf("unable to write tx data, %w", writeErr) + } + } + bufferedTxs = nil + } + // Check if there needs to be a watcher setup if cfg.Watch { s.logger.Info( diff --git a/contribs/tx-archive/backup/backup_test.go b/contribs/tx-archive/backup/backup_test.go index aa86792728f..b9bfcbe1097 100644 --- a/contribs/tx-archive/backup/backup_test.go +++ b/contribs/tx-archive/backup/backup_test.go @@ -237,6 +237,9 @@ func TestBackup_ExecuteBackup_FixedRange(t *testing.T) { blockTime.Add(time.Duration(expectedBlock)*time.Minute).Local(), time.UnixMilli(txData.Metadata.Timestamp), ) + assert.Equal(t, int64(expectedBlock), txData.Metadata.BlockHeight) + assert.Equal(t, "test-chain", txData.Metadata.ChainID) + assert.False(t, txData.Metadata.Failed) } // Check for errors during scanning diff --git a/contribs/tx-archive/backup/client/client.go b/contribs/tx-archive/backup/client/client.go index a01b9529e87..3c2eaaca5dc 100644 --- a/contribs/tx-archive/backup/client/client.go +++ b/contribs/tx-archive/backup/client/client.go @@ -4,6 +4,7 @@ import ( "context" abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" + "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/std" ) @@ -12,6 +13,9 @@ type Client interface { // GetLatestBlockNumber returns the latest block height from the chain GetLatestBlockNumber() (uint64, error) + // GetChainID returns the chain ID of the source chain + GetChainID() (string, error) + // GetBlocks returns a slice of Block - including the block height and its // timestamp in milliseconds - in the requested range only if they contain // transactions @@ -19,6 +23,12 @@ type Client interface { // GetTxResults returns the block transaction results (if any) GetTxResults(block uint64) ([]*abci.ResponseDeliverTx, error) + + // GetAccountAtHeight returns the (account_number, sequence) pair for + // the given address at the given block height. Used by the hardfork- + // metadata signer-info resolver to anchor brute-force sequence search. + // Returns (0, 0, nil) when the account doesn't exist yet at that height. + GetAccountAtHeight(addr crypto.Address, height uint64) (accNum, sequence uint64, err error) } type Block struct { diff --git a/contribs/tx-archive/backup/client/rpc/rpc.go b/contribs/tx-archive/backup/client/rpc/rpc.go index 6817454742e..4e0ea419178 100644 --- a/contribs/tx-archive/backup/client/rpc/rpc.go +++ b/contribs/tx-archive/backup/client/rpc/rpc.go @@ -11,6 +11,7 @@ import ( abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" rpcClient "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" ctypes "github.com/gnolang/gno/tm2/pkg/bft/rpc/core/types" + "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/std" "github.com/gnolang/gno/contribs/tx-archive/backup/client" @@ -59,6 +60,50 @@ func (c *Client) GetLatestBlockNumber() (uint64, error) { return uint64(status.SyncInfo.LatestBlockHeight), nil } +// GetChainID returns the chain ID of the source chain, fetched from /status. +func (c *Client) GetChainID() (string, error) { + status, err := c.client.Status(context.Background(), nil) + if err != nil { + return "", fmt.Errorf("unable to fetch chain ID, %w", err) + } + + return status.NodeInfo.Network, nil +} + +// GetAccountAtHeight queries auth/accounts/ at the given block height +// and returns (account_number, sequence). Returns (0, 0, nil) when the +// account does not yet exist at that height (i.e. genesis-less / pre-creation). +func (c *Client) GetAccountAtHeight(addr crypto.Address, height uint64) (uint64, uint64, error) { + path := fmt.Sprintf("auth/accounts/%s", addr) + res, err := c.client.ABCIQueryWithOptions( + context.Background(), + path, nil, + rpcClient.ABCIQueryOptions{Height: int64(height)}, + ) + if err != nil { + return 0, 0, fmt.Errorf("abci query %s at %d: %w", path, height, err) + } + if res.Response.Error != nil || len(res.Response.Data) == 0 { + // Account doesn't exist yet — not an error. + return 0, 0, nil + } + + // Response is amino JSON. Try wrapped form first, then direct. + var wrapper struct { + BaseAccount std.BaseAccount `json:"BaseAccount"` + } + if err := amino.UnmarshalJSON(res.Response.Data, &wrapper); err == nil && + wrapper.BaseAccount.Address == addr { + return wrapper.BaseAccount.AccountNumber, wrapper.BaseAccount.Sequence, nil + } + + var acc std.BaseAccount + if err := amino.UnmarshalJSON(res.Response.Data, &acc); err != nil { + return 0, 0, fmt.Errorf("decode BaseAccount for %s: %w", addr, err) + } + return acc.AccountNumber, acc.Sequence, nil +} + func (c *Client) GetBlocks(ctx context.Context, from, to uint64) ([]*client.Block, error) { // Check if the block range is valid if from > to { diff --git a/contribs/tx-archive/backup/mock_test.go b/contribs/tx-archive/backup/mock_test.go index 57946cb2ab1..51cb2b8a41a 100644 --- a/contribs/tx-archive/backup/mock_test.go +++ b/contribs/tx-archive/backup/mock_test.go @@ -5,18 +5,23 @@ import ( "github.com/gnolang/gno/contribs/tx-archive/backup/client" abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" + "github.com/gnolang/gno/tm2/pkg/crypto" ) type ( getLatestBlockNumberDelegate func() (uint64, error) + getChainIDDelegate func() (string, error) getBlocksDelegate func(context.Context, uint64, uint64) ([]*client.Block, error) getTxResultsDelegate func(uint64) ([]*abci.ResponseDeliverTx, error) + getAccountAtHeightDelegate func(crypto.Address, uint64) (uint64, uint64, error) ) type mockClient struct { getLatestBlockNumberFn getLatestBlockNumberDelegate + getChainIDFn getChainIDDelegate getBlocksFn getBlocksDelegate getTxResultsFn getTxResultsDelegate + getAccountAtHeightFn getAccountAtHeightDelegate } func (m *mockClient) GetLatestBlockNumber() (uint64, error) { @@ -27,6 +32,14 @@ func (m *mockClient) GetLatestBlockNumber() (uint64, error) { return 0, nil } +func (m *mockClient) GetChainID() (string, error) { + if m.getChainIDFn != nil { + return m.getChainIDFn() + } + + return "test-chain", nil +} + func (m *mockClient) GetBlocks(ctx context.Context, from, to uint64) ([]*client.Block, error) { if m.getBlocksFn != nil { return m.getBlocksFn(ctx, from, to) @@ -42,3 +55,11 @@ func (m *mockClient) GetTxResults(block uint64) ([]*abci.ResponseDeliverTx, erro return nil, nil } + +func (m *mockClient) GetAccountAtHeight(addr crypto.Address, height uint64) (uint64, uint64, error) { + if m.getAccountAtHeightFn != nil { + return m.getAccountAtHeightFn(addr, height) + } + + return 0, 0, nil +} diff --git a/contribs/tx-archive/backup/options.go b/contribs/tx-archive/backup/options.go index e9ca9cda8bb..5c48c472422 100644 --- a/contribs/tx-archive/backup/options.go +++ b/contribs/tx-archive/backup/options.go @@ -24,3 +24,13 @@ func WithSkipFailedTxs(skip bool) Option { s.skipFailedTxs = skip } } + +// WithPopulateSignerInfo enables/disables per-tx SignerInfo population. +// Default is true. Disable for lightweight stream backups that don't need +// to be replay-ready (and avoids the brute-force sequence search cost). +// Ignored in watch mode (always off). +func WithPopulateSignerInfo(populate bool) Option { + return func(s *Service) { + s.populateSignerInfo = populate + } +} diff --git a/contribs/tx-archive/backup/signerinfo.go b/contribs/tx-archive/backup/signerinfo.go new file mode 100644 index 00000000000..338512414d1 --- /dev/null +++ b/contribs/tx-archive/backup/signerinfo.go @@ -0,0 +1,246 @@ +package backup + +//nolint:revive // See https://github.com/gnolang/gno/issues/1197 +import ( + "fmt" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/std" + + "github.com/gnolang/gno/contribs/tx-archive/backup/client" +) + +// signerResolver tracks per-signer account state during backup so that each +// exported tx carries a SignerInfo entry with the (account_num, sequence) +// values used to sign it on the source chain. +// +// Hardfork replay needs these values to force-set account state on the new +// chain before signature verification — see gno.land/pkg/gnoland.InitChainer +// loadAppState (PR #5511). +// +// Brute-force resolution strategy (ported from misc/hardfork/source_rpc.go): +// 1. On first sight of a signer, query auth/accounts/ at the halt +// height to learn the *final* (accNum, finalSeq). +// 2. On the signer's first *successful* tx in the stream, brute-force +// sequences in [0, finalSeq] against the tx signature to find the +// starting sequence at that point in history. Subsequent successful +// txs simply increment a counter. +// 3. Failed txs buffer until the next success — if any, re-brute-force to +// figure out how many of them actually consumed sequence (ante-fail = +// no consume, msg-fail = consume). Trailing failed txs (no later +// success to anchor) are handled in Finalize(). +// +// Failed-tx sequence values are cosmetic — replay skips failed txs — so the +// resolver's fallbacks err on the side of "roughly right" rather than +// re-fetching more RPC state. +type signerResolver struct { + client client.Client + chainID string + haltHeight uint64 + states map[crypto.Address]*signerState +} + +type signerState struct { + accNum uint64 + finalSeq uint64 // from RPC query at halt_height + seq uint64 // current pre-tx counter + initialized bool // true after first success brute-force resolves start + pendingFails []*pendingFailedTx +} + +type pendingFailedTx struct { + info *gnoland.SignerAccountInfo // direct pointer into tx.Metadata.SignerInfo + ownerSS *signerState +} + +func newSignerResolver(c client.Client, chainID string, haltHeight uint64) *signerResolver { + return &signerResolver{ + client: c, + chainID: chainID, + haltHeight: haltHeight, + states: map[crypto.Address]*signerState{}, + } +} + +// Populate fills tx.Metadata.SignerInfo for one tx. Must be called in the +// order txs were produced on the source chain (block-ascending, within-block +// index-ascending). +func (r *signerResolver) Populate(tx *gnoland.TxWithMetadata) error { + if tx.Metadata == nil { + tx.Metadata = &gnoland.GnoTxMetadata{} + } + + stdTx := tx.Tx + signers := stdTx.GetSigners() + sigs := stdTx.GetSignatures() + failed := tx.Metadata.Failed + + infos := make([]gnoland.SignerAccountInfo, len(signers)) + + for j, signer := range signers { + ss, err := r.state(signer) + if err != nil { + return err + } + infos[j] = gnoland.SignerAccountInfo{ + Address: signer, + AccountNum: ss.accNum, + // Sequence filled below. + } + } + tx.Metadata.SignerInfo = infos + + if failed { + // Buffer failed tx signer info pointers — sequences are back-patched + // at the next success (or in Finalize). + for j, signer := range signers { + ss := r.states[signer] + ss.pendingFails = append(ss.pendingFails, &pendingFailedTx{ + info: &tx.Metadata.SignerInfo[j], + ownerSS: ss, + }) + // Placeholder sequence. + tx.Metadata.SignerInfo[j].Sequence = ss.seq + } + return nil + } + + // Successful tx — resolve sequence per signer. + for j, signer := range signers { + ss := r.states[signer] + + needResolve := !ss.initialized || len(ss.pendingFails) > 0 + if needResolve { + lo := ss.seq + hi := ss.seq + uint64(len(ss.pendingFails)) + if !ss.initialized { + lo = 0 + hi = ss.finalSeq + } + + var sig std.Signature + if j < len(sigs) { + sig = sigs[j] + } + + resolved, err := bruteForceSignerSequence( + stdTx, sig, ss.accNum, lo, hi, r.chainID, + ) + if err != nil { + // Last resort: keep current counter. Subsequent txs may fail + // verification, but at least export proceeds. + resolved = ss.seq + } + + // Back-patch buffered failed txs now that we know how much + // sequence was consumed between the last success and this one. + assignFailedTxSequences(ss.pendingFails, ss.seq, resolved) + ss.pendingFails = nil + ss.seq = resolved + ss.initialized = true + } + + tx.Metadata.SignerInfo[j].Sequence = ss.seq + ss.seq++ + } + return nil +} + +// Finalize back-patches any trailing failed txs (those with no successor +// success to anchor against). Must be called once after the last Populate. +func (r *signerResolver) Finalize() { + for _, ss := range r.states { + if len(ss.pendingFails) == 0 { + continue + } + + var consumed uint64 + if ss.finalSeq > ss.seq { + consumed = ss.finalSeq - ss.seq + } + if !ss.initialized && consumed > uint64(len(ss.pendingFails)) { + // Never had a successful tx — cap consumed. + ss.seq = ss.finalSeq - uint64(len(ss.pendingFails)) + consumed = uint64(len(ss.pendingFails)) + } + assignTrailingFailedTxSequences(ss.pendingFails, ss.seq, consumed) + ss.pendingFails = nil + } +} + +// state fetches-or-creates the signerState for addr. +func (r *signerResolver) state(addr crypto.Address) (*signerState, error) { + if ss, ok := r.states[addr]; ok { + return ss, nil + } + accNum, finalSeq, err := r.client.GetAccountAtHeight(addr, r.haltHeight) + if err != nil { + return nil, fmt.Errorf("fetch account state for %s at %d: %w", + addr, r.haltHeight, err) + } + ss := &signerState{accNum: accNum, finalSeq: finalSeq} + r.states[addr] = ss + return ss, nil +} + +// bruteForceSignerSequence tries sequences in [lo, hi] to find the one that +// makes the tx signature verify. Returns the pre-tx sequence (the value that +// was used in GetSignBytes on the source chain). +func bruteForceSignerSequence( + tx std.Tx, sig std.Signature, accNum uint64, + lo, hi uint64, chainID string, +) (uint64, error) { + pubKey := sig.PubKey + if pubKey == nil { + return lo, fmt.Errorf("no pubkey in signature") + } + + for seq := lo; seq <= hi; seq++ { + signBytes, err := std.GetSignaturePayload(std.SignDoc{ + ChainID: chainID, + AccountNumber: accNum, + Sequence: seq, + Fee: tx.Fee, + Msgs: tx.Msgs, + Memo: tx.Memo, + }) + if err != nil { + continue + } + if pubKey.VerifyBytes(signBytes, sig.Signature) { + return seq, nil + } + } + return lo, fmt.Errorf("no sequence in [%d, %d] verified for account %d", + lo, hi, accNum) +} + +// assignFailedTxSequences back-patches SignerInfo.Sequence on buffered failed +// txs when we finally resolve the next successful-tx sequence. +// +// Cosmetic: failed txs are skipped on replay, so exact values don't matter +// for correctness. We approximate by assuming msg-fails (which consume +// sequence) come first in the gap, then ante-fails (which don't). +func assignFailedTxSequences(pending []*pendingFailedTx, startSeq, resolvedSeq uint64) { + consumed := resolvedSeq - startSeq + seq := startSeq + for i, pf := range pending { + pf.info.Sequence = seq + if uint64(i) < consumed { + seq++ + } + } +} + +// assignTrailingFailedTxSequences handles trailing failed txs with no later +// success to anchor against. +func assignTrailingFailedTxSequences(pending []*pendingFailedTx, startSeq, consumed uint64) { + seq := startSeq + for i, pf := range pending { + pf.info.Sequence = seq + if uint64(i) < consumed { + seq++ + } + } +} diff --git a/contribs/tx-archive/cmd/backup.go b/contribs/tx-archive/cmd/backup.go index 151213fa80e..f18540f9806 100644 --- a/contribs/tx-archive/cmd/backup.go +++ b/contribs/tx-archive/cmd/backup.go @@ -41,12 +41,13 @@ type backupCfg struct { fromBlock uint64 batchSize uint - ws bool - overwrite bool - legacy bool - watch bool - verbose bool - skipFailedTxs bool + ws bool + overwrite bool + legacy bool + watch bool + verbose bool + skipFailedTxs bool + noPopulateSigners bool } // newBackupCmd creates the backup command @@ -143,6 +144,16 @@ func (c *backupCfg) registerFlags(fs *flag.FlagSet) { false, "flag indicating if failed txs should be skipped", ) + + fs.BoolVar( + &c.noPopulateSigners, + "no-populate-signer-info", + false, + "disable per-tx SignerInfo population (account_num + sequence). "+ + "SignerInfo is required for hardfork-replay — leave off unless you "+ + "only need a plain stream backup and want to skip the brute-force "+ + "sequence resolution", + ) } // exec executes the backup command @@ -250,6 +261,7 @@ func (c *backupCfg) exec(ctx context.Context, _ []string) error { backup.WithLogger(logger), backup.WithBatchSize(c.batchSize), backup.WithSkipFailedTxs(c.skipFailedTxs), + backup.WithPopulateSignerInfo(!c.noPopulateSigners), ) // Run the backup service diff --git a/docs/resources/gnoland-networks.md b/docs/resources/gnoland-networks.md index b67d28fe402..7ceef5f1fa0 100644 --- a/docs/resources/gnoland-networks.md +++ b/docs/resources/gnoland-networks.md @@ -2,10 +2,11 @@ ## Network configurations -| Network | RPC Endpoint | Chain ID | -|---------|------------------------------------------|-----------| -| Staging | https://rpc.gno.land:443 | `staging` | -| Test11 | https://rpc.test11.testnets.gno.land:443 | `test11` | +| Network | RPC Endpoint | Chain ID | +|-------------------|------------------------------------------|--------------| +| Betanet (current) | https://rpc.gno.land:443 | `gnoland-1` | +| Staging | https://rpc.staging.gno.land:443 | `staging` | +| Test11 | https://rpc.test11.testnets.gno.land:443 | `test11` | ### WebSocket endpoints @@ -68,8 +69,8 @@ After genesis has been replayed, the chain continues working as normal. ### Using the Staging network -The Staging network deployment can be found at [gno.land](https://gno.land), while -the exposed RPC endpoints can be found on `https://rpc.gno.land:443`. +The Staging network deployment can be found at [staging.gno.land](https://staging.gno.land), while +the exposed RPC endpoints can be found on `https://rpc.staging.gno.land:443`. #### A warning note @@ -113,7 +114,7 @@ Below you can find a breakdown of each existing testnet by these categories. ### Staging chain The Staging chain is an always up-to-date rolling testnet. It is meant to be used as -a nightly build of the Gno tech stack. The home page of [gno.land](https://gno.land) +a nightly build of the Gno tech stack. The home page of [staging.gno.land](https://staging.gno.land) is the `gnoweb` render of the Staging testnet. - **Persistence of state:** @@ -150,7 +151,7 @@ These testnets are deprecated and currently serve as archives of previous progre ### Test10 (archive) -Test9 is the testnet released on the 18th of December, 2025. +Test10 is the testnet released on the 18th of December, 2025. ### Test9 (archive) diff --git a/examples/gno.land/r/sys/params/halt.gno b/examples/gno.land/r/sys/params/halt.gno new file mode 100644 index 00000000000..f9dccaac0ea --- /dev/null +++ b/examples/gno.land/r/sys/params/halt.gno @@ -0,0 +1,51 @@ +package params + +import ( + "strconv" + + "chain" + prms "sys/params" + + "gno.land/r/gov/dao" +) + +const ( + nodeModulePrefix = "node" + haltHeightKey = "halt_height" + haltMinVersionKey = "halt_min_version" +) + +// NewSetHaltRequest creates a GovDAO proposal to halt all chain nodes at the given block height. +// Once approved and executed, nodes will gracefully stop after committing the specified block, +// enabling coordinated chain upgrades. +// +// minVersion, if non-empty, sets the minimum binary version required to resume after the halt. +// Nodes will refuse to restart unless their version satisfies the minimum requirement, +// preventing old binaries from accidentally resuming a chain halted for an upgrade. +// Example: minVersion="chain/gnoland1.1" prevents gnoland1.0 from resuming. +// +// Use height=0 to cancel a previously scheduled halt. +func NewSetHaltRequest(height int64, minVersion string) dao.ProposalRequest { + callback := func(cur realm) error { + prms.SetSysParamInt64(nodeModulePrefix, "p", haltHeightKey, height) + prms.SetSysParamString(nodeModulePrefix, "p", haltMinVersionKey, minVersion) + chain.Emit("set_halt", + "height", strconv.FormatInt(height, 10), + "min_version", minVersion, + ) + return nil + } + + var desc string + if height == 0 { + desc = "Cancel the scheduled chain halt and clear the minimum version requirement." + } else { + desc = "Halt the chain at block " + strconv.FormatInt(height, 10) + "." + if minVersion != "" { + desc += " Requires binary version >= " + minVersion + " to resume." + } + } + + e := dao.NewSimpleExecutor(callback, "") + return dao.NewProposalRequest("Set node halt height", desc, e) +} diff --git a/examples/gno.land/r/sys/params/params_test.gno b/examples/gno.land/r/sys/params/params_test.gno index e0e901e2dbb..f82b2451a19 100644 --- a/examples/gno.land/r/sys/params/params_test.gno +++ b/examples/gno.land/r/sys/params/params_test.gno @@ -15,3 +15,24 @@ func TestNewStringPropRequest(t *testing.T) { t.Errorf("executor shouldn't be nil") } } + +func TestNewSetHaltRequest(t *testing.T) { + pr := NewSetHaltRequest(100_000, "chain/gnoland1.1") + if pr.Title() == "" { + t.Errorf("proposal title shouldn't be empty") + } +} + +func TestNewSetHaltRequestNoVersion(t *testing.T) { + pr := NewSetHaltRequest(100_000, "") + if pr.Title() == "" { + t.Errorf("proposal title shouldn't be empty") + } +} + +func TestNewSetHaltRequestCancel(t *testing.T) { + pr := NewSetHaltRequest(0, "") + if pr.Title() == "" { + t.Errorf("proposal title shouldn't be empty") + } +} diff --git a/gno.land/adr/pr5511_chain_upgrade_genesis_replay.md b/gno.land/adr/pr5511_chain_upgrade_genesis_replay.md new file mode 100644 index 00000000000..e7e8d1ce093 --- /dev/null +++ b/gno.land/adr/pr5511_chain_upgrade_genesis_replay.md @@ -0,0 +1,243 @@ +# PR5511: Chain upgrade genesis replay + +## Context + +gno.land needs to support in-place chain hardforks: halt the source +chain at some height `H`, export its full state + transaction history, +and start a new chain whose genesis includes all that history so the +new chain can reach the same state by replaying it from scratch. After +replay, the new chain starts producing fresh blocks at height `H + 1`. + +Historical transactions were signed against the source chain's +`chain_id`, `account_number`, and `sequence`. For signatures to verify +during replay, all three must be available in their original form — we +can't re-sign because we don't have the private keys. + +The tm2-level consensus / state / app changes that enable +`InitialHeight > 1` live in +[tm2/adr/pr5511_initial_height.md](../../tm2/adr/pr5511_initial_height.md). + +Earlier design iterations used a single `OriginalChainID` field +(simpler, but fragile across multi-hop upgrades). This ADR describes +the final design with `PastChainIDs` + per-tx `ChainID`. + +## Decision + +### `GnoTxMetadata` — per-tx replay metadata + +Populated by the hardfork export tool (`gnogenesis fork generate`): + +- **`Timestamp`** (`int64`) — Unix timestamp of the original block. + When non-zero, overrides the block header time during replay. +- **`BlockHeight`** (`int64`) — original block height. When `> 0`, the + ctx's block header height is set to this value during replay, which + makes the ante handler treat the tx as non-genesis (full sig + verification, real account numbers, sequences). +- **`ChainID`** (`string`) — originating chain ID. Used for per-tx + chain-ID override during replay if `ChainID ∈ GnoGenesisState.PastChainIDs`. +- **`Failed`** (`bool`) — true if the tx had a non-zero return code on + the source chain. Failed txs are included in the genesis for + sequence-tracking purposes but are NOT re-executed during replay + (re-executing could double-spend or succeed unexpectedly if a VM fix + makes them now pass). The replay emits a non-empty `ResponseDeliverTx` + with an error marker so indexers don't mistake the skip for success. +- **`SignerInfo`** (`[]SignerAccountInfo`) — per-signer `(Address, + AccountNum, Sequence)`. Before each historical tx is delivered, the + replay loop force-sets each signer's account number and pre-tx + sequence from this. If the account doesn't exist yet, + `auth.NewAccountWithNumber` creates it with the specified number, + bypassing the auto-increment counter. +- **`GasUsed`**, **`GasWanted`** (`int64`) — source-chain gas; used by + `GasReplayMode="source"` and the replay report. + +### `GnoGenesisState` — genesis-level replay configuration + +- **`PastChainIDs`** (`[]string`) — allowlist of chain IDs from which + historical transactions originated. Only chain IDs in this slice can + override the context chain ID during replay. `PastChainIDs[0]` is + also used for sig verification of genesis-mode txs (no metadata or + `BlockHeight == 0`) when a hardfork is in progress, since those txs + were signed against the source chain. Empty = no overrides. + `PastChainIDs` MAY contain the current chain ID — this is valid for + same-chain-ID hardforks (e.g. minor fork with no external identity + change). Do NOT add validation that rejects this. +- **`InitialHeight`** (`int64`) — new chain's starting block height. + Cross-checked against `GenesisDoc.InitialHeight` via + `RequestInitChain.InitialHeight`; `loadAppState` rejects the genesis + on divergence. +- **`GasReplayMode`** (`string`) — historical-tx gas metering: + - `""` or `"strict"` (default) — new VM's gas meter is authoritative. + Historical txs may fail if gas requirements changed between chains. + - `"source"` — historical txs (`BlockHeight > 0`) bypass the new VM's + gas meter via `auth.SkipGasMeteringKey`, preserving source-chain + outcomes even when gas metering changed. Response records + `metadata.GasUsed` for audit. + +### Sequence recovery algorithm (`gnogenesis fork generate`) + +Account numbers come from one RPC call per address at halt height — +they're stable, never change once assigned. + +Sequences are harder: they advance through both genesis-mode txs and +successful historical txs, but failed txs sometimes consume a sequence +(msg-fail) and sometimes don't (ante-fail). We can't tell the two apart +without re-verifying the signature, and the tx bytes don't carry a +"sequence used" field. + +The tool uses a single-pass algorithm with buffered brute-force: + +1. **Initialisation**: for each signer, query their current sequence + at halt height (`finalSeq`). Brute-force their first successful + historical tx's signature against `[0, finalSeq]` — the matching + value is the signer's starting counter (typically `0`, or `N` if + they had `N` genesis-mode txs). + +2. **Forward pass**, block-ordered: + - **Successful tx, no pending failures**: assign current counter as + pre-tx sequence, increment counter. + - **Failed tx**: buffer it. + - **Successful tx after failed txs from same signer**: brute-force + the successful tx's signature against `[counter, counter + len(buffer)]`. + The matching value is this tx's pre-tx sequence; work backwards to + assign sequences to each buffered failed tx (ante-fails keep the + counter, msg-fails consume one). + +3. **Trailing failures** (no subsequent success): query sequence at + halt height and diff against the last known counter. + +Signature verification is offline — `GetSignBytes` only needs +`chain_id`, `account_number`, `sequence`, fee, msgs, memo, all of +which are available from the tx bytes + metadata. + +### Genesis replay flow + +1. `InitChain` → `loadAppState` validates + `state.InitialHeight == req.InitialHeight` (if set) and that + `GasReplayMode` is recognised. +2. Genesis-mode txs (no metadata or `BlockHeight == 0`) → current + genesis behaviour: package deploys, infinite gas, auto-account + creation. Sig-verify against `PastChainIDs[0]` when a hardfork is + in progress (these txs were signed with the source chain ID). +3. Historical txs (`BlockHeight > 0`) → full ante handler. For each: + a. Ctx's block header height is overridden to `metadata.BlockHeight`. + b. If `metadata.ChainID ∈ PastChainIDs`, ctx's chain ID is + overridden for sig verification. + c. If `metadata.Timestamp != 0`, ctx's time is overridden. + d. If `SignerInfo` is present, each signer's account number and + pre-tx sequence are force-set. + e. If `GasReplayMode == "source"`, ctx carries + `auth.SkipGasMeteringKey` so `auth.SetGasMeter` installs an + infinite gas meter for this tx. + f. If `Failed`, skip `Deliver` and emit an error-marker response. + Otherwise `Deliver` normally. +4. At end of loop, the replay report is emitted via logger: summary + counts (`ok` / `ok_gas_differs` / `failed` / `skipped_failed`) and + per-failure detail. +5. Consensus advances `state.LastBlockHeight` to + `GenesisDoc.InitialHeight - 1` so the next block is produced at + `InitialHeight`. + +### Replay report + +`replayReport` accumulates per-tx outcomes and emits via the `slog` +logger at the end of `InitChain`. Categories: +- `ok` — succeeded, gas matched source (or no source gas recorded). +- `ok_gas_differs` — succeeded but gas consumption differs from source. +- `failed` — delivery failed (detail logged per-failure). +- `skipped_failed` — marked `Failed` in metadata, correctly skipped. + +Summary counts at info level; each failure gets its own warn line with +source height, gas delta, and error. `replayReport.Outcomes()` exposes +the data for tooling that wants to write a structured +`replay-report.json`. + +### Tooling — `gnogenesis fork` + +Integrated into the existing `gnogenesis` CLI as a subcommand group +(`contribs/gnogenesis/internal/fork/`): + +- **`gnogenesis fork generate`** — reads the source (RPC URL / local + dir / tarball), runs sequence recovery, emits a ready-to-replay + genesis with `PastChainIDs`, `InitialHeight`, and per-tx metadata. +- **`--patch-realm PKGPATH=SRCDIR`** (repeatable, on `generate`) — + rewrites the genesis-mode `addpkg` tx for `PKGPATH` in-place with + files from `SRCDIR` before writing. The source genesis on disk is + untouched. This is the only way to land a realm code change as part + of a fork (you can't re-`addpkg` post-deploy). +- **`gnogenesis fork test`** — in-process `InitChain` smoke-test + against a genesis.json. + +A chain-specific wrapper (`misc/deployments/gnoland-1/generate-genesis.sh`) +hardcodes the gnoland1→gnoland-1 chain IDs and delegates to the CLI. + +## Alternatives considered + +1. **Re-sign all transactions** — requires access to all private keys. + Not feasible. +2. **Skip sig verification entirely** — reduces security guarantees. +3. **Single `OriginalChainID string`** — simpler but fragile; assumes + all historical txs come from one chain, breaks for multi-hop + upgrades (chain A → B → C). `PastChainIDs` + per-tx `ChainID` + handles the multi-hop case cleanly. +4. **Absorb `hardfork` tool as a standalone CLI in `misc/`** — + original design, but it's really a genesis-manipulation tool, so + it lives with its siblings under `gnogenesis`. + +## Consequences + +- Genesis files for chain upgrades are large (all historical txs with + metadata). ~192 MB for gnoland1 → gnoland-1 at halt height 704052. +- `GnoGenesisState.InitialHeight`, `GnoGenesisState.GasReplayMode`, + and all the `GnoTxMetadata` fields use `omitempty`; existing genesis + files are unaffected. +- Future chain A → B → C upgrades can set + `PastChainIDs: ["A", "B"]` to replay both predecessors' histories. + +## Bugs found and fixed during review + +### App layer + +- **Failed-tx `ResponseDeliverTx` was empty (looked like success)** — + now carries an explicit error marker so indexers can distinguish. +- **`state.InitialHeight` wasn't cross-checked against + `GenesisDoc.InitialHeight`** — `RequestInitChain.InitialHeight` plumbed + through, `loadAppState` validates match. + +### Tooling (`gnogenesis fork`) + +- **`applyOverlay` silent no-op** — listed scripts but didn't execute + them, returned success. Entire overlay mechanism removed (dead code). +- **JSONL export used `encoding/json` instead of amino** — lost + interface types (`std.Msg`) on round-trip. Both writer and reader + now use amino. +- **`verifyGenesisFile` failure returned success** — now aborts + (opt out with `--no-verify`). +- **Zero unit tests for `bruteForceSignerSequence`** — added 10 + table-driven tests. + +### Docs linter (side fix to unblock CI) + +- Added `staging.gno.land` + `archive.org` to skip list, added retry + with backoff and HTTP timeout so transient external-host failures + don't block unrelated PRs. + +## Known unfixed (follow-up PRs) + +1. **RPC source has no retry/resume.** A single transient error aborts + the entire multi-block fetch. Needs exponential backoff + + checkpointing. +2. **All txs accumulated in memory.** Full tx history is held in a + single slice — will OOM on large chains. Needs streaming writer. +3. **`NewAccountWithNumber` has no duplicate check.** Pre-flight + validation in `loadAppState` is the recommended approach (see PR + discussion). +4. **`queryAccountAtHeight` silent nil.** All error paths return nil + with no indication; flaky RPC → wrong sequence metadata. + +## Validation + +End-to-end test via the hf-glue testbed +([#5486](https://github.com/gnolang/gno/pull/5486)): production-sized +hardfork genesis (~192 MB, 2715 historical txs, +`InitialHeight = 704053`) replays with **0 tx failures** and boots a +live `gnoland-1` node producing fresh blocks. diff --git a/gno.land/cmd/gnoland/start.go b/gno.land/cmd/gnoland/start.go index 55e23fa76e3..51c64220852 100644 --- a/gno.land/cmd/gnoland/start.go +++ b/gno.land/cmd/gnoland/start.go @@ -264,6 +264,7 @@ func execStart(ctx context.Context, c *startCfg, io commands.IO) error { cfg.Application, evsw, logger, + cfg.BaseConfig.SkipUpgradeHeight, ) if err != nil { return fmt.Errorf("unable to create the Gnoland app, %w", err) diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index af8b35e4179..b0936fd7764 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -41,6 +41,7 @@ type AppOptions struct { EventSwitch events.EventSwitch // required VMOutput io.Writer // optional SkipGenesisSigVerification bool // default to verify genesis transactions + SkipUpgradeHeight int64 // if set, skip the halt_min_version check at this height InitChainerConfig // options related to InitChainer MinGasPrices string // optional PruneStrategy types.PruneStrategy @@ -114,6 +115,7 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { prmk.Register(auth.ModuleName, acck) prmk.Register(bank.ModuleName, bankk) prmk.Register(vm.ModuleName, vmk) + prmk.Register("node", nodeParamsKeeper{}) // Set InitChainer icc := cfg.InitChainerConfig @@ -188,6 +190,7 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { acck, gpk, vmk, + prmk, baseApp, ), ) @@ -208,6 +211,11 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { vmk.Initialize(cfg.Logger, ms) ms.MultiWrite() // XXX why was't this needed? + // Verify node startup constraints set by governance halt proposals. + if err := checkNodeStartupParams(prmk, baseApp.GetCacheMultiStore(), baseApp.LastBlockHeight(), cfg.SkipUpgradeHeight); err != nil { + return nil, err + } + return baseApp, nil } @@ -233,6 +241,7 @@ func NewApp( appCfg *sdkCfg.AppConfig, evsw events.EventSwitch, logger *slog.Logger, + skipUpgradeHeight int64, ) (abci.Application, error) { var err error @@ -245,6 +254,7 @@ func NewApp( }, MinGasPrices: appCfg.MinGasPrices, SkipGenesisSigVerification: genesisCfg.SkipSigVerification, + SkipUpgradeHeight: skipUpgradeHeight, PruneStrategy: appCfg.PruneStrategy, } if genesisCfg.SkipFailingTxs { @@ -313,7 +323,7 @@ func (cfg InitChainerConfig) InitChainer(ctx sdk.Context, req abci.RequestInitCh // load app state. AppState may be nil mostly in some minimal testing setups; // so log a warning when that happens. - txResponses, err := cfg.loadAppState(ctx, req.AppState) + txResponses, err := cfg.loadAppState(ctx, req.AppState, req.InitialHeight) if err != nil { return abci.ResponseInitChain{ ResponseBase: abci.ResponseBase{ @@ -350,12 +360,34 @@ func (cfg InitChainerConfig) loadStdlibs(ctx sdk.Context) { msCache.MultiWrite() } -func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci.ResponseDeliverTx, error) { +func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any, reqInitialHeight int64) ([]abci.ResponseDeliverTx, error) { state, ok := appState.(GnoGenesisState) if !ok { return nil, fmt.Errorf("invalid AppState of type %T", appState) } + // If GnoGenesisState.InitialHeight is set, it must match the authoritative + // GenesisDoc.InitialHeight (which comes in via req.InitialHeight). These + // fields are duplicated so tooling can read the app-level one; if they + // diverge, the genesis file is malformed. + if state.InitialHeight != 0 && state.InitialHeight != reqInitialHeight { + return nil, fmt.Errorf( + "InitialHeight mismatch: GnoGenesisState.InitialHeight=%d, GenesisDoc.InitialHeight=%d", + state.InitialHeight, reqInitialHeight, + ) + } + + if err := validateGasReplayMode(state.GasReplayMode); err != nil { + return nil, err + } + + if len(state.PastChainIDs) > 0 { + ctx.Logger().Info("Chain upgrade genesis replay", + "past_chain_ids", state.PastChainIDs, + "initial_height", reqInitialHeight, + ) + } + cfg.bankk.InitGenesis(ctx, state.Bank) // Apply genesis balances. for _, bal := range state.Balances { @@ -392,9 +424,10 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci // Replay genesis txs. txResponses := make([]abci.ResponseDeliverTx, 0, len(state.Txs)) + report := newReplayReport(state.GasReplayMode) // Run genesis txs - for _, tx := range state.Txs { + for txIdx, tx := range state.Txs { var ( stdTx = tx.Tx metadata = tx.Metadata @@ -404,18 +437,88 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci // Check if there is metadata associated with the tx if metadata != nil { - // Create a custom context modifier ctxFn = func(ctx sdk.Context) sdk.Context { - // Create a copy of the header, in - // which only the timestamp information is modified header := ctx.BlockHeader().(*bft.Header).Copy() - header.Time = time.Unix(metadata.Timestamp, 0) + if metadata.Timestamp != 0 { + header.Time = time.Unix(metadata.Timestamp, 0) + } + if metadata.BlockHeight > 0 { + header.Height = metadata.BlockHeight + } + + ctx = ctx.WithBlockHeader(header) + + // For historical txs (BlockHeight > 0), override the chain ID + // for signature verification using the per-tx ChainID, provided + // it is in the genesis allowlist. This allows replaying txs from + // multiple past chains during a hard fork. + if metadata.BlockHeight > 0 && metadata.ChainID != "" && isPastChainID(state.PastChainIDs, metadata.ChainID) { + ctx = ctx.WithChainID(metadata.ChainID) + } + + // GasReplayMode="source": bypass the new VM's gas meter for + // historical txs so outcomes match the source chain even when + // gas metering changed. + if state.GasReplayMode == "source" && metadata.BlockHeight > 0 { + ctx = ctx.WithValue(auth.SkipGasMeteringKey{}, true) + } - // Save the modified header - return ctx.WithBlockHeader(header) + return ctx } } + // Genesis-mode txs (no metadata or BlockHeight == 0) were signed with + // the original chain ID. During a hardfork (PastChainIDs is set), we + // need to verify their signatures against the original chain ID, not + // the new one. Use the first PastChainID as the signing context. + if (metadata == nil || metadata.BlockHeight == 0) && len(state.PastChainIDs) > 0 { + originalChainID := state.PastChainIDs[0] + ctxFn = func(ctx sdk.Context) sdk.Context { + return ctx.WithChainID(originalChainID) + } + } + + // For historical txs with signer metadata, force-set account state + // so signature verification succeeds even if prior txs diverged. + // Uses pre-tx sequence — the value the signature was signed with. + // + // Invariant: SignerInfo is only populated by the export tool for historical + // txs (BlockHeight > 0). Genesis-mode txs (BlockHeight == 0) must never + // carry SignerInfo — if they did, the force-set would corrupt fresh account + // state. The BlockHeight > 0 guard enforces this. + if metadata != nil && metadata.BlockHeight > 0 && len(metadata.SignerInfo) > 0 { + for _, si := range metadata.SignerInfo { + acc := cfg.acck.GetAccount(ctx, si.Address) + if acc == nil { + // Account doesn't exist yet — create with specific account + // number, bypassing the auto-increment counter. + acc = cfg.acck.NewAccountWithNumber(ctx, si.Address, si.AccountNum) + } else { + acc.SetAccountNumber(si.AccountNum) + } + acc.SetSequence(si.Sequence) + cfg.acck.SetAccount(ctx, acc) + } + } + + // Failed txs: pre-tx sequence already set above. Skip execution — + // re-executing failed txs could cause double spends or unexpected + // behavior if the VM fix makes them succeed. The next tx's force-set + // will handle the correct sequence state. + // Response carries an explicit error so downstream consumers + // (indexers, explorers) don't mistake a skipped failed tx for a + // successful one. + if metadata != nil && metadata.Failed { + txResponses = append(txResponses, abci.ResponseDeliverTx{ + ResponseBase: abci.ResponseBase{ + Error: abci.StringError("replay skipped: tx failed on source chain"), + Log: "genesis replay: skipped failed tx from source chain", + }, + }) + report.record(txIdx, metadata, 0, 0, replayCategorySkippedFailed, nil) + continue + } + res := cfg.baseApp.Deliver(stdTx, ctxFn) if res.IsErr() { ctx.Logger().Error( @@ -431,9 +534,19 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci GasWanted: res.GasWanted, GasUsed: res.GasUsed, }) + report.recordDeliverResult(txIdx, metadata, res) cfg.GenesisTxResultHandler(ctx, stdTx, res) } + + if reqInitialHeight > 1 { + ctx.Logger().Info("Genesis replay complete, chain will start from initial height", + "initial_height", reqInitialHeight, + ) + } + + report.emit(ctx.Logger()) + return txResponses, nil } @@ -444,22 +557,31 @@ type endBlockerApp interface { // Logger returns the logger reference Logger() *slog.Logger + + // SetHaltHeight sets the block height at which the node will halt. + SetHaltHeight(uint64) +} + +// isPastChainID reports whether chainID is present in the pastChainIDs allowlist. +func isPastChainID(pastChainIDs []string, chainID string) bool { + return slices.Contains(pastChainIDs, chainID) } // EndBlocker defines the logic executed after every block. // Currently, it parses events that happened during execution to calculate -// validator set changes +// validator set changes, and checks for a governance-requested chain halt. func EndBlocker( collector *collector[validatorUpdate], acck auth.AccountKeeperI, gpk auth.GasPriceKeeperI, vmk vm.VMKeeperI, + prmk params.ParamsKeeperI, app endBlockerApp, ) func( ctx sdk.Context, req abci.RequestEndBlock, ) abci.ResponseEndBlock { - return func(ctx sdk.Context, _ abci.RequestEndBlock) abci.ResponseEndBlock { + return func(ctx sdk.Context, req abci.RequestEndBlock) abci.ResponseEndBlock { // set the auth params value in the ctx. The EndBlocker will use InitialGasPrice in // the params to calculate the updated gas price. if acck != nil { @@ -469,6 +591,24 @@ func EndBlocker( auth.EndBlocker(ctx, gpk) } + // Check if GovDAO has requested a halt at this height. + // Use == (not >=) so we only trigger once: at the exact halt height. + // SetHaltHeight causes BeginBlock of the *next* block to panic, ensuring + // this block is fully committed before the node stops. + // On restart, req.Height > halt_height, so == never re-fires — no infinite loop. + if prmk != nil { + var haltHeight int64 + prmk.GetInt64(ctx, nodeParamHaltHeight, &haltHeight) + if haltHeight > 0 && req.Height == haltHeight { + app.Logger().Info( + "GovDAO halt height reached, will halt after this block", + "height", req.Height, + "halt_height", haltHeight, + ) + app.SetHaltHeight(uint64(haltHeight)) + } + } + // Check if there was a valset change if len(collector.getEvents()) == 0 { // No valset updates diff --git a/gno.land/pkg/gnoland/app_test.go b/gno.land/pkg/gnoland/app_test.go index 8ccfb0de6c3..6d38d876431 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -143,11 +143,29 @@ func TestNewAppWithOptions_ErrNoDB(t *testing.T) { assert.ErrorContains(t, err, "no db provided") } +func TestNewAppWithOptions_ErrNoLogger(t *testing.T) { + t.Parallel() + + opts := TestAppOptions(memdb.NewMemDB()) + opts.Logger = nil + _, err := NewAppWithOptions(opts) + assert.ErrorContains(t, err, "no logger provided") +} + +func TestNewAppWithOptions_ErrNoEventSwitch(t *testing.T) { + t.Parallel() + + opts := TestAppOptions(memdb.NewMemDB()) + opts.EventSwitch = nil + _, err := NewAppWithOptions(opts) + assert.ErrorContains(t, err, "no event switch provided") +} + func TestNewApp(t *testing.T) { // NewApp should have good defaults and manage to run InitChain. td := t.TempDir() - app, err := NewApp(td, NewTestGenesisAppConfig(), config.DefaultAppConfig(), events.NewEventSwitch(), log.NewNoopLogger()) + app, err := NewApp(td, NewTestGenesisAppConfig(), config.DefaultAppConfig(), events.NewEventSwitch(), log.NewNoopLogger(), 0) require.NoError(t, err, "NewApp should be successful") resp := app.InitChain(abci.RequestInitChain{ @@ -296,6 +314,18 @@ func createAndSignTx( ) std.Tx { t.Helper() + return createAndSignTxWithAccSeq(t, msgs, chainID, key, 0, 0) +} + +func createAndSignTxWithAccSeq( + t *testing.T, + msgs []std.Msg, + chainID string, + key crypto.PrivKey, + accNum, seq uint64, +) std.Tx { + t.Helper() + tx := std.Tx{ Msgs: msgs, Fee: std.Fee{ @@ -304,7 +334,7 @@ func createAndSignTx( }, } - signBytes, err := tx.GetSignBytes(chainID, 0, 0) + signBytes, err := tx.GetSignBytes(chainID, accNum, seq) require.NoError(t, err) // Sign the tx @@ -358,6 +388,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 +426,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 { @@ -533,7 +587,7 @@ func TestEndBlocker(t *testing.T) { c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) // Create the EndBlocker - eb := EndBlocker(c, nil, nil, nil, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, nil, nil, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ @@ -577,7 +631,7 @@ func TestEndBlocker(t *testing.T) { mockEventSwitch.FireEvent(chain.Event{}) // Create the EndBlocker - eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ @@ -624,7 +678,7 @@ func TestEndBlocker(t *testing.T) { mockEventSwitch.FireEvent(chain.Event{}) // Create the EndBlocker - eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ @@ -696,7 +750,7 @@ func TestEndBlocker(t *testing.T) { mockEventSwitch.FireEvent(txEvent) // Create the EndBlocker - eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ @@ -770,7 +824,7 @@ func TestEndBlocker(t *testing.T) { c := newCollector[validatorUpdate](mockEventSwitch, validatorEventFilter) mockEventSwitch.FireEvent(txEvent) - eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ Validator: &abci.ValidatorParams{ PubKeyTypeURLs: []string{"/tm.PubKeySecp256k1"}, @@ -835,7 +889,7 @@ func TestEndBlocker(t *testing.T) { c := newCollector[validatorUpdate](mockEventSwitch, validatorEventFilter) mockEventSwitch.FireEvent(txEvent) - eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ Validator: &abci.ValidatorParams{ PubKeyTypeURLs: []string{"/tm.PubKeySecp256k1"}, @@ -890,7 +944,7 @@ func TestEndBlocker(t *testing.T) { c := newCollector[validatorUpdate](mockEventSwitch, validatorEventFilter) mockEventSwitch.FireEvent(txEvent) - eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ Validator: &abci.ValidatorParams{ PubKeyTypeURLs: []string{"/tm.PubKeyEd25519"}, @@ -900,6 +954,40 @@ func TestEndBlocker(t *testing.T) { // Verify only the valid update is returned require.Len(t, res.ValidatorUpdates, 0) }) + + t.Run("extract updates error", func(t *testing.T) { + t.Parallel() + + var ( + noFilter = func(_ events.Event) []validatorUpdate { + return make([]validatorUpdate, 1) // 1 update + } + + mockEventSwitchInner = newCommonEvSwitch() + + mockVMKeeperInner = &mockVMKeeper{ + queryFn: func(_ sdk.Context, pkgPath, expr string) (string, error) { + require.Equal(t, valRealm, pkgPath) + // Return a response that matches the regex but has an invalid bech32 address. + // This causes extractUpdatesFromResponse to return an error. + return `{("notabech32" std.Address),("notapubkey" string),(1 uint64)}`, nil + }, + } + ) + + c := newCollector[validatorUpdate](mockEventSwitchInner, noFilter) + mockEventSwitchInner.FireEvent(chain.Event{}) + + eb := EndBlocker(c, nil, nil, mockVMKeeperInner, nil, &mockEndBlockerApp{}) + res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{"/tm.PubKeySecp256k1"}, + }, + }), abci.RequestEndBlock{}) + + // Error from extractUpdatesFromResponse → EndBlocker returns empty response + assert.Equal(t, abci.ResponseEndBlock{}, res) + }) } func TestGasPriceUpdate(t *testing.T) { @@ -1120,6 +1208,7 @@ func newGasPriceTestApp(t *testing.T) abci.Application { acck, gpk, nil, + nil, baseApp, ), ) @@ -1261,6 +1350,7 @@ func TestPruneStrategyNothing(t *testing.T) { appCfg, events.NewEventSwitch(), log.NewNoopLogger(), + 0, ) require.NoError(t, err) @@ -1315,3 +1405,1260 @@ func TestPruneStrategyNothing(t *testing.T) { err = db.Close() require.NoError(t, err) } + +func TestChainUpgradeGenesisReplay(t *testing.T) { + t.Parallel() + + t.Run("fields serialize correctly", func(t *testing.T) { + t.Parallel() + + state := GnoGenesisState{ + Balances: []Balance{}, + Txs: []TxWithMetadata{}, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"old-chain-1", "old-chain-2"}, + InitialHeight: 100, + } + + // Serialize and deserialize + data, err := amino.MarshalJSON(state) + require.NoError(t, err) + + var decoded GnoGenesisState + require.NoError(t, amino.UnmarshalJSON(data, &decoded)) + + assert.Equal(t, []string{"old-chain-1", "old-chain-2"}, decoded.PastChainIDs) + assert.Equal(t, int64(100), decoded.InitialHeight) + }) + + t.Run("historical tx replays with correct block height", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "new-chain" + + path = "gno.land/r/demo/upgradetest" + body = `package upgradetest + +import "chain/runtime" + +var height int64 = runtime.ChainHeight() + +func GetHeight(cur realm) int64 { return height } +` + ) + + // Create a fresh app instance + app, err := NewAppWithOptions(TestAppOptions(db)) + require.NoError(t, err) + + // Prepare the deploy transaction + msg := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "upgradetest", + Path: path, + Files: []*std.MemFile{ + { + Name: "file.gno", + Body: body, + }, + { + Name: "gnomod.toml", + Body: gnolang.GenGnoModLatest(path), + }, + }, + }, + MaxDeposit: nil, + } + + // Sign with the old chain ID — metadata.BlockHeight > 0 and metadata.ChainID + // in PastChainIDs will cause the ctxFn to override the chain ID for sig verification. + // Account number=0 and sequence=0 because the account is created from balances + // but hasn't processed any transactions yet. + tx := createAndSignTx(t, []std.Msg{msg}, "old-chain", key) + + // Run InitChain with PastChainIDs and InitialHeight set, + // and the deploy tx using metadata with BlockHeight=42 and ChainID="old-chain" + app.InitChain(abci.RequestInitChain{ + ChainID: chainID, + Time: time.Now(), + InitialHeight: 100, + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{}, + }, + }, + AppState: GnoGenesisState{ + Txs: []TxWithMetadata{ + { + Tx: tx, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 42, + ChainID: "old-chain", // must be in PastChainIDs for override + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"old-chain"}, + InitialHeight: 100, + }, + }) + + // Call GetHeight to verify the realm captured height=42 + callMsg := vm.MsgCall{ + Caller: key.PubKey().Address(), + PkgPath: path, + Func: "GetHeight", + } + + callTx := createAndSignTx(t, []std.Msg{callMsg}, chainID, key) + + marshalledTx, err := amino.Marshal(callTx) + require.NoError(t, err) + + resp := app.DeliverTx(abci.RequestDeliverTx{ + Tx: marshalledTx, + }) + + require.True(t, resp.IsOK(), "DeliverTx failed: %s", resp.Log) + + // The realm should have captured block height 42 + assert.Contains(t, string(resp.Data), "(42 int64)") + }) + + t.Run("metadata block height in GnoTxMetadata serializes correctly", func(t *testing.T) { + t.Parallel() + + txm := TxWithMetadata{ + Tx: std.Tx{}, + Metadata: &GnoTxMetadata{ + Timestamp: 1234567890, + BlockHeight: 42, + ChainID: "gnoland1", + }, + } + + data, err := amino.MarshalJSON(txm) + require.NoError(t, err) + + var decoded TxWithMetadata + require.NoError(t, amino.UnmarshalJSON(data, &decoded)) + + require.NotNil(t, decoded.Metadata) + assert.Equal(t, int64(1234567890), decoded.Metadata.Timestamp) + assert.Equal(t, int64(42), decoded.Metadata.BlockHeight) + assert.Equal(t, "gnoland1", decoded.Metadata.ChainID) + }) + + t.Run("chain ID not overridden when BlockHeight is zero in metadata", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "new-chain" + + path = "gno.land/r/demo/chainidtest" + body = `package chainidtest + +var Deployed = true + +func IsDeployed(cur realm) bool { return Deployed } +` + ) + + app, err := NewAppWithOptions(TestAppOptions(db)) + require.NoError(t, err) + + msg := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "chainidtest", + Path: path, + Files: []*std.MemFile{ + {Name: "file.gno", Body: body}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path)}, + }, + }, + } + + // When metadata.BlockHeight == 0, the chain ID override must NOT happen. + // So the tx must be signed with the current chain ID (chainID), not any past chain ID. + tx := createAndSignTx(t, []std.Msg{msg}, chainID, key) + + app.InitChain(abci.RequestInitChain{ + ChainID: chainID, + Time: time.Now(), + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{}, + }, + }, + AppState: GnoGenesisState{ + Txs: []TxWithMetadata{ + { + Tx: tx, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 0, // zero — no chain ID override + ChainID: "old-chain", // present but ignored since BlockHeight == 0 + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"old-chain"}, // set, but should NOT be used since BlockHeight == 0 + }, + }) + }) + + t.Run("no chain ID override when metadata.ChainID not in PastChainIDs", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "new-chain" + + path = "gno.land/r/demo/nooverride" + body = `package nooverride + +var Deployed = true +` + ) + + app, err := NewAppWithOptions(TestAppOptions(db)) + require.NoError(t, err) + + msg := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "nooverride", + Path: path, + Files: []*std.MemFile{ + {Name: "file.gno", Body: body}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path)}, + }, + }, + } + + // BlockHeight > 0 and metadata.ChainID is set, but the chain ID is NOT in + // PastChainIDs — no chain ID override should happen. The tx is signed with + // chainID so it verifies correctly without the override. + tx := createAndSignTx(t, []std.Msg{msg}, chainID, key) + + app.InitChain(abci.RequestInitChain{ + ChainID: chainID, + Time: time.Now(), + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{}, + }, + }, + AppState: GnoGenesisState{ + Txs: []TxWithMetadata{ + { + Tx: tx, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 10, + ChainID: "unknown-chain", // not in PastChainIDs — no override + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + // PastChainIDs intentionally empty — no chain ID override allowed + }, + }) + }) + + t.Run("txs from multiple past chains replay correctly", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "new-chain" + + path1 = "gno.land/r/demo/multichain1" + path2 = "gno.land/r/demo/multichain2" + body = `package %s +var Deployed = true +` + ) + + app, err := NewAppWithOptions(TestAppOptions(db)) + require.NoError(t, err) + + // Both txs come from the same account (accNum=0) but different past chains. + // tx1: seq=0, chain-a; tx2: seq=1, chain-b (sequence incremented by tx1). + msg1 := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "multichain1", + Path: path1, + Files: []*std.MemFile{ + {Name: "file.gno", Body: fmt.Sprintf(body, "multichain1")}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path1)}, + }, + }, + } + msg2 := vm.MsgAddPackage{ + Creator: key.PubKey().Address(), + Package: &std.MemPackage{ + Name: "multichain2", + Path: path2, + Files: []*std.MemFile{ + {Name: "file.gno", Body: fmt.Sprintf(body, "multichain2")}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path2)}, + }, + }, + } + + tx1 := createAndSignTx(t, []std.Msg{msg1}, "chain-a", key) // accNum=0, seq=0 + + // tx2 must use seq=1 because tx1 already incremented the sequence. + tx2Raw := std.Tx{ + Msgs: []std.Msg{msg2}, + Fee: std.Fee{GasFee: std.NewCoin("ugnot", 2_000_000), GasWanted: 10_000_000}, + } + signBytes2, err := tx2Raw.GetSignBytes("chain-b", 0, 1) // accNum=0, seq=1 + require.NoError(t, err) + sig2, err := key.Sign(signBytes2) + require.NoError(t, err) + tx2Raw.Signatures = []std.Signature{{PubKey: key.PubKey(), Signature: sig2}} + + // Both chain IDs in the allowlist; each tx carries its own ChainID + app.InitChain(abci.RequestInitChain{ + ChainID: chainID, + Time: time.Now(), + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{}, + }, + }, + AppState: GnoGenesisState{ + Txs: []TxWithMetadata{ + { + Tx: tx1, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 10, + ChainID: "chain-a", + }, + }, + { + Tx: tx2Raw, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 20, + ChainID: "chain-b", + }, + }, + }, + Balances: []Balance{ + {Address: key.PubKey().Address(), Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000))}, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"chain-a", "chain-b"}, + }, + }) + }) +} + +func TestNodeParamsKeeperWillSetParam(t *testing.T) { + t.Parallel() + + npk := nodeParamsKeeper{} + + t.Run("valid halt_height (no block context)", func(t *testing.T) { + t.Parallel() + // Without a block header, safeBlockHeight returns 0, so no future check. + assert.NotPanics(t, func() { + npk.WillSetParam(sdk.Context{}, "p:halt_height", int64(100)) + }) + }) + + t.Run("halt_height zero is allowed (cancel sentinel)", func(t *testing.T) { + t.Parallel() + assert.NotPanics(t, func() { + npk.WillSetParam(sdk.Context{}, "p:halt_height", int64(0)) + }) + }) + + t.Run("halt_height in the future is valid when block height is known", func(t *testing.T) { + t.Parallel() + ctx := sdk.Context{}.WithBlockHeader(&bft.Header{Height: 50}) + assert.NotPanics(t, func() { + npk.WillSetParam(ctx, "p:halt_height", int64(100)) + }) + }) + + t.Run("halt_height equal to current block height panics", func(t *testing.T) { + t.Parallel() + ctx := sdk.Context{}.WithBlockHeader(&bft.Header{Height: 100}) + assert.Panics(t, func() { + npk.WillSetParam(ctx, "p:halt_height", int64(100)) + }) + }) + + t.Run("halt_height in the past panics", func(t *testing.T) { + t.Parallel() + ctx := sdk.Context{}.WithBlockHeader(&bft.Header{Height: 200}) + assert.Panics(t, func() { + npk.WillSetParam(ctx, "p:halt_height", int64(100)) + }) + }) + + t.Run("negative halt_height panics", func(t *testing.T) { + t.Parallel() + assert.Panics(t, func() { + npk.WillSetParam(sdk.Context{}, "p:halt_height", int64(-1)) + }) + }) + + t.Run("halt_height wrong type panics", func(t *testing.T) { + t.Parallel() + assert.Panics(t, func() { + npk.WillSetParam(sdk.Context{}, "p:halt_height", "not-an-int64") + }) + }) + + t.Run("valid halt_min_version", func(t *testing.T) { + t.Parallel() + assert.NotPanics(t, func() { + npk.WillSetParam(sdk.Context{}, "p:halt_min_version", "chain/gnoland1.1") + }) + }) + + t.Run("empty halt_min_version is allowed", func(t *testing.T) { + t.Parallel() + assert.NotPanics(t, func() { + npk.WillSetParam(sdk.Context{}, "p:halt_min_version", "") + }) + }) + + t.Run("halt_min_version wrong type panics", func(t *testing.T) { + t.Parallel() + assert.Panics(t, func() { + npk.WillSetParam(sdk.Context{}, "p:halt_min_version", int64(1)) + }) + }) + + t.Run("unknown p: key panics", func(t *testing.T) { + t.Parallel() + assert.Panics(t, func() { + npk.WillSetParam(sdk.Context{}, "p:unknown_key", int64(0)) + }) + }) + + t.Run("non-p: key is allowed", func(t *testing.T) { + t.Parallel() + assert.NotPanics(t, func() { + npk.WillSetParam(sdk.Context{}, "other:key", "value") + }) + }) +} + +// TestInitChainer_InitialHeightMismatch verifies that loadAppState rejects +// a genesis where GnoGenesisState.InitialHeight diverges from the +// GenesisDoc.InitialHeight passed in via RequestInitChain. +func TestInitChainer_InitialHeightMismatch(t *testing.T) { + t.Parallel() + + t.Run("mismatch is rejected", func(t *testing.T) { + t.Parallel() + + app, err := NewAppWithOptions(TestAppOptions(memdb.NewMemDB())) + require.NoError(t, err) + resp := app.InitChain(abci.RequestInitChain{ + ChainID: "test-chain", + Time: time.Now(), + InitialHeight: 100, + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{PubKeyTypeURLs: []string{}}, + }, + AppState: GnoGenesisState{ + Balances: []Balance{}, + Txs: []TxWithMetadata{}, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + InitialHeight: 200, // diverges from RequestInitChain.InitialHeight + }, + }) + require.NotNil(t, resp.Error, "InitChainer should reject InitialHeight mismatch") + assert.Contains(t, resp.Error.Error(), "InitialHeight mismatch") + }) + + t.Run("match is accepted", func(t *testing.T) { + t.Parallel() + + app, err := NewAppWithOptions(TestAppOptions(memdb.NewMemDB())) + require.NoError(t, err) + resp := app.InitChain(abci.RequestInitChain{ + ChainID: "test-chain", + Time: time.Now(), + InitialHeight: 100, + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{PubKeyTypeURLs: []string{}}, + }, + AppState: GnoGenesisState{ + Balances: []Balance{}, + Txs: []TxWithMetadata{}, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + InitialHeight: 100, + }, + }) + require.Nil(t, resp.Error, "matching InitialHeight should be accepted: %v", resp.Error) + }) + + t.Run("zero app-level InitialHeight is accepted", func(t *testing.T) { + t.Parallel() + + // GnoGenesisState.InitialHeight = 0 means "not set"; no check needed. + app, err := NewAppWithOptions(TestAppOptions(memdb.NewMemDB())) + require.NoError(t, err) + resp := app.InitChain(abci.RequestInitChain{ + ChainID: "test-chain", + Time: time.Now(), + InitialHeight: 100, + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{PubKeyTypeURLs: []string{}}, + }, + AppState: GnoGenesisState{ + Balances: []Balance{}, + Txs: []TxWithMetadata{}, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + // InitialHeight not set + }, + }) + require.Nil(t, resp.Error, "zero app-level InitialHeight should pass validation: %v", resp.Error) + }) +} + +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)) + }) + } +} + +func TestMeetsMinVersion(t *testing.T) { + t.Parallel() + + cases := []struct { + binary string + minVer string + want bool + }{ + // Empty minVersion always passes + {"chain/gnoland1.0", "", true}, + {"develop", "", true}, + + // Same version passes + {"chain/gnoland1.0", "chain/gnoland1.0", true}, + {"chain/gnoland1.1", "chain/gnoland1.1", true}, + + // Newer binary passes + {"chain/gnoland1.1", "chain/gnoland1.0", true}, + {"chain/gnoland2.0", "chain/gnoland1.0", true}, + {"chain/gnoland1.2", "chain/gnoland1.1", true}, + + // Older binary fails + {"chain/gnoland1.0", "chain/gnoland1.1", false}, + {"chain/gnoland1.0", "chain/gnoland2.0", false}, + + // Non-gnoland format: requires exact match + {"develop", "chain/gnoland1.1", false}, + {"v1.0.0", "v1.0.0", true}, + {"v1.0.0", "v1.1.0", false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.binary+">="+tc.minVer, func(t *testing.T) { + t.Parallel() + got := meetsMinVersion(tc.binary, tc.minVer) + assert.Equal(t, tc.want, got, + "meetsMinVersion(%q, %q)", tc.binary, tc.minVer) + }) + } +} + +func TestSignerInfoForceSetAccountState(t *testing.T) { + t.Parallel() + + t.Run("force-sets existing account sequence and number", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "new-chain" + + path = "gno.land/r/demo/signertest" + body = `package signertest + +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: "signertest", + Path: path, + Files: []*std.MemFile{ + {Name: "file.gno", Body: body}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path)}, + }, + }, + } + + // Sign with old chain, accNum=5, seq=10 — the SignerInfo will force-set + // the account to these values before signature verification. + tx := createAndSignTxWithAccSeq(t, []std.Msg{msg}, "old-chain", key, 5, 10) + + app.InitChain(abci.RequestInitChain{ + ChainID: chainID, + Time: time.Now(), + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{}, + }, + }, + AppState: GnoGenesisState{ + Txs: []TxWithMetadata{ + { + Tx: tx, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 42, + ChainID: "old-chain", + SignerInfo: []SignerAccountInfo{ + { + Address: key.PubKey().Address(), + AccountNum: 5, + Sequence: 10, + }, + }, + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"old-chain"}, + }, + }) + + // If SignerInfo was correctly applied, the tx would have been + // delivered successfully (sig verification passed). + // Verify by calling the deployed realm. + callMsg := vm.MsgCall{ + Caller: key.PubKey().Address(), + PkgPath: path, + Func: "IsDeployed", + } + + callTx := createAndSignTxWithAccSeq(t, []std.Msg{callMsg}, chainID, key, 5, 11) + + 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) + assert.Contains(t, string(resp.Data), "true") + }) + + t.Run("creates new account via SignerInfo when account does not exist", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "new-chain" + + path = "gno.land/r/demo/newacctest" + body = `package newacctest + +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: "newacctest", + Path: path, + Files: []*std.MemFile{ + {Name: "file.gno", Body: body}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path)}, + }, + }, + } + + // Sign with accNum=7 — account won't exist from balances, + // so NewAccountWithNumber must be called. + tx := createAndSignTxWithAccSeq(t, []std.Msg{msg}, "old-chain", key, 7, 0) + + app.InitChain(abci.RequestInitChain{ + ChainID: chainID, + Time: time.Now(), + ConsensusParams: &abci.ConsensusParams{ + Block: defaultBlockParams(), + Validator: &abci.ValidatorParams{ + PubKeyTypeURLs: []string{}, + }, + }, + AppState: GnoGenesisState{ + Txs: []TxWithMetadata{ + { + Tx: tx, + Metadata: &GnoTxMetadata{ + Timestamp: time.Now().Unix(), + BlockHeight: 10, + ChainID: "old-chain", + SignerInfo: []SignerAccountInfo{ + { + Address: key.PubKey().Address(), + AccountNum: 7, + Sequence: 0, + }, + }, + }, + }, + }, + // No balances — account doesn't exist before SignerInfo creates it. + // But the account needs funds for gas, so we must provide balances. + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"old-chain"}, + }, + }) + + // Verify deployment succeeded + callMsg := vm.MsgCall{ + Caller: key.PubKey().Address(), + PkgPath: path, + Func: "IsDeployed", + } + + callTx := createAndSignTxWithAccSeq(t, []std.Msg{callMsg}, chainID, key, 7, 1) + + 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) + assert.Contains(t, string(resp.Data), "true") + }) + + t.Run("failed tx is skipped and does not execute", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "new-chain" + + path = "gno.land/r/demo/failedtest" + body = `package failedtest + +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: "failedtest", + Path: path, + Files: []*std.MemFile{ + {Name: "file.gno", Body: body}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path)}, + }, + }, + } + + // This tx is marked as Failed — it should be skipped entirely. + tx := createAndSignTxWithAccSeq(t, []std.Msg{msg}, "old-chain", key, 0, 0) + + initResp := 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: 5, + ChainID: "old-chain", + Failed: true, + SignerInfo: []SignerAccountInfo{ + { + Address: key.PubKey().Address(), + AccountNum: 0, + Sequence: 0, + }, + }, + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + PastChainIDs: []string{"old-chain"}, + }, + }) + + // The skipped failed tx should produce a non-success response so + // downstream consumers (indexers, explorers) don't mistake it for + // success. + require.Len(t, initResp.TxResponses, 1) + skippedResp := initResp.TxResponses[0] + require.NotNil(t, skippedResp.Error, "skipped failed tx response should carry an error marker") + assert.Contains(t, skippedResp.Error.Error(), "replay skipped") + + // The package should NOT be deployed since the tx was marked as failed. + // Trying to call it should fail. + callMsg := vm.MsgCall{ + Caller: key.PubKey().Address(), + PkgPath: path, + Func: "IsDeployed", + } + + callTx := createAndSignTxWithAccSeq(t, []std.Msg{callMsg}, chainID, key, 0, 1) + + marshalledTx, err := amino.Marshal(callTx) + require.NoError(t, err) + + resp := app.DeliverTx(abci.RequestDeliverTx{Tx: marshalledTx}) + // Should fail because the package was never deployed + require.False(t, resp.IsOK(), "DeliverTx should have failed — failed tx should not deploy package") + }) + + t.Run("SignerInfo is ignored when BlockHeight is zero", func(t *testing.T) { + t.Parallel() + + var ( + db = memdb.NewMemDB() + key = getDummyKey(t) + chainID = "test-chain" + + path = "gno.land/r/demo/genesismode" + body = `package genesismode + +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: "genesismode", + Path: path, + Files: []*std.MemFile{ + {Name: "file.gno", Body: body}, + {Name: "gnomod.toml", Body: gnolang.GenGnoModLatest(path)}, + }, + }, + } + + // Sign with the current chain ID (genesis-mode tx). + // BlockHeight=0 means SignerInfo should be ignored entirely. + 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, // genesis-mode — SignerInfo must be ignored + SignerInfo: []SignerAccountInfo{ + { + Address: key.PubKey().Address(), + AccountNum: 999, // would corrupt state if applied + Sequence: 999, + }, + }, + }, + }, + }, + Balances: []Balance{ + { + Address: key.PubKey().Address(), + Amount: std.NewCoins(std.NewCoin("ugnot", 20_000_000)), + }, + }, + Auth: auth.DefaultGenesisState(), + Bank: bank.DefaultGenesisState(), + VM: vm.DefaultGenesisState(), + }, + }) + + // If SignerInfo was correctly ignored, the deployment should succeed + // with the normal account state (accNum=0, seq=0). + callMsg := vm.MsgCall{ + Caller: key.PubKey().Address(), + PkgPath: path, + Func: "IsDeployed", + } + + 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) + assert.Contains(t, string(resp.Data), "true") + }) +} + +func TestParseGnolandVersion(t *testing.T) { + t.Parallel() + + cases := []struct { + input string + major int + minor int + ok bool + }{ + {"chain/gnoland1.0", 1, 0, true}, + {"chain/gnoland1.1", 1, 1, true}, + {"chain/gnoland2.3", 2, 3, true}, + {"develop", 0, 0, false}, + {"v1.0.0", 0, 0, false}, + {"chain/gnoland", 0, 0, false}, + {"chain/gnolandX.Y", 0, 0, false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + major, minor, ok := parseGnolandVersion(tc.input) + assert.Equal(t, tc.ok, ok) + if tc.ok { + assert.Equal(t, tc.major, major) + assert.Equal(t, tc.minor, minor) + } + }) + } +} + +// newTestParamsKeeper creates a minimal ParamsKeeper with an in-memory store +// and pre-seeds it with the given halt params. +func newTestParamsKeeper(t *testing.T, haltHeight int64, minVersion string) (params.ParamsKeeper, store.MultiStore) { + t.Helper() + + db := memdb.NewMemDB() + mainKey := store.NewStoreKey("main") + + cms := store.NewCommitMultiStore(db) + cms.MountStoreWithDB(mainKey, iavl.StoreConstructor, db) + require.NoError(t, cms.LoadLatestVersion()) + + prmk := params.NewParamsKeeper(mainKey) + prmk.Register("node", nodeParamsKeeper{}) + + ms := cms.MultiCacheWrap() + ctx := sdk.Context{}.WithMultiStore(ms).WithChainID("_") + + prmk.SetInt64(ctx, nodeParamHaltHeight, haltHeight) + prmk.SetString(ctx, nodeParamHaltMinVersion, minVersion) + ms.MultiWrite() + cms.Commit() + + return prmk, cms.MultiCacheWrap() +} + +func TestCheckNodeStartupParams(t *testing.T) { + t.Parallel() + + t.Run("no halt configured", func(t *testing.T) { + t.Parallel() + prmk, ms := newTestParamsKeeper(t, 0, "") + require.NoError(t, checkNodeStartupParams(prmk, ms, 50, 0)) + }) + + t.Run("halt with no version passes", func(t *testing.T) { + t.Parallel() + prmk, ms := newTestParamsKeeper(t, 100, "") + require.NoError(t, checkNodeStartupParams(prmk, ms, 100, 0)) + }) + + t.Run("binary meets version after halt", func(t *testing.T) { + t.Parallel() + prmk, ms := newTestParamsKeeper(t, 100, "develop") + // binary "develop" == "develop" -> meetsMinVersion (exact match), lastBlock >= haltHeight + require.NoError(t, checkNodeStartupParams(prmk, ms, 100, 0)) + }) + + t.Run("old binary rejected after halt", func(t *testing.T) { + t.Parallel() + prmk, ms := newTestParamsKeeper(t, 100, "chain/gnoland9.9") + // binary "develop" doesn't meet "chain/gnoland9.9" -> rejected + err := checkNodeStartupParams(prmk, ms, 100, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not meet the minimum version") + }) + + t.Run("new binary rejected before halt height", func(t *testing.T) { + t.Parallel() + prmk, ms := newTestParamsKeeper(t, 100, "develop") + // binary "develop" == "develop" -> meetsMinVersion, but chain hasn't halted yet + err := checkNodeStartupParams(prmk, ms, 50, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "upgrade intended for halt height") + }) + + t.Run("old binary allowed before halt height", func(t *testing.T) { + t.Parallel() + prmk, ms := newTestParamsKeeper(t, 100, "chain/gnoland9.9") + // binary "develop" doesn't meet "chain/gnoland9.9", chain hasn't halted -> old binary, OK + require.NoError(t, checkNodeStartupParams(prmk, ms, 50, 0)) + }) + + t.Run("skip_upgrade_height bypasses check", func(t *testing.T) { + t.Parallel() + prmk, ms := newTestParamsKeeper(t, 100, "develop") + // Even though binary meets version before halt, skip_upgrade_height=100 bypasses + require.NoError(t, checkNodeStartupParams(prmk, ms, 50, 100)) + }) +} + +func TestEndBlockerHalt(t *testing.T) { + t.Parallel() + + noFilter := func(_ events.Event) []validatorUpdate { return nil } + + t.Run("halts at exact height", func(t *testing.T) { + t.Parallel() + + var haltSet uint64 + mockApp := &mockEndBlockerApp{ + setHaltHeightFn: func(h uint64) { haltSet = h }, + } + mockPrmk := &mockConfigurableParamsKeeper{ + int64s: map[string]int64{nodeParamHaltHeight: 100}, + } + + c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) + eb := EndBlocker(c, nil, nil, nil, mockPrmk, mockApp) + eb(sdk.Context{}, abci.RequestEndBlock{Height: 100}) + + assert.Equal(t, uint64(100), haltSet, "SetHaltHeight should be called with halt_height") + }) + + t.Run("does not halt before halt height", func(t *testing.T) { + t.Parallel() + + var haltSet uint64 + mockApp := &mockEndBlockerApp{ + setHaltHeightFn: func(h uint64) { haltSet = h }, + } + mockPrmk := &mockConfigurableParamsKeeper{ + int64s: map[string]int64{nodeParamHaltHeight: 100}, + } + + c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) + eb := EndBlocker(c, nil, nil, nil, mockPrmk, mockApp) + eb(sdk.Context{}, abci.RequestEndBlock{Height: 99}) + + assert.Equal(t, uint64(0), haltSet, "SetHaltHeight should NOT be called before halt height") + }) + + t.Run("does not re-halt after halt height (no infinite loop)", func(t *testing.T) { + t.Parallel() + + var haltSet uint64 + mockApp := &mockEndBlockerApp{ + setHaltHeightFn: func(h uint64) { haltSet = h }, + } + mockPrmk := &mockConfigurableParamsKeeper{ + int64s: map[string]int64{nodeParamHaltHeight: 100}, + } + + c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) + eb := EndBlocker(c, nil, nil, nil, mockPrmk, mockApp) + // After restart at height 101, halt_height=100 still in params but == doesn't re-fire + eb(sdk.Context{}, abci.RequestEndBlock{Height: 101}) + + assert.Equal(t, uint64(0), haltSet, "SetHaltHeight must NOT be called after halt height (prevents infinite loop)") + }) + + t.Run("cancel: halt_height zero never halts", func(t *testing.T) { + t.Parallel() + + var haltSet uint64 + mockApp := &mockEndBlockerApp{ + setHaltHeightFn: func(h uint64) { haltSet = h }, + } + mockPrmk := &mockConfigurableParamsKeeper{ + int64s: map[string]int64{nodeParamHaltHeight: 0}, + } + + c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) + eb := EndBlocker(c, nil, nil, nil, mockPrmk, mockApp) + eb(sdk.Context{}, abci.RequestEndBlock{Height: 100}) + + assert.Equal(t, uint64(0), haltSet, "SetHaltHeight should NOT be called when halt_height=0 (cancelled)") + }) +} + +func TestExtractUpdatesFromResponse(t *testing.T) { + t.Parallel() + + t.Run("empty response returns nil", func(t *testing.T) { + t.Parallel() + updates, err := extractUpdatesFromResponse("") + require.NoError(t, err) + assert.Nil(t, updates) + }) + + t.Run("no regex match returns nil", func(t *testing.T) { + t.Parallel() + updates, err := extractUpdatesFromResponse("some random string with no validator data") + require.NoError(t, err) + assert.Nil(t, updates) + }) + + t.Run("invalid address", func(t *testing.T) { + t.Parallel() + // The regex captures any quoted string as the address, so we can inject an invalid bech32. + response := `{("notabech32" std.Address),("notapubkey" string),(1 uint64)}` + _, err := extractUpdatesFromResponse(response) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to parse address") + }) + + t.Run("invalid pubkey", func(t *testing.T) { + t.Parallel() + // Valid bech32 address, but invalid pubkey string. + addr := crypto.AddressFromPreimage([]byte("test")) + response := fmt.Sprintf(`{(%q std.Address),("notapubkey" string),(1 uint64)}`, addr) + _, err := extractUpdatesFromResponse(response) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to parse public key") + }) +} diff --git a/gno.land/pkg/gnoland/mock_test.go b/gno.land/pkg/gnoland/mock_test.go index 9732d2a2f56..bd8ceb9d766 100644 --- a/gno.land/pkg/gnoland/mock_test.go +++ b/gno.land/pkg/gnoland/mock_test.go @@ -158,6 +158,14 @@ type mockAuthKeeper struct{} func (m *mockAuthKeeper) NewAccountWithAddress(ctx sdk.Context, addr crypto.Address) std.Account { return nil } + +// NewAccountWithNumber returns nil. This mock is only safe in tests where no +// TxWithMetadata carries SignerInfo — if SignerInfo is present and an account +// doesn't exist, the replay loop calls this and then calls acc.SetSequence, +// which will panic on a nil return. Use a real AccountKeeper for those tests. +func (m *mockAuthKeeper) NewAccountWithNumber(ctx sdk.Context, addr crypto.Address, accNum uint64) std.Account { + return nil +} func (m *mockAuthKeeper) GetAccount(ctx sdk.Context, addr crypto.Address) std.Account { return nil } func (m *mockAuthKeeper) GetAllAccounts(ctx sdk.Context) []std.Account { return nil } func (m *mockAuthKeeper) SetAccount(ctx sdk.Context, acc std.Account) {} @@ -197,11 +205,13 @@ func (m *mockGasPriceKeeper) UpdateGasPrice(ctx sdk.Context) {} type ( lastBlockHeightDelegate func() int64 loggerDelegate func() *slog.Logger + setHaltHeightDelegate func(uint64) ) type mockEndBlockerApp struct { lastBlockHeightFn lastBlockHeightDelegate loggerFn loggerDelegate + setHaltHeightFn setHaltHeightDelegate } func (m *mockEndBlockerApp) LastBlockHeight() int64 { @@ -219,3 +229,43 @@ func (m *mockEndBlockerApp) Logger() *slog.Logger { return log.NewNoopLogger() } + +func (m *mockEndBlockerApp) SetHaltHeight(height uint64) { + if m.setHaltHeightFn != nil { + m.setHaltHeightFn(height) + } +} + +// mockConfigurableParamsKeeper is a ParamsKeeperI that returns values from pre-seeded maps. +type mockConfigurableParamsKeeper struct { + int64s map[string]int64 + strings map[string]string +} + +func (m *mockConfigurableParamsKeeper) GetInt64(ctx sdk.Context, key string, ptr *int64) { + if v, ok := m.int64s[key]; ok { + *ptr = v + } +} +func (m *mockConfigurableParamsKeeper) GetString(ctx sdk.Context, key string, ptr *string) { + if v, ok := m.strings[key]; ok { + *ptr = v + } +} +func (m *mockConfigurableParamsKeeper) GetUint64(ctx sdk.Context, key string, ptr *uint64) {} +func (m *mockConfigurableParamsKeeper) GetBool(ctx sdk.Context, key string, ptr *bool) {} +func (m *mockConfigurableParamsKeeper) GetBytes(ctx sdk.Context, key string, ptr *[]byte) {} +func (m *mockConfigurableParamsKeeper) GetStrings(ctx sdk.Context, key string, ptr *[]string) { +} +func (m *mockConfigurableParamsKeeper) SetString(ctx sdk.Context, key, value string) {} +func (m *mockConfigurableParamsKeeper) SetInt64(ctx sdk.Context, key string, value int64) {} +func (m *mockConfigurableParamsKeeper) SetUint64(ctx sdk.Context, key string, value uint64) {} +func (m *mockConfigurableParamsKeeper) SetBool(ctx sdk.Context, key string, value bool) {} +func (m *mockConfigurableParamsKeeper) SetBytes(ctx sdk.Context, key string, value []byte) {} +func (m *mockConfigurableParamsKeeper) SetStrings(ctx sdk.Context, key string, value []string) { +} +func (m *mockConfigurableParamsKeeper) Has(ctx sdk.Context, key string) bool { return false } +func (m *mockConfigurableParamsKeeper) GetStruct(ctx sdk.Context, key string, strctPtr any) {} +func (m *mockConfigurableParamsKeeper) SetStruct(ctx sdk.Context, key string, strct any) {} +func (m *mockConfigurableParamsKeeper) GetAny(ctx sdk.Context, key string) any { return nil } +func (m *mockConfigurableParamsKeeper) SetAny(ctx sdk.Context, key string, value any) {} diff --git a/gno.land/pkg/gnoland/node_initial_height_test.go b/gno.land/pkg/gnoland/node_initial_height_test.go new file mode 100644 index 00000000000..8512ff71481 --- /dev/null +++ b/gno.land/pkg/gnoland/node_initial_height_test.go @@ -0,0 +1,80 @@ +package gnoland + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" + bft "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/db/memdb" + "github.com/gnolang/gno/tm2/pkg/log" + + "github.com/gnolang/gno/gnovm/pkg/gnoenv" +) + +// TestNodeBootWithInitialHeight boots a full in-memory node whose genesis doc +// has InitialHeight = 100. It verifies that: +// +// - The node starts without panicking (exercises all the InitialHeight paths +// through Handshaker → ConsensusState.reconstructLastCommit → +// BlockchainReactor.NewBlockchainReactor). +// - The first committed block is at height 100, not 1. +func TestNodeBootWithInitialHeight(t *testing.T) { + const initialHeight = int64(100) + + td := t.TempDir() + tmcfg := NewDefaultTMConfig(td) + + pv := bft.NewMockPV() + pk := pv.PubKey() + + genesis := &bft.GenesisDoc{ + GenesisTime: time.Now(), + ChainID: tmcfg.ChainID(), + InitialHeight: initialHeight, + ConsensusParams: abci.ConsensusParams{ + Block: defaultBlockParams(), + }, + Validators: []bft.GenesisValidator{ + { + Address: pk.Address(), + PubKey: pk, + Power: 10, + Name: "self", + }, + }, + AppState: DefaultGenState(), + } + + cfg := &InMemoryNodeConfig{ + PrivValidator: pv, + Genesis: genesis, + TMConfig: tmcfg, + DB: memdb.NewMemDB(), + InitChainerConfig: InitChainerConfig{ + GenesisTxResultHandler: PanicOnFailingTxResultHandler, + StdlibDir: filepath.Join(gnoenv.RootDir(), "gnovm", "stdlibs"), + CacheStdlibLoad: true, + }, + } + + n, err := NewInMemoryNode(log.NewTestingLogger(t), cfg) + require.NoError(t, err) + + require.NoError(t, n.Start()) + t.Cleanup(func() { require.NoError(t, n.Stop()) }) + + select { + case <-n.Ready(): + // first block committed + case <-time.After(30 * time.Second): + t.Fatal("timeout waiting for node to produce first block") + } + + height := n.BlockStore().Height() + require.Equal(t, initialHeight, height, + "first committed block should be at InitialHeight (%d), got %d", initialHeight, height) +} diff --git a/gno.land/pkg/gnoland/node_params.go b/gno.land/pkg/gnoland/node_params.go new file mode 100644 index 00000000000..7efe785942c --- /dev/null +++ b/gno.land/pkg/gnoland/node_params.go @@ -0,0 +1,152 @@ +package gnoland + +import ( + "fmt" + "strconv" + "strings" + + "github.com/gnolang/gno/tm2/pkg/sdk" + sdkparams "github.com/gnolang/gno/tm2/pkg/sdk/params" + "github.com/gnolang/gno/tm2/pkg/store" + tmver "github.com/gnolang/gno/tm2/pkg/version" +) + +const ( + nodeParamHaltHeight = "node:p:halt_height" + nodeParamHaltMinVersion = "node:p:halt_min_version" +) + +// nodeParamsKeeper implements a minimal ParamfulKeeper for the "node" module. +// It validates node-level parameters set through governance proposals. +type nodeParamsKeeper struct{} + +// WillSetParam validates node parameters before they are written to the params store. +func (nodeParamsKeeper) WillSetParam(ctx sdk.Context, key string, value any) { + switch key { + case "p:halt_height": + h, ok := value.(int64) + if !ok { + panic(fmt.Sprintf("halt_height must be an int64, got %T", value)) + } + if h < 0 { + panic(fmt.Sprintf("halt_height must be non-negative, got %d", h)) + } + // Reject halt heights that are in the past or present. + // h == 0 is the cancel sentinel and is always allowed. + // safeBlockHeight handles genesis/test contexts where the block header may not be set. + if curHeight := safeBlockHeight(ctx); h > 0 && curHeight > 0 && h <= curHeight { + panic(fmt.Sprintf("halt_height %d must be greater than the current block height %d", h, curHeight)) + } + case "p:halt_min_version": + _, ok := value.(string) + if !ok { + panic(fmt.Sprintf("halt_min_version must be a string, got %T", value)) + } + default: + if strings.HasPrefix(key, "p:") { + panic(fmt.Sprintf("unknown node param key: %q", key)) + } + } +} + +// checkNodeStartupParams reads halt-related params from the committed state and verifies: +// 1. The running binary meets the minimum version requirement set by governance. +// 2. A new (upgraded) binary is not started before the chain has actually halted. +// +// skipUpgradeHeight, if non-zero, skips all upgrade checks at that specific height. +func checkNodeStartupParams(prmk sdkparams.ParamsKeeperI, ms store.MultiStore, lastBlockHeight, skipUpgradeHeight int64) error { + // Build a minimal read-only context with just the multistore and a placeholder chain ID. + // We only need store access to read params; no block execution context is required. + ctx := sdk.Context{}.WithMultiStore(ms).WithChainID("_") + + var haltHeight int64 + prmk.GetInt64(ctx, nodeParamHaltHeight, &haltHeight) + + var minVersion string + prmk.GetString(ctx, nodeParamHaltMinVersion, &minVersion) + + // Nothing to check if no governance halt is configured. + if haltHeight == 0 || minVersion == "" { + return nil + } + + // Allow skipping upgrade checks at a specific height (e.g., validator already migrated). + if skipUpgradeHeight > 0 && skipUpgradeHeight == haltHeight { + return nil + } + + binaryVersion := tmver.Version + + // Check 1: Prevent old binaries from resuming after a halt. + if lastBlockHeight >= haltHeight { + if !meetsMinVersion(binaryVersion, minVersion) { + return fmt.Errorf( + "binary version %q does not meet the minimum version %q required by governance; "+ + "please upgrade to a compatible binary before restarting", + binaryVersion, minVersion, + ) + } + return nil + } + + // Check 2: Prevent new (upgraded) binaries from running before the halt height. + // Any binary that meets the minimum version is rejected until the halt occurs. + if meetsMinVersion(binaryVersion, minVersion) { + return fmt.Errorf( + "binary version %q is an upgrade intended for halt height %d, "+ + "but the chain is at height %d; please use the previous binary until the halt, "+ + "or set skip_upgrade_height = %d in config.toml if you have already migrated", + binaryVersion, haltHeight, lastBlockHeight, haltHeight, + ) + } + + return nil +} + +// safeBlockHeight returns ctx.BlockHeight() or 0 if the context has no block header. +// This handles genesis and test contexts where the header may not be initialized. +func safeBlockHeight(ctx sdk.Context) (h int64) { + defer func() { recover() }() //nolint:errcheck + return ctx.BlockHeight() +} + +// meetsMinVersion reports whether binaryVersion satisfies the minVersion requirement. +// Versions are expected to follow the "chain/gnolandX.Y" format used for gno.land chain releases. +// If either version cannot be parsed in that format, an exact string match is required. +func meetsMinVersion(binaryVersion, minVersion string) bool { + if minVersion == "" { + return true + } + + bMajor, bMinor, bOK := parseGnolandVersion(binaryVersion) + mMajor, mMinor, mOK := parseGnolandVersion(minVersion) + + if bOK && mOK { + if bMajor != mMajor { + return bMajor > mMajor + } + return bMinor >= mMinor + } + + // Fall back to exact match if versions are not in the recognized format. + return binaryVersion == minVersion +} + +// parseGnolandVersion parses a version string like "chain/gnoland1.2" into its major and minor parts. +func parseGnolandVersion(v string) (major, minor int, ok bool) { + const prefix = "chain/gnoland" + if !strings.HasPrefix(v, prefix) { + return 0, 0, false + } + rest := v[len(prefix):] + dot := strings.IndexByte(rest, '.') + if dot < 0 { + return 0, 0, false + } + maj, err1 := strconv.Atoi(rest[:dot]) + minor, err2 := strconv.Atoi(rest[dot+1:]) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return maj, minor, true +} diff --git a/gno.land/pkg/gnoland/package.go b/gno.land/pkg/gnoland/package.go index e4b2449c972..4cae4d99cd9 100644 --- a/gno.land/pkg/gnoland/package.go +++ b/gno.land/pkg/gnoland/package.go @@ -13,4 +13,5 @@ var Package = amino.RegisterPackage(amino.NewPackage( GnoGenesisState{}, "GenesisState", TxWithMetadata{}, "TxWithMetadata", GnoTxMetadata{}, "GnoTxMetadata", + SignerAccountInfo{}, "SignerAccountInfo", )) diff --git a/gno.land/pkg/gnoland/replay_report.go b/gno.land/pkg/gnoland/replay_report.go new file mode 100644 index 00000000000..fb3cf2d4864 --- /dev/null +++ b/gno.land/pkg/gnoland/replay_report.go @@ -0,0 +1,150 @@ +package gnoland + +import ( + "fmt" + "log/slog" + + "github.com/gnolang/gno/tm2/pkg/sdk" +) + +// ReplayCategory classifies the outcome of a genesis tx replay. +type ReplayCategory string + +const ( + // ReplayCategoryOK: tx replayed successfully (gas matched source within tolerance, if source gas was recorded). + ReplayCategoryOK ReplayCategory = "ok" + // ReplayCategoryOKGasDiffers: tx succeeded but gas consumption differs from source chain. + ReplayCategoryOKGasDiffers ReplayCategory = "ok_gas_differs" + // ReplayCategoryFailed: tx failed during replay (any reason not covered by specific categories). + ReplayCategoryFailed ReplayCategory = "failed" + // ReplayCategorySkippedFailed: tx was marked Failed in source metadata, correctly skipped. + ReplayCategorySkippedFailed ReplayCategory = "skipped_failed" + + // aliases for callers (lowercase internal): + replayCategorySkippedFailed = ReplayCategorySkippedFailed +) + +// replayOutcome is a single tx outcome during genesis replay. +type replayOutcome struct { + TxIndex int `json:"tx_index"` + SourceHeight int64 `json:"source_height,omitempty"` // metadata.BlockHeight + SourceChainID string `json:"source_chain_id,omitempty"` + Category ReplayCategory `json:"category"` + GasSource int64 `json:"gas_source,omitempty"` // metadata.GasUsed (from tx-archive) + GasReplay int64 `json:"gas_replay,omitempty"` // actual gas consumed during replay + Error string `json:"error,omitempty"` // brief error if failed +} + +// replayReport accumulates per-tx outcomes and emits a summary. +type replayReport struct { + mode string // GasReplayMode from GnoGenesisState + outcomes []replayOutcome +} + +func newReplayReport(mode string) *replayReport { + return &replayReport{mode: mode} +} + +// record appends an outcome with fully explicit values (used for skipped txs). +func (r *replayReport) record(txIdx int, metadata *GnoTxMetadata, gasReplay int64, gasSource int64, cat ReplayCategory, err error) { + o := replayOutcome{ + TxIndex: txIdx, + Category: cat, + GasReplay: gasReplay, + GasSource: gasSource, + } + if metadata != nil { + o.SourceHeight = metadata.BlockHeight + o.SourceChainID = metadata.ChainID + if o.GasSource == 0 { + o.GasSource = metadata.GasUsed + } + } + if err != nil { + o.Error = err.Error() + } + r.outcomes = append(r.outcomes, o) +} + +// recordDeliverResult derives the outcome from a Deliver result and metadata. +func (r *replayReport) recordDeliverResult(txIdx int, metadata *GnoTxMetadata, res sdk.Result) { + o := replayOutcome{ + TxIndex: txIdx, + GasReplay: res.GasUsed, + } + if metadata != nil { + o.SourceHeight = metadata.BlockHeight + o.SourceChainID = metadata.ChainID + o.GasSource = metadata.GasUsed + } + if res.IsErr() { + o.Category = ReplayCategoryFailed + if res.Error != nil { + o.Error = res.Error.Error() + } else if res.Log != "" { + o.Error = res.Log + } + } else if o.GasSource > 0 && o.GasReplay != o.GasSource { + o.Category = ReplayCategoryOKGasDiffers + } else { + o.Category = ReplayCategoryOK + } + r.outcomes = append(r.outcomes, o) +} + +// emit writes a summary to the logger. +func (r *replayReport) emit(logger *slog.Logger) { + if logger == nil || len(r.outcomes) == 0 { + return + } + counts := map[ReplayCategory]int{} + for _, o := range r.outcomes { + counts[o.Category]++ + } + logger.Info( + "Genesis replay report", + "mode", modeOrDefault(r.mode), + "total", len(r.outcomes), + "ok", counts[ReplayCategoryOK], + "ok_gas_differs", counts[ReplayCategoryOKGasDiffers], + "failed", counts[ReplayCategoryFailed], + "skipped_failed", counts[ReplayCategorySkippedFailed], + ) + // For failures, log each one so operators can review. + for _, o := range r.outcomes { + if o.Category == ReplayCategoryFailed { + logger.Warn("Genesis replay failure", + "tx_index", o.TxIndex, + "source_height", o.SourceHeight, + "gas_source", o.GasSource, + "gas_replay", o.GasReplay, + "error", o.Error, + ) + } + } +} + +// Outcomes returns a copy of recorded outcomes. Exposed for tests and tooling +// that wants to write its own replay-report.json. +func (r *replayReport) Outcomes() []replayOutcome { + out := make([]replayOutcome, len(r.outcomes)) + copy(out, r.outcomes) + return out +} + +func modeOrDefault(mode string) string { + if mode == "" { + return "strict" + } + return mode +} + +// validateGasReplayMode returns an error if the given mode is not recognised. +func validateGasReplayMode(mode string) error { + switch mode { + case "", "strict", "source": + return nil + default: + return fmt.Errorf("unknown GasReplayMode %q (valid: \"\", \"strict\", \"source\")", mode) + } +} diff --git a/gno.land/pkg/gnoland/replay_report_test.go b/gno.land/pkg/gnoland/replay_report_test.go new file mode 100644 index 00000000000..8e3a2172484 --- /dev/null +++ b/gno.land/pkg/gnoland/replay_report_test.go @@ -0,0 +1,89 @@ +package gnoland + +import ( + "errors" + "testing" + + abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" + "github.com/gnolang/gno/tm2/pkg/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateGasReplayMode(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + mode string + wantErr bool + }{ + {"", false}, + {"strict", false}, + {"source", false}, + {"max", true}, // not implemented yet + {"skip", true}, // not implemented yet + {"STRICT", true}, // case-sensitive + {"garbage", true}, + } { + tc := tc + t.Run(tc.mode, func(t *testing.T) { + t.Parallel() + err := validateGasReplayMode(tc.mode) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestReplayReport_Categorization(t *testing.T) { + t.Parallel() + + r := newReplayReport("source") + + // Tx 0: success, gas differs from source + r.recordDeliverResult(0, &GnoTxMetadata{BlockHeight: 10, GasUsed: 50_000}, sdk.Result{ + GasUsed: 75_000, + }) + // Tx 1: success, gas matches source + r.recordDeliverResult(1, &GnoTxMetadata{BlockHeight: 11, GasUsed: 30_000}, sdk.Result{ + GasUsed: 30_000, + }) + // Tx 2: success, no source gas recorded + r.recordDeliverResult(2, &GnoTxMetadata{BlockHeight: 12}, sdk.Result{ + GasUsed: 10_000, + }) + // Tx 3: delivery failed + r.recordDeliverResult(3, &GnoTxMetadata{BlockHeight: 13, GasUsed: 20_000}, sdk.Result{ + ResponseBase: abci.ResponseBase{ + Error: abci.StringError("out of gas"), + }, + GasUsed: 20_000, + }) + // Tx 4: skipped (source failed) + r.record(4, &GnoTxMetadata{BlockHeight: 14, Failed: true}, 0, 0, ReplayCategorySkippedFailed, nil) + + outcomes := r.Outcomes() + require.Len(t, outcomes, 5) + assert.Equal(t, ReplayCategoryOKGasDiffers, outcomes[0].Category) + assert.Equal(t, ReplayCategoryOK, outcomes[1].Category) + assert.Equal(t, ReplayCategoryOK, outcomes[2].Category) + assert.Equal(t, ReplayCategoryFailed, outcomes[3].Category) + assert.Contains(t, outcomes[3].Error, "out of gas") + assert.Equal(t, ReplayCategorySkippedFailed, outcomes[4].Category) + + // Explicit record with error + r.record(5, &GnoTxMetadata{BlockHeight: 15}, 0, 0, ReplayCategoryFailed, errors.New("boom")) + outcomes = r.Outcomes() + require.Len(t, outcomes, 6) + assert.Equal(t, "boom", outcomes[5].Error) +} + +func TestReplayReport_ModeDefault(t *testing.T) { + t.Parallel() + + assert.Equal(t, "strict", modeOrDefault("")) + assert.Equal(t, "source", modeOrDefault("source")) +} diff --git a/gno.land/pkg/gnoland/types.go b/gno.land/pkg/gnoland/types.go index 050eda60c92..94b17985eb6 100644 --- a/gno.land/pkg/gnoland/types.go +++ b/gno.land/pkg/gnoland/types.go @@ -126,6 +126,18 @@ type GnoGenesisState struct { Auth auth.GenesisState `json:"auth"` Bank bank.GenesisState `json:"bank"` VM vm.GenesisState `json:"vm"` + // Chain upgrade fields + PastChainIDs []string `json:"past_chain_ids,omitempty"` // Allowlist of chain IDs valid for historical tx signature verification + InitialHeight int64 `json:"initial_height,omitempty"` // Block height to start from after genesis replay + // GasReplayMode controls how historical txs (metadata.BlockHeight > 0) are + // metered during replay. Valid values: + // "" or "strict" — use the new VM's gas meter (default; may fail txs + // that worked on the source chain if gas requirements changed) + // "source" — bypass the new gas meter for historical txs; they + // execute with unlimited gas and the response records + // metadata.GasUsed from the source chain. This preserves the + // historical outcome even if the VM's gas metering changed. + GasReplayMode string `json:"gas_replay_mode,omitempty"` } type TxWithMetadata struct { @@ -134,7 +146,22 @@ type TxWithMetadata struct { } type GnoTxMetadata struct { - Timestamp int64 `json:"timestamp"` + Timestamp int64 `json:"timestamp"` + BlockHeight int64 `json:"block_height,omitempty"` // Original block height for historical tx replay + ChainID string `json:"chain_id,omitempty"` // Originating chain ID, populated by tx-archive export + Failed bool `json:"failed,omitempty"` // True if tx had non-zero return code on source chain + SignerInfo []SignerAccountInfo `json:"signer_info,omitempty"` // Per-signer account metadata for signature verification + GasUsed int64 `json:"gas_used,omitempty"` // Gas consumed on source chain (used when GasReplayMode="source") + GasWanted int64 `json:"gas_wanted,omitempty"` // Gas requested on source chain (informational / report) +} + +// SignerAccountInfo records a signer's account number and sequence at the time +// a historical tx was executed on the source chain. Used during hardfork replay +// to force-set account state so signatures verify correctly. +type SignerAccountInfo struct { + Address crypto.Address `json:"address"` + AccountNum uint64 `json:"account_num"` // Stable, never changes once assigned + Sequence uint64 `json:"sequence"` // Pre-tx sequence (value used in GetSignBytes) } // ReadGenesisTxs reads the genesis txs from the given file path diff --git a/misc/deployments/gnoland-1/.gitignore b/misc/deployments/gnoland-1/.gitignore new file mode 100644 index 00000000000..5fcebe2d84a --- /dev/null +++ b/misc/deployments/gnoland-1/.gitignore @@ -0,0 +1 @@ +genesis-work/ diff --git a/misc/deployments/gnoland-1/README.md b/misc/deployments/gnoland-1/README.md new file mode 100644 index 00000000000..68dc9a11610 --- /dev/null +++ b/misc/deployments/gnoland-1/README.md @@ -0,0 +1,99 @@ +# gnoland-1 — Hard Fork of gnoland1 + +`gnoland-1` is the upgraded successor to `gnoland1`. It is produced via a +coordinated hard fork at a governance-approved halt height. + +## Chain ID change + +| Old | New | +|----------|------------| +| gnoland1 | gnoland-1 | + +The hyphen was added to make the chain ID upgrade-compatible — `gnoland1` +could not support chain upgrades that preserve the chain ID cleanly because +the naming convention conflated chain identity with version. `gnoland-1` is +the permanent base name; future upgrades increment a suffix on a sub-release +tag (e.g., `chain/gnoland-1.1`). + +## What changed + +This hard fork bundles the following upgrades in one shot: + +- **`r/sys/params` halt height** (gnolang/gno#5368): GovDAO can now vote to + halt the chain at a specific block and enforce a minimum binary version on + restart. _(awaiting merge)_ +- **`r/gnops/valopers` fee = 0**: Registration fee was set to 0 via a GovDAO + transaction on gnoland1; preserved in genesis replay. No code change needed. +- **Namereg GovDAO whitelist** (gnolang/gno#5293): Namereg now checks GovDAO + membership before allowing name registration. ✅ _merged_ +- **GovDAO scripts** (gnolang/gno#5375): Updated scripts for the new chain. ✅ _merged_ + +**Not confirmed for this hard fork** (need explicit sign-off from Jae): +- Gas parameter updates (gnolang/gno#5291, #5289, #5274) + +## Upgrade workflow + +Approach: **Scenario A — genesis tx-replay with InitialHeight preservation**. +All historical txs from gnoland1 are exported with their original block heights +and timestamps, then assembled into the genesis for gnoland-1. The new chain +starts at `initial_height = halt_height + 1`, preserving height continuity. + +``` +gnoland1 (running) + │ + ├── [gnoland1.2] Operators rolling-update with halt_height config + │ + ├── Chain halts at GovDAO-approved height + │ + ├── Each validator runs migrate-from-gnoland1.sh ← NOT YET IMPLEMENTED + │ - tx-archive exports all txs with block height + timestamp + │ - genesis-assemble produces genesis.json for gnoland-1 + │ (chain_id=gnoland-1, initial_height=halt+1, original_chain_id=gnoland1) + │ + ├── Validators compare genesis.json SHA-256 + │ (must all match before anyone restarts) + │ + └── Validators restart with new binary + new genesis + chain-id: gnoland-1, starts at height halt+1 +``` + +## ⚠️ Migration script not yet written + +**The migration script (`migrate-from-gnoland1.sh`) is the critical missing +piece.** Until it exists and has been tested on a dry-run on test12, the +hard fork cannot happen. + +Blockers: +- `tx-archive genesis-assemble` command (companion to gnolang/gno#5411) +- `tx-archive` offline export from block store (no live node required) +- Jae's tm2 `GenesisDoc.InitialHeight` port (hard blocker for gnolang/gno#5411) +- test12 dry-run: full halt → export → genesis-assemble → restart + +See the TODO block inside `migrate-from-gnoland1.sh` for details. + +Dry-run target: test12 (see gnoland1/govdao-scripts/ for tooling). + +## GovDAO scripts + +The govdao scripts in `govdao-scripts/` are identical to those in +`../gnoland1/govdao-scripts/` but default to `CHAIN_ID=gnoland-1`. + +All scripts default to `GNOKEY_NAME=moul`, `CHAIN_ID=gnoland-1`, and +`REMOTE=https://rpc.gno.land:443`. Override via env vars. + +```bash +./govdao-scripts/add-validator-from-valopers.sh ADDR +./govdao-scripts/add-validator.sh ADDR PUBKEY [POWER] +./govdao-scripts/rm-validator.sh ADDR +./govdao-scripts/unrestrict-account.sh ADDR [ADDR...] +``` + +## Config + +Copy `config.toml` and edit the `# Change me` fields: + +```shell +mkdir -p gnoland-data/config +cp config.toml gnoland-data/config/config.toml +grep -n "Change me" gnoland-data/config/config.toml +``` diff --git a/misc/deployments/gnoland-1/config.toml b/misc/deployments/gnoland-1/config.toml new file mode 100644 index 00000000000..9d07079c679 --- /dev/null +++ b/misc/deployments/gnoland-1/config.toml @@ -0,0 +1,256 @@ +# Mechanism to connect to the ABCI application: socket | grpc +abci = "socket" + +# Database backend: pebbledb | goleveldb | boltdb +#* pebbledb (github.com/cockroachdb/pebble) +# - pure go +# - stable +#* goleveldb (github.com/syndtr/goleveldb) +# - pure go +# - stable +# - use goleveldb build tag +#* boltdb (uses etcd's fork of bolt - go.etcd.io/bbolt) +# - EXPERIMENTAL +# - may be faster is some use-cases (random reads - indexer) +# - use boltdb build tag (go build -tags boltdb) +db_backend = "pebbledb" + +# Database directory +db_dir = "db" + +# If this node is many blocks behind the tip of the chain, FastSync +# allows them to catchup quickly by downloading blocks in parallel +# and verifying their commits +fast_sync = true +home = "" + +# A custom human readable name for this node +moniker = "" # TODO: Change me! + +# Path to the JSON file containing the private key to use for node authentication in the p2p protocol +node_key_file = "secrets/node_key.json" + +# TCP or UNIX socket address for the profiling server to listen on +prof_laddr = "" + +# TCP or UNIX socket address of the ABCI application, +# or the name of an ABCI application compiled in with the Tendermint binary +proxy_app = "tcp://127.0.0.1:26658" + +##### app settings ##### +[application] + +# Lowest gas prices accepted by a validator +min_gas_prices = "" + +# State pruning strategy [everything, nothing, syncable] +prune_strategy = "syncable" + +##### consensus configuration options ##### +[consensus] + +# EmptyBlocks mode and possible interval between empty blocks +create_empty_blocks = true +create_empty_blocks_interval = "0s" +home = "" + +# Reactor sleep duration parameters +peer_gossip_sleep_duration = "10ms" # Do NOT change me, leave me at 10ms! +peer_query_maj23_sleep_duration = "2s" + +# Make progress as soon as we have all the precommits (as if TimeoutCommit = 0) +skip_timeout_commit = false +timeout_commit = "3s" # Do NOT change me, leave me at 3s! +timeout_precommit = "1s" +timeout_precommit_delta = "500ms" +timeout_prevote = "1s" +timeout_prevote_delta = "500ms" +timeout_propose = "3s" +timeout_propose_delta = "500ms" +wal_file = "wal/cs.wal/wal" + +##### private validator configuration options ##### +[consensus.priv_validator] +home = "" + +# Path to the JSON file containing the private key to use for signing using a local signer +local_signer = "priv_validator_key.json" + +# Path to the JSON file containing the last validator state to prevent double-signing +sign_state = "priv_validator_state.json" + +# Configuration for the remote signer client +[consensus.priv_validator.remote_signer] + +# Maximum number of retries to dial the remote signer. If set to -1, will retry indefinitely +dial_max_retries = -1 + +# Interval between retries to dial the remote signer +dial_retry_interval = "5s" + +# Timeout to dial the remote signer +dial_timeout = "5s" + +# Timeout for requests to the remote signer +request_timeout = "5s" + +# Address of the remote signer to dial (UNIX or TCP). If set, the local signer is disabled +server_address = "" + +# List of authorized public keys for the remote signer (only for TCP). If empty, all keys are authorized +tcp_authorized_keys = [] + +# Keep alive period for the remote signer connection (only for TCP) +tcp_keep_alive_period = "2s" + +##### mempool configuration options ##### +[mempool] +broadcast = true + +# Size of the cache (used to filter transactions we saw earlier) in transactions +cache_size = 10000 +home = "" + +# Limit the total size of all txs in the mempool. +# This only accounts for raw transactions (e.g. given 1MB transactions and +# max_txs_bytes=5MB, mempool will only accept 5 transactions). +max_pending_txs_bytes = 1073741824 # ~1GB +recheck = true + +# Maximum number of transactions in the mempool +size = 10000 # Advised value is 10000 +wal_dir = "" + +##### peer to peer configuration options ##### +[p2p] + +# Address to advertise to peers for them to dial +# If empty, will use the same port as the laddr, +# and will introspect on the listener or use UPnP +# to figure out the address. +external_address = "" # TODO: Change me! + +# Time to wait before flushing messages out on the connection +flush_throttle_timeout = "10ms" # Do NOT change me, leave me at 10ms! +home = "" + +# Address to listen for incoming connections +laddr = "tcp://0.0.0.0:26656" + +# Maximum number of inbound peers +max_num_inbound_peers = 40 + +# Maximum number of outbound peers to connect to, excluding persistent peers +max_num_outbound_peers = 40 # Advised value is 40 + +# Maximum size of a message packet payload, in bytes +max_packet_msg_payload_size = 1024 + +# Comma separated list of nodes to keep persistent connections to +persistent_peers = "" # Change me: update with gnoland-1 peer addresses after the hard fork + +# Set true to enable the peer-exchange reactor +pex = true + +# Comma separated list of peer IDs to keep private (will not be gossiped to other peers) +private_peer_ids = "" + +# Rate at which packets can be received, in bytes/second +recv_rate = 5120000 + +# Comma separated list of seed nodes to connect to +seeds = "" # Change me: update with gnoland-1 seed addresses after the hard fork + +# Rate at which packets can be sent, in bytes/second +send_rate = 5120000 + +##### rpc server configuration options ##### +[rpc] + +# A list of non simple headers the client is allowed to use with cross-domain requests +cors_allowed_headers = ["Origin", "Accept", "Content-Type", "X-Requested-With", "X-Server-Time"] + +# A list of methods the client is allowed to use with cross-domain requests +cors_allowed_methods = ["HEAD", "GET", "POST", "OPTIONS"] + +# A list of origins a cross-domain request can be executed from +# Default value '[]' disables cors support +# Use '["*"]' to allow any origin +cors_allowed_origins = ["*"] + +# TCP or UNIX socket address for the gRPC server to listen on +# NOTE: This server only supports /broadcast_tx_commit +grpc_laddr = "" + +# Maximum number of simultaneous connections. +# Does not include RPC (HTTP&WebSocket) connections. See max_open_connections +# If you want to accept a larger number than the default, make sure +# you increase your OS limits. +# 0 - unlimited. +# Should be < {ulimit -Sn} - {MaxNumInboundPeers} - {MaxNumOutboundPeers} - {N of wal, db and other open files} +# 1024 - 40 - 10 - 50 = 924 = ~900 +grpc_max_open_connections = 900 +home = "" + +# TCP or UNIX socket address for the RPC server to listen on +laddr = "tcp://0.0.0.0:26657" # Please use a reverse proxy! + +# Maximum size of request body, in bytes +max_body_bytes = 1000000 + +# Maximum size of request header, in bytes +max_header_bytes = 1048576 + +# Maximum number of simultaneous connections (including WebSocket). +# Does not include gRPC connections. See grpc_max_open_connections +# If you want to accept a larger number than the default, make sure +# you increase your OS limits. +# 0 - unlimited. +# Should be < {ulimit -Sn} - {MaxNumInboundPeers} - {MaxNumOutboundPeers} - {N of wal, db and other open files} +# 1024 - 40 - 10 - 50 = 924 = ~900 +max_open_connections = 900 + +# How long to wait for a tx to be committed during /broadcast_tx_commit. +# WARNING: Using a value larger than 10s will result in increasing the +# global HTTP write timeout, which applies to all connections and endpoints. +# See https://github.com/tendermint/tendermint/issues/3435 +timeout_broadcast_tx_commit = "10s" + +# The path to a file containing certificate that is used to create the HTTPS server. +# Might be either absolute path or path related to tendermint's config directory. +# If the certificate is signed by a certificate authority, +# the certFile should be the concatenation of the server's certificate, any intermediates, +# and the CA's certificate. +# NOTE: both tls_cert_file and tls_key_file must be present for Tendermint to create HTTPS server. Otherwise, HTTP server is run. +tls_cert_file = "" + +# The path to a file containing matching private key that is used to create the HTTPS server. +# Might be either absolute path or path related to tendermint's config directory. +# NOTE: both tls_cert_file and tls_key_file must be present for Tendermint to create HTTPS server. Otherwise, HTTP server is run. +tls_key_file = "" + +# Activate unsafe RPC commands like /dial_seeds and /unsafe_flush_mempool +unsafe = false + +##### node telemetry ##### +[telemetry] +# the endpoint to export metrics to, like a local OpenTelemetry collector +exporter_endpoint = "" # Change me to the OTEL endpoint! +meter_name = "gnoland-1" +metrics_enabled = false # Advised to be `true` + +# the ID helps to distinguish instances of the same service that exist at the same time (e.g. instances of a horizontally scaled service), in Prometheus this is transformed into the label 'exported_instance +service_instance_id = "gno-node-1" # Change me! + +# in Prometheus this is transformed into the label 'exported_job' +service_name = "gno.land" +traces_enabled = false + +##### event store ##### +[tx_event_store] + +# Type of event store +event_store_type = "none" + +# Event store parameters +[tx_event_store.event_store_params] diff --git a/misc/deployments/gnoland-1/generate-genesis.sh b/misc/deployments/gnoland-1/generate-genesis.sh new file mode 100755 index 00000000000..77bf970bb23 --- /dev/null +++ b/misc/deployments/gnoland-1/generate-genesis.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Generate gnoland-1 hardfork genesis. +# +# Wraps `gnogenesis fork generate` with gnoland-1 chain IDs hardcoded. +# +# Env vars: +# SOURCE source to fetch state from (default: production RPC) +# http://... RPC of a running or recently-halted node +# /path/to/dir local node data directory (stopped node) +# /path/to/*.json exported genesis +# HALT_HEIGHT block height at which gnoland1 was halted +# (empty = auto-detect from source) +# PV_KEY path to the new validator's priv_validator_key.json. +# When set, a valset-reset migration tx is built and +# appended at the end of replay (updates r/sys/validators/v2 +# to match the new GenesisDoc.Validators). Leave empty to +# skip migrations. +# CALLER govDAO T1 address that runs the migration MsgRun +# (default: g1manfred47...) +# +# Usage: +# ./generate-genesis.sh +# SOURCE=http://rpc.gno.land:26657 ./generate-genesis.sh +# HALT_HEIGHT=704052 ./generate-genesis.sh +# PV_KEY=./my-valkey.json ./generate-genesis.sh + +set -euo pipefail + +CHAIN_ID="gnoland-1" +ORIGINAL_CHAIN_ID="gnoland1" + +SOURCE="${SOURCE:-http://rpc.gno.land:26657}" +HALT_HEIGHT="${HALT_HEIGHT:-}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +GNOGENESIS_DIR="$REPO_ROOT/contribs/gnogenesis" +OUTPUT="$SCRIPT_DIR/genesis.json" + +# Build the gnogenesis binary if not already available. +if command -v gnogenesis >/dev/null 2>&1; then + BIN="gnogenesis" +else + BIN="$SCRIPT_DIR/genesis-work/bin/gnogenesis" + if [[ ! -x "$BIN" ]]; then + printf "Building gnogenesis...\n" + mkdir -p "$(dirname "$BIN")" + go build -C "$GNOGENESIS_DIR" -o "$BIN" . + fi +fi + +CMD_ARGS=( + fork generate + --source "$SOURCE" + --chain-id "$CHAIN_ID" + --original-chain-id "$ORIGINAL_CHAIN_ID" + --output "$OUTPUT" +) +[[ -n "$HALT_HEIGHT" ]] && CMD_ARGS+=(--halt-height "$HALT_HEIGHT") + +# Build the post-replay migration jsonl if a new-valset priv_validator_key +# is provided. This appends a govDAO proposal tx (MsgRun) at the end of +# appState.Txs that resets r/sys/validators/v2 to match the new +# GenesisDoc.Validators — reconciling the in-gno side with the tm2 side. +if [[ -n "${PV_KEY:-}" ]]; then + MIG_JSONL="$SCRIPT_DIR/migrations/migrations.jsonl" + printf "Building migrations (PV_KEY=%s)...\n" "$PV_KEY" + CALLER="${CALLER:-g1manfred47kzduec920z88wfr64ylksmdcedlf5}" \ + PV_KEY="$PV_KEY" \ + OUT_JSONL="$MIG_JSONL" \ + CHAIN_ID="$CHAIN_ID" \ + REPO_ROOT="$REPO_ROOT" \ + "$SCRIPT_DIR/migrations/build.sh" + CMD_ARGS+=(--migration-tx "$MIG_JSONL") +fi + +"$BIN" "${CMD_ARGS[@]}" + +# Print sha256 for cross-validator coordination. +if [[ -f "$OUTPUT" ]]; then + if command -v sha256sum >/dev/null 2>&1; then + SHA256=$(sha256sum "$OUTPUT" | cut -d' ' -f1) + elif command -v shasum >/dev/null 2>&1; then + SHA256=$(shasum -a 256 "$OUTPUT" | cut -d' ' -f1) + fi + printf "\nsha256: %s\n" "${SHA256:-}" +fi diff --git a/misc/deployments/gnoland-1/govdao-scripts/add-validator-from-valopers.sh b/misc/deployments/gnoland-1/govdao-scripts/add-validator-from-valopers.sh new file mode 100755 index 00000000000..31928940ece --- /dev/null +++ b/misc/deployments/gnoland-1/govdao-scripts/add-validator-from-valopers.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Add a validator from the r/gnops/valopers registry via govDAO proposal. +# +# Uses r/gnops/valopers/proposal.NewValidatorProposalRequest to look up the +# valoper profile on-chain and create a governance proposal, then votes YES +# and executes it immediately. +# +# Usage: +# ./add-validator-from-valopers.sh
+# +# Environment: +# GNOKEY_NAME - gnokey key name (default: moul) +# CHAIN_ID - chain ID (default: gnoland-1) +# REMOTE - RPC endpoint (default: https://rpc.betanet.testnets.gno.land:443) +# GAS_WANTED - gas limit (default: 50000000) +# GAS_FEE - gas fee (default: 1000000ugnot) +set -eo pipefail + +GNOKEY_NAME="${GNOKEY_NAME:-moul}" +CHAIN_ID="${CHAIN_ID:-gnoland-1}" +REMOTE="${REMOTE:-https://rpc.betanet.testnets.gno.land:443}" +GAS_WANTED="${GAS_WANTED:-50000000}" +GAS_FEE="${GAS_FEE:-1000000ugnot}" + +if [ $# -lt 1 ]; then + echo "Usage: $0
" + echo "" + echo "Looks up the valoper profile from r/gnops/valopers and creates a" + echo "govDAO proposal to add them to the validator set, votes YES, and" + echo "executes it." + echo "" + echo "The valoper must have registered at r/gnops/valopers first." + echo "" + echo "Example:" + echo " $0 g1abc...xyz" + exit 1 +fi + +ADDR="$1" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/add_from_valopers.gno" < [voting_power] +# +# Environment: +# GNOKEY_NAME - gnokey key name (default: moul) +# CHAIN_ID - chain ID (default: gnoland-1) +# REMOTE - RPC endpoint (default: https://rpc.betanet.testnets.gno.land:443) +# GAS_WANTED - gas limit (default: 50000000) +# GAS_FEE - gas fee (default: 1000000ugnot) +set -eo pipefail + +GNOKEY_NAME="${GNOKEY_NAME:-moul}" +CHAIN_ID="${CHAIN_ID:-gnoland-1}" +REMOTE="${REMOTE:-https://rpc.betanet.testnets.gno.land:443}" +GAS_WANTED="${GAS_WANTED:-50000000}" +GAS_FEE="${GAS_FEE:-1000000ugnot}" + +if [ $# -lt 2 ]; then + echo "Usage: $0
[voting_power]" + echo "" + echo "Example:" + echo " $0 g1abc...xyz gpub1pggj7... 1" + exit 1 +fi + +ADDR="$1" +PUB_KEY="$2" +POWER="${3:-1}" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/add_validator.gno" < +# +# Environment: +# GNOKEY_NAME - gnokey key name (default: moul) +# CHAIN_ID - chain ID (default: gnoland-1) +# REMOTE - RPC endpoint (default: https://rpc.betanet.testnets.gno.land:443) +# GAS_WANTED - gas limit (default: 50000000) +# GAS_FEE - gas fee (default: 1000000ugnot) +set -eo pipefail + +GNOKEY_NAME="${GNOKEY_NAME:-moul}" +CHAIN_ID="${CHAIN_ID:-gnoland-1}" +REMOTE="${REMOTE:-https://rpc.betanet.testnets.gno.land:443}" +GAS_WANTED="${GAS_WANTED:-50000000}" +GAS_FEE="${GAS_FEE:-1000000ugnot}" + +if [ $# -lt 1 ]; then + echo "Usage: $0
" + echo "" + echo "Example:" + echo " $0 g1abc...xyz" + exit 1 +fi + +ADDR="$1" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/rm_validator.gno" <&2 + exit 1 +fi + +GNOKEY_NAME="${GNOKEY_NAME:-moul}" +CHAIN_ID="${CHAIN_ID:-gnoland-1}" +REMOTE="${REMOTE:-https://rpc.betanet.testnets.gno.land:443}" +GAS_WANTED="${GAS_WANTED:-50000000}" +GAS_FEE="${GAS_FEE:-1000000ugnot}" + +# Build address list for the Gno code. +ADDR_ARGS="" +for addr in "$@"; do + ADDR_ARGS="${ADDR_ARGS} address(\"${addr}\"), +" +done + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/unrestrict.gno" < +# app_state.txs: full tx array with metadata preserved +# - Height AND timestamp are preserved (Jae's correctness requirement). +# - Validators independently run this script and compare genesis SHA-256 +# before restarting. +# +# Usage: +# ./migrate-from-gnoland1.sh --data-dir [--halt-height N] +# +# The script writes genesis.json (gnoland-1) to the current directory. +# +# Prerequisites: +# - gnoland1 must be fully halted at the governance-approved halt height +# - tx-archive (with genesis-assemble subcommand) must be installed +# See: https://github.com/gnolang/tx-archive +# - The new gnoland binary (>= gnoland-1) must be in PATH +# +# Dependencies (PRs that must be merged in the new binary): +# - #5334: halt_height config field (MERGED) +# - #5293: namereg GovDAO whitelist (MERGED) +# - #5375: new govdao-scripts (MERGED) +# - #5368: GovDAO-based halt height via r/sys/params (awaiting merge) +# - #5411: genesis replay with OriginalChainID (awaiting merge) +# - #5390: GnoTxMetadata block_height + chain_id extension (awaiting merge) +set -eo pipefail + +# ============================================================================= +# TODO: IMPLEMENT THIS MIGRATION SCRIPT +# +# This is the critical piece that makes the gnoland1 → gnoland-1 hard fork +# possible. Until it is written AND dry-run on test12, the hard fork CANNOT happen. +# +# The decisions below have been made (as of 2026-04-09): +# ✅ Scenario A chosen: genesis tx-replay with InitialHeight preservation +# ✅ Scenario B (height reset) deprioritized +# ✅ Chain ID: gnoland1 → gnoland-1 (one-time rename, agreed) +# ✅ Height + timestamp preservation required (Jae's correctness requirement) +# ✅ #5334, #5293, #5375 merged +# ⏳ #5368 (GovDAO halt) awaiting reviews +# ⏳ #5411 (genesis replay), #5390 (tx metadata) awaiting merge +# ⏳ #5377 (--migrate flag) may be superseded by #5411 approach +# +# Implementation steps: +# +# 1. HALT VERIFICATION +# Check the data dir to confirm gnoland1 stopped at the expected height. +# Read the committed block height from the blockstore and compare against +# the halt_height that was voted on via GovDAO (#5368). +# +# 2. TX EXPORT +# Run tx-archive backup against the halted gnoland1 data dir to produce +# a JSONL file with all successful txs, each including: +# - timestamp: Unix seconds of the block that included the tx +# - block_height: block number the tx ran at (#5390) +# - chain_id: "gnoland1" (#5390) +# Command (once tx-archive supports --data-dir mode): +# tx-archive backup \ +# --data-dir "$DATA_DIR" \ +# --output txs.jsonl +# +# 3. GENESIS ASSEMBLY +# Run tx-archive genesis-assemble to produce genesis.json: +# tx-archive genesis-assemble \ +# --input txs.jsonl \ +# --chain-id gnoland-1 \ +# --original-chain-id gnoland1 \ +# --initial-height $((HALT_HEIGHT + 1)) \ +# --output genesis.json +# The genesis will have: +# chain_id: "gnoland-1" +# initial_height: halt_height + 1 (Jae's InitialHeight tm2 port required) +# app_state.txs: full tx array with metadata +# app_state.original_chain_id: "gnoland1" (for sig verification) +# +# 4. VERIFICATION +# All validators independently run this script and compare: +# sha256sum genesis.json +# Hashes MUST match before anyone restarts. +# Optionally run gnoupgrade statediff for before/after comparison. +# +# 5. RESTART COORDINATION +# Validators restart with the new binary and the new genesis.json. +# The new binary must be >= chain/gnoland-1 tag. +# +# BLOCKERS (must be resolved before implementing): +# [ ] tx-archive genesis-assemble command (companion to #5411) +# [ ] tx-archive --data-dir mode for offline export from block store +# [ ] Jae's tm2 GenesisDoc.InitialHeight field (hard blocker for #5411) +# [ ] test12 dry-run: validate full halt → export → genesis-assemble → restart +# +# RELATED: +# Issue #5374: chain upgrade strategy meta-issue +# PR #5411: genesis replay mechanism (main hardfork PR) +# PR #5390: GnoTxMetadata block_height + chain_id extension +# PR #5368: GovDAO-based halt height via r/sys/params +# PR #5377: in-place block-replay --migrate flag (may complement #5411) +# PR #5369: gnoupgrade toolkit (replay/statediff/healthcheck) +# ============================================================================= + +echo "ERROR: migrate-from-gnoland1.sh is not yet implemented." +echo "" +echo "Blockers:" +echo " - tx-archive genesis-assemble command (companion to gnolang/gno#5411)" +echo " - Jae's tm2 GenesisDoc.InitialHeight field" +echo " - test12 dry-run of the full halt → export → genesis-assemble → restart flow" +echo "" +echo "Track progress at: https://github.com/gnolang/gno/issues/5374" +exit 1 diff --git a/misc/deployments/gnoland-1/migrations/01_reset_valset.gno.tmpl b/misc/deployments/gnoland-1/migrations/01_reset_valset.gno.tmpl new file mode 100644 index 00000000000..46e917dbe56 --- /dev/null +++ b/misc/deployments/gnoland-1/migrations/01_reset_valset.gno.tmpl @@ -0,0 +1,44 @@ +// 01_reset_valset.gno.tmpl — MsgRun body that runs AT THE END of the +// hardfork replay (post-history) to update r/sys/validators/v2 so the +// in-gno valset matches the new GenesisDoc.Validators. +// +// Why: tm2 consensus uses GenesisDoc.Validators (which `gnogenesis fork` +// rewrites in the output genesis), but r/sys/validators/v2 still holds +// the ORIGINAL gnoland1 valset from govdao_prop1. Without this migration, +// queries against the realm return stale data and any govDAO proposal +// that touches the valset via the realm would be working against a ghost +// of the pre-fork world. +// +// Placeholders (replaced by migrations/build.sh before signing): +// OLD_VALIDATORS_GO placeholder — zero-voting-power entries for each pre-fork validator +// NEW_VALIDATORS_GO placeholder — full entries for the post-fork validator(s) +// +// Caller: a govDAO T1 member (see build.sh CALLER env). Sig verify is +// skipped at genesis-replay via --skip-genesis-sig-verification. +package main + +import ( + "gno.land/p/sys/validators" + "gno.land/r/gov/dao" + valr "gno.land/r/sys/validators/v2" +) + +func main() { + req := valr.NewPropRequest( + func() []validators.Validator { + return []validators.Validator{ + // ---- remove pre-fork validators (voting_power=0 = remove) ---- + {{OLD_VALIDATORS_GO}} + // ---- add post-fork validators ---- + {{NEW_VALIDATORS_GO}} + } + }, + "Hardfork: reset validator set", + "Reconcile r/sys/validators/v2 with the new GenesisDoc.Validators "+ + "after the gnoland1 → gnoland-1 hardfork.", + ) + + id := dao.MustCreateProposal(cross, req) + dao.MustVoteOnProposal(cross, id, dao.YesVote) + dao.ExecuteProposal(cross, id) +} diff --git a/misc/deployments/gnoland-1/migrations/build.sh b/misc/deployments/gnoland-1/migrations/build.sh new file mode 100755 index 00000000000..3b222d89e9b --- /dev/null +++ b/misc/deployments/gnoland-1/migrations/build.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Synthesise the gnoland-1 hardfork migration jsonl from templates. +# +# Output: $OUT_JSONL (one amino-JSON TxWithMetadata per line). +# Passed to `gnogenesis fork generate --migration-tx $OUT_JSONL`. +# +# What it does +# ============ +# 1. Fills 01_reset_valset.gno.tmpl with: +# - OLD_VALIDATORS_GO = voting_power=0 entries for all INITIAL_VALSET +# entries of gnoland1 (removes them from +# r/sys/validators/v2) +# - NEW_VALIDATORS_GO = the single post-fork validator described by +# $NEW_VALSET_JSON (produced by hf-glue +# init-node.sh, or by a manual list for a +# coordinated hardfork) +# 2. Wraps the rendered .gno body in a MsgRun tx signed by any local key; +# the tx's `caller` field is set to $CALLER (a govDAO T1 member, e.g. +# g1manfred...) so the proposal executes as that member when +# --skip-genesis-sig-verification kicks in at replay. +# 3. Emits one `{tx: {...}}` per migration into $OUT_JSONL. +# +# Env +# === +# CALLER govDAO T1 member address (required) +# default: g1manfred47kzduec920z88wfr64ylksmdcedlf5 +# NEW_VALSET_JSON path to JSON with new validators, format: +# [{"address": "g1...", "pub_key": "gpub1...", +# "voting_power": 10, "name": "hf-local"}, ...] +# default: synthesised from a priv_validator_key.json if +# $PV_KEY is set. +# PV_KEY alternate: path to a priv_validator_key.json; if set +# and $NEW_VALSET_JSON is empty, a single-validator set +# is derived from it (power=10, name=hf-local). +# OUT_JSONL output path (default: ./migrations.jsonl) +# GNOKEY_BIN gnokey binary (auto-built if missing) +# REPO_ROOT repo root (auto-detected) +# +# Example +# ======= +# CALLER=g1manfred47kzduec920z88wfr64ylksmdcedlf5 \ +# PV_KEY=/path/to/priv_validator_key.json \ +# ./build.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${REPO_ROOT:-$(cd "$SCRIPT_DIR/../../../.." && pwd)}" +OUT_JSONL="${OUT_JSONL:-$SCRIPT_DIR/migrations.jsonl}" +CALLER="${CALLER:-g1manfred47kzduec920z88wfr64ylksmdcedlf5}" +CHAIN_ID="${CHAIN_ID:-gnoland-1}" + +# Initial gnoland1 valset (mirrors INITIAL_VALSET in +# misc/deployments/gnoland1/gen-genesis.sh). All seven are removed by this +# migration — add to this list if the source chain's valset changed post- +# genesis and should also be removed. +OLD_ADDRS=( + "g1vta7dwp4guuhkfzksenfcheky4xf9hue8mgne4" + "g1d5hh9fw3l00gugfzafskaxqlmsyvxfaj6l2q60" + "g1uhv7wr7nku89se3t7v8fpquc7n5sf8rfkywxpc" + "g10jdd8vlgydfypynrk23ul90jnsg5twrtvmcmh4" + "g1eueypc9w524ctda3y0kwd4jruw5p4zqpjna0jq" + "g1kn7p0wqumvqlcqzhkwnavkhf0z4qnr73ltwsae" + "g10j90aqjv6uju3dksq8m08s6u47x59glkdxqzm2" +) + +# ---- resolve GNOKEY_BIN ---- +if [[ -z "${GNOKEY_BIN:-}" ]]; then + if command -v gnokey >/dev/null 2>&1; then + GNOKEY_BIN="gnokey" + else + GNOKEY_BIN="$REPO_ROOT/contribs/gnogenesis/genesis-work/bin/gnokey" + [[ -x "$GNOKEY_BIN" ]] || { + mkdir -p "$(dirname "$GNOKEY_BIN")" + go build -C "$REPO_ROOT/gno.land/cmd/gnokey" -o "$GNOKEY_BIN" . + } + fi +fi + +# ---- assemble NEW_VALSET_JSON from PV_KEY if needed ---- +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +if [[ -z "${NEW_VALSET_JSON:-}" ]]; then + : "${PV_KEY:?either NEW_VALSET_JSON or PV_KEY is required}" + # r/sys/validators/v2 wants the bech32 (gpub1...) pubkey — priv_validator_key.json + # stores the raw base64 under pub_key.value. Use `gnoland secrets get` to convert. + SECRETS_DIR="$(dirname "$PV_KEY")" + BECH_PUBKEY="$(go run -C "$REPO_ROOT" ./gno.land/cmd/gnoland secrets get validator_key.pub_key --raw -data-dir "$SECRETS_DIR" | tail -n 1 | tr -d '[:space:]')" + [[ "$BECH_PUBKEY" == gpub1* ]] || { echo "ERROR: failed to derive bech32 pubkey from $PV_KEY (got: $BECH_PUBKEY)" >&2; exit 1; } + ADDR="$(jq -r '.address' "$PV_KEY")" + NEW_VALSET_JSON="$WORK/new_valset.json" + jq -n --arg addr "$ADDR" --arg pub "$BECH_PUBKEY" '[{ + address: $addr, + pub_key: $pub, + voting_power: 10, + name: "hf-local" + }]' > "$NEW_VALSET_JSON" +fi + +# ---- render template ---- +OLD_GO="" +for a in "${OLD_ADDRS[@]}"; do + OLD_GO+="{Address: \"$a\", VotingPower: 0},"$'\n\t\t\t\t' +done + +NEW_GO=$(jq -r '.[] | "{Address: \"\(.address)\", PubKey: \"\(.pub_key)\", VotingPower: \(.voting_power)},"' "$NEW_VALSET_JSON" | awk 'BEGIN{ORS="\n\t\t\t\t"}{print}') + +RENDERED="$WORK/01_reset_valset.gno" +# awk-based substitution (BSD sed can't handle newlines in replacement). +OLD_GO="$OLD_GO" NEW_GO="$NEW_GO" awk ' + { gsub(/\{\{OLD_VALIDATORS_GO\}\}/, ENVIRON["OLD_GO"]) + gsub(/\{\{NEW_VALIDATORS_GO\}\}/, ENVIRON["NEW_GO"]) + print } +' "$SCRIPT_DIR/01_reset_valset.gno.tmpl" > "$RENDERED" + +# ---- build the MsgRun tx ---- +# We use any local ephemeral key to sign; only the serialized form matters +# because --skip-genesis-sig-verification is on at replay. The msg's +# `caller` field is what the VM reads (runtime.OriginCaller), so we patch +# it to $CALLER after maketx. +GK_HOME="$WORK/gnokey-home" +mkdir -p "$GK_HOME" +EPHEMERAL_MNEMONIC="source bonus chronic canvas draft south burst lottery vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast" +# stdin order for `gnokey add --recover --insecure-password-stdin`: +# 1. passphrase (empty line = no passphrase, no confirm prompt) +# 2. mnemonic +printf '\n%s\n' "$EPHEMERAL_MNEMONIC" | \ + "$GNOKEY_BIN" add --recover --insecure-password-stdin --home "$GK_HOME" ephemeral >/dev/null + +TX_JSON="$WORK/tx.json" +"$GNOKEY_BIN" maketx run \ + --gas-wanted 100000000 \ + --gas-fee 1ugnot \ + --chainid "$CHAIN_ID" \ + --home "$GK_HOME" \ + ephemeral \ + "$RENDERED" > "$TX_JSON" + +# Patch the caller field so the MsgRun executes as $CALLER (not ephemeral). +jq --arg caller "$CALLER" '.msg[0].caller = $caller' "$TX_JSON" > "$TX_JSON.patched" +mv "$TX_JSON.patched" "$TX_JSON" + +# Sign (bogus sig, skipped at replay — but the tx format requires one). +echo "" | "$GNOKEY_BIN" sign \ + --tx-path "$TX_JSON" \ + --chainid "$CHAIN_ID" \ + --account-number 0 \ + --account-sequence 0 \ + --home "$GK_HOME" \ + --insecure-password-stdin \ + ephemeral >/dev/null + +# Wrap as {tx: {...}} — TxWithMetadata accepts this with empty metadata; +# BlockHeight is forced to 0 by gnogenesis readMigrationTxs. +jq -c '{tx: .}' "$TX_JSON" > "$OUT_JSONL" + +printf ' migration: %s (caller=%s)\n' "$(basename "$RENDERED")" "$CALLER" +printf ' written: %s\n' "$OUT_JSONL" diff --git a/misc/hf-glue/.gitignore b/misc/hf-glue/.gitignore new file mode 100644 index 00000000000..89f9ac04aac --- /dev/null +++ b/misc/hf-glue/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/misc/hf-glue/Makefile b/misc/hf-glue/Makefile new file mode 100644 index 00000000000..b0741cd3f8d --- /dev/null +++ b/misc/hf-glue/Makefile @@ -0,0 +1,128 @@ +# misc/hf-glue — HIGHLY EXPERIMENTAL hardfork testbed +# +# See README.md. Do not merge. + +SHELL := bash + +# ---- configurable ---------------------------------------------------------- +SOURCE ?= https://github.com/gnolang/gno/releases/download/chain/gnoland1.0/genesis.json +RPC_URL ?= https://rpc.gno.land +ORIGINAL_CHAIN_ID ?= gnoland1 +CHAIN_ID ?= gnoland-1 +HALT_HEIGHT ?= +VALIDATOR_NAME ?= hf-glue-local +# Only used by `make fetch-from-dir` (alternative local-source flow). +NODE_DIR ?= +TXS_JSONL ?= +# Extra realm patches applied at hardfork-assembly time (see scripts/migrate.sh). +# Space-separated PKGPATH=SRCDIR entries. Set to empty to disable. +# (migrate.sh always patches r/sys/params from examples/; use this for more.) +PATCH_REALMS ?= + +export SOURCE RPC_URL ORIGINAL_CHAIN_ID CHAIN_ID HALT_HEIGHT VALIDATOR_NAME NODE_DIR TXS_JSONL PATCH_REALMS + +# ---- paths ----------------------------------------------------------------- +HERE := $(abspath .) +REPO := $(abspath ../..) +OUT := $(HERE)/out + +.DEFAULT_GOAL := help + +.PHONY: help +help: ## show this help + @printf "misc/hf-glue — experimental hardfork testbed\n\n" + @awk 'BEGIN {FS=":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +.PHONY: gen-local-genesis +gen-local-genesis: ## rebuild gnoland1 base genesis locally (runs misc/deployments/gnoland1/gen-genesis.sh); output at out/source-genesis.json + @mkdir -p $(OUT) + @OUT=$(OUT) REPO=$(REPO) $(HERE)/scripts/gen-local-genesis.sh + +.PHONY: fetch migrate +fetch: migrate ## alias for `make migrate` (kept for back-compat) + +migrate: ## run scripts/migrate.sh — declarative hardfork recipe (fetch + patch + assemble) + @mkdir -p $(OUT) + @SOURCE=$(SOURCE) \ + RPC_URL=$(RPC_URL) \ + ORIGINAL_CHAIN_ID=$(ORIGINAL_CHAIN_ID) \ + CHAIN_ID=$(CHAIN_ID) \ + HALT_HEIGHT=$(HALT_HEIGHT) \ + PATCH_REALMS='$(PATCH_REALMS)' \ + OUT=$(OUT) REPO=$(REPO) \ + $(HERE)/scripts/migrate.sh + +.PHONY: fetch-from-dir +fetch-from-dir: ## (alt) build hardfork genesis from a local gnoland data dir ($NODE_DIR, $TXS_JSONL, $HALT_HEIGHT required) + @mkdir -p $(OUT) + @NODE_DIR=$(NODE_DIR) TXS_JSONL=$(TXS_JSONL) \ + ORIGINAL_CHAIN_ID=$(ORIGINAL_CHAIN_ID) \ + CHAIN_ID=$(CHAIN_ID) \ + HALT_HEIGHT=$(HALT_HEIGHT) \ + OUT=$(OUT) REPO=$(REPO) \ + $(HERE)/scripts/fetch-from-dir.sh + +.PHONY: init +init: ## generate single-validator secrets and patch out/genesis.json + @mkdir -p $(OUT) + @VALIDATOR_NAME=$(VALIDATOR_NAME) \ + OUT=$(OUT) REPO=$(REPO) \ + $(HERE)/scripts/init-node.sh + +.PHONY: up +up: ## start the gnoland node in docker (requires fetch+init first) + @test -f $(OUT)/genesis.json || { echo "missing out/genesis.json — run 'make fetch init' first"; exit 1; } + @test -f $(OUT)/gnoland-home/secrets/priv_validator_key.json || { echo "missing secrets — run 'make init' first"; exit 1; } + docker compose up -d --build + @echo "" + @echo "Node RPC: http://localhost:26657" + @echo "Chain ID: $(CHAIN_ID)" + @echo "Tail logs: make logs" + +.PHONY: down +down: ## stop the node (keep state) + docker compose down + +.PHONY: reset-db +reset-db: down ## wipe node db + WAL + last-signed state, keep genesis + keys + rm -rf $(OUT)/gnoland-home/db $(OUT)/gnoland-home/wal $(OUT)/gnoland-home/data + printf '{"height":"0","round":"0","step":0}\n' > $(OUT)/gnoland-home/secrets/priv_validator_state.json + @echo "db wiped; next 'make up' will re-replay genesis from scratch" + +.PHONY: logs +logs: ## tail node logs + docker compose logs -f --tail=200 + +.PHONY: status +status: ## show node /status via RPC + @curl -s http://localhost:26657/status | jq . || true + +.PHONY: reset +reset: down ## stop and wipe ALL generated state (genesis, keys, db) + rm -rf $(OUT) + @echo "out/ removed." + +.PHONY: smoketest +smoketest: ## run 'hardfork test' in-memory against out/genesis.json + @test -f $(OUT)/genesis.json || { echo "missing out/genesis.json — run 'make fetch' first"; exit 1; } + cd $(REPO)/misc/hardfork && go run . test --genesis $(OUT)/genesis.json --verbose + +.PHONY: replay-log +replay-log: ## run in-process genesis replay, tee full log to out/replay.log + @$(HERE)/scripts/replay-log.sh + +.PHONY: report-replay +report-replay: ## generate out/REPLAY-REPORT.md from out/replay.log + @$(HERE)/scripts/report-replay.sh + +.PHONY: check-state +check-state: ## probe running node, compare to gno.land, write out/STATE-REPORT.md + @$(HERE)/scripts/check-state.sh + +.PHONY: reports +reports: replay-log report-replay check-state ## full reporting pipeline (replay + compare + write) + @echo "" + @echo "Reports:" + @echo " $(OUT)/replay.log" + @echo " $(OUT)/REPLAY-REPORT.md" + @echo " $(OUT)/STATE-REPORT.md" diff --git a/misc/hf-glue/README.md b/misc/hf-glue/README.md new file mode 100644 index 00000000000..c38509cf8d8 --- /dev/null +++ b/misc/hf-glue/README.md @@ -0,0 +1,216 @@ +# misc/hf-glue — HIGHLY EXPERIMENTAL hardfork testbed + +> ⚠️ **DO NOT MERGE. DO NOT USE IN PRODUCTION.** ⚠️ +> +> Throwaway integration harness for the hardfork-replay mechanism. +> Depends on (now split into) these PRs: +> +> - [#5511](https://github.com/gnolang/gno/pull/5511) `feat/genesis-replay-upgrade3` — replay engine: `PastChainIDs`, `GnoTxMetadata.{BlockHeight,ChainID,Failed,SignerInfo}`, `InitialHeight` +> - [#5540](https://github.com/gnolang/gno/pull/5540) hardfork-replay improvements — `tm2/sdk` `InitialHeight > 1` fixes, genesis-mode `PastChainIDs[0]` override, `hardfork --patch-realm` +> - [#5533](https://github.com/gnolang/gno/pull/5533) `contribs/tx-archive` — hardfork-replay metadata, `SignerInfo` brute-force resolver, progress log, gas-replay report +> - [#5376](https://github.com/gnolang/gno/pull/5376) gnoland-1 chain config +> - [#5368](https://github.com/gnolang/gno/pull/5368) govDAO halt-height — fuels the `--patch-realm` demo +> +> Findings land in the PRs above, **not** on this branch. + +## What this gives you + +One command pulls a full source chain and replays it into a single-validator +fork that runs in Docker, serves RPC + gnoweb, and can optionally ship realm +upgrades inside the fork (via `--patch-realm`). + +``` +┌──────────────┐ 1) base genesis ┌───────────────────────────────┐ +│ GitHub │ ─────────────────►│ │ +│ release │ │ misc/hardfork: │ 3) hardfork +│ gnoland1.0 │ │ assemble genesis.json │ ───────────►┌──────────┐ +└──────────────┘ │ + PastChainIDs │ │ Docker │ + │ + InitialHeight │ │ gnoland │ +┌──────────────┐ 2) historical │ + SignerInfo per tx │ │ (single │ +│ rpc.gno.land │ ─ txs (batched) ─►│ + single local validator │ │ validator│ +│ (gnoland1) │ contribs/ │ + optional --patch-realm │ │ + gnoweb)│ +└──────────────┘ tx-archive └───────────────────────────────┘ └──────────┘ + │ + ▼ + http://localhost:26657 (RPC) + http://localhost:8888 (gnoweb) +``` + +End-to-end tested against gnoland1 (halt @ 704052): 2 637 historical txs, 192 MB +output genesis, **0 / 2715 tx failures** on replay, 1:1 render parity vs. prod +gno.land for sampled realms. + +## Requirements + +- Go 1.24+ (for building `hardfork` / `tx-archive` locally) +- Docker + `docker compose` +- `jq`, `curl`, `bash` + +## Quick start + +```bash +cd misc/hf-glue + +# 1. Pull base genesis (GitHub release by default) + all historical txs from RPC +# → out/source/config/genesis.json + out/source/txs.jsonl +# Then assemble the hardfork genesis → out/genesis.json +make fetch # ~12 min on full gnoland1 + +# 2. Generate a fresh single-validator identity and patch the genesis to use it. +# Also writes a config.toml binding RPC + p2p to 0.0.0.0. +make init + +# 3. Start the node + gnoweb in Docker. +make up + +# RPC at http://localhost:26657 +# gnoweb at http://localhost:8888 + +# Tail logs +make logs + +# Post txs against the fork from another terminal +gnokey maketx ... -remote http://localhost:26657 -chainid gnoland-1 + +# Stop but keep state +make down + +# Wipe db + WAL + last-signed state (keeps genesis + keys — lets you re-replay) +make reset-db + +# Nuclear reset (wipe everything, including out/) +make reset +``` + +### Picking a different source chain + +Everything is env-driven: + +```bash +SOURCE=http://rpc.test11.testnets.gno.land:443 \ +RPC_URL=http://rpc.test11.testnets.gno.land:443 \ +ORIGINAL_CHAIN_ID=test11 \ +CHAIN_ID=test11-hf \ +make fetch init up +``` + +| Variable | Default | Meaning | +|---|---|---| +| `SOURCE` | `https://github.com/gnolang/gno/releases/download/chain/gnoland1.0/genesis.json` | Base genesis. Direct `.json` URL, RPC endpoint (`/genesis` is fetched + unwrapped), or local file. | +| `RPC_URL` | `https://rpc.gno.land` | RPC endpoint used by `tx-archive` to pull historical blocks. | +| `ORIGINAL_CHAIN_ID` | `gnoland1` | Source chain ID — goes into `PastChainIDs` so historical tx sigs verify. | +| `CHAIN_ID` | `gnoland-1` | New chain ID after the fork. | +| `HALT_HEIGHT` | *(auto)* | Block to stop pulling at. Empty = RPC's current latest at start time. | +| `VALIDATOR_NAME` | `hf-glue-local` | Name baked into the single-validator entry in the genesis. | +| `PATCH_REALMS` | `gno.land/r/sys/params=$REPO/examples/gno.land/r/sys/params` | Space-separated `PKGPATH=SRCDIR` entries. Rewrites matching genesis-mode addpkg txs in-memory with files from the given dir — lets you deliver realm upgrades as part of the fork. Empty to disable. | +| `NODE_DIR`, `TXS_JSONL` | *(unset)* | Only used by `make fetch-from-dir` — local data dir alternative to the RPC pull. | + +## Delivering a realm upgrade inside the fork + +Default `PATCH_REALMS` swaps `r/sys/params` with the repo's examples copy. After +merging [#5368](https://github.com/gnolang/gno/pull/5368), that copy gains +`halt.gno` with `NewSetHaltRequest` — so the fork boots with the govDAO halt +mechanism available, without the realm ever having been redeployed on-chain: + +``` +$ curl -sG "http://localhost:26657/abci_query?path=%22vm%2Fqfile%22" \ + --data-urlencode "data=gno.land/r/sys/params" \ + | jq -r '.result.response.ResponseBase.Data' | base64 -d +fee_collector.gno +gnomod.toml +halt.gno ← added by --patch-realm +params.gno +unlock.gno +``` + +The source genesis on disk stays pristine — patches are applied only during +hardfork assembly, in memory. + +## Make targets + +| target | what | +|---|---| +| `make fetch` | Pull base genesis + all blocks, assemble `out/genesis.json` | +| `make fetch-from-dir` | Alt: assemble from a local gnoland data dir (requires `NODE_DIR` + `TXS_JSONL`) | +| `make init` | Generate validator secrets + `config.toml` + patch genesis to single validator | +| `make up` | Docker compose up (gnoland + gnoweb) | +| `make down` | Stop containers, keep state | +| `make logs` | Tail `gnoland` logs | +| `make status` | Print `/status` JSON | +| `make reset-db` | Wipe DB + WAL + last-signed state (lets you re-replay without nuking keys) | +| `make reset` | Nuclear — wipe all of `out/` | +| `make smoketest` | In-memory replay via `hardfork test --verbose` (no Docker, no persisted state) | +| `make replay-log` | Same as smoketest, tee full log to `out/replay.log` + summary | +| `make report-replay` | Build categorized `out/REPLAY-REPORT.md` from replay log | +| `make check-state` | Compare running node vs `gno.land` prod, write `out/STATE-REPORT.md` | +| `make reports` | Full pipeline: `replay-log` + `report-replay` + `check-state` | +| `make gen-local-genesis` | (Alternative) Rebuild gnoland1 base genesis locally via `misc/deployments/gnoland1/gen-genesis.sh` instead of downloading | + +## Files + +| path | purpose | +|---|---| +| `Makefile` | Entrypoint targets above | +| `docker-compose.yml` | `gnoland` + `gnoweb` services, Dockerfile `target=all` image | +| `scripts/fetch.sh` | 3-stage: download genesis, run `tx-archive backup`, assemble with `hardfork genesis` | +| `scripts/fetch-from-dir.sh` | Local-dir alternative (no RPC) | +| `scripts/init-node.sh` | `gnoland secrets init` + `config init` + rewrite validator via `fixvalidator` | +| `scripts/gen-local-genesis.sh` | Calls `misc/deployments/gnoland1/gen-genesis.sh` if you want to rebuild rather than download | +| `scripts/replay-log.sh` | In-process replay + log capture | +| `scripts/report-replay.sh` | Build `REPLAY-REPORT.md` from log | +| `scripts/check-state.sh` | Local vs prod comparison report | +| `fixvalidator/` | Tiny Go helper that overwrites the genesis validator set with a single local key | +| `out/` | *(gitignored)* all generated artifacts — genesis, secrets, node data, reports | + +## Status (halt @ 704052, full gnoland1 chain) + +What's been validated end-to-end on this branch: + +- [x] Account numbers / sequences preserved via `SignerInfo` brute-force resolver +- [x] Historical tx signatures verify via `PastChainIDs` allowlist +- [x] `InitialHeight > 1` handled across consensus, state, store, SDK (`BaseApp.validateHeight`, `BaseApp.Info`, `saveState`) +- [x] First block produced at `InitialHeight` exactly (704053) +- [x] Node restarts from persisted state cleanly +- [x] Genesis-mode + historical tx replay: **0 / 2715 failures** (one unrelated `r/sys/txfees` storage-deposit failure not caused by the replay itself) +- [x] Chain-ID switch `gnoland1` → `gnoland-1` verified on `/status` + on every historical-tx sig verification +- [x] Realm parity vs. prod: `r/sys/names`, `r/sys/users`, `r/gov/dao` (+`:proposals`), `r/gnoland/blog`, `r/gnoland/coins`, `r/gnoland/wugnot` all ✅ +- [x] Manfred's account: `account_num=3096261`, `sequence=31` — matches production exactly +- [x] Delivering a realm upgrade as part of the fork via `--patch-realm` (demo: `r/sys/params` gains `halt.gno` from [#5368](https://github.com/gnolang/gno/pull/5368)) +- [x] `contribs/tx-archive` pulls the full chain in ~12 min with progress logging + +Things the testbed does **not** cover: + +- Multi-validator scenarios (we run a single local validator) +- `--skip-failing-genesis-txs` is still enabled; a few gnogenesis txs with + `msg.Creator ≠ signing key` fail the pubkey-address check and are skipped. + Production gnoland1 uses the same flag for the same reason. +- Parameter-set preservation (e.g. `valoper` gas-fee=0) is not explicitly + asserted — relies on `app_state` carrying over. + +## Relation to `hardfork test` + +`hardfork test` (in `misc/hardfork`) does an in-memory smoke-test — node runs, +replays in RAM, exits. Perfect for CI. **This testbed is the opposite**: +persistent disk state, real Docker node, keeps running, accepts txs, exposes +gnoweb — meant for a human to poke at. + +## Reproducing end-to-end + +```bash +cd misc/hf-glue +make fetch && make init && make up + +# Wait ~30s for replay to finish and the first block to commit. +curl -s http://localhost:26657/status | jq '.result.sync_info.latest_block_height' +# → 704053+ + +# Verify the patched realm +curl -sG "http://localhost:26657/abci_query?path=%22vm%2Fqfile%22" \ + --data-urlencode "data=gno.land/r/sys/params" \ + | jq -r '.result.response.ResponseBase.Data' | base64 -d | grep halt.gno +# → halt.gno + +# Generate reports +make reports +ls -la out/*.md +``` diff --git a/misc/hf-glue/docker-compose.yml b/misc/hf-glue/docker-compose.yml new file mode 100644 index 00000000000..92455c224c5 --- /dev/null +++ b/misc/hf-glue/docker-compose.yml @@ -0,0 +1,55 @@ +# misc/hf-glue — HIGHLY EXPERIMENTAL hardfork testbed. +# Runs a single-validator gnoland node replaying a hardforked genesis. +# +# Prereqs (run from misc/hf-glue): +# make fetch init +# Then: +# make up + +services: + gnoland: + container_name: hf-glue-node + image: hf-glue-gnoland:latest + build: + context: ../.. + dockerfile: Dockerfile + target: all + restart: unless-stopped + command: + - start + - --lazy=true + # Genesis-mode txs (BlockHeight==0) from gnogenesis are signed by the + # deployer key while msg.Creator is a different address (manfred, etc). + # The ante handler's pubkey-address check rejects these. Production + # gnoland1 uses the same flag; historical txs (BlockHeight>0) are still + # signature-verified via PastChainIDs. + - --skip-genesis-sig-verification + - --skip-failing-genesis-txs + - --data-dir=/gnoroot/gnoland-data + - --genesis=/gnoroot/gnoland-data/genesis.json + environment: + GNOROOT: /gnoroot + volumes: + - ./out/gnoland-home:/gnoroot/gnoland-data + entrypoint: ["/usr/bin/gnoland"] + ports: + - "26656:26656" # p2p + - "26657:26657" # rpc + stop_grace_period: 30s + + gnoweb: + container_name: hf-glue-gnoweb + image: hf-glue-gnoland:latest + depends_on: + - gnoland + restart: unless-stopped + command: + - -remote=http://gnoland:26657 + - -chainid=${CHAIN_ID:-gnoland-1} + - -help-chainid=${CHAIN_ID:-gnoland-1} + - -help-remote=http://127.0.0.1:26657 + - -bind=0.0.0.0:8888 + entrypoint: ["/usr/bin/gnoweb"] + ports: + - "8888:8888" + stop_grace_period: 10s diff --git a/misc/hf-glue/fixvalidator/main.go b/misc/hf-glue/fixvalidator/main.go new file mode 100644 index 00000000000..4061dd1619b --- /dev/null +++ b/misc/hf-glue/fixvalidator/main.go @@ -0,0 +1,84 @@ +// fixvalidator rewrites the validator set in a gnoland genesis.json to a +// single validator loaded from a priv_validator_key.json file. +// +// Usage: +// +// fixvalidator --priv-key --genesis [--name NAME] [--power N] +// +// This is testbed glue (misc/hf-glue). Not intended to be installed. +package main + +import ( + "flag" + "fmt" + "os" + + _ "github.com/gnolang/gno/gno.land/pkg/gnoland" // register GnoGenesisState amino type + "github.com/gnolang/gno/tm2/pkg/amino" + signer "github.com/gnolang/gno/tm2/pkg/bft/privval/signer/local" + bft "github.com/gnolang/gno/tm2/pkg/bft/types" +) + +func main() { + var ( + privPath string + genesisPath string + name string + power int64 + ) + + flag.StringVar(&privPath, "priv-key", "", "path to priv_validator_key.json") + flag.StringVar(&genesisPath, "genesis", "", "path to genesis.json to rewrite in place") + flag.StringVar(&name, "name", "hf-glue-local", "validator name") + flag.Int64Var(&power, "power", 10, "validator voting power") + flag.Parse() + + if privPath == "" || genesisPath == "" { + fmt.Fprintln(os.Stderr, "both --priv-key and --genesis are required") + os.Exit(2) + } + + if err := run(privPath, genesisPath, name, power); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func run(privPath, genesisPath, name string, power int64) error { + pv, err := signer.LoadFileKey(privPath) + if err != nil { + return fmt.Errorf("load priv key: %w", err) + } + + genDoc, err := bft.GenesisDocFromFile(genesisPath) + if err != nil { + return fmt.Errorf("load genesis: %w", err) + } + + oldCount := len(genDoc.Validators) + genDoc.Validators = []bft.GenesisValidator{{ + Address: pv.Address, + PubKey: pv.PubKey, + Power: power, + Name: name, + }} + + if err := genDoc.ValidateAndComplete(); err != nil { + return fmt.Errorf("validate genesis after rewrite: %w", err) + } + + data, err := amino.MarshalJSONIndent(genDoc, "", " ") + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + if err := os.WriteFile(genesisPath, data, 0o644); err != nil { + return fmt.Errorf("write genesis: %w", err) + } + + fmt.Printf("replaced %d validator(s) with single validator:\n", oldCount) + fmt.Printf(" address: %s\n", pv.Address.String()) + fmt.Printf(" pubkey: %s\n", pv.PubKey.String()) + fmt.Printf(" name: %s\n", name) + fmt.Printf(" power: %d\n", power) + return nil +} diff --git a/misc/hf-glue/scripts/check-state.sh b/misc/hf-glue/scripts/check-state.sh new file mode 100755 index 00000000000..38d7facd76d --- /dev/null +++ b/misc/hf-glue/scripts/check-state.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# Probe the running hardfork node, compare against live gno.land, +# write a STATE-REPORT.md with findings. +# +# Usage: ./scripts/check-state.sh [address] +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="$HERE/out" +REPORT="$OUT/STATE-REPORT.md" +mkdir -p "$OUT" + +LOCAL_RPC="${LOCAL_RPC:-http://127.0.0.1:26657}" +LOCAL_WEB="${LOCAL_WEB:-http://127.0.0.1:8888}" +PROD_RPC="${PROD_RPC:-https://rpc.gno.land}" +PROD_WEB="${PROD_WEB:-https://gno.land}" +ADDRESS="${1:-g1manfred47kzduec920z88wfr64ylksmdcedlf5}" + +b64() { printf '%s' "$1" | base64 | tr -d '\n'; } + +# Query vm/qrender: prints one of +# "OK:" +# "ERR:" +# "UNREACHABLE" +qrender() { + local rpc="$1" path="$2" + local data resp + data=$(b64 "$path") + resp=$(curl -sS --max-time 10 "${rpc}/abci_query?path=%22vm%2Fqrender%22&data=${data}" 2>/dev/null || echo "") + if [[ -z "$resp" ]]; then + echo "UNREACHABLE" + return + fi + local err + err=$(printf '%s' "$resp" | jq -r '.result.response.ResponseBase.Error."@type" // empty' 2>/dev/null || echo "") + if [[ -n "$err" ]]; then + echo "ERR:$err" + return + fi + local data_out + data_out=$(printf '%s' "$resp" | jq -r '.result.response.ResponseBase.Data // empty' 2>/dev/null || echo "") + local preview + preview=$(printf '%s' "$data_out" | tr -d '\n' | head -c 80) + echo "OK:${preview}" +} + +# Query chain status as JSON +chain_status() { + local rpc="$1" + curl -sS --max-time 5 "${rpc}/status" 2>/dev/null \ + | jq -c '{chain_id: .result.node_info.network, latest_block: .result.sync_info.latest_block_height, catching_up: .result.sync_info.catching_up}' \ + 2>/dev/null || echo '"unreachable"' +} + +# Query account balance via auth/accounts +account_info() { + local rpc="$1" addr="$2" + local resp data + resp=$(curl -sS --max-time 10 "${rpc}/abci_query?path=%22auth%2Faccounts%2F${addr}%22" 2>/dev/null || echo "") + data=$(printf '%s' "$resp" | jq -r '.result.response.ResponseBase.Data // empty' 2>/dev/null || echo "") + if [[ -z "$data" || "$data" == "null" ]]; then + echo "(no account)" + return + fi + printf '%s' "$data" | base64 -d 2>/dev/null | jq -c '.BaseAccount | {coins, account_number, sequence}' 2>/dev/null || echo "(decode failed)" +} + +# ---- start report ---------------------------------------------------------- +{ + echo "# Hardfork State Report" + echo "" + echo "_Generated $(date -u +%Y-%m-%dT%H:%M:%SZ)_" + echo "" + echo "- **Local**: $LOCAL_RPC (gnoweb: $LOCAL_WEB)" + echo "- **Prod**: $PROD_RPC (gnoweb: $PROD_WEB)" + echo "- **Address under test**: \`$ADDRESS\`" + echo "" + echo "## Chain status" + echo "" + echo "| Chain | Status |" + echo "|-------|--------|" + echo "| Local | \`$(chain_status "$LOCAL_RPC")\` |" + echo "| Prod | \`$(chain_status "$PROD_RPC")\` |" + echo "" + echo "## Expected realms (should exist after hardfork)" + echo "" + echo "| Realm | Local | Prod |" + echo "|-------|-------|------|" + for realm in \ + "gno.land/r/sys/params:" \ + "gno.land/r/sys/names:" \ + "gno.land/r/sys/users:" \ + "gno.land/r/gov/dao:" \ + "gno.land/r/gov/dao:proposals" \ + "gno.land/r/gnoland/home:" \ + "gno.land/r/gnoland/blog:" \ + "gno.land/r/gnoland/valopers:" \ + "gno.land/r/gnoland/coins:" \ + "gno.land/r/gnoland/wugnot:" \ + ; do + l=$(qrender "$LOCAL_RPC" "$realm") + p=$(qrender "$PROD_RPC" "$realm") + # collapse to status icon + lt="❌ $l"; [[ $l == OK:* ]] && lt="✅" + pt="❌ $p"; [[ $p == OK:* ]] && pt="✅" + echo "| \`$realm\` | $lt | $pt |" + done + echo "" + echo "## Bank balance — \`$ADDRESS\`" + echo "" + echo "| Chain | auth/accounts | r/gnoland/coins:balances |" + echo "|-------|---------------|--------------------------|" + la=$(account_info "$LOCAL_RPC" "$ADDRESS") + pa=$(account_info "$PROD_RPC" "$ADDRESS") + lc=$(qrender "$LOCAL_RPC" "gno.land/r/gnoland/coins:balances?address=${ADDRESS}&coin" | head -c 120) + pc=$(qrender "$PROD_RPC" "gno.land/r/gnoland/coins:balances?address=${ADDRESS}&coin" | head -c 120) + echo "| Local | \`$la\` | \`$lc\` |" + echo "| Prod | \`$pa\` | \`$pc\` |" + echo "" + echo "## Gas / consensus params" + echo "" + echo "### Local consensus" + echo '```json' + curl -sS --max-time 5 "$LOCAL_RPC/consensus_params" 2>/dev/null \ + | jq '.result.consensus_params.Block' 2>/dev/null || echo "(unreachable)" + echo '```' + echo "" + echo "### Prod consensus" + echo '```json' + curl -sS --max-time 5 "$PROD_RPC/consensus_params" 2>/dev/null \ + | jq '.result.consensus_params.Block' 2>/dev/null || echo "(unreachable)" + echo '```' + echo "" + echo "### Local gas price" + echo '```json' + curl -sS --max-time 10 "$LOCAL_RPC/abci_query?path=%22auth%2Fgasprice%22" 2>/dev/null \ + | jq -r '.result.response.ResponseBase.Data // empty' \ + | base64 -d 2>/dev/null \ + | jq '.' 2>/dev/null || echo "(no data)" + echo '```' + echo "" + echo "### Prod gas price" + echo '```json' + curl -sS --max-time 10 "$PROD_RPC/abci_query?path=%22auth%2Fgasprice%22" 2>/dev/null \ + | jq -r '.result.response.ResponseBase.Data // empty' \ + | base64 -d 2>/dev/null \ + | jq '.' 2>/dev/null || echo "(no data)" + echo '```' + echo "" + echo "## Visual comparison (open these side-by-side)" + echo "" + echo "| Page | Local | Prod |" + echo "|------|-------|------|" + for p in "r/gov/dao" "r/gnoland/blog" "r/gnoland/home" "r/sys/params" "r/gnoland/coins:balances?address=${ADDRESS}&coin"; do + echo "| \`${p}\` | [$LOCAL_WEB/$p]($LOCAL_WEB/$p) | [$PROD_WEB/$p]($PROD_WEB/$p) |" + done + echo "" +} > "$REPORT" + +echo "Report written to: $REPORT" +echo "" +cat "$REPORT" diff --git a/misc/hf-glue/scripts/fetch-from-dir.sh b/misc/hf-glue/scripts/fetch-from-dir.sh new file mode 100755 index 00000000000..1219aa91c78 --- /dev/null +++ b/misc/hf-glue/scripts/fetch-from-dir.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Alternative to fetch.sh that pulls source-chain state from a LOCAL gnoland +# node data directory instead of hitting RPC endpoints. +# +# Use this when you have a locally-synced gnoland1 node — replaying its +# blockstore.db is far faster (and offline) than pulling blocks via RPC. +# +# Expected layout of $NODE_DIR (matches `gnoland start --data-dir` output): +# $NODE_DIR/ +# config/genesis.json +# db/ +# blockstore.db/ ← historical txs live here +# state.db/ +# ... +# +# Currently the misc/hardfork tool's dirSource reads genesis.json + an optional +# txs.jsonl. Reading blockstore.db directly is not yet implemented, so this +# script first runs tx-archive against a locally-spawned RPC (or — future — +# reads the block store directly once misc/hardfork/source_dir.go grows that +# support). For now we assume the caller also provides a txs.jsonl alongside +# the genesis, or has run an RPC locally. +# +# Inputs (env): +# NODE_DIR path to a gnoland-data dir +# TXS_JSONL optional — path to a pre-exported txs.jsonl +# (if absent, an error is raised — see TODO below) +# ORIGINAL_CHAIN_ID source chain ID +# CHAIN_ID new chain ID +# HALT_HEIGHT required for local mode (we can't auto-detect without RPC) +# OUT output directory (absolute) +# REPO repo root (absolute) +set -euo pipefail + +: "${NODE_DIR:?NODE_DIR is required (path to a gnoland data directory)}" +: "${ORIGINAL_CHAIN_ID:?ORIGINAL_CHAIN_ID is required}" +: "${CHAIN_ID:?CHAIN_ID is required}" +: "${HALT_HEIGHT:?HALT_HEIGHT is required when using local source}" +: "${OUT:?OUT is required}" +: "${REPO:?REPO is required}" + +GENESIS="$OUT/genesis.json" +STAGE="$OUT/source" +STAGE_GEN="$STAGE/config/genesis.json" +STAGE_TXS="$STAGE/txs.jsonl" + +echo "── fetch hardfork genesis (local dir) ────────────────────────" +echo " node dir: $NODE_DIR" +echo " original chain id: $ORIGINAL_CHAIN_ID" +echo " new chain id: $CHAIN_ID" +echo " halt height: $HALT_HEIGHT" +echo " output: $GENESIS" +echo "" + +mkdir -p "$STAGE/config" + +# ---- genesis.json from the local node ---- +SRC_GEN="$NODE_DIR/config/genesis.json" +if [[ ! -f "$SRC_GEN" ]]; then + SRC_GEN="$NODE_DIR/genesis.json" +fi +if [[ ! -f "$SRC_GEN" ]]; then + echo "ERROR: genesis.json not found under $NODE_DIR" >&2 + exit 1 +fi +echo "[1/3] using base genesis: $SRC_GEN" +cp "$SRC_GEN" "$STAGE_GEN" + +# ---- txs.jsonl ---- +if [[ -n "${TXS_JSONL:-}" ]]; then + if [[ ! -f "$TXS_JSONL" ]]; then + echo "ERROR: TXS_JSONL=$TXS_JSONL does not exist" >&2 + exit 1 + fi + echo "[2/3] using provided txs.jsonl: $TXS_JSONL" + cp "$TXS_JSONL" "$STAGE_TXS" +else + # TODO: once misc/hardfork/source_dir.go reads blockstore.db directly, + # point dirSource at $NODE_DIR/db/blockstore.db and skip this step. + echo "ERROR: no TXS_JSONL provided." >&2 + echo " Either (a) pass TXS_JSONL=/path/to/txs.jsonl, or" >&2 + echo " (b) run 'contribs/tx-archive backup' against a local RPC on this node," >&2 + echo " (c) wait for misc/hardfork to grow blockstore.db support (open issue)." >&2 + exit 1 +fi + +# ---- assemble ---- +echo "" +echo "[3/3] assembling hardfork genesis..." +cd "$REPO/misc/hardfork" + +ARGS=( + genesis + --source "$STAGE" + --chain-id "$CHAIN_ID" + --original-chain-id "$ORIGINAL_CHAIN_ID" + --halt-height "$HALT_HEIGHT" + --output "$GENESIS" +) +go run . "${ARGS[@]}" + +echo "" +if command -v sha256sum >/dev/null 2>&1; then + echo "sha256: $(sha256sum "$GENESIS" | cut -d' ' -f1)" +elif command -v shasum >/dev/null 2>&1; then + echo "sha256: $(shasum -a 256 "$GENESIS" | cut -d' ' -f1)" +fi +echo "done — genesis written to $GENESIS" diff --git a/misc/hf-glue/scripts/gen-local-genesis.sh b/misc/hf-glue/scripts/gen-local-genesis.sh new file mode 100755 index 00000000000..ccb8d970e5d --- /dev/null +++ b/misc/hf-glue/scripts/gen-local-genesis.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Rebuild the gnoland1 base genesis locally via +# misc/deployments/gnoland1/gen-genesis.sh, then stage it at +# out/source-genesis.json for the hardfork tool to consume as a file source. +# +# Inputs (env): +# OUT output directory (absolute) +# REPO repo root (absolute) +set -euo pipefail + +: "${OUT:?OUT is required}" +: "${REPO:?REPO is required}" + +GENESIS_SRC="$REPO/misc/deployments/gnoland1/genesis.json" +OUT_FILE="$OUT/source-genesis.json" + +echo "── rebuild gnoland1 genesis locally ────────────────────────" +echo " this runs misc/deployments/gnoland1/gen-genesis.sh (takes a few minutes)" +echo " output will be staged at: $OUT_FILE" +echo "" + +mkdir -p "$OUT" + +# Reuse a pre-existing build if present to shave time on reruns. +EXTRA=() +if [[ -d "$REPO/misc/deployments/gnoland1/genesis-work/bin" ]]; then + EXTRA+=(--no-install) +fi + +( cd "$REPO/misc/deployments/gnoland1" && ./gen-genesis.sh "${EXTRA[@]}" ) + +if [[ ! -f "$GENESIS_SRC" ]]; then + echo "ERROR: expected $GENESIS_SRC after gen-genesis.sh but it does not exist" >&2 + exit 1 +fi + +cp "$GENESIS_SRC" "$OUT_FILE" +echo "" +echo "done — source genesis at $OUT_FILE" +echo "" +echo "Next:" +echo " SOURCE=$OUT_FILE make fetch init up" diff --git a/misc/hf-glue/scripts/init-node.sh b/misc/hf-glue/scripts/init-node.sh new file mode 100755 index 00000000000..45b2b2c242c --- /dev/null +++ b/misc/hf-glue/scripts/init-node.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Initialise gnoland-home for the testbed: +# - run `gnoland secrets init` to generate the single validator identity +# - rewrite validators in out/genesis.json so it contains ONLY that key +# +# The docker container mounts $OUT/gnoland-home/ as its --data-dir, so the +# node boots with the key generated here. +# +# Inputs (env): +# VALIDATOR_NAME name baked into the genesis validator entry +# OUT output directory (absolute) +# REPO repo root (absolute) +set -euo pipefail + +: "${VALIDATOR_NAME:?VALIDATOR_NAME is required}" +: "${OUT:?OUT is required}" +: "${REPO:?REPO is required}" + +GENESIS="$OUT/genesis.json" +HOME_DIR="$OUT/gnoland-home" +SECRETS_DIR="$HOME_DIR/secrets" +PV_KEY="$SECRETS_DIR/priv_validator_key.json" + +if [[ ! -f "$GENESIS" ]]; then + echo "missing $GENESIS — run 'make fetch' first" >&2 + exit 1 +fi + +echo "── init single-validator node ───────────────────────────────" +mkdir -p "$HOME_DIR" + +# ---- 1. generate validator secrets if not already present ---- +if [[ -f "$PV_KEY" ]]; then + echo " secrets already present at $SECRETS_DIR — reusing" +else + echo " generating secrets in $SECRETS_DIR" + mkdir -p "$SECRETS_DIR" + go run -C "$REPO" ./gno.land/cmd/gnoland secrets init --data-dir "$SECRETS_DIR" +fi + +# ---- 2. rewrite validator set in the genesis to a single entry ---- +echo "" +echo " rewriting validator set in genesis..." +go run -C "$REPO/misc/hf-glue/fixvalidator" . \ + --priv-key "$PV_KEY" \ + --genesis "$GENESIS" \ + --name "$VALIDATOR_NAME" \ + --power 10 + +# ---- 3. write config.toml so RPC binds to 0.0.0.0 (accessible from host) ---- +CONFIG_DIR="$HOME_DIR/config" +mkdir -p "$CONFIG_DIR" +go run -C "$REPO" ./gno.land/cmd/gnoland config init -config-path "$CONFIG_DIR/config.toml" +# Patch the generated config to bind to 0.0.0.0 (accessible from Docker host) +if command -v sed >/dev/null 2>&1; then + sed -i.bak 's|tcp://127.0.0.1:26657|tcp://0.0.0.0:26657|' "$CONFIG_DIR/config.toml" + sed -i.bak 's|tcp://127.0.0.1:26656|tcp://0.0.0.0:26656|' "$CONFIG_DIR/config.toml" + rm -f "$CONFIG_DIR/config.toml.bak" +fi +echo " config written to $CONFIG_DIR/config.toml" + +# ---- 4. stage genesis.json next to the node data ---- +cp "$GENESIS" "$HOME_DIR/genesis.json" + +echo "" +echo "done — node home ready at $HOME_DIR" diff --git a/misc/hf-glue/scripts/lib/hf.sh b/misc/hf-glue/scripts/lib/hf.sh new file mode 100755 index 00000000000..13204f1c590 --- /dev/null +++ b/misc/hf-glue/scripts/lib/hf.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# misc/hf-glue/scripts/lib/hf.sh — helpers used by migrate.sh (and friends). +# +# The intent is that migrate.sh reads like a config: each line describes a +# piece of the migration (where to get the genesis, where to get the txs, +# what to patch, etc). Plumbing lives here. +# +# Env set by the caller's Makefile: +# OUT, REPO absolute paths +# ORIGINAL_CHAIN_ID, CHAIN_ID +# HALT_HEIGHT (may be empty — auto-detected from RPC by hf_fetch_txs_via_rpc) + +set -euo pipefail + +# ---- state ---------------------------------------------------------------- +# Filled by the hf_* functions. Read at the end by hf_assemble. +_HF_STAGE="" # staging dir (gnoland-data layout) +_HF_STAGE_GEN="" # path to base genesis.json +_HF_STAGE_TXS="" # path to historical txs.jsonl (empty until step 2) +_HF_PATCHES=() # list of "pkgpath=srcdir" entries for --patch-realm +_HF_OVERLAYS=() # overlay tx files (pre-history, not yet supported) +_HF_MIGRATIONS=() # migration tx files (post-history, not yet supported) + +# ---- presentation --------------------------------------------------------- +hf_banner() { + printf '\n\033[1;36m━━━ %s ━━━\033[0m\n' "$*" +} + +hf_kv() { + printf " %-22s \033[36m%s\033[0m\n" "$1" "$2" +} + +hf_die() { + printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2 + exit 1 +} + +# ---- setup ---------------------------------------------------------------- +# hf_init — must be the first call. Prints a header, creates the staging dir. +hf_init() { + : "${OUT:?OUT is required}" + : "${REPO:?REPO is required}" + : "${ORIGINAL_CHAIN_ID:?ORIGINAL_CHAIN_ID is required}" + : "${CHAIN_ID:?CHAIN_ID is required}" + + _HF_STAGE="$OUT/source" + _HF_STAGE_GEN="$_HF_STAGE/config/genesis.json" + _HF_STAGE_TXS="$_HF_STAGE/txs.jsonl" + mkdir -p "$_HF_STAGE/config" + + hf_banner "hardfork migration" + hf_kv "original chain id" "$ORIGINAL_CHAIN_ID" + hf_kv "new chain id" "$CHAIN_ID" + hf_kv "halt height" "${HALT_HEIGHT:-}" + hf_kv "output genesis" "$OUT/genesis.json" + hf_kv "staging dir" "$_HF_STAGE" + echo "" +} + +# ---- step 1: base genesis ------------------------------------------------- +# hf_fetch_genesis_from_url URL +# Direct .json asset (e.g. GitHub release). +hf_fetch_genesis_from_url() { + local url="$1" + hf_banner "step 1 — base genesis (URL)" + if [[ -f "$_HF_STAGE_GEN" ]]; then + hf_kv "cached" "$(_hf_size "$_HF_STAGE_GEN") bytes" + return 0 + fi + hf_kv "url" "$url" + curl -fSL --retry 3 --retry-delay 5 --max-time 600 --progress-bar \ + -o "$_HF_STAGE_GEN" "$url" + hf_kv "size" "$(_hf_size "$_HF_STAGE_GEN") bytes" +} + +# hf_fetch_genesis_from_rpc RPC_URL +# Fetches ${RPC_URL}/genesis and unwraps the JSON-RPC envelope. +hf_fetch_genesis_from_rpc() { + local rpc="$1" + hf_banner "step 1 — base genesis (RPC)" + if [[ -f "$_HF_STAGE_GEN" ]]; then + hf_kv "cached" "$(_hf_size "$_HF_STAGE_GEN") bytes" + return 0 + fi + local env="${rpc%/}/genesis" + hf_kv "url" "$env" + curl -fSL --retry 3 --retry-delay 5 --max-time 600 --progress-bar \ + -o "$_HF_STAGE/envelope.json" "$env" + jq -c '.result.genesis' < "$_HF_STAGE/envelope.json" > "$_HF_STAGE_GEN" + rm -f "$_HF_STAGE/envelope.json" + hf_kv "size" "$(_hf_size "$_HF_STAGE_GEN") bytes" +} + +# hf_fetch_genesis_from_file PATH +# Local file copy. +hf_fetch_genesis_from_file() { + local src="$1" + hf_banner "step 1 — base genesis (file)" + [[ -f "$src" ]] || hf_die "genesis file not found: $src" + if [[ -f "$_HF_STAGE_GEN" ]]; then + hf_kv "cached" "$(_hf_size "$_HF_STAGE_GEN") bytes" + return 0 + fi + hf_kv "from" "$src" + cp "$src" "$_HF_STAGE_GEN" + hf_kv "size" "$(_hf_size "$_HF_STAGE_GEN") bytes" +} + +# ---- step 2: historical txs ----------------------------------------------- +# hf_fetch_txs_via_rpc RPC_URL +# Uses contribs/tx-archive with batching. Auto-detects HALT_HEIGHT from +# the RPC's /status if HALT_HEIGHT is empty. +hf_fetch_txs_via_rpc() { + local rpc="$1" + hf_banner "step 2 — historical txs (RPC)" + if [[ -z "${HALT_HEIGHT:-}" ]]; then + HALT_HEIGHT=$(curl -fsS --max-time 30 "${rpc%/}/status" \ + | jq -r '.result.sync_info.latest_block_height') + hf_kv "halt (auto)" "$HALT_HEIGHT" + else + hf_kv "halt" "$HALT_HEIGHT" + fi + if [[ -f "$_HF_STAGE_TXS" ]]; then + hf_kv "cached" "$(wc -l < "$_HF_STAGE_TXS" | tr -d ' ') txs" + return 0 + fi + hf_kv "rpc" "$rpc" + hf_kv "range" "1..$HALT_HEIGHT" + ( cd "$REPO/contribs/tx-archive" && go run ./cmd backup \ + -remote "$rpc" \ + -from-block 1 \ + -to-block "$HALT_HEIGHT" \ + -batch 1000 \ + -output-path "$_HF_STAGE_TXS" \ + -overwrite ) + hf_kv "total" "$(wc -l < "$_HF_STAGE_TXS" | tr -d ' ') txs" +} + +# hf_fetch_txs_from_jsonl PATH +# Copy a pre-exported txs.jsonl. Still requires HALT_HEIGHT. +hf_fetch_txs_from_jsonl() { + local src="$1" + hf_banner "step 2 — historical txs (jsonl)" + [[ -f "$src" ]] || hf_die "txs.jsonl not found: $src" + : "${HALT_HEIGHT:?HALT_HEIGHT is required when pulling txs from a file}" + hf_kv "from" "$src" + cp "$src" "$_HF_STAGE_TXS" + hf_kv "total" "$(wc -l < "$_HF_STAGE_TXS" | tr -d ' ') txs" +} + +# hf_skip_txs +# No historical txs at all (genesis-only hardfork). +hf_skip_txs() { + hf_banner "step 2 — historical txs (none)" + : "${HALT_HEIGHT:?HALT_HEIGHT is required when skipping tx pull}" + hf_kv "halt" "$HALT_HEIGHT" + : > "$_HF_STAGE_TXS" +} + +# ---- patches + overlays --------------------------------------------------- +# hf_patch_addpkg PKGPATH SRCDIR +# Rewrites the genesis-mode addpkg tx for PKGPATH in-place with the +# *.gno + gnomod.toml files from SRCDIR. Source genesis on disk stays +# untouched — the patch is applied in memory during hf_assemble. +hf_patch_addpkg() { + local pkg="$1" src="$2" + [[ -d "$src" ]] || hf_die "patch srcdir not found: $src" + _HF_PATCHES+=("$pkg=$src") +} + +# hf_overlay_txs PATH +# Future: inject extra genesis-mode txs BEFORE historical tx replay +# (post-genesis-mode, pre-history). Not yet plumbed in misc/hardfork — +# hf_assemble will refuse if any overlay was requested. +hf_overlay_txs() { + local src="$1" + _HF_OVERLAYS+=("$src") +} + +# hf_migration_tx PATH +# Inject a migration tx jsonl that runs AFTER historical replay +# (e.g. to update r/sys/validators/v2 to the new valset, to reset +# chain params, etc). "Reproduce history, then mutate". +# +# Each jsonl line is an amino-JSON TxWithMetadata; BlockHeight is +# forced to 0 at replay (genesis-mode execution). +# +# Valset-swap note: gnoland1 seeds its valset via govdao_prop1.gno +# at genesis. A hardfork inherits that state, so r/sys/validators/v2 +# still lists the original 7 validators even though tm2 consensus is +# driven by GenesisDoc.Validators (which `gnogenesis fork` rewrites). +# The migration tx reconciles the two sides. +hf_migration_tx() { + local src="$1" + [[ -f "$src" ]] || hf_die "migration tx jsonl not found: $src" + _HF_MIGRATIONS+=("$src") +} + +# ---- step 3: assemble ----------------------------------------------------- +# hf_assemble +# Runs `gnogenesis fork generate` against the staged source dir, +# applying any accumulated --patch-realm and --migration-tx entries. +hf_assemble() { + hf_banner "step 3 — assemble hardfork genesis" + : "${HALT_HEIGHT:?HALT_HEIGHT must be set (auto-detected earlier, or pass explicitly)}" + + if [[ ${#_HF_OVERLAYS[@]} -gt 0 ]]; then + hf_die "hf_overlay_txs is not supported by gnogenesis fork yet (${#_HF_OVERLAYS[@]} requested)" + fi + + local args=( + fork generate + --source "$_HF_STAGE" + --chain-id "$CHAIN_ID" + --original-chain-id "$ORIGINAL_CHAIN_ID" + --halt-height "$HALT_HEIGHT" + --output "$OUT/genesis.json" + ) + local p + for p in "${_HF_PATCHES[@]:-}"; do + [[ -z "$p" ]] && continue + hf_kv "patch" "$p" + args+=(--patch-realm "$p") + done + local m + for m in "${_HF_MIGRATIONS[@]:-}"; do + [[ -z "$m" ]] && continue + hf_kv "migration" "$m" + args+=(--migration-tx "$m") + done + + ( cd "$REPO/contribs/gnogenesis" && go run . "${args[@]}" ) + + echo "" + if command -v sha256sum >/dev/null 2>&1; then + hf_kv "sha256" "$(sha256sum "$OUT/genesis.json" | cut -d' ' -f1)" + elif command -v shasum >/dev/null 2>&1; then + hf_kv "sha256" "$(shasum -a 256 "$OUT/genesis.json" | cut -d' ' -f1)" + fi + hf_kv "output" "$OUT/genesis.json" +} + +# ---- internal ------------------------------------------------------------- +_hf_size() { wc -c < "$1" | tr -d ' '; } diff --git a/misc/hf-glue/scripts/migrate.sh b/misc/hf-glue/scripts/migrate.sh new file mode 100755 index 00000000000..f6514a5912e --- /dev/null +++ b/misc/hf-glue/scripts/migrate.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# misc/hf-glue/scripts/migrate.sh +# +# Declarative hardfork migration — configured here, plumbed in lib/hf.sh. +# Defaults target gnoland1 → gnoland-1. Override by exporting any of +# SOURCE / RPC_URL / CHAIN_ID / ORIGINAL_CHAIN_ID / HALT_HEIGHT / PATCH_REALMS +# before running. +# +# Think of this file as a config that happens to be executable. Each hf_* +# call below is one line of intent; add / remove / reorder them to describe +# a different migration. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/hf.sh +source "$SCRIPT_DIR/lib/hf.sh" + +hf_init + +# ------------------------------------------------------------------------- +# 1) Where to get the BASE GENESIS +# ------------------------------------------------------------------------- +# Pick one. $SOURCE from the Makefile decides which branch runs. +: "${SOURCE:=https://github.com/gnolang/gno/releases/download/chain/gnoland1.0/genesis.json}" +case "$SOURCE" in + *.json|*/genesis.json) hf_fetch_genesis_from_url "$SOURCE" ;; + http://*|https://*) hf_fetch_genesis_from_rpc "$SOURCE" ;; + *) hf_fetch_genesis_from_file "$SOURCE" ;; +esac + +# ------------------------------------------------------------------------- +# 2) Where to get the HISTORICAL TXS +# ------------------------------------------------------------------------- +: "${RPC_URL:=https://rpc.gno.land}" +hf_fetch_txs_via_rpc "$RPC_URL" +# Alternatives: +# hf_fetch_txs_from_jsonl /path/to/txs.jsonl +# hf_skip_txs + +# ------------------------------------------------------------------------- +# 3) REALM PATCHES (ride along the hardfork) +# ------------------------------------------------------------------------- +# Swap r/sys/params with the repo's current examples copy. After merging +# #5368 that copy has halt.gno (NewSetHaltRequest), so the forked chain +# boots with the govDAO halt mechanism available. +hf_patch_addpkg "gno.land/r/sys/params" "$REPO/examples/gno.land/r/sys/params" + +# Extra patches from $PATCH_REALMS (space-separated PKGPATH=SRCDIR). +for spec in ${PATCH_REALMS:-}; do + [[ -z "$spec" ]] && continue + hf_patch_addpkg "${spec%%=*}" "${spec#*=}" +done + +# ------------------------------------------------------------------------- +# 4) OVERLAY TXS (pre-history, not yet supported) +# ------------------------------------------------------------------------- +# Future: inject extra txs between genesis-mode and historical replay. +# hf_overlay_txs "$SCRIPT_DIR/../overlays/20260417_add_moderator.jsonl" + +# ------------------------------------------------------------------------- +# 5) MIGRATION TXS (post-history) +# ------------------------------------------------------------------------- +# These run AFTER historical replay — "reproduce history, then mutate". +# +# Valset swap: gnoland1 seeds its valset via govdao_prop1.gno, so the +# post-fork r/sys/validators/v2 still lists the *original* 7 validators +# even though tm2 consensus is driven by GenesisDoc.Validators (which +# `gnogenesis fork` rewrites to our local validator via fixvalidator). +# The migration below reconciles the two: it wipes the 7 originals and +# registers the new valset via a govDAO proposal signed as manfred +# (T1 member) under --skip-genesis-sig-verification. +# +# Delegates to misc/deployments/gnoland-1/migrations/build.sh, which +# renders the template with the local priv_validator_key.json and +# produces a signed jsonl under $OUT/migrations.jsonl. +PV_KEY_DEFAULT="$OUT/gnoland-home/secrets/priv_validator_key.json" +PV_KEY="${PV_KEY:-$PV_KEY_DEFAULT}" +if [[ -f "$PV_KEY" ]]; then + hf_banner "step 5 — post-replay migration (valset swap)" + hf_kv "pv_key" "$PV_KEY" + MIG_JSONL="$OUT/migrations.jsonl" + CALLER="${CALLER:-g1manfred47kzduec920z88wfr64ylksmdcedlf5}" \ + PV_KEY="$PV_KEY" \ + OUT_JSONL="$MIG_JSONL" \ + CHAIN_ID="$CHAIN_ID" \ + REPO_ROOT="$REPO" \ + bash "$REPO/misc/deployments/gnoland-1/migrations/build.sh" + hf_migration_tx "$MIG_JSONL" +else + hf_banner "step 5 — post-replay migration (skipped)" + hf_kv "reason" "no priv_validator_key.json at $PV_KEY — run 'make init' first" +fi + +# ------------------------------------------------------------------------- +# 6) ASSEMBLE the hardfork genesis +# ------------------------------------------------------------------------- +hf_assemble diff --git a/misc/hf-glue/scripts/replay-log.sh b/misc/hf-glue/scripts/replay-log.sh new file mode 100755 index 00000000000..c2d26710099 --- /dev/null +++ b/misc/hf-glue/scripts/replay-log.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Run an in-process genesis replay via `hardfork test --verbose` and capture +# the per-tx log for analysis. Exits cleanly after replay completes (no docker, +# no persistent state). +# +# Usage: ./scripts/replay-log.sh [path/to/genesis.json] +# +# Output: out/replay.log (full log) + stdout summary +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +OUT="$HERE/out" + +GENESIS="${1:-$OUT/genesis.json}" +LOG="$OUT/replay.log" + +if [[ ! -f "$GENESIS" ]]; then + echo "missing $GENESIS — run 'make fetch' first" >&2 + exit 1 +fi + +mkdir -p "$OUT" + +echo "── genesis replay smoke-test ────────────────────────────────" +echo " genesis: $GENESIS" +echo " log: $LOG" +echo "" + +cd "$REPO/misc/hardfork" +go run . test \ + --genesis "$GENESIS" \ + --verbose \ + --timeout 30m 2>&1 | tee "$LOG" + +echo "" +echo "── summary ──────────────────────────────────────────────────" +echo "" +printf " OK txs: %d\n" "$(grep -c '^ \[OK\]' "$LOG" || true)" +printf " FAIL txs: %d\n" "$(grep -c '^ \[FAIL\]' "$LOG" || true)" +echo "" +echo " Unique failure reasons:" +grep -oE 'error=[^"]+' "$LOG" 2>/dev/null \ + | sed -E 's/error=//; s/\\n.*//' \ + | sort | uniq -c | sort -rn | head -20 | sed 's/^/ /' \ + || true +echo "" +echo " Failed packages (addpkg):" +grep '^ \[FAIL\]' "$LOG" 2>/dev/null \ + | grep -oE 'gno\.land/[pr]/[a-zA-Z0-9/_-]+' \ + | sort -u | head -30 | sed 's/^/ /' || true +echo "" +echo "Full log: $LOG" diff --git a/misc/hf-glue/scripts/report-replay.sh b/misc/hf-glue/scripts/report-replay.sh new file mode 100755 index 00000000000..c9db237d219 --- /dev/null +++ b/misc/hf-glue/scripts/report-replay.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Produce a structured REPORT.md from a replay log. +# +# The goal: separate root-cause failures (sig mismatch, missing param, etc) +# from cascade failures (import errors from deps that failed earlier), so we +# can decide what to fix upstream vs ignore for testing. +# +# Usage: ./scripts/report-replay.sh [path/to/replay.log] +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="$HERE/out" +LOG="${1:-$OUT/replay.log}" +REPORT="$OUT/REPLAY-REPORT.md" + +if [[ ! -f "$LOG" ]]; then + echo "missing $LOG — run 'make replay-log' first" >&2 + exit 1 +fi + +mkdir -p "$OUT" + +total_ok=$(grep -c '^ \[OK\]' "$LOG" || true) +total_fail=$(grep -c '^ \[FAIL\]' "$LOG" || true) +total=$((total_ok + total_fail)) + +# Extract failure lines. Each [FAIL] line contains the error text inline. +# Bucket by error kind. +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT +grep '^ \[FAIL\]' "$LOG" > "$tmp" || true + +# --- bucketing ---------------------------------------------------------------- +pubkey_mismatch=$(grep -c 'PubKey does not match' "$tmp" || true) +chain_id_err=$(grep -c 'signature verification failed' "$tmp" || true) +cascade_import=$(grep -c 'could not import' "$tmp" || true) +type_check=$(grep -c 'type check errors' "$tmp" || true) +insufficient_funds=$(grep -cE '(insufficient|out of gas|insufficient funds)' "$tmp" || true) +other=$((total_fail - pubkey_mismatch - cascade_import - type_check - insufficient_funds)) +[[ $other -lt 0 ]] && other=0 + +# --- distinct root-cause (non-cascade) packages ------------------------------ +# Cascade = "could not import gno.land/..." — the package itself is fine, +# its dep failed earlier. Surface the DEPS that are missing instead. +missing_imports=$(grep -oE 'could not import gno\.land/[^ \"\\]+' "$tmp" \ + | sort | uniq -c | sort -rn | head -20) + +# --- packages that failed with a non-import reason (potential root causes) ---- +# Grab the addpkg package path from fail lines that do NOT mention "could not import". +root_cause_fails=$(grep -v 'could not import' "$tmp" \ + | grep -oE 'gno\.land/[pr]/[a-zA-Z0-9/_.-]+' \ + | sort -u | head -30 || true) + +# --- write report ------------------------------------------------------------ +{ + echo "# Genesis Replay Report" + echo "" + echo "_Generated $(date -u +%Y-%m-%dT%H:%M:%SZ) from $LOG_" + echo "" + echo "## Summary" + echo "" + echo "| Metric | Count |" + echo "|--------|------:|" + echo "| Total txs | $total |" + echo "| ✅ OK | $total_ok |" + echo "| ❌ Failed | $total_fail |" + echo "" + echo "## Failure categories" + echo "" + echo "| Category | Count | Kind |" + echo "|----------|------:|------|" + echo "| PubKey does not match signer address | $pubkey_mismatch | **root cause** — genesis signature mismatch |" + echo "| Signature verification failed (chain-id) | $chain_id_err | **root cause** — chain-id leak during sig verify |" + echo "| Type check — \`could not import\` | $cascade_import | **cascade** — dep package failed earlier |" + echo "| Type check — other | $type_check | investigate |" + echo "| Insufficient funds / out of gas | $insufficient_funds | investigate |" + echo "| Other | $other | investigate |" + echo "" + echo "## Root-cause failures" + echo "" + echo "These are failures NOT caused by a missing import. If any of these are" + echo "library packages, they cause a downstream cascade." + echo "" + echo '```' + if [[ -z "$root_cause_fails" ]]; then + echo "(none detected)" + else + echo "$root_cause_fails" + fi + echo '```' + echo "" + echo "## Cascade — missing imports" + echo "" + echo "Each line is a package that downstream txs tried to import but wasn't" + echo "deployed. If it appears here, either (a) its deploy tx failed as a" + echo "root cause, or (b) it's not in the genesis at all." + echo "" + echo '```' + if [[ -z "$missing_imports" ]]; then + echo "(none)" + else + echo "$missing_imports" + fi + echo '```' + echo "" + echo "## First 10 failure log lines (for context)" + echo "" + echo '```' + head -10 "$tmp" | sed 's/\\n/\n /g' + echo '```' + echo "" + echo "## Recommendation" + echo "" + if [[ $pubkey_mismatch -gt 0 ]]; then + echo "- **Fix pubkey mismatch first** ($pubkey_mismatch tx). The gnoland1 genesis" + echo " carries signatures whose pubkey doesn't derive to the signer address." + echo " Either the source genesis is malformed, or our ante-handler is" + echo " reading the wrong pubkey during hardfork replay." + fi + if [[ $chain_id_err -gt 0 ]]; then + echo "- **Chain-id leak** ($chain_id_err tx). Genesis-mode txs are being verified" + echo " against the new chain id instead of the original. Check the" + echo " \`PastChainIDs\` handling in \`loadAppState\`." + fi + if [[ $cascade_import -gt $((total_fail / 2)) ]]; then + echo "- **Most failures are cascade.** Fixing the root causes above will" + echo " likely drop the failure count dramatically." + fi + echo "" +} > "$REPORT" + +echo "report written to: $REPORT" +echo "" +cat "$REPORT" | head -60 diff --git a/tm2/adr/pr5511_initial_height.md b/tm2/adr/pr5511_initial_height.md new file mode 100644 index 00000000000..3fb5270d654 --- /dev/null +++ b/tm2/adr/pr5511_initial_height.md @@ -0,0 +1,100 @@ +# PR5511: `InitialHeight` support for chain upgrades + +## Context + +Chain hardforks on gno.land export state and historical transactions from +the source chain and replay them in the new chain's `InitChain`. After +`InitChain`, the new chain must start producing blocks at the source +chain's halt height + 1, not at 1. + +Before this PR, tm2 hard-coded "fresh chain starts at height 1" in +multiple places: block store, block pool (fast-sync), validator/consensus +param persistence, block validation, and the app's height-tracking logic. + +The gno.land side of this work (genesis replay, `PastChainIDs`, +`GnoTxMetadata`, replay tooling) lives in +[gno.land/adr/pr5511_chain_upgrade_genesis_replay.md](../../gno.land/adr/pr5511_chain_upgrade_genesis_replay.md). + +## Decision + +### `GenesisDoc.InitialHeight` (`tm2/pkg/bft/types`) + +New `int64` field. When `> 1`, the consensus `Handshaker` sets +`state.LastBlockHeight = InitialHeight - 1` after `InitChain`, so the +first produced block has height `InitialHeight`. Validated non-negative +in `GenesisDoc.ValidateAndComplete`. `omitempty` for backwards +compatibility with existing genesis files. + +### `abci.RequestInitChain.InitialHeight` + +New field on the ABCI struct, populated by the consensus handshaker from +`GenesisDoc.InitialHeight`. Lets the app cross-check against any +app-level `InitialHeight` field in the AppState (gno.land uses this to +reject divergent genesis files). + +### `auth.SkipGasMeteringKey` (`tm2/pkg/sdk/auth`) + +Context key. When set to `true`, `auth.SetGasMeter` installs an infinite +gas meter even for non-genesis blocks. Used by gno.land's +`GasReplayMode="source"` so historical txs can replay with source-chain +outcomes when gas metering has changed between chains. + +### Consensus + state layer fixes + +All fixes land together so `InitialHeight > 1` works end-to-end: + +- **BlockPool** (`tm2/pkg/bft/blockchain/reactor.go`) — start syncing at + `state.LastBlockHeight + 1` when the store is empty, not at + `store.Height() + 1 = 1`. +- **`saveState`** (`tm2/pkg/bft/state/store.go`) — detect the first-block + case generically (not just `nextHeight == 1`), so validators and + consensus params are persisted at `InitialHeight` and can be loaded + when processing `InitialHeight + 1`. +- **`Block.ValidateBasic`** (`tm2/pkg/bft/types/block.go`) — only skip + `LastCommit` validation when the commit is also nil/empty (the + legitimate genesis pattern). Explicitly reject a zero `LastBlockID` + paired with a non-empty commit, which would otherwise bypass commit + validation. +- **`BaseApp.validateHeight`** (`tm2/pkg/sdk/baseapp.go`) — the + multistore version counter auto-increments from 0 and lags block + height by `InitialHeight - 1`. Accept the monotonic jump between + block height and store version instead of requiring strict equality. +- **`BaseApp.Info`** — return the persisted header's `LastBlockHeight` + when it exceeds the store version, so on restart the handshaker + doesn't rewind to `InitialHeight` and try to re-replay. +- **`BaseApp.Info`** — guard against a not-yet-loaded multistore to + avoid a nil-dereference at startup. + +## Alternatives considered + +1. **Keep hard-coding height-1** and have the app layer translate all + heights — unworkable, would leak fork semantics into consensus state. +2. **Track `InitialHeight` on state but not in GenesisDoc** — doesn't + survive node restart, would need a new sidecar file. + +## Consequences + +- Any chain started with `GenesisDoc.InitialHeight > 1` transparently + begins producing blocks at `InitialHeight`. All existing paths + (fast-sync, state bootstrap, block validation, restart) work. +- Existing chains (`InitialHeight` unset or `1`) are unaffected — all + new fields use `omitempty` and all new code paths are conditional. +- `RequestInitChain.InitialHeight` is opt-in for the app: it can be + ignored with no downside (behavior matches pre-PR). + +## Tests + +New unit tests cover each fix in isolation: + +- `tm2/pkg/bft/blockchain` — reactor starts pool at correct height. +- `tm2/pkg/bft/state` — validators/params saved at `InitialHeight`. +- `tm2/pkg/bft/types` — `ValidateBasic` rejects tampered blocks. +- `tm2/pkg/bft/consensus` — replay feeds `InitialHeight` to InitChain; + `Handshaker` sets `state.LastBlockHeight` correctly. +- `tm2/pkg/sdk` — BaseApp `Info` and `validateHeight` handle + `InitialHeight > 1`. +- `tm2/pkg/sdk/auth` — `SetGasMeter` respects `SkipGasMeteringKey`. + +End-to-end validation: a production-sized hardfork genesis +(~2700 txs, `InitialHeight = 704053`) replays and boots a live node +with zero tx failures (see the gno.land ADR for details). diff --git a/tm2/pkg/bft/abci/types/abci.proto b/tm2/pkg/bft/abci/types/abci.proto index 15b8ffa219e..12f2dafb873 100644 --- a/tm2/pkg/bft/abci/types/abci.proto +++ b/tm2/pkg/bft/abci/types/abci.proto @@ -38,6 +38,7 @@ message RequestInitChain { ConsensusParams consensus_params = 4 [json_name = "ConsensusParams"]; repeated ValidatorUpdate validators = 5 [json_name = "Validators"]; google.protobuf.Any app_state = 6 [json_name = "AppState"]; + sint64 initial_height = 7 [json_name = "InitialHeight"]; } message RequestQuery { diff --git a/tm2/pkg/bft/abci/types/types.go b/tm2/pkg/bft/abci/types/types.go index 9350f85f68d..5ddcc077b76 100644 --- a/tm2/pkg/bft/abci/types/types.go +++ b/tm2/pkg/bft/abci/types/types.go @@ -46,6 +46,7 @@ type RequestInitChain struct { ConsensusParams *ConsensusParams Validators []ValidatorUpdate AppState any + InitialHeight int64 // block height the chain will start from after InitChain } type RequestQuery struct { diff --git a/tm2/pkg/bft/blockchain/reactor.go b/tm2/pkg/bft/blockchain/reactor.go index 02714a7619e..f32b649930c 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())) } @@ -88,8 +91,17 @@ func NewBlockchainReactor( const capacity = 1000 // must be bigger than peers count errorsCh := make(chan peerError, capacity) // so we don't block in #Receive#pool.AddBlock + // When the store is empty (fresh chain) and InitialHeight > 1, the + // Handshaker has set state.LastBlockHeight = InitialHeight - 1 but the + // store is still at height 0. Use the state height so the pool starts + // syncing at InitialHeight, not at 1. + startHeight := store.Height() + 1 + if store.Height() == 0 && state.LastBlockHeight > 0 { + startHeight = state.LastBlockHeight + 1 + } + pool := NewBlockPool( - store.Height()+1, + startHeight, requestsCh, errorsCh, ) diff --git a/tm2/pkg/bft/blockchain/reactor_test.go b/tm2/pkg/bft/blockchain/reactor_test.go index c3e55fce3ca..872dee44155 100644 --- a/tm2/pkg/bft/blockchain/reactor_test.go +++ b/tm2/pkg/bft/blockchain/reactor_test.go @@ -390,6 +390,67 @@ 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) + }) +} + +// TestBlockPoolStartsAtInitialHeight verifies that when InitialHeight > 1, +// the BlockPool starts syncing at InitialHeight (not height 1). +// This test demonstrates a bug: the pool uses store.Height()+1 = 1 instead +// of state.LastBlockHeight+1 = InitialHeight. +func TestBlockPoolStartsAtInitialHeight(t *testing.T) { + t.Parallel() + + config, _ = cfg.ResetTestRoot("blockchain_reactor_pool_height_test") + defer os.RemoveAll(config.RootDir) + + genDoc, _ := randGenesisDoc(1, false, 30) + state, err := sm.MakeGenesisState(genDoc) + assert.NoError(t, err) + + // Simulate Handshaker with InitialHeight = 100. + initialHeight := int64(100) + state.LastBlockHeight = initialHeight - 1 // 99 + + db := memdb.NewMemDB() + sm.SaveState(db, state) + blockExec := sm.NewBlockExecutor(db, log.NewNoopLogger(), nil, mock.Mempool{}) + blockStore := store.NewBlockStore(memdb.NewMemDB()) + + bcR := NewBlockchainReactor(state.Copy(), blockExec, blockStore, true, nil) + + // The pool should start at initialHeight (100), not at 1. + poolHeight, _, _ := bcR.pool.GetStatus() + assert.Equal(t, initialHeight, poolHeight, + "BlockPool should start at InitialHeight (%d), got %d", initialHeight, poolHeight) +} + // ---------------------------------------------- // utility funcs diff --git a/tm2/pkg/bft/config/config.go b/tm2/pkg/bft/config/config.go index 4793860391b..25b243a339f 100644 --- a/tm2/pkg/bft/config/config.go +++ b/tm2/pkg/bft/config/config.go @@ -328,6 +328,10 @@ type BaseConfig struct { // If non-zero, the node will halt after committing this block height. // Useful for coordinated chain upgrades. HaltHeight int64 `toml:"halt_height" comment:"If non-zero, the node will halt after committing this block height.\n Useful for coordinated chain upgrades."` + + // If non-zero, skip the governance upgrade check at this height. + // Use when the validator has already migrated state but the chain is still at halt height. + SkipUpgradeHeight int64 `toml:"skip_upgrade_height" comment:"If non-zero, skip the governance upgrade check at this height.\n Use when the validator has already migrated but the chain is still at halt height."` } // DefaultBaseConfig returns a default base configuration for a Tendermint node diff --git a/tm2/pkg/bft/consensus/replay.go b/tm2/pkg/bft/consensus/replay.go index 7dd866d9d43..e5a4576f93a 100644 --- a/tm2/pkg/bft/consensus/replay.go +++ b/tm2/pkg/bft/consensus/replay.go @@ -301,6 +301,7 @@ func (h *Handshaker) ReplayBlocks( ConsensusParams: &csParams, Validators: nextVals, AppState: h.genDoc.AppState, + InitialHeight: h.genDoc.InitialHeight, } res, err := proxyApp.Consensus().InitChainSync(req) if err != nil { @@ -328,6 +329,18 @@ func (h *Handshaker) ReplayBlocks( if res.ConsensusParams != nil { state.ConsensusParams = state.ConsensusParams.Update(*res.ConsensusParams) } + + // If InitialHeight is set, the chain starts at that height. + // This is used for chain upgrades where historical txs are replayed + // during genesis and the chain should continue from the halted height. + if h.genDoc.InitialHeight > 1 { + state.LastBlockHeight = h.genDoc.InitialHeight - 1 + h.logger.Info("Setting initial height from genesis", + "initial_height", h.genDoc.InitialHeight, + "last_block_height", state.LastBlockHeight, + ) + } + sm.SaveState(h.stateDB, state) } } diff --git a/tm2/pkg/bft/consensus/replay_test.go b/tm2/pkg/bft/consensus/replay_test.go index a79b6ea252c..5fde4de44a6 100644 --- a/tm2/pkg/bft/consensus/replay_test.go +++ b/tm2/pkg/bft/consensus/replay_test.go @@ -96,7 +96,10 @@ func startNewConsensusStateAndWaitForBlock( err := cs.Start() require.NoError(t, err) }() - defer cs.Stop() + defer func() { + cs.Stop() + cs.Wait() + }() LOOP: for { @@ -225,6 +228,7 @@ LOOP: // stop consensus state and transactions sender (initFn) cs.Stop() + cs.Wait() cancel() // if we reached the required height, exit @@ -1208,3 +1212,250 @@ type initChainApp struct { func (m initChainApp) InitChain(req abci.RequestInitChain) abci.ResponseInitChain { return m.initChain(req) } + +// nilReturningBlockStore is a mock that returns nil for LoadBlock/LoadBlockMeta +// at specified heights, to test nil guard paths. +type nilReturningBlockStore struct { + height int64 + nilBlockAt int64 // height at which LoadBlock returns nil + nilBlockMetaAt int64 // height at which LoadBlockMeta returns nil +} + +func (bs *nilReturningBlockStore) Height() int64 { return bs.height } +func (bs *nilReturningBlockStore) LoadBlock(height int64) *types.Block { + if height == bs.nilBlockAt { + return nil + } + return &types.Block{Header: types.Header{Height: height}} +} + +func (bs *nilReturningBlockStore) LoadBlockMeta(height int64) *types.BlockMeta { + if height == bs.nilBlockMetaAt { + return nil + } + return &types.BlockMeta{ + Header: types.Header{Height: height}, + } +} + +func (bs *nilReturningBlockStore) LoadBlockPart(int64, int) *types.Part { return nil } +func (bs *nilReturningBlockStore) LoadBlockCommit(int64) *types.Commit { return nil } +func (bs *nilReturningBlockStore) LoadSeenCommit(int64) *types.Commit { return nil } +func (bs *nilReturningBlockStore) SaveBlock(*types.Block, *types.PartSet, *types.Commit) { +} + +func TestReplayNilGuards(t *testing.T) { + t.Parallel() + + testCfg, genesisFile := ResetConfig("replay_nil_guards_test") + t.Cleanup(func() { os.RemoveAll(testCfg.RootDir) }) + + genesisState, _ := sm.MakeGenesisStateFromFile(genesisFile) + genDoc, _ := sm.MakeGenesisDocFromFile(genesisFile) + + tests := []struct { + name string + store *nilReturningBlockStore + stateHeight int64 // override state.LastBlockHeight (0 = use genesis) + appBlockHeight int64 + wantErr string + }{ + { + // replayBlocks loop: storeHeight == stateHeight, appBlockHeight < storeHeight + name: "nil block in replayBlocks loop", + store: &nilReturningBlockStore{height: 5, nilBlockAt: 3}, + stateHeight: 5, + appBlockHeight: 2, + wantErr: "block not found for height 3", + }, + { + // replayBlock (singular): storeHeight == stateHeight+1, appBlockHeight == stateHeight + name: "nil block in replayBlock", + store: &nilReturningBlockStore{height: 1, nilBlockAt: 1}, + wantErr: "block not found for height 1", + }, + { + // replayBlock (singular): storeHeight == stateHeight+1, appBlockHeight == stateHeight + name: "nil block meta in replayBlock", + store: &nilReturningBlockStore{height: 1, nilBlockMetaAt: 1}, + wantErr: "block meta not found for height 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + stateDB := memdb.NewMemDB() + state := genesisState + state.LastBlockHeight = tt.stateHeight + sm.SaveState(stateDB, state) + + handshaker := NewHandshaker(stateDB, state, tt.store, genDoc) + handshaker.SetLogger(log.NewNoopLogger()) + + proxyApp := appconn.NewAppConns(proxy.NewLocalClientCreator(kvstore.NewKVStoreApplication())) + require.NoError(t, proxyApp.Start()) + defer proxyApp.Stop() + + _, err := handshaker.ReplayBlocks(state, nil, tt.appBlockHeight, proxyApp) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +// TestReconstructLastCommit_InitialHeight verifies that reconstructLastCommit +// does not panic when LastBlockHeight > 0 but the block store is empty +// (the scenario that arises when InitialHeight > 1 is set after InitChain). +func TestReconstructLastCommit_InitialHeight(t *testing.T) { + t.Parallel() + + testCfg, genesisFile := ResetConfig("reconstruct_last_commit_initial_height_test") + t.Cleanup(func() { os.RemoveAll(testCfg.RootDir) }) + + state, err := sm.MakeGenesisStateFromFile(genesisFile) + require.NoError(t, err) + + // Simulate what the Handshaker does after InitChain with InitialHeight=100: + // LastBlockHeight is set to InitialHeight-1 but the block store is empty. + state.LastBlockHeight = 99 + + stateDB := memdb.NewMemDB() + sm.SaveState(stateDB, state) + + mempool := mock.Mempool{} + blockExec := sm.NewBlockExecutor(stateDB, log.NewNoopLogger(), nil, mempool) + + // Empty block store — Height() returns 0, LoadSeenCommit returns nil. + store := &nilReturningBlockStore{height: 0} + + // NewConsensusState calls reconstructLastCommit; this must not panic. + require.NotPanics(t, func() { + _ = NewConsensusState(testCfg.Consensus, state, blockExec, store, mempool) + }) +} + +// TestCreateProposalBlock_InitialHeight verifies that createProposalBlock uses +// an empty commit (genesis behaviour) when the block store is empty and height > 1, +// rather than falling through to the "No commit for previous block" error branch. +func TestCreateProposalBlock_InitialHeight(t *testing.T) { + t.Parallel() + + testCfg, genesisFile := ResetConfig("create_proposal_block_initial_height_test") + t.Cleanup(func() { os.RemoveAll(testCfg.RootDir) }) + + state, err := sm.MakeGenesisStateFromFile(genesisFile) + require.NoError(t, err) + + state.LastBlockHeight = 99 // simulates InitialHeight=100 + + stateDB := memdb.NewMemDB() + sm.SaveState(stateDB, state) + + mempool := mock.Mempool{} + blockExec := sm.NewBlockExecutor(stateDB, log.NewNoopLogger(), nil, mempool) + + store := &nilReturningBlockStore{height: 0, nilBlockMetaAt: 99} + cs := NewConsensusState(testCfg.Consensus, state, blockExec, store, mempool) + + // Supply a private validator so createProposalBlock can proceed past the + // commit-selection logic and reach the actual block creation. + pv := loadPrivValidator(testCfg) + cs.SetPrivValidator(pv) + + // cs.Height == 100, cs.blockStore.Height() == 0, cs.LastCommit == nil. + // createProposalBlock used to fall through to the "No commit" default branch + // and return nil without creating a block. After the fix it must return a + // non-nil block at height 100. + block, parts := cs.createProposalBlock() + require.NotNil(t, block, "createProposalBlock should return a valid block at InitialHeight") + require.NotNil(t, parts) + assert.Equal(t, int64(100), block.Height) +} + +// TestNeedProofBlock_InitialHeight verifies that needProofBlock returns true +// when the block store is empty and height > 1 (InitialHeight > 1 scenario), +// rather than panicking from a nil LoadBlockMeta result. +func TestNeedProofBlock_InitialHeight(t *testing.T) { + t.Parallel() + + testCfg, genesisFile := ResetConfig("need_proof_block_initial_height_test") + t.Cleanup(func() { os.RemoveAll(testCfg.RootDir) }) + + state, err := sm.MakeGenesisStateFromFile(genesisFile) + require.NoError(t, err) + + // Simulate InitialHeight=100: LastBlockHeight is set to 99, block store empty. + state.LastBlockHeight = 99 + + stateDB := memdb.NewMemDB() + sm.SaveState(stateDB, state) + + mempool := mock.Mempool{} + blockExec := sm.NewBlockExecutor(stateDB, log.NewNoopLogger(), nil, mempool) + + // Empty block store: Height() == 0, and LoadBlockMeta returns nil for all heights + // (no blocks exist yet — this is what a real empty store does). + store := &nilReturningBlockStore{height: 0, nilBlockMetaAt: 99} + + cs := NewConsensusState(testCfg.Consensus, state, blockExec, store, mempool) + + // cs.Height is 100 (LastBlockHeight+1). + // needProofBlock(100) used to panic: LoadBlockMeta(99) == nil on empty store. + // After the fix it should return true (genesis-equivalent block). + var result bool + require.NotPanics(t, func() { + result = cs.needProofBlock(cs.Height) + }) + assert.True(t, result, "needProofBlock should return true at InitialHeight with empty block store") +} + +// TestHandshaker_InitialHeight verifies that when GenesisDoc.InitialHeight > 1, +// the handshaker sets state.LastBlockHeight = InitialHeight - 1 after InitChain, +// so the chain starts producing blocks at the correct height. +func TestHandshaker_InitialHeight(t *testing.T) { + t.Parallel() + + testCfg, genesisFile := ResetConfig("initial_height_test") + t.Cleanup(func() { os.RemoveAll(testCfg.RootDir) }) + + state, err := sm.MakeGenesisStateFromFile(genesisFile) + require.NoError(t, err) + + genDoc, err := sm.MakeGenesisDocFromFile(genesisFile) + require.NoError(t, err) + + // Simulate a chain upgrade: new chain starts at a height > 1 + const initialHeight = int64(100) + genDoc.InitialHeight = initialHeight + + stateDB := memdb.NewMemDB() + sm.SaveState(stateDB, state) + + // App that echoes back validators from InitChain (required for state update) + app := initChainApp{ + initChain: func(req abci.RequestInitChain) abci.ResponseInitChain { + return abci.ResponseInitChain{Validators: req.Validators} + }, + } + + // Empty block store: no blocks committed yet (fresh genesis scenario) + store := &nilReturningBlockStore{height: 0} + + handshaker := NewHandshaker(stateDB, state, store, genDoc) + handshaker.SetLogger(log.NewNoopLogger()) + + proxyApp := appconn.NewAppConns(proxy.NewLocalClientCreator(app)) + require.NoError(t, proxyApp.Start()) + t.Cleanup(func() { require.NoError(t, proxyApp.Stop()) }) + + // appBlockHeight=0 triggers InitChain; storeBlockHeight=0 so no block replay + _, err = handshaker.ReplayBlocks(state, nil, 0, proxyApp) + require.NoError(t, err) + + // After InitChain with InitialHeight=100, the saved state must have LastBlockHeight=99 + // so the first produced block will be at height 100. + savedState := sm.LoadState(stateDB) + assert.Equal(t, initialHeight-1, savedState.LastBlockHeight) +} diff --git a/tm2/pkg/bft/consensus/state.go b/tm2/pkg/bft/consensus/state.go index 9fc068f49a1..2cf53f81e95 100644 --- a/tm2/pkg/bft/consensus/state.go +++ b/tm2/pkg/bft/consensus/state.go @@ -14,7 +14,6 @@ import ( "github.com/gnolang/gno/tm2/pkg/amino" cnscfg "github.com/gnolang/gno/tm2/pkg/bft/consensus/config" cstypes "github.com/gnolang/gno/tm2/pkg/bft/consensus/types" - "github.com/gnolang/gno/tm2/pkg/bft/fail" "github.com/gnolang/gno/tm2/pkg/bft/privval/signer/remote/client" sm "github.com/gnolang/gno/tm2/pkg/bft/state" "github.com/gnolang/gno/tm2/pkg/bft/types" @@ -482,7 +481,7 @@ func (cs *ConsensusState) sendInternalMessage(mi msgInfo) { // be processed out of order. // TODO: use CList here for strict determinism and // attempt push to internalMsgQueue in receiveRoutine - cs.Logger.Info("Internal msg queue is full. Using a go-routine") + cs.Logger.Warn("Internal msg queue is full. Using a go-routine") go func() { cs.internalMsgQueue <- mi }() } } @@ -494,6 +493,15 @@ func (cs *ConsensusState) reconstructLastCommit(state sm.State) { return } 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) if !lastPrecommits.HasTwoThirdsMajority() { panic("Failed to reconstruct LastCommit: Does not have +2/3 maj") @@ -521,7 +529,7 @@ func (cs *ConsensusState) updateToState(state sm.State) { // signal the new round step, because other services (eg. txNotifier) // depend on having an up-to-date peer state! if !cs.state.IsEmpty() && (state.LastBlockHeight <= cs.state.LastBlockHeight) { - cs.Logger.Info("Ignoring updateToState()", "newHeight", state.LastBlockHeight+1, "oldHeight", cs.state.LastBlockHeight+1) + cs.Logger.Debug("Ignoring updateToState()", "newHeight", state.LastBlockHeight+1, "oldHeight", cs.state.LastBlockHeight+1) cs.newStep() return } @@ -645,14 +653,6 @@ func (cs *ConsensusState) receiveRoutine(maxSteps int) { panic(fmt.Sprintf("Failed to write %v msg to consensus wal due to %v. Check your FS and restart the node", mi, err)) } - if _, ok := mi.Msg.(*VoteMessage); ok { - // we actually want to simulate failing during - // the previous WriteSync, but this isn't easy to do. - // Equivalent would be to fail here and manually remove - // some bytes from the end of the wal. - fail.Fail() // XXX - } - // handles proposals, block parts, votes cs.handleMsg(mi) case ti := <-cs.timeoutTicker.Chan(): // tockChan: @@ -855,13 +855,18 @@ 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 } lastBlockMeta := cs.blockStore.LoadBlockMeta(height - 1) + if lastBlockMeta == nil { + panic(fmt.Sprintf("Failed to load block meta for height %d", height-1)) + } return !bytes.Equal(cs.state.AppHash, lastBlockMeta.Header.AppHash) } @@ -991,9 +996,9 @@ func (cs *ConsensusState) isProposalComplete() bool { func (cs *ConsensusState) createProposalBlock() (block *types.Block, blockParts *types.PartSet) { var commit *types.Commit switch { - case cs.Height == 1: - // We're creating a proposal for the first block. - // The commit is empty, but not nil. + case cs.Height == 1 || cs.blockStore.Height() == 0: + // We're creating a proposal for the genesis block (height 1, or InitialHeight > 1 + // where the block store is still empty). The commit is empty, but not nil. commit = types.NewCommit(types.BlockID{}, nil) case cs.LastCommit.HasTwoThirdsMajority(): // Make the commit from LastCommit @@ -1332,8 +1337,6 @@ func (cs *ConsensusState) finalizeCommit(height int64) { "num txs", block.NumTxs, ) - fail.Fail() // XXX - // Save to blockStore. if cs.blockStore.Height() < block.Height { // NOTE: the seenCommit is local justification to commit this block, @@ -1346,8 +1349,6 @@ func (cs *ConsensusState) finalizeCommit(height int64) { cs.Logger.Info("Calling finalizeCommit on already stored block", "height", block.Height) } - fail.Fail() // XXX - // Write MetaMessage{Height+1} for this height, implying that the // blockstore has saved the block for height Height. // @@ -1366,8 +1367,6 @@ func (cs *ConsensusState) finalizeCommit(height int64) { panic(fmt.Sprintf("Failed to write %v msg to consensus wal due to %v. Check your FS and restart the node", meta, err)) } - fail.Fail() // XXX - // Create a copy of the state for staging and an event cache for txs. stateCopy := cs.state.Copy() @@ -1384,13 +1383,9 @@ func (cs *ConsensusState) finalizeCommit(height int64) { return } - fail.Fail() // XXX - // NewHeightStep! cs.updateToState(stateCopy) - fail.Fail() // XXX - // cs.StartTime is already set. // Schedule Round0 to start soon. cs.scheduleRound0(&cs.RoundState) 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/store.go b/tm2/pkg/bft/state/store.go index 9fb34279e5e..3526e81a637 100644 --- a/tm2/pkg/bft/state/store.go +++ b/tm2/pkg/bft/state/store.go @@ -107,17 +107,24 @@ func SaveState(db dbm.DB, state State) { func saveState(db dbm.DB, state State, key []byte) { nextHeight := state.LastBlockHeight + 1 - // If first block, save validators for block 1. - if nextHeight == 1 { + // If first block (standard genesis at height 1 or InitialHeight > 1), + // save the full validator set and consensus params at nextHeight. + // This is needed so that LoadValidators/LoadConsensusParams can find + // the data when processing the first real block. + isFirstBlock := nextHeight == 1 || loadValidatorsInfo(db, nextHeight) == nil + if isFirstBlock { // This extra logic due to Tendermint validator set changes being delayed 1 block. // It may get overwritten due to InitChain validator updates. - lastHeightVoteChanged := int64(1) - saveValidatorsInfo(db, nextHeight, lastHeightVoteChanged, state.Validators) + saveValidatorsInfo(db, nextHeight, nextHeight, state.Validators) + // Save full consensus params (not just a reference) by setting + // changeHeight == nextHeight. + saveConsensusParamsInfo(db, nextHeight, nextHeight, state.ConsensusParams) + } else { + // Save next consensus params (may be just a reference if unchanged). + saveConsensusParamsInfo(db, nextHeight, state.LastHeightConsensusParamsChanged, state.ConsensusParams) } // Save next validators. saveValidatorsInfo(db, nextHeight+1, state.LastHeightValidatorsChanged, state.NextValidators) - // Save next consensus params. - saveConsensusParamsInfo(db, nextHeight, state.LastHeightConsensusParamsChanged, state.ConsensusParams) db.SetSync(key, state.Bytes()) } diff --git a/tm2/pkg/bft/state/store_test.go b/tm2/pkg/bft/state/store_test.go index e9197cb05cd..bc0b7399da1 100644 --- a/tm2/pkg/bft/state/store_test.go +++ b/tm2/pkg/bft/state/store_test.go @@ -57,6 +57,83 @@ func TestStoreLoadValidators(t *testing.T) { assert.NotZero(t, loadedVals.Size()) } +// TestLoadValidatorsAtInitialHeight verifies that when a genesis has +// InitialHeight > 1, validators are saved and loadable at that height. +// This test demonstrates a bug: saveState only saves validators when +// nextHeight == 1, so InitialHeight > 1 causes LoadValidators to fail. +func TestLoadValidatorsAtInitialHeight(t *testing.T) { + t.Parallel() + + val, _ := types.RandValidator(true, 10) + vals := []*types.Validator{val} + genDoc := &types.GenesisDoc{ + ChainID: "test-chain", + InitialHeight: 50, + Validators: []types.GenesisValidator{ + {Address: val.Address, PubKey: val.PubKey, Power: 10}, + }, + } + require.NoError(t, genDoc.ValidateAndComplete()) + + state, err := sm.MakeGenesisState(genDoc) + require.NoError(t, err) + + // Simulate what the Handshaker does after InitChain with InitialHeight > 1: + // it sets LastBlockHeight = InitialHeight - 1. + state.LastBlockHeight = 49 // InitialHeight - 1 + + stateDB := memdb.NewMemDB() + sm.SaveState(stateDB, state) + + // Should be able to load validators at InitialHeight (50). + // BUG: This fails with NoValSetForHeightError because saveState only + // saves validators at nextHeight when nextHeight == 1. + loadedVals, err := sm.LoadValidators(stateDB, 50) + require.NoError(t, err, "should load validators at InitialHeight") + require.NotNil(t, loadedVals) + assert.Equal(t, len(vals), loadedVals.Size()) +} + +// TestLoadConsensusParamsAtInitialHeight verifies that consensus params are +// saved and loadable at InitialHeight when InitialHeight > 1. +// This test demonstrates the same class of bug as TestLoadValidatorsAtInitialHeight +// but for consensus params. +func TestLoadConsensusParamsAtInitialHeight(t *testing.T) { + t.Parallel() + + val, _ := types.RandValidator(true, 10) + genDoc := &types.GenesisDoc{ + ChainID: "test-chain", + InitialHeight: 50, + Validators: []types.GenesisValidator{ + {Address: val.Address, PubKey: val.PubKey, Power: 10}, + }, + } + require.NoError(t, genDoc.ValidateAndComplete()) + + state, err := sm.MakeGenesisState(genDoc) + require.NoError(t, err) + + // Simulate Handshaker setting LastBlockHeight = InitialHeight - 1. + state.LastBlockHeight = 49 + + stateDB := memdb.NewMemDB() + sm.SaveState(stateDB, state) + + // Should be able to load consensus params at InitialHeight (50). + // BUG: saveConsensusParamsInfo stores at nextHeight=50 with + // changeHeight=1 (LastHeightConsensusParamsChanged defaults to 1 from + // MakeGenesisState). Since changeHeight(1) != nextHeight(50), only a + // reference is saved (not the full params). When loading at height 50, + // it finds empty params, looks up LastHeightChanged=1, which doesn't + // exist => panic. + require.NotPanics(t, func() { + params, err := sm.LoadConsensusParams(stateDB, 50) + require.NoError(t, err, "should load consensus params at InitialHeight") + assert.NotEmpty(t, params.Block) + }, "LoadConsensusParams should not panic at InitialHeight") +} + func BenchmarkLoadValidators(b *testing.B) { const valSetSize = 100 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 ec08ef1de03..8e414ea8205 100644 --- a/tm2/pkg/bft/store/store.go +++ b/tm2/pkg/bft/store/store.go @@ -160,8 +160,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") @@ -200,7 +205,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 f3cd14676d7..7fe638b5e44 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", }, { @@ -449,3 +453,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..4de89d83844 100644 --- a/tm2/pkg/bft/types/block.go +++ b/tm2/pkg/bft/types/block.go @@ -65,10 +65,26 @@ func (b *Block) ValidateBasic() error { } // Validate the last commit and its hash. - if b.Header.Height > 1 { + // The genesis block (height 1 for standard genesis, or InitialHeight for + // chains starting at a higher height) has an empty last commit and a zero + // LastBlockID. Non-genesis blocks must have a valid commit. + // + // ValidateBasic is stateless: it cannot know InitialHeight, so we allow + // a zero-LastBlockID block to skip full commit validation ONLY when the + // commit is also nil/empty. This prevents an attacker from zeroing + // LastBlockID on a real block while keeping a crafted commit with + // precommits. Full genesis validation is done in the stateful + // ValidateBlock (validation.go) via state.LastBlockID.IsZero(). + isGenesisBlock := b.Height == 1 || (b.Header.LastBlockID.IsZero() && (b.LastCommit == nil || b.LastCommit.Size() == 0)) + if !isGenesisBlock { if b.LastCommit == nil { return errors.New("nil LastCommit") } + // A zero LastBlockID with a non-empty commit is inconsistent: + // genesis blocks have no previous block to commit to. + if b.Header.LastBlockID.IsZero() && b.LastCommit.Size() > 0 { + return errors.New("zero LastBlockID with non-empty LastCommit") + } if err := b.LastCommit.ValidateBasic(); err != nil { return fmt.Errorf("wrong LastCommit") } diff --git a/tm2/pkg/bft/types/block_test.go b/tm2/pkg/bft/types/block_test.go index 60152b82ef6..d4e5ced09b6 100644 --- a/tm2/pkg/bft/types/block_test.go +++ b/tm2/pkg/bft/types/block_test.go @@ -62,6 +62,7 @@ func TestBlockValidateBasic(t *testing.T) { t.Parallel() block := MakeBlock(h, txs, commit) + block.Header.LastBlockID = lastID block.ProposerAddress = valSet.GetProposer().Address tc.malleateBlock(block) err = block.ValidateBasic() @@ -70,6 +71,51 @@ func TestBlockValidateBasic(t *testing.T) { } } +// TestValidateBasicRejectsZeroLastBlockIDAtNonGenesisHeight tests that +// ValidateBasic rejects blocks that try to bypass LastCommit validation. +// +// An attacker could try to skip commit validation by: +// 1. Zeroing LastBlockID and setting nil LastCommit (mimics genesis) +// 2. Setting a real LastBlockID but nil LastCommit +// 3. Zeroing LastBlockID but keeping precommits in LastCommit +// +// Case 1 is indistinguishable from a legitimate genesis block in a stateless +// check — it's caught by the stateful ValidateBlock instead. +// Cases 2 and 3 must be caught by ValidateBasic. +func TestValidateBasicRejectsZeroLastBlockIDAtNonGenesisHeight(t *testing.T) { + t.Parallel() + + t.Run("non-zero LastBlockID with nil LastCommit", func(t *testing.T) { + t.Parallel() + + block := MakeBlock(50, []Tx{Tx("tx1")}, nil) + // Set a real LastBlockID — this is NOT a genesis block. + block.Header.LastBlockID = makeBlockIDRandom() + + err := block.ValidateBasic() + require.Error(t, err, "should reject block with real LastBlockID but nil LastCommit") + assert.Contains(t, err.Error(), "nil LastCommit") + }) + + t.Run("zero LastBlockID with non-empty LastCommit", func(t *testing.T) { + t.Parallel() + + // Create a valid block first, then tamper with it. + lastID := makeBlockIDRandom() + voteSet, _, vals := randVoteSet(49, 1, PrecommitType, 10, 1) + commit, err := MakeCommit(lastID, 49, 1, voteSet, vals) + require.NoError(t, err) + + block := MakeBlock(50, []Tx{Tx("tx1")}, commit) + // Zero out LastBlockID to try to look like genesis, but keep + // the real commit with precommits. + block.Header.LastBlockID = BlockID{} + + err = block.ValidateBasic() + require.Error(t, err, "should reject block with zeroed LastBlockID but non-empty LastCommit") + }) +} + func TestBlockHash(t *testing.T) { t.Parallel() diff --git a/tm2/pkg/bft/types/genesis.go b/tm2/pkg/bft/types/genesis.go index b09cdfc101b..e4fbabb224e 100644 --- a/tm2/pkg/bft/types/genesis.go +++ b/tm2/pkg/bft/types/genesis.go @@ -22,6 +22,7 @@ var ( ErrEmptyChainID = errors.New("chain ID is empty") ErrLongChainID = fmt.Errorf("chain ID cannot be longer than %d chars", MaxChainIDLen) ErrInvalidGenesisTime = errors.New("invalid genesis time") + ErrInvalidInitialHeight = errors.New("initial height must be non-negative") ErrNoValidators = errors.New("no validators in set") ErrInvalidValidatorVotingPower = errors.New("validator has no voting power") ErrInvalidValidatorAddress = errors.New("invalid validator address") @@ -46,6 +47,7 @@ type GenesisValidator struct { type GenesisDoc struct { GenesisTime time.Time `json:"genesis_time"` ChainID string `json:"chain_id"` + InitialHeight int64 `json:"initial_height,omitempty"` ConsensusParams abci.ConsensusParams `json:"consensus_params,omitempty"` Validators []GenesisValidator `json:"validators,omitempty"` AppHash []byte `json:"app_hash"` @@ -88,6 +90,11 @@ func (genDoc *GenesisDoc) Validate() error { return ErrInvalidGenesisTime } + // Make sure the initial height is non-negative + if genDoc.InitialHeight < 0 { + return ErrInvalidInitialHeight + } + // Validate the consensus params if consensusParamsErr := ValidateConsensusParams(genDoc.ConsensusParams); consensusParamsErr != nil { return consensusParamsErr @@ -129,6 +136,10 @@ func (genDoc *GenesisDoc) ValidateAndComplete() error { return errors.New("chain_id in genesis doc is too long (max: %d)", MaxChainIDLen) } + if genDoc.InitialHeight < 0 { + return errors.New("initial_height in genesis doc must be non-negative") + } + // Start from defaults and fill in consensus params from GenesisDoc. genDoc.ConsensusParams = DefaultConsensusParams().Update(genDoc.ConsensusParams) if err := ValidateConsensusParams(genDoc.ConsensusParams); err != nil { diff --git a/tm2/pkg/bft/types/genesis_test.go b/tm2/pkg/bft/types/genesis_test.go index 24c69c6a28e..6c676b5afe5 100644 --- a/tm2/pkg/bft/types/genesis_test.go +++ b/tm2/pkg/bft/types/genesis_test.go @@ -251,4 +251,58 @@ func TestGenesis_Validate(t *testing.T) { assert.ErrorIs(t, g.Validate(), ErrValidatorPubKeyMismatch) }) + + t.Run("valid initial height", func(t *testing.T) { + t.Parallel() + + g := getValidTestGenesis() + g.InitialHeight = 1000 + + require.NoError(t, g.Validate()) + }) + + t.Run("invalid initial height (negative)", func(t *testing.T) { + t.Parallel() + + g := getValidTestGenesis() + g.InitialHeight = -1 + + assert.ErrorIs(t, g.Validate(), ErrInvalidInitialHeight) + }) +} + +func TestGenesisDoc_ValidateAndComplete_InitialHeight(t *testing.T) { + t.Parallel() + + pubkey := ed25519.GenPrivKey().PubKey() + newDoc := func() *GenesisDoc { + return &GenesisDoc{ + ChainID: "test-chain", + Validators: []GenesisValidator{{pubkey.Address(), pubkey, 10, "val"}}, + } + } + + t.Run("negative initial height rejected", func(t *testing.T) { + t.Parallel() + + g := newDoc() + g.InitialHeight = -1 + assert.Error(t, g.ValidateAndComplete()) + }) + + t.Run("zero initial height accepted", func(t *testing.T) { + t.Parallel() + + g := newDoc() + g.InitialHeight = 0 + assert.NoError(t, g.ValidateAndComplete()) + }) + + t.Run("positive initial height accepted", func(t *testing.T) { + t.Parallel() + + g := newDoc() + g.InitialHeight = 100 + assert.NoError(t, g.ValidateAndComplete()) + }) } diff --git a/tm2/pkg/sdk/auth/ante.go b/tm2/pkg/sdk/auth/ante.go index 1e2327f0f53..cd233b273f5 100644 --- a/tm2/pkg/sdk/auth/ante.go +++ b/tm2/pkg/sdk/auth/ante.go @@ -406,6 +406,13 @@ func EnsureSufficientMempoolFees(ctx sdk.Context, fee std.Fee) sdk.Result { )) } +// SkipGasMeteringKey is a context key used to bypass gas metering for +// historical tx replay during chain upgrades. When set on the context, +// SetGasMeter installs an infinite gas meter even for non-genesis blocks. +// Used by gnoland's GasReplayMode="source" during genesis replay to +// preserve source-chain outcomes when gas requirements have changed. +type SkipGasMeteringKey struct{} + // SetGasMeter returns a new context with a gas meter set from a given context. func SetGasMeter(ctx sdk.Context, gasLimit int64) sdk.Context { // In various cases such as simulation and during the genesis block, we do not @@ -414,6 +421,12 @@ func SetGasMeter(ctx sdk.Context, gasLimit int64) sdk.Context { return ctx.WithGasMeter(store.NewInfiniteGasMeter()) } + // Historical tx replay in source-gas mode: bypass the new VM's gas meter + // so source-chain outcomes are preserved regardless of gas-metering changes. + if skip, _ := ctx.Value(SkipGasMeteringKey{}).(bool); skip { + return ctx.WithGasMeter(store.NewInfiniteGasMeter()) + } + return ctx.WithGasMeter(store.NewGasMeter(gasLimit)) } diff --git a/tm2/pkg/sdk/auth/ante_test.go b/tm2/pkg/sdk/auth/ante_test.go index ef56885dc3c..f15c0958e31 100644 --- a/tm2/pkg/sdk/auth/ante_test.go +++ b/tm2/pkg/sdk/auth/ante_test.go @@ -948,3 +948,54 @@ func TestInvalidUserFee(t *testing.T) { require.False(t, res2.IsOK()) assert.Contains(t, res2.Log, "Gas price denominations should be equal;") } + +// TestSetGasMeter_SkipGasMeteringKey verifies that setting the +// SkipGasMeteringKey context value causes SetGasMeter to return an infinite +// gas meter, even for non-genesis heights. Used by gnoland's +// GasReplayMode="source" to preserve source-chain outcomes during hardfork +// replay. +func TestSetGasMeter_SkipGasMeteringKey(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + ctx := env.ctx // default height 1 (not genesis) + + t.Run("default meters gas", func(t *testing.T) { + t.Parallel() + got := SetGasMeter(ctx, 1000) + // Bounded meter: consuming >1000 should panic. + require.Panics(t, func() { + got.GasMeter().ConsumeGas(2000, "test") + }) + }) + + t.Run("SkipGasMeteringKey yields infinite meter", func(t *testing.T) { + t.Parallel() + skipCtx := ctx.WithValue(SkipGasMeteringKey{}, true) + got := SetGasMeter(skipCtx, 1000) + // Infinite meter: should handle consumption way beyond gasLimit. + require.NotPanics(t, func() { + got.GasMeter().ConsumeGas(1_000_000_000, "test") + }) + }) + + t.Run("SkipGasMeteringKey=false uses bounded meter", func(t *testing.T) { + t.Parallel() + noSkipCtx := ctx.WithValue(SkipGasMeteringKey{}, false) + got := SetGasMeter(noSkipCtx, 1000) + require.Panics(t, func() { + got.GasMeter().ConsumeGas(2000, "test") + }) + }) + + t.Run("genesis height stays infinite regardless of key", func(t *testing.T) { + t.Parallel() + header := ctx.BlockHeader().(*bft.Header) + header.Height = 0 + genCtx := ctx.WithBlockHeader(header) + got := SetGasMeter(genCtx, 1000) + require.NotPanics(t, func() { + got.GasMeter().ConsumeGas(10_000_000, "test") + }) + }) +} diff --git a/tm2/pkg/sdk/auth/keeper.go b/tm2/pkg/sdk/auth/keeper.go index ab376944738..be51f5bd7e9 100644 --- a/tm2/pkg/sdk/auth/keeper.go +++ b/tm2/pkg/sdk/auth/keeper.go @@ -141,6 +141,39 @@ func (ak AccountKeeper) GetSequence(ctx sdk.Context, addr crypto.Address) (uint6 return acc.GetSequence(), nil } +// NewAccountWithNumber creates an account with a specific account number, +// bypassing the auto-increment counter. Updates the global counter if the +// given number would cause future collisions. Used during hardfork genesis +// replay where accounts must be created with their original account numbers. +func (ak AccountKeeper) NewAccountWithNumber(ctx sdk.Context, addr crypto.Address, accNum uint64) std.Account { + acc := ak.proto() + if err := acc.SetAddress(addr); err != nil { + panic(err) + } + if err := acc.SetAccountNumber(accNum); err != nil { + panic(err) + } + + // Read global counter directly (don't call GetNextAccountNumber — + // it has side effects: reads AND increments). + stor := ctx.GasStore(ak.key) + bz := stor.Get([]byte(GlobalAccountNumberKey)) + var currentNum uint64 + if bz != nil { + if err := amino.Unmarshal(bz, ¤tNum); err != nil { + panic(err) + } + } + + // Update counter if our number would cause collisions. + if accNum >= currentNum { + bz = amino.MustMarshal(accNum + 1) + stor.Set([]byte(GlobalAccountNumberKey), bz) + } + + return acc +} + // GetNextAccountNumber Returns and increments the global account number counter func (ak AccountKeeper) GetNextAccountNumber(ctx sdk.Context) uint64 { var accNumber uint64 diff --git a/tm2/pkg/sdk/auth/keeper_test.go b/tm2/pkg/sdk/auth/keeper_test.go index 4622fba1a87..f3e3fc189cb 100644 --- a/tm2/pkg/sdk/auth/keeper_test.go +++ b/tm2/pkg/sdk/auth/keeper_test.go @@ -1,6 +1,7 @@ package auth import ( + "fmt" "math/big" "testing" @@ -176,3 +177,85 @@ func TestCalcBlockGasPrice(t *testing.T) { newGasPrice = gk.calcBlockGasPrice(lastGasPrice, gasUsed, maxGas, params) require.Equal(t, int64(100), newGasPrice.Price.Amount) } + +func TestNewAccountWithNumber(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + addr := crypto.AddressFromPreimage([]byte("test-addr-1")) + + // Create account with specific number + acc := env.acck.NewAccountWithNumber(env.ctx, addr, 42) + require.NotNil(t, acc) + require.Equal(t, addr, acc.GetAddress()) + require.EqualValues(t, 42, acc.GetAccountNumber()) + require.EqualValues(t, 0, acc.GetSequence()) + + // Global counter should be updated to 43 + nextNum := env.acck.GetNextAccountNumber(env.ctx) + require.EqualValues(t, 43, nextNum) + // GetNextAccountNumber increments, so next call returns 44 + nextNum2 := env.acck.GetNextAccountNumber(env.ctx) + require.EqualValues(t, 44, nextNum2) +} + +func TestNewAccountWithNumber_Zero(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + addr := crypto.AddressFromPreimage([]byte("test-addr-zero")) + + // Account number 0 is valid (first account) + acc := env.acck.NewAccountWithNumber(env.ctx, addr, 0) + require.NotNil(t, acc) + require.EqualValues(t, 0, acc.GetAccountNumber()) + + // Global counter should be 1 + nextNum := env.acck.GetNextAccountNumber(env.ctx) + require.EqualValues(t, 1, nextNum) +} + +func TestNewAccountWithNumber_DoesNotLowerCounter(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + + // Create some accounts to advance the counter + for i := range 5 { + addr := crypto.AddressFromPreimage([]byte(fmt.Sprintf("addr-%d", i))) + acc := env.acck.NewAccountWithAddress(env.ctx, addr) + env.acck.SetAccount(env.ctx, acc) + } + // Counter is now 5 + + // Create account with number lower than counter + addr := crypto.AddressFromPreimage([]byte("low-number-addr")) + acc := env.acck.NewAccountWithNumber(env.ctx, addr, 2) + require.NotNil(t, acc) + require.EqualValues(t, 2, acc.GetAccountNumber()) + + // Counter should still be 5, not lowered to 3 + nextNum := env.acck.GetNextAccountNumber(env.ctx) + require.EqualValues(t, 5, nextNum) +} + +func TestNewAccountWithNumber_HighNumber(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + + // Create account with high number (simulating hardfork replay) + addr := crypto.AddressFromPreimage([]byte("high-number-addr")) + acc := env.acck.NewAccountWithNumber(env.ctx, addr, 1000000) + require.NotNil(t, acc) + require.EqualValues(t, 1000000, acc.GetAccountNumber()) + + // Counter should jump to 1000001 + nextNum := env.acck.GetNextAccountNumber(env.ctx) + require.EqualValues(t, 1000001, nextNum) + + // Normal account creation should get 1000002 + addr2 := crypto.AddressFromPreimage([]byte("normal-addr")) + acc2 := env.acck.NewAccountWithAddress(env.ctx, addr2) + require.EqualValues(t, 1000002, acc2.GetAccountNumber()) +} diff --git a/tm2/pkg/sdk/auth/types.go b/tm2/pkg/sdk/auth/types.go index 4965122bf67..476de201d25 100644 --- a/tm2/pkg/sdk/auth/types.go +++ b/tm2/pkg/sdk/auth/types.go @@ -9,6 +9,7 @@ import ( // AccountKeeper manages access to accounts. type AccountKeeperI interface { NewAccountWithAddress(ctx sdk.Context, addr crypto.Address) std.Account + NewAccountWithNumber(ctx sdk.Context, addr crypto.Address, accNum uint64) std.Account GetAccount(ctx sdk.Context, addr crypto.Address) std.Account GetAllAccounts(ctx sdk.Context) []std.Account SetAccount(ctx sdk.Context, acc std.Account) diff --git a/tm2/pkg/sdk/baseapp.go b/tm2/pkg/sdk/baseapp.go index d8809e240d6..4adebaa1840 100644 --- a/tm2/pkg/sdk/baseapp.go +++ b/tm2/pkg/sdk/baseapp.go @@ -301,10 +301,29 @@ func (app *BaseApp) getMaximumBlockGas() int64 { func (app *BaseApp) Info(req abci.RequestInfo) (res abci.ResponseInfo) { lastCommitID := app.cms.LastCommitID() - // return res res.Data = []byte(app.Name()) res.LastBlockHeight = lastCommitID.Version res.LastBlockAppHash = lastCommitID.Hash + + // When InitialHeight > 1 (chain upgrades), the multistore version counter + // starts from 0 and auto-increments, so it may be lower than the actual + // block height. If we have a persisted header from a previous Commit, use + // its height as the authoritative value. + // + // Only attempt this if at least one commit has landed — otherwise the + // multistore hasn't been loaded with stores yet (e.g. in unit tests that + // call Info before LoadLatestVersion), and GetStore would panic. + if lastCommitID.Version > 0 && app.baseKey != nil { + baseStore := app.cms.GetStore(app.baseKey) + if baseStore != nil { + if headerBz := baseStore.Get(mainLastHeaderKey); headerBz != nil { + var header bft.Header + if err := amino.Unmarshal(headerBz, &header); err == nil && header.Height > res.LastBlockHeight { + res.LastBlockHeight = header.Height + } + } + } + } return } @@ -515,8 +534,27 @@ func (app *BaseApp) validateHeight(req abci.RequestBeginBlock) error { } prevHeight := app.LastBlockHeight() - if req.Header.GetHeight() != prevHeight+1 { - return fmt.Errorf("invalid height: %d; expected: %d", 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 { + return nil + } + + // Normal sequential check: next block should be prevHeight+1. + // However, with InitialHeight > 1, the multistore version counter starts + // from 0 and auto-increments, so prevHeight (store version) can be less + // than the actual block height. In that case, we allow the jump as long as + // the height is increasing. + expected := prevHeight + 1 + actual := req.Header.GetHeight() + if actual != expected && actual > prevHeight { + // Allow height jump — this happens when InitialHeight > 1 causes the + // store version to lag behind the block height. + return nil + } + if actual != expected { + return fmt.Errorf("invalid height: %d; expected: %d", actual, expected) } return nil diff --git a/tm2/pkg/sdk/baseapp_test.go b/tm2/pkg/sdk/baseapp_test.go index 7cda311a697..5e140ae3a29 100644 --- a/tm2/pkg/sdk/baseapp_test.go +++ b/tm2/pkg/sdk/baseapp_test.go @@ -1325,3 +1325,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}, + }) + }) +} From 2dfe0b993aa37c8d0aca60014d2b6a4f44d12c83 Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 21 Apr 2026 14:28:02 +0200 Subject: [PATCH 02/92] fix(hf-glue): point replay-log.sh at gnogenesis fork test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #5486 commit bd3580d7b ("refactor: absorb misc/hardfork into 'gnogenesis fork' subcommand") moved misc/hardfork/ into contribs/gnogenesis/internal/fork/ and rewired the CLI: misc/hardfork test → gnogenesis fork test That refactor updated misc/deployments/gnoland-1/generate-genesis.sh but missed the references in misc/hf-glue/. As a result, scripts/replay-log.sh (invoked by `make replay-log` and `make reports`) still does: cd "$REPO/misc/hardfork" go run . test ... which fails with "No such file or directory" because misc/hardfork no longer exists on the PR head. Update the cd target to contribs/gnogenesis and the subcommand to `fork test` to match the new layout. Other stale refs in misc/hf-glue/ (Makefile smoketest target, fetch-from-dir.sh, README, lib/hf.sh comment) are also broken but addressed in separate commits / left as docs. --- misc/hf-glue/scripts/replay-log.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/misc/hf-glue/scripts/replay-log.sh b/misc/hf-glue/scripts/replay-log.sh index c2d26710099..d6856279c1a 100755 --- a/misc/hf-glue/scripts/replay-log.sh +++ b/misc/hf-glue/scripts/replay-log.sh @@ -27,8 +27,8 @@ echo " genesis: $GENESIS" echo " log: $LOG" echo "" -cd "$REPO/misc/hardfork" -go run . test \ +cd "$REPO/contribs/gnogenesis" +go run . fork test \ --genesis "$GENESIS" \ --verbose \ --timeout 30m 2>&1 | tee "$LOG" From fa9f9dc6bac15aa0edeadd19bead1c0649fdecfa Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 21 Apr 2026 14:42:47 +0200 Subject: [PATCH 03/92] fix(hf-glue): tolerate empty grep in report-replay.sh missing_imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/report-replay.sh runs under `set -euo pipefail`. The assignment: missing_imports=$(grep -oE 'could not import gno\.land/[^ \"\\]+' "$tmp" \ | sort | uniq -c | sort -rn | head -20) fails the whole script when the [FAIL] log contains no "could not import" lines: grep exits 1, pipefail propagates the non-zero status, and the outer command substitution triggers set -e. This is the happy path when a hardfork replay has no cascade failures — e.g. the current run on chain/test13-base has exactly one root-cause failure (r/sys/txfees storage deposit) and zero cascades, so the pipeline returned empty and aborted the script before the report was written. Append `|| true` to the pipeline so an empty result is treated as "missing_imports is empty", consistent with how the adjacent root_cause_fails assignment already handles it. --- misc/hf-glue/scripts/report-replay.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/hf-glue/scripts/report-replay.sh b/misc/hf-glue/scripts/report-replay.sh index c9db237d219..ee716c9ab94 100755 --- a/misc/hf-glue/scripts/report-replay.sh +++ b/misc/hf-glue/scripts/report-replay.sh @@ -43,7 +43,7 @@ other=$((total_fail - pubkey_mismatch - cascade_import - type_check - insufficie # Cascade = "could not import gno.land/..." — the package itself is fine, # its dep failed earlier. Surface the DEPS that are missing instead. missing_imports=$(grep -oE 'could not import gno\.land/[^ \"\\]+' "$tmp" \ - | sort | uniq -c | sort -rn | head -20) + | sort | uniq -c | sort -rn | head -20 || true) # --- packages that failed with a non-import reason (potential root causes) ---- # Grab the addpkg package path from fail lines that do NOT mention "could not import". From a8c05f718a04f180cae7851133d6a26aff467a0d Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 21 Apr 2026 14:51:16 +0200 Subject: [PATCH 04/92] fix(hf-glue): disambiguate $LOG var in report-replay.sh markdown header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/report-replay.sh runs under `set -u`. The line: echo "_Generated $(date ...) from $LOG_" is parsed as expanding a variable named `LOG_` (trailing underscore is a valid identifier character), not `LOG` followed by a literal underscore. Since $LOG_ is unbound, set -u aborts the script with: line 58: LOG_: unbound variable The trailing underscore was intended as the closing italic marker for the markdown header `_Generated ... from /path/to/replay.log_`. Wrap the variable in braces — `${LOG}_` — so bash parses it as $LOG followed by a literal underscore, preserving the markdown emphasis. --- misc/hf-glue/scripts/report-replay.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/hf-glue/scripts/report-replay.sh b/misc/hf-glue/scripts/report-replay.sh index ee716c9ab94..ac0e9944c7f 100755 --- a/misc/hf-glue/scripts/report-replay.sh +++ b/misc/hf-glue/scripts/report-replay.sh @@ -55,7 +55,7 @@ root_cause_fails=$(grep -v 'could not import' "$tmp" \ { echo "# Genesis Replay Report" echo "" - echo "_Generated $(date -u +%Y-%m-%dT%H:%M:%SZ) from $LOG_" + echo "_Generated $(date -u +%Y-%m-%dT%H:%M:%SZ) from ${LOG}_" echo "" echo "## Summary" echo "" From 7afc5df7e71cff14ca127b3fa2ff59f5df022564 Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 21 Apr 2026 16:05:08 +0200 Subject: [PATCH 05/92] feat(hf-glue): add hf_topup_balance escape hatch for replay invariant failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause ---------- master's storage-deposit code (4e1745ab4, #5415) locks a deposit from msg.Creator for every addpkg, proportional to realm size. This feature did not exist on gnoland1. The 85 genesis-mode addpkg txs in the gnoland1 snapshot were all signed with max_deposit="" (the field did not exist yet). Under the new SDK the empty field defaults to the parameter ceiling (600 Mugnot), so the actual transfer is `diff × 100 ugnot` per realm. The creator g1r929wt2qplfawe4lvqv9zuwfdcz4vxdun7qh8l deploys 7 consecutive r/sys/* realms in genesis-mode, depleting its 58.7 Mugnot balance. The 8th deploy (r/sys/txfees) then fails with insufficient funds. Every downstream realm that imports r/sys/txfees cascades. Why cherry-pick is not viable ------------------------------ The realm code on master is byte-for-byte identical to gnoland1's. The gap is purely between old unsigned tx format and new SDK semantics. There is nothing to cherry-pick: the fix would have to be a new feature (e.g. a genesis-mode VM bypass), which is a larger PR outside this testbed's scope. --skip-genesis-sig-verification is already set; leaving the payload stable is the honest choice. Design: hf_topup_balance ------------------------- New DSL primitive in lib/hf.sh. Called from migrate.sh after the realm patches section. Applied in a post-assembly Python pass that: • opens $OUT/genesis.json • increments the target address's balance line (or appends if absent) • writes the mutated genesis back in place • writes $OUT/TOPUP-REPORT.md — a persistent audit table with before / after / delta / reason for every synthetic change The Python path uses an indexed O(1) lookup (dict keyed by addr+denom) because app_state.balances is a multi-million-entry list on mainnet-scale snapshots and a linear scan would be too slow. After the pass, hf_assemble prints a red `hf_warn` banner: "N synthetic balance top-up(s) applied — see $OUT/TOPUP-REPORT.md" This is intentionally noisy: synthetic state must not be silent. Visibility in STATE-REPORT.md ------------------------------ check-state.sh now reads $OUT/TOPUP-REPORT.md (when present) and emits a "⚠ Synthetic state modifications" table that probes the topped-up addresses on both local and prod chains. A reviewer looking at STATE-REPORT.md sees the full divergence picture: local/prod deltas for these addresses are expected and documented, not replay bugs. Files changed ------------- scripts/lib/hf.sh — new _HF_TOPUPS state var, hf_warn helper, hf_topup_balance DSL function, _hf_apply_topups internal, hf_assemble calls _hf_apply_topups scripts/migrate.sh — section 3b calls hf_topup_balance for the r/sys/* creator with a 1 Bugnot top-up and a documented reason scripts/check-state.sh — synthetic modifications section in STATE-REPORT Result: gnogenesis fork test now reports 2696/2696 OK, 0 failures. --- misc/hf-glue/scripts/check-state.sh | 75 ++++++++---- misc/hf-glue/scripts/lib/hf.sh | 177 +++++++++++++++++++++++----- misc/hf-glue/scripts/migrate.sh | 39 ++++-- 3 files changed, 231 insertions(+), 60 deletions(-) diff --git a/misc/hf-glue/scripts/check-state.sh b/misc/hf-glue/scripts/check-state.sh index 38d7facd76d..25b4925b769 100755 --- a/misc/hf-glue/scripts/check-state.sh +++ b/misc/hf-glue/scripts/check-state.sh @@ -47,9 +47,9 @@ qrender() { # Query chain status as JSON chain_status() { local rpc="$1" - curl -sS --max-time 5 "${rpc}/status" 2>/dev/null \ - | jq -c '{chain_id: .result.node_info.network, latest_block: .result.sync_info.latest_block_height, catching_up: .result.sync_info.catching_up}' \ - 2>/dev/null || echo '"unreachable"' + curl -sS --max-time 5 "${rpc}/status" 2>/dev/null | + jq -c '{chain_id: .result.node_info.network, latest_block: .result.sync_info.latest_block_height, catching_up: .result.sync_info.catching_up}' \ + 2>/dev/null || echo '"unreachable"' } # Query account balance via auth/accounts @@ -96,13 +96,14 @@ account_info() { "gno.land/r/gnoland/blog:" \ "gno.land/r/gnoland/valopers:" \ "gno.land/r/gnoland/coins:" \ - "gno.land/r/gnoland/wugnot:" \ - ; do + "gno.land/r/gnoland/wugnot:"; do l=$(qrender "$LOCAL_RPC" "$realm") - p=$(qrender "$PROD_RPC" "$realm") + p=$(qrender "$PROD_RPC" "$realm") # collapse to status icon - lt="❌ $l"; [[ $l == OK:* ]] && lt="✅" - pt="❌ $p"; [[ $p == OK:* ]] && pt="✅" + lt="❌ $l" + [[ $l == OK:* ]] && lt="✅" + pt="❌ $p" + [[ $p == OK:* ]] && pt="✅" echo "| \`$realm\` | $lt | $pt |" done echo "" @@ -111,9 +112,9 @@ account_info() { echo "| Chain | auth/accounts | r/gnoland/coins:balances |" echo "|-------|---------------|--------------------------|" la=$(account_info "$LOCAL_RPC" "$ADDRESS") - pa=$(account_info "$PROD_RPC" "$ADDRESS") + pa=$(account_info "$PROD_RPC" "$ADDRESS") lc=$(qrender "$LOCAL_RPC" "gno.land/r/gnoland/coins:balances?address=${ADDRESS}&coin" | head -c 120) - pc=$(qrender "$PROD_RPC" "gno.land/r/gnoland/coins:balances?address=${ADDRESS}&coin" | head -c 120) + pc=$(qrender "$PROD_RPC" "gno.land/r/gnoland/coins:balances?address=${ADDRESS}&coin" | head -c 120) echo "| Local | \`$la\` | \`$lc\` |" echo "| Prod | \`$pa\` | \`$pc\` |" echo "" @@ -121,32 +122,60 @@ account_info() { echo "" echo "### Local consensus" echo '```json' - curl -sS --max-time 5 "$LOCAL_RPC/consensus_params" 2>/dev/null \ - | jq '.result.consensus_params.Block' 2>/dev/null || echo "(unreachable)" + curl -sS --max-time 5 "$LOCAL_RPC/consensus_params" 2>/dev/null | + jq '.result.consensus_params.Block' 2>/dev/null || echo "(unreachable)" echo '```' echo "" echo "### Prod consensus" echo '```json' - curl -sS --max-time 5 "$PROD_RPC/consensus_params" 2>/dev/null \ - | jq '.result.consensus_params.Block' 2>/dev/null || echo "(unreachable)" + curl -sS --max-time 5 "$PROD_RPC/consensus_params" 2>/dev/null | + jq '.result.consensus_params.Block' 2>/dev/null || echo "(unreachable)" echo '```' echo "" echo "### Local gas price" echo '```json' - curl -sS --max-time 10 "$LOCAL_RPC/abci_query?path=%22auth%2Fgasprice%22" 2>/dev/null \ - | jq -r '.result.response.ResponseBase.Data // empty' \ - | base64 -d 2>/dev/null \ - | jq '.' 2>/dev/null || echo "(no data)" + curl -sS --max-time 10 "$LOCAL_RPC/abci_query?path=%22auth%2Fgasprice%22" 2>/dev/null | + jq -r '.result.response.ResponseBase.Data // empty' | + base64 -d 2>/dev/null | + jq '.' 2>/dev/null || echo "(no data)" echo '```' echo "" echo "### Prod gas price" echo '```json' - curl -sS --max-time 10 "$PROD_RPC/abci_query?path=%22auth%2Fgasprice%22" 2>/dev/null \ - | jq -r '.result.response.ResponseBase.Data // empty' \ - | base64 -d 2>/dev/null \ - | jq '.' 2>/dev/null || echo "(no data)" + curl -sS --max-time 10 "$PROD_RPC/abci_query?path=%22auth%2Fgasprice%22" 2>/dev/null | + jq -r '.result.response.ResponseBase.Data // empty' | + base64 -d 2>/dev/null | + jq '.' 2>/dev/null || echo "(no data)" echo '```' echo "" + # Surface synthetic balance top-ups (from hf_topup_balance) so a + # reviewer looking at STATE-REPORT.md sees the full picture: any + # address appearing here has a local/prod delta that is expected and + # documented, not a replay divergence. + if [[ -f "$OUT/TOPUP-REPORT.md" ]]; then + echo "## ⚠ Synthetic state modifications" + echo "" + echo "The hardfork replay ran against a genesis where the following" + echo "balances were synthetically increased by \`hf_topup_balance\`." + echo "These are NOT replay divergences — see \`out/TOPUP-REPORT.md\`" + echo "for the full audit trail with reasons." + echo "" + echo "| Address | Local balance | Prod balance | Reason |" + echo "|---------|--------------:|-------------:|--------|" + # Parse each table row from TOPUP-REPORT.md and probe both chains. + # Row shape: | `addr` | before | after | +delta | reason | + while IFS='|' read -r _ addr_cell _ _ _ reason_cell _; do + addr=$(printf '%s' "$addr_cell" | tr -d ' `') + [[ "$addr" =~ ^g1[0-9a-z]+$ ]] || continue + reason=$(printf '%s' "$reason_cell" | sed -e 's/^ *//' -e 's/ *$//') + la=$(account_info "$LOCAL_RPC" "$addr") + pa=$(account_info "$PROD_RPC" "$addr") + lc=$(printf '%s' "$la" | jq -r '.coins // "(n/a)"' 2>/dev/null || echo "(n/a)") + pc=$(printf '%s' "$pa" | jq -r '.coins // "(n/a)"' 2>/dev/null || echo "(n/a)") + echo "| \`$addr\` | $lc | $pc | $reason |" + done <"$OUT/TOPUP-REPORT.md" + echo "" + fi echo "## Visual comparison (open these side-by-side)" echo "" echo "| Page | Local | Prod |" @@ -155,7 +184,7 @@ account_info() { echo "| \`${p}\` | [$LOCAL_WEB/$p]($LOCAL_WEB/$p) | [$PROD_WEB/$p]($PROD_WEB/$p) |" done echo "" -} > "$REPORT" +} >"$REPORT" echo "Report written to: $REPORT" echo "" diff --git a/misc/hf-glue/scripts/lib/hf.sh b/misc/hf-glue/scripts/lib/hf.sh index 13204f1c590..80681b485e8 100755 --- a/misc/hf-glue/scripts/lib/hf.sh +++ b/misc/hf-glue/scripts/lib/hf.sh @@ -14,12 +14,13 @@ set -euo pipefail # ---- state ---------------------------------------------------------------- # Filled by the hf_* functions. Read at the end by hf_assemble. -_HF_STAGE="" # staging dir (gnoland-data layout) -_HF_STAGE_GEN="" # path to base genesis.json -_HF_STAGE_TXS="" # path to historical txs.jsonl (empty until step 2) -_HF_PATCHES=() # list of "pkgpath=srcdir" entries for --patch-realm -_HF_OVERLAYS=() # overlay tx files (pre-history, not yet supported) -_HF_MIGRATIONS=() # migration tx files (post-history, not yet supported) +_HF_STAGE="" # staging dir (gnoland-data layout) +_HF_STAGE_GEN="" # path to base genesis.json +_HF_STAGE_TXS="" # path to historical txs.jsonl (empty until step 2) +_HF_PATCHES=() # list of "pkgpath=srcdir" entries for --patch-realm +_HF_OVERLAYS=() # overlay tx files (pre-history, not yet supported) +_HF_MIGRATIONS=() # migration tx files (post-history, not yet supported) +_HF_TOPUPS=() # balance top-ups: "addr=amount=reason" (post-assemble) # ---- presentation --------------------------------------------------------- hf_banner() { @@ -35,6 +36,10 @@ hf_die() { exit 1 } +hf_warn() { + printf '\033[1;31m⚠ WARN:\033[0m %s\n' "$*" >&2 +} + # ---- setup ---------------------------------------------------------------- # hf_init — must be the first call. Prints a header, creates the staging dir. hf_init() { @@ -50,10 +55,10 @@ hf_init() { hf_banner "hardfork migration" hf_kv "original chain id" "$ORIGINAL_CHAIN_ID" - hf_kv "new chain id" "$CHAIN_ID" - hf_kv "halt height" "${HALT_HEIGHT:-}" - hf_kv "output genesis" "$OUT/genesis.json" - hf_kv "staging dir" "$_HF_STAGE" + hf_kv "new chain id" "$CHAIN_ID" + hf_kv "halt height" "${HALT_HEIGHT:-}" + hf_kv "output genesis" "$OUT/genesis.json" + hf_kv "staging dir" "$_HF_STAGE" echo "" } @@ -86,7 +91,7 @@ hf_fetch_genesis_from_rpc() { hf_kv "url" "$env" curl -fSL --retry 3 --retry-delay 5 --max-time 600 --progress-bar \ -o "$_HF_STAGE/envelope.json" "$env" - jq -c '.result.genesis' < "$_HF_STAGE/envelope.json" > "$_HF_STAGE_GEN" + jq -c '.result.genesis' <"$_HF_STAGE/envelope.json" >"$_HF_STAGE_GEN" rm -f "$_HF_STAGE/envelope.json" hf_kv "size" "$(_hf_size "$_HF_STAGE_GEN") bytes" } @@ -114,26 +119,26 @@ hf_fetch_txs_via_rpc() { local rpc="$1" hf_banner "step 2 — historical txs (RPC)" if [[ -z "${HALT_HEIGHT:-}" ]]; then - HALT_HEIGHT=$(curl -fsS --max-time 30 "${rpc%/}/status" \ - | jq -r '.result.sync_info.latest_block_height') + HALT_HEIGHT=$(curl -fsS --max-time 30 "${rpc%/}/status" | + jq -r '.result.sync_info.latest_block_height') hf_kv "halt (auto)" "$HALT_HEIGHT" else hf_kv "halt" "$HALT_HEIGHT" fi if [[ -f "$_HF_STAGE_TXS" ]]; then - hf_kv "cached" "$(wc -l < "$_HF_STAGE_TXS" | tr -d ' ') txs" + hf_kv "cached" "$(wc -l <"$_HF_STAGE_TXS" | tr -d ' ') txs" return 0 fi hf_kv "rpc" "$rpc" hf_kv "range" "1..$HALT_HEIGHT" - ( cd "$REPO/contribs/tx-archive" && go run ./cmd backup \ - -remote "$rpc" \ - -from-block 1 \ - -to-block "$HALT_HEIGHT" \ - -batch 1000 \ - -output-path "$_HF_STAGE_TXS" \ - -overwrite ) - hf_kv "total" "$(wc -l < "$_HF_STAGE_TXS" | tr -d ' ') txs" + (cd "$REPO/contribs/tx-archive" && go run ./cmd backup \ + -remote "$rpc" \ + -from-block 1 \ + -to-block "$HALT_HEIGHT" \ + -batch 1000 \ + -output-path "$_HF_STAGE_TXS" \ + -overwrite) + hf_kv "total" "$(wc -l <"$_HF_STAGE_TXS" | tr -d ' ') txs" } # hf_fetch_txs_from_jsonl PATH @@ -145,7 +150,7 @@ hf_fetch_txs_from_jsonl() { : "${HALT_HEIGHT:?HALT_HEIGHT is required when pulling txs from a file}" hf_kv "from" "$src" cp "$src" "$_HF_STAGE_TXS" - hf_kv "total" "$(wc -l < "$_HF_STAGE_TXS" | tr -d ' ') txs" + hf_kv "total" "$(wc -l <"$_HF_STAGE_TXS" | tr -d ' ') txs" } # hf_skip_txs @@ -154,7 +159,7 @@ hf_skip_txs() { hf_banner "step 2 — historical txs (none)" : "${HALT_HEIGHT:?HALT_HEIGHT is required when skipping tx pull}" hf_kv "halt" "$HALT_HEIGHT" - : > "$_HF_STAGE_TXS" + : >"$_HF_STAGE_TXS" } # ---- patches + overlays --------------------------------------------------- @@ -196,6 +201,29 @@ hf_migration_tx() { _HF_MIGRATIONS+=("$src") } +# hf_topup_balance ADDR AMOUNT [REASON] +# Add coins to ADDR in the assembled genesis balances. Used when a +# replay under newer code exposes an account that can't cover invariants +# that didn't exist when the original tx was signed. +# +# Concrete case: master's storage-deposit code (added after gnoland1 +# launched) locks a deposit from msg.Creator for every addpkg. A +# creator that deploys many realms in a row runs out of ugnot mid- +# replay. The original txs were signed with max_deposit="" (the field +# didn't exist yet) and can't be retroactively modified without +# invalidating signatures (though we currently run with +# --skip-genesis-sig-verification, leaving the payload stable is the +# honest move). Top up the creator instead. +# +# The top-up is applied as a post-process on $OUT/genesis.json after +# gnogenesis fork generate completes. Balances in the source genesis +# on disk stay untouched. +hf_topup_balance() { + local addr="$1" amount="$2" reason="${3:-unspecified}" + [[ -n "$addr" && -n "$amount" ]] || hf_die "hf_topup_balance: addr and amount are required" + _HF_TOPUPS+=("$addr=$amount=$reason") +} + # ---- step 3: assemble ----------------------------------------------------- # hf_assemble # Runs `gnogenesis fork generate` against the staged source dir, @@ -210,11 +238,11 @@ hf_assemble() { local args=( fork generate - --source "$_HF_STAGE" - --chain-id "$CHAIN_ID" + --source "$_HF_STAGE" + --chain-id "$CHAIN_ID" --original-chain-id "$ORIGINAL_CHAIN_ID" - --halt-height "$HALT_HEIGHT" - --output "$OUT/genesis.json" + --halt-height "$HALT_HEIGHT" + --output "$OUT/genesis.json" ) local p for p in "${_HF_PATCHES[@]:-}"; do @@ -229,7 +257,9 @@ hf_assemble() { args+=(--migration-tx "$m") done - ( cd "$REPO/contribs/gnogenesis" && go run . "${args[@]}" ) + (cd "$REPO/contribs/gnogenesis" && go run . "${args[@]}") + + _hf_apply_topups echo "" if command -v sha256sum >/dev/null 2>&1; then @@ -241,4 +271,91 @@ hf_assemble() { } # ---- internal ------------------------------------------------------------- -_hf_size() { wc -c < "$1" | tr -d ' '; } +_hf_size() { wc -c <"$1" | tr -d ' '; } + +# _hf_apply_topups +# Apply _HF_TOPUPS to $OUT/genesis.json in-place. Each entry adds the +# given amount to the target address's balance (creating the balance +# line if absent). Python is used because app_state.balances is a +# multi-million-entry list on mainnet-scale snapshots. +_hf_apply_topups() { + [[ ${#_HF_TOPUPS[@]} -gt 0 ]] || return 0 + hf_banner "post-assemble — balance top-ups" + local t + for t in "${_HF_TOPUPS[@]}"; do + hf_kv "topup" "$t" + done + + GENESIS="$OUT/genesis.json" TOPUP_REPORT="$OUT/TOPUP-REPORT.md" \ + python3 - "${_HF_TOPUPS[@]}" <<'PY' +import datetime +import json +import os +import re +import sys + +path = os.environ["GENESIS"] +report = os.environ["TOPUP_REPORT"] + +with open(path) as f: + g = json.load(f) + +bals = g.setdefault("app_state", {}).setdefault("balances", []) +entry_re = re.compile(r"^(g1[0-9a-z]+)=([0-9]+)([a-zA-Z]+)$") +coin_re = re.compile(r"^([0-9]+)([a-zA-Z]+)$") + +idx = {} +for i, line in enumerate(bals): + m = entry_re.match(line) + if m: + idx[(m.group(1), m.group(3))] = i + +applied = [] +for raw in sys.argv[1:]: + addr, amount, reason = raw.split("=", 2) + m = coin_re.match(amount) + if not m: + sys.exit(f"hf_topup_balance: invalid amount: {amount}") + n, denom = int(m.group(1)), m.group(2) + key = (addr, denom) + if key in idx: + old = entry_re.match(bals[idx[key]]) + prev = int(old.group(2)) + new_amt = prev + n + bals[idx[key]] = f"{addr}={new_amt}{denom}" + applied.append((addr, prev, new_amt, denom, reason)) + else: + bals.append(f"{addr}={n}{denom}") + applied.append((addr, 0, n, denom, reason)) + +with open(path, "w") as f: + json.dump(g, f, indent=2) + +# Audit trail: persistent report of every synthetic balance change. +with open(report, "w") as f: + f.write("# Synthetic balance top-ups\n\n") + f.write( + f"_Generated {datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds')}_\n\n" + ) + f.write( + "> ⚠ **These balances were injected into the hardfork genesis after\n" + "> `gnogenesis fork generate` ran.** The replay that follows is no\n" + "> longer a faithful reproduction of the source chain — divergence\n" + "> against prod is expected for the addresses listed below.\n" + ">\n" + "> Each entry documents the reason the top-up was needed. Prefer\n" + "> fixing the underlying issue (e.g. a VM bypass for genesis-mode\n" + "> txs predating a feature) over accumulating top-ups.\n\n" + ) + f.write("| Address | Before | After | Δ | Reason |\n") + f.write("|---------|-------:|------:|---:|--------|\n") + for addr, before, after, denom, reason in applied: + delta = after - before + f.write( + f"| `{addr}` | {before}{denom} | {after}{denom} | +{delta}{denom} | {reason} |\n" + ) +PY + + hf_kv "report" "$OUT/TOPUP-REPORT.md" + hf_warn "${#_HF_TOPUPS[@]} synthetic balance top-up(s) applied — see $OUT/TOPUP-REPORT.md" +} diff --git a/misc/hf-glue/scripts/migrate.sh b/misc/hf-glue/scripts/migrate.sh index f6514a5912e..e856ec23ef2 100755 --- a/misc/hf-glue/scripts/migrate.sh +++ b/misc/hf-glue/scripts/migrate.sh @@ -23,9 +23,9 @@ hf_init # Pick one. $SOURCE from the Makefile decides which branch runs. : "${SOURCE:=https://github.com/gnolang/gno/releases/download/chain/gnoland1.0/genesis.json}" case "$SOURCE" in - *.json|*/genesis.json) hf_fetch_genesis_from_url "$SOURCE" ;; - http://*|https://*) hf_fetch_genesis_from_rpc "$SOURCE" ;; - *) hf_fetch_genesis_from_file "$SOURCE" ;; +*.json | */genesis.json) hf_fetch_genesis_from_url "$SOURCE" ;; +http://* | https://*) hf_fetch_genesis_from_rpc "$SOURCE" ;; +*) hf_fetch_genesis_from_file "$SOURCE" ;; esac # ------------------------------------------------------------------------- @@ -51,6 +51,31 @@ for spec in ${PATCH_REALMS:-}; do hf_patch_addpkg "${spec%%=*}" "${spec#*=}" done +# ------------------------------------------------------------------------- +# 3b) BALANCE TOP-UPS (pre-replay synthetic seeding) +# ------------------------------------------------------------------------- +# Last-resort escape hatch when a genesis-mode tx would fail under newer +# code because an invariant that didn't exist when the tx was signed +# cannot be covered from the replay's starting state. +# +# This diverges the replayed state from the source chain — use only when +# the alternative is an incomplete replay. Every top-up is written to +# out/TOPUP-REPORT.md and surfaced in out/STATE-REPORT.md so the synthetic +# change is visible in the audit trail, not just in the fetch console. +# +# Current case (r/sys/txfees / tx_index=76): +# Under master's storage-deposit logic (added after gnoland1 launched), +# every addpkg *locks* a deposit from msg.Creator proportional to realm +# size. The creator g1r929wt2qplfawe4lvqv9zuwfdcz4vxdun7qh8l deploys 7 +# r/sys/* realms in a row and exhausts its 58.7 Mugnot balance at the +# 8th (r/sys/txfees). Since all 85 genesis-mode addpkg txs were signed +# with max_deposit="" (the field didn't exist on gnoland1) and the +# realm code on master is identical to gnoland1's, no cherry-pick fixes +# this — the gap is purely between old txs and new SDK semantics. +# Top up the creator so the locks fit. +hf_topup_balance "g1r929wt2qplfawe4lvqv9zuwfdcz4vxdun7qh8l" "1000000000ugnot" \ + "storage-deposit headroom for r/sys/* genesis-mode deploys" + # ------------------------------------------------------------------------- # 4) OVERLAY TXS (pre-history, not yet supported) # ------------------------------------------------------------------------- @@ -80,10 +105,10 @@ if [[ -f "$PV_KEY" ]]; then hf_kv "pv_key" "$PV_KEY" MIG_JSONL="$OUT/migrations.jsonl" CALLER="${CALLER:-g1manfred47kzduec920z88wfr64ylksmdcedlf5}" \ - PV_KEY="$PV_KEY" \ - OUT_JSONL="$MIG_JSONL" \ - CHAIN_ID="$CHAIN_ID" \ - REPO_ROOT="$REPO" \ + PV_KEY="$PV_KEY" \ + OUT_JSONL="$MIG_JSONL" \ + CHAIN_ID="$CHAIN_ID" \ + REPO_ROOT="$REPO" \ bash "$REPO/misc/deployments/gnoland-1/migrations/build.sh" hf_migration_tx "$MIG_JSONL" else From 552fb01a41a7420d09616aa504fec96c93513ae0 Mon Sep 17 00:00:00 2001 From: ltzmaxwell Date: Sun, 15 Mar 2026 18:54:21 +0800 Subject: [PATCH 06/92] fix(gnovm): proper gas consumption for mem allocation (#5091) alloc per-byte, first step to correct/improve gas assumption for memory allocation. (cherry picked from commit 5d5f9213fa69f9091e79c8b59df32de66672da43) --- gno.land/pkg/integration/testdata/gc.txtar | 6 ++--- gnovm/pkg/gnolang/alloc.go | 28 ++++++++++------------ gnovm/pkg/gnolang/garbage_collector.go | 8 +++---- gnovm/tests/files/gas/slice_alloc.gno | 2 +- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/gno.land/pkg/integration/testdata/gc.txtar b/gno.land/pkg/integration/testdata/gc.txtar index 2af7d95a789..5d07c6d0e17 100644 --- a/gno.land/pkg/integration/testdata/gc.txtar +++ b/gno.land/pkg/integration/testdata/gc.txtar @@ -6,8 +6,8 @@ loadpkg gno.land/r/gc $WORK/r/gc gnoland start -no-parallel -gnokey maketx call -pkgpath gno.land/r/gc -func Alloc -gas-fee 10000000ugnot -gas-wanted 300000000 -simulate skip -broadcast -chainid tendermint_test test1 -stdout 'GAS USED: 262693098' +gnokey maketx call -pkgpath gno.land/r/gc -func Alloc -gas-fee 10000000ugnot -gas-wanted 3000000000 -simulate skip -broadcast -chainid tendermint_test test1 +stdout 'GAS USED: 1048705180' -- r/gc/gc.gno -- package gc @@ -17,7 +17,7 @@ func gen() { } func Alloc(cur realm) { - for i := 0; i < 100; i++ { + for i := 0; i < 2; i++ { gen() gen() } diff --git a/gnovm/pkg/gnolang/alloc.go b/gnovm/pkg/gnolang/alloc.go index 6e07623d32d..b7900795b57 100644 --- a/gnovm/pkg/gnolang/alloc.go +++ b/gnovm/pkg/gnolang/alloc.go @@ -14,13 +14,8 @@ import ( type Allocator struct { maxBytes int64 bytes int64 - // `peakBytes` represents the maximum memory - // usage during a single transaction, and is used - // to calculate the corresponding gas usage. - // It increases monotonically. - peakBytes int64 - collect func() (left int64, ok bool) // gc callback - gasMeter store.GasMeter + collect func() (left int64, ok bool) // gc callback + gasMeter store.GasMeter } // for gonative, which doesn't consider the allocator. @@ -120,6 +115,13 @@ func (alloc *Allocator) Reset() *Allocator { return alloc } +// Recount adds size to bytes without charging gas. +// Used during GC re-walk to re-count surviving objects +// without double-charging for already-paid allocations. +func (alloc *Allocator) Recount(size int64) { + alloc.bytes += size +} + func (alloc *Allocator) Fork() *Allocator { if alloc == nil { return nil @@ -151,15 +153,11 @@ func (alloc *Allocator) Allocate(size int64) { } else { alloc.bytes += size } - // The value of `bytes` decreases during GC, and fees - // are only charged when it exceeds peakBytes (again). - if alloc.bytes > alloc.peakBytes { - if alloc.gasMeter != nil { - change := alloc.bytes - alloc.peakBytes - alloc.gasMeter.ConsumeGas(overflow.Mulp(change, GasCostPerByte), "memory allocation") - } - alloc.peakBytes = alloc.bytes + // Charge gas for every allocation unconditionally (cpu/throughput). + // This ensures repeated allocate-then-GC cycles are not free. + if alloc.gasMeter != nil { + alloc.gasMeter.ConsumeGas(overflow.Mulp(size, GasCostPerByte), "memory allocation (cpu)") } } diff --git a/gnovm/pkg/gnolang/garbage_collector.go b/gnovm/pkg/gnolang/garbage_collector.go index d3d5cb3e7e3..32cf3f9c48f 100644 --- a/gnovm/pkg/gnolang/garbage_collector.go +++ b/gnovm/pkg/gnolang/garbage_collector.go @@ -160,7 +160,7 @@ func GCVisitorFn(gcCycle int64, alloc *Allocator, visitCount *int64) Visitor { return true } - alloc.Allocate(size) + alloc.Recount(size) // bump before visiting associated, // this avoids infinite recursion. @@ -406,7 +406,7 @@ func (tv TypeValue) VisitAssociated(vis Visitor) (stop bool) { func (fr *Frame) Visit(alloc *Allocator, vis Visitor) (stop bool) { // vis receiver if fr.Receiver.IsDefined() { - alloc.Allocate(allocTypedValue) // alloc shallowly + alloc.Recount(allocTypedValue) // reclaim shallowly if v := fr.Receiver.V; v != nil { stop = vis(v) @@ -435,7 +435,7 @@ func (fr *Frame) Visit(alloc *Allocator, vis Visitor) (stop bool) { } for _, arg := range dfr.Args { - alloc.Allocate(allocTypedValue) + alloc.Recount(allocTypedValue) if arg.V != nil { stop = vis(arg.V) @@ -466,7 +466,7 @@ func (fr *Frame) Visit(alloc *Allocator, vis Visitor) (stop bool) { func (e *Exception) Visit(alloc *Allocator, vis Visitor) (stop bool) { // vis value - alloc.Allocate(allocTypedValue) + alloc.Recount(allocTypedValue) if v := e.Value.V; v != nil { stop = vis(v) } diff --git a/gnovm/tests/files/gas/slice_alloc.gno b/gnovm/tests/files/gas/slice_alloc.gno index e8c615844a8..c5f263b4629 100644 --- a/gnovm/tests/files/gas/slice_alloc.gno +++ b/gnovm/tests/files/gas/slice_alloc.gno @@ -11,4 +11,4 @@ func alloc(n int) { } // Gas: -// 500003015 +// 500003087 From 13ff93152f3c710d753af14a1e6094a181ec6707 Mon Sep 17 00:00:00 2001 From: Alexis Colin Date: Mon, 16 Mar 2026 18:17:40 +0900 Subject: [PATCH 07/92] style(gnoweb): update SVG logo to be visible on Safari (#5255) Safari iOS does not re-evaluate CSS custom properties in SVG inline style attributes when variable values change dynamically. The logo fills were invisible in dark mode because `fill: var(--s-logo-hat)` set via inline style was never recalculated after the theme switch. **Fix: moved fills to CSS stylesheet rules (`b-gnome/b-logo blocks`) so the browser cascades correctly on theme change.** Tested on Safari iOS (iPhone) in dark mode: logo now renders correctly on initial load without requiring any repaint. IMG_4054 IMG_4053 (cherry picked from commit c1a785ad79a67ab22dd8918c6d8d38b248ba1078) --- gno.land/pkg/gnoweb/components/ui/gnome.html | 6 +++--- gno.land/pkg/gnoweb/components/ui/logo.html | 6 +++--- gno.land/pkg/gnoweb/frontend/css/02-tools.css | 4 ++++ gno.land/pkg/gnoweb/frontend/css/06-blocks.css | 12 ++++++++++++ gno.land/pkg/gnoweb/public/main.css | 2 +- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/gno.land/pkg/gnoweb/components/ui/gnome.html b/gno.land/pkg/gnoweb/components/ui/gnome.html index d97bb255565..fef7edf6e20 100644 --- a/gno.land/pkg/gnoweb/components/ui/gnome.html +++ b/gno.land/pkg/gnoweb/components/ui/gnome.html @@ -1,10 +1,10 @@ {{ define "ui/gnome" }} - + class="hat"> {{ end }} \ No newline at end of file diff --git a/gno.land/pkg/gnoweb/components/ui/logo.html b/gno.land/pkg/gnoweb/components/ui/logo.html index 49eb1a0a286..3074b631f72 100644 --- a/gno.land/pkg/gnoweb/components/ui/logo.html +++ b/gno.land/pkg/gnoweb/components/ui/logo.html @@ -1,12 +1,12 @@ {{ define "ui/logo" }} - +Screenshot 2026-03-19 at 14 28 25 (cherry picked from commit 3c9f07b4c65174481a98026656968e1a25cac616) --- contribs/gnofaucet/github/fetcher.go | 3 +- gno.land/cmd/gnoweb/main.go | 14 +- gno.land/pkg/gnoweb/app.go | 3 + .../pkg/gnoweb/components/layout_index.go | 15 +++ gno.land/pkg/gnoweb/components/layout_test.go | 127 ++++++++++++++++++ .../pkg/gnoweb/components/layouts/index.html | 11 ++ .../pkg/gnoweb/frontend/css/06-blocks.css | 24 ++++ gno.land/pkg/gnoweb/handler_http.go | 4 +- gno.land/pkg/gnoweb/public/main.css | 2 +- 9 files changed, 199 insertions(+), 4 deletions(-) diff --git a/contribs/gnofaucet/github/fetcher.go b/contribs/gnofaucet/github/fetcher.go index dd6e53db176..6541142b6c8 100644 --- a/contribs/gnofaucet/github/fetcher.go +++ b/contribs/gnofaucet/github/fetcher.go @@ -25,7 +25,8 @@ func NewGHFetcher( rClient *redis.Client, repos map[string][]string, logger *slog.Logger, - interval time.Duration) *GHFetcher { + interval time.Duration, +) *GHFetcher { return &GHFetcher{ ghClient: ghClient, redisClient: rClient, diff --git a/gno.land/cmd/gnoweb/main.go b/gno.land/cmd/gnoweb/main.go index 41c0020c8b1..93167de9794 100644 --- a/gno.land/cmd/gnoweb/main.go +++ b/gno.land/cmd/gnoweb/main.go @@ -77,7 +77,11 @@ func main() { Name: "gnoweb", ShortUsage: "gnoweb [flags] [path ...]", ShortHelp: "runs gno.land web interface", - LongHelp: `gnoweb web interface`, + LongHelp: `gnoweb web interface + +Environment variables: + GNOWEB_BANNER_TEXT Banner text displayed at the top of the page. + GNOWEB_BANNER_URL Optional link for the banner (requires GNOWEB_BANNER_TEXT).`, }, &cfg, func(ctx context.Context, args []string) error { @@ -235,6 +239,14 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) { appcfg.UnsafeHTML = cfg.html appcfg.FaucetURL = cfg.faucetURL + // Parse banner from env + if text := os.Getenv("GNOWEB_BANNER_TEXT"); text != "" { + appcfg.Banner.Text = text + appcfg.Banner.URL = os.Getenv("GNOWEB_BANNER_URL") + } else if os.Getenv("GNOWEB_BANNER_URL") != "" { + logger.Warn("GNOWEB_BANNER_URL is set but GNOWEB_BANNER_TEXT is empty; banner will not be shown") + } + if cfg.noDefaultAliases { appcfg.Aliases = map[string]gnoweb.AliasTarget{} } diff --git a/gno.land/pkg/gnoweb/app.go b/gno.land/pkg/gnoweb/app.go index 2015fa522d3..174af39138f 100644 --- a/gno.land/pkg/gnoweb/app.go +++ b/gno.land/pkg/gnoweb/app.go @@ -52,6 +52,8 @@ type AppConfig struct { FaucetURL string // Domain is the domain used by the node. Domain string + // Banner, if set, displays a site-wide banner above the header. + Banner components.BannerData // Aliases is a map of aliases pointing to another path or a static file. Aliases map[string]AliasTarget // RenderConfig defines the default configuration for rendering realms and source files. @@ -112,6 +114,7 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) { ChainId: cfg.ChainID, Analytics: cfg.Analytics, BuildTime: buildTime, + Banner: cfg.Banner, } // Configure Markdown renderer diff --git a/gno.land/pkg/gnoweb/components/layout_index.go b/gno.land/pkg/gnoweb/components/layout_index.go index f68d69a3991..245143c52b4 100644 --- a/gno.land/pkg/gnoweb/components/layout_index.go +++ b/gno.land/pkg/gnoweb/components/layout_index.go @@ -1,5 +1,7 @@ package components +import "strings" + // ViewMode represents the current view mode of the application // It affects the layout, navigation, and display of content type ViewMode int @@ -43,6 +45,18 @@ type HeadData struct { BuildTime string } +// BannerData holds configuration for the site-wide banner displayed above the header. +type BannerData struct { + Text string + URL string +} + +func (b BannerData) HasURL() bool { + return strings.HasPrefix(b.URL, "https://") || strings.HasPrefix(b.URL, "http://") +} + +func (b BannerData) Enabled() bool { return b.Text != "" } + type IndexData struct { HeadData HeaderData @@ -50,6 +64,7 @@ type IndexData struct { BodyView *View Mode ViewMode Theme string + Banner BannerData } type indexLayoutParams struct { diff --git a/gno.land/pkg/gnoweb/components/layout_test.go b/gno.land/pkg/gnoweb/components/layout_test.go index e53fec020ae..b387a44cd6e 100644 --- a/gno.land/pkg/gnoweb/components/layout_test.go +++ b/gno.land/pkg/gnoweb/components/layout_test.go @@ -369,3 +369,130 @@ func TestIndexLayout_ThemePropagation(t *testing.T) { }) } } + +func TestBannerData(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + banner BannerData + wantEnabled bool + wantHasURL bool + }{ + { + name: "empty banner is disabled", + banner: BannerData{}, + wantEnabled: false, + wantHasURL: false, + }, + { + name: "text only", + banner: BannerData{Text: "Beta"}, + wantEnabled: true, + wantHasURL: false, + }, + { + name: "text with https URL", + banner: BannerData{Text: "Beta", URL: "https://example.com"}, + wantEnabled: true, + wantHasURL: true, + }, + { + name: "text with http URL", + banner: BannerData{Text: "Beta", URL: "http://example.com"}, + wantEnabled: true, + wantHasURL: true, + }, + { + name: "rejects javascript scheme", + banner: BannerData{Text: "Click me", URL: "javascript:alert(1)"}, + wantEnabled: true, + wantHasURL: false, + }, + { + name: "rejects data scheme", + banner: BannerData{Text: "Click me", URL: "data:text/html,

hi

"}, + wantEnabled: true, + wantHasURL: false, + }, + { + name: "rejects schemeless URL", + banner: BannerData{Text: "Click me", URL: "example.com"}, + wantEnabled: true, + wantHasURL: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.wantEnabled, tc.banner.Enabled()) + assert.Equal(t, tc.wantHasURL, tc.banner.HasURL()) + }) + } +} + +func TestIndexLayout_Banner(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + banner BannerData + wantBanner bool + wantLink bool + wantContains string + }{ + { + name: "no banner when empty", + banner: BannerData{}, + wantBanner: false, + }, + { + name: "text-only renders div", + banner: BannerData{Text: "Maintenance"}, + wantBanner: true, + wantLink: false, + wantContains: "Maintenance", + }, + { + name: "text with URL renders link", + banner: BannerData{Text: "Beta", URL: "https://example.com"}, + wantBanner: true, + wantLink: true, + wantContains: "https://example.com", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + data := IndexData{ + HeadData: HeadData{Title: "Test"}, + Mode: ViewModeHome, + Banner: tc.banner, + BodyView: &View{ + Type: "test-view", + Component: NewReaderComponent(strings.NewReader("testdata")), + }, + } + + var buf strings.Builder + err := IndexLayout(data).Render(&buf) + require.NoError(t, err) + + output := buf.String() + if !tc.wantBanner { + assert.NotContains(t, output, "b-banner") + } else { + assert.Contains(t, output, "b-banner") + assert.Contains(t, output, tc.wantContains) + if tc.wantLink { + assert.Contains(t, output, " {{ template "ui/icons" -}} + {{ if .IndexData.Banner.Enabled -}} + {{ if .IndexData.Banner.HasURL -}} + + {{ .IndexData.Banner.Text }} + + {{ else -}} +
+ {{ .IndexData.Banner.Text }} +
+ {{ end -}} + {{ end -}} {{ template "layouts/header" .IndexData.HeaderData -}}
diff --git a/gno.land/pkg/gnoweb/frontend/css/06-blocks.css b/gno.land/pkg/gnoweb/frontend/css/06-blocks.css index a6bd7b8994e..b18936cba52 100644 --- a/gno.land/pkg/gnoweb/frontend/css/06-blocks.css +++ b/gno.land/pkg/gnoweb/frontend/css/06-blocks.css @@ -14,6 +14,30 @@ } } +/* ===== BANNER ===== */ +.b-banner { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + padding: var(--g-space-1-5) var(--g-space-4); + background-color: var(--s-color-bg-brand-default); + color: var(--s-color-text-base); + font-size: var(--g-font-size-50); + font-weight: var(--g-font-semibold); + text-align: center; + text-decoration: none; + + @media (--md) { + font-size: var(--g-font-size-100); + } +} + +a.b-banner:hover { + opacity: 0.9; + text-decoration: underline; +} + /* ===== HEADER COMPONENT ===== */ .b-header { position: sticky; diff --git a/gno.land/pkg/gnoweb/handler_http.go b/gno.land/pkg/gnoweb/handler_http.go index a76c3e7bf50..dc8499c806d 100644 --- a/gno.land/pkg/gnoweb/handler_http.go +++ b/gno.land/pkg/gnoweb/handler_http.go @@ -31,6 +31,7 @@ type StaticMetadata struct { ChainId string Analytics bool BuildTime string + Banner components.BannerData } type AliasKind int @@ -138,7 +139,8 @@ func (h *HTTPHandler) Get(w http.ResponseWriter, r *http.Request) { AssetsPath: h.Static.AssetsPath, BuildTime: h.Static.BuildTime, }, - Theme: theme, + Theme: theme, + Banner: h.Static.Banner, } // Parse the URL diff --git a/gno.land/pkg/gnoweb/public/main.css b/gno.land/pkg/gnoweb/public/main.css index e65122e6acb..ece537a6e51 100644 --- a/gno.land/pkg/gnoweb/public/main.css +++ b/gno.land/pkg/gnoweb/public/main.css @@ -1,4 +1,4 @@ -:root{--g-px-base:16;--g-space-mult:4;--g-space-base:calc(1rem/var(--g-space-mult));--g-breakpoint-max:calc(1580/var(--g-px-base)*1rem);--g-z-min:-1;--g-z-1:1;--g-z-max:9999;--g-duration-75:75ms;--g-duration-150:150ms;--g-opacity-50:0.5;--g-grid-1:repeat(1,minmax(0,1fr));--g-grid-10:repeat(10,minmax(0,1fr));--g-space-px:1px;--g-space-0-5:calc(var(--g-space-base)*0.5);--g-space-1:var(--g-space-base);--g-space-1-5:calc(var(--g-space-base)*1.5);--g-space-2:calc(var(--g-space-base)*2);--g-space-2-5:calc(var(--g-space-base)*2.5);--g-space-3:calc(var(--g-space-base)*3);--g-space-4:calc(var(--g-space-base)*4);--g-space-4-5:calc(var(--g-space-base)*4.5);--g-space-5:calc(var(--g-space-base)*5);--g-space-6:calc(var(--g-space-base)*6);--g-space-7:calc(var(--g-space-base)*7);--g-space-8:calc(var(--g-space-base)*8);--g-space-10:calc(var(--g-space-base)*10);--g-space-12:calc(var(--g-space-base)*12);--g-space-14:calc(var(--g-space-base)*14);--g-space-20:calc(var(--g-space-base)*20);--g-space-24:calc(var(--g-space-base)*24);--g-space-28:calc(var(--g-space-base)*28);--g-space-32:calc(var(--g-space-base)*32);--g-space-36:calc(var(--g-space-base)*36);--g-space-44:calc(var(--g-space-base)*44);--g-space-48:calc(var(--g-space-base)*48);--g-space-52:calc(var(--g-space-base)*52);--g-space-72:calc(var(--g-space-base)*72);--g-space-96:calc(var(--g-space-base)*96);--g-font-size-50:calc(12/var(--g-px-base)*1rem);--g-font-size-100:calc(14/var(--g-px-base)*1rem);--g-font-size-200:calc(16/var(--g-px-base)*1rem);--g-font-size-300:calc(18/var(--g-px-base)*1rem);--g-font-size-400:calc(20/var(--g-px-base)*1rem);--g-font-size-500:calc(22/var(--g-px-base)*1rem);--g-font-size-600:calc(24/var(--g-px-base)*1rem);--g-font-size-700:calc(32/var(--g-px-base)*1rem);--g-font-size-800:calc(38/var(--g-px-base)*1rem);--g-font-family-mono:"Roboto",'Menlo, Consolas, "Ubuntu Mono", "Roboto Mono", "DejaVu Sans Mono", monospace';--g-font-family-inter-var:"Inter",'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif';--g-font-normal:400;--g-font-medium:500;--g-font-semibold:600;--g-font-bold:700;--g-italic:oblique 14deg;--g-line-height-tight:1.25;--g-line-height-snug:1.375;--g-line-height-normal:1.5;--g-border-radius-sm:calc(4/var(--g-px-base)*1rem);--g-border-radius:calc(6/var(--g-px-base)*1rem);--g-border-radius-full:9999px;--g-color-light:#fff;--g-color-transparent:transparent;--g-color-gray-50:#f0f0f0;--g-color-gray-100:#e2e2e2;--g-color-gray-200:#bdbdbd;--g-color-gray-300:#999;--g-color-gray-400:#7c7c7c;--g-color-gray-500:#696969;--g-color-gray-600:#585858;--g-color-gray-700:#292929;--g-color-gray-750:#1f1f1f;--g-color-gray-800:#141414;--g-color-gray-850:#0e0e0e;--g-color-gray-900:#090909;--g-color-green-50:#e7efed;--g-color-green-400:#60ab96;--g-color-green-500:#277b63;--g-color-green-600:#226c57;--g-color-green-900:#144134;--g-color-green-950:#002c20;--g-color-blue-400:#49afeb;--g-color-blue-600:#3e96c9;--g-color-blue-900:#21506b;--g-color-yellow-50:#fff7eb;--g-color-yellow-400:#facc32;--g-color-yellow-600:#fbbf24;--g-color-yellow-900:#7b4807;--g-color-yellow-950:#362600;--g-color-red-400:#eb6c49;--g-color-red-600:#c95c3e;--g-color-red-900:#6b2521;--g-color-purple-400:#7f49eb;--g-color-purple-600:#6c3ec9;--g-color-purple-900:#39216b}@supports (color:color(display-p3 0 0 0%)){:root{--g-color-green-950:#002c20;--g-color-yellow-50:#fff7eb;--g-color-yellow-950:#362600}@media (color-gamut:p3){:root{--g-color-green-950:color(display-p3 0.04602 0.17026 0.1277);--g-color-yellow-50:color(display-p3 0.99709 0.97106 0.92232);--g-color-yellow-950:color(display-p3 0.2031 0.15112 0.01811)}}}:root{--s-color-bg-base:var(--g-color-light,#fff);--s-color-bg-base-dev:var(--g-color-gray-50,#f0f0f0);--s-color-bg-surface-primary:var(--g-color-gray-50,#f0f0f0);--s-color-bg-surface-primary-hover:var(--g-color-gray-100,#f0f0f0);--s-color-bg-surface-secondary:var(--g-color-gray-100,#e2e2e2);--s-color-bg-surface-quaternary:var(--g-color-gray-400,#7c7c7c);--s-color-bg-brand-default:var(--g-color-green-600,#226c57);--s-color-bg-brand-weak:var(--g-color-green-50,#f0f9ff);--s-color-bg-success-default:var(--g-color-green-600,#144134);--s-color-bg-info-default:var(--g-color-blue-600,#21506b);--s-color-bg-warning-default:var(--g-color-yellow-600,#665100);--s-color-bg-warning-weak:var(--g-color-yellow-50,#f9d985);--s-color-bg-warning-action:var(--g-color-yellow-400,#f9d985);--s-color-bg-caution-default:var(--g-color-red-600,#610);--s-color-bg-tip-default:var(--g-color-purple-600,#49216b);--s-color-bg-note-default:var(--g-color-gray-600,#21506b);--s-color-bg-input:var(--g-color-light,#fff);--s-color-text-base:var(--g-color-light,#fff);--s-color-text-primary:var(--g-color-gray-900,#080809);--s-color-text-secondary:var(--g-color-gray-600,#454a4e);--s-color-text-tertiary:var(--g-color-gray-400,#f0f0f0);--s-color-text-tertiary-hover:var(--g-color-gray-600,#e2e2e2);--s-color-text-quaternary:var(--g-color-gray-100,#f0f0f0);--s-color-text-brand-default:var(--g-color-light,#fff);--s-color-text-link:var(--g-color-green-600,#226c57);--s-color-text-link-hover:var(--g-color-green-600,#226c57);--s-color-text-success:var(--g-color-green-900,#144134);--s-color-text-info:var(--g-color-blue-900,#21506b);--s-color-text-warning:var(--g-color-yellow-900,#665100);--s-color-text-caution:var(--g-color-red-900,#610);--s-color-text-tip:var(--g-color-purple-900,#49216b);--s-color-border-primary:var(--g-color-gray-200,#bdbdbd);--s-color-border-secondary:var(--g-color-gray-100,#e2e2e2);--s-color-border-tertiary:var(--g-color-gray-300,#999);--s-color-border-quaternary:var(--g-color-gray-400,#7c7c7c);--s-color-border-transparent:var(--g-color-transparent,transparent);--s-color-border-input:var(--g-color-gray-300,#999);--s-color-border-brand-default:var(--g-color-green-600,#226c57);--s-color-border-success:var(--g-color-green-600,#144134);--s-color-border-info:var(--g-color-blue-600,#21506b);--s-color-border-warning:var(--g-color-yellow-600,#665100);--s-color-border-error:var(--g-color-red-600,#610);--s-color-border-tip:var(--g-color-purple-600,#49216b);--s-color-border-note:var(--g-color-gray-600,#21506b);--s-rounded-sm:var(--g-border-radius-sm,4px);--s-rounded:var(--g-border-radius,6px);--s-rounded-full:var(--g-border-radius-full,9999px);--s-border:var(--g-space-px,1px) solid var(--s-color-border-primary);--s-border-secondary:var(--g-space-px,1px) solid var(--s-color-border-secondary);--s-logo-hat:var(--g-color-green-600,#226c57);--s-logo-beard:var(--g-color-gray-300,#999)}[data-theme=dark]{--s-color-bg-base:var(--g-color-gray-850);--s-color-bg-base-dev:var(--g-color-gray-800);--s-color-bg-surface-primary:var(--g-color-gray-800);--s-color-bg-surface-primary-hover:var(--g-color-gray-750);--s-color-bg-surface-secondary:var(--g-color-gray-750);--s-color-bg-surface-quaternary:var(--g-color-gray-600);--s-color-bg-brand-weak:var(--g-color-green-950);--s-color-bg-warning-weak:var(--g-color-yellow-950);--s-color-bg-input:var(--g-color-gray-800);--s-color-text-primary:var(--g-color-gray-100);--s-color-text-secondary:var(--g-color-gray-200);--s-color-text-tertiary:var(--g-color-gray-400);--s-color-text-tertiary-hover:var(--g-color-gray-300);--s-color-text-quaternary:var(--g-color-gray-500);--s-color-text-brand-default:var(--g-color-light);--s-color-text-link:var(--g-color-green-500);--s-color-text-link-hover:var(--g-color-green-400);--s-color-text-success:var(--g-color-green-400);--s-color-text-info:var(--g-color-blue-400);--s-color-text-warning:var(--g-color-yellow-400);--s-color-text-caution:var(--g-color-red-400);--s-color-text-tip:var(--g-color-purple-400);--s-color-border-primary:var(--g-color-gray-700);--s-color-border-secondary:var(--g-color-gray-750);--s-color-border-tertiary:var(--g-color-gray-600);--s-color-border-quaternary:var(--g-color-gray-500);--s-color-border-input:var(--g-color-gray-700);--s-color-border-brand-default:var(--g-color-green-600);--s-color-border-success:var(--g-color-green-400);--s-color-border-info:var(--g-color-blue-400);--s-color-border-warning:var(--g-color-yellow-400);--s-color-border-error:var(--g-color-red-400);--s-color-border-tip:var(--g-color-purple-400);--s-color-border-note:var(--g-color-gray-600);--s-logo-hat:#fff;--s-logo-beard:grey}*,::backdrop,::file-selector-button,:after,:before{border:0 solid;box-sizing:border-box;margin:0;padding:0}html{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}h1,h2,h3{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub{bottom:-.25em;font-size:75%;line-height:0;position:relative;vertical-align:baseline}table{border-collapse:collapse;border-color:inherit;text-indent:0}summary{display:list-item}menu,ol,ul{list-style:none}embed,img,object,svg{display:block;vertical-align:middle}img{height:auto;max-width:100%}::file-selector-button,button,input,select,textarea{background-color:transparent;border-radius:0;color:inherit;font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;opacity:1}::file-selector-button{margin-right:4px}::-moz-placeholder{opacity:1}::placeholder{opacity:1}@supports (not (-webkit-appearance:-apple-pay-button)) or (contain-intrinsic-size:1px){::-moz-placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}::-webkit-calendar-picker-indicator{line-height:1}::file-selector-button,button,input:where([type=button],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}@font-face{font-display:swap;font-family:Roboto;font-style:normal;font-weight:900;src:url(fonts/roboto/roboto-mono-normal.woff2) format("woff2"),url(fonts/roboto/roboto-mono-normal.woff) format("woff")}@font-face{font-display:block;font-family:Inter;font-style:oblique 0deg 10deg;font-variant:normal;font-weight:100 900;src:url(fonts/intervar/Intervar.woff2) format("woff2")}html{background-color:var(--s-color-bg-base);color:var(--s-color-text-secondary);font-family:var(--g-font-family-inter-var);font-feature-settings:"kern" on,"liga" on,"calt" off,"zero" on,contextual common-ligatures,"kern";-webkit-font-feature-settings:"kern" on,"liga" on,"calt" off,"zero" on;font-size:calc(var(--g-px-base)*1px);line-height:var(--g-line-height-normal);-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-kerning:normal;font-variant-ligatures:contextual common-ligatures;text-rendering:optimizeLegibility}body{display:flex;flex-direction:column;min-height:100vh}main{background-color:var(--s-color-bg-base);flex-grow:2;width:100%}main.dev-mode{background-color:var(--s-color-bg-base-dev)}main>section{display:grid;grid-auto-flow:dense;grid-template-columns:var(--g-grid-1);grid-column-gap:var(--g-space-20);-moz-column-gap:var(--g-space-20);column-gap:var(--g-space-20);min-height:100%;padding-left:var(--g-space-4);padding-right:var(--g-space-4)}@media (min-width:calc(640 / 16 * 1rem)){main>section{padding-left:var(--g-space-10);padding-right:var(--g-space-10)}}@media (min-width:calc(820 / 16 * 1rem)){main>section{grid-template-columns:var(--g-grid-10)}}@media (min-width:calc(1366 / 16 * 1rem)){main>section{-moz-column-gap:var(--g-space-32);column-gap:var(--g-space-32)}}svg{max-height:100%;max-width:100%}form{margin-bottom:0;margin-top:0}code{font-family:var(--g-font-mono)}summary{cursor:pointer}md-renderer{margin-top:var(--g-space-4);padding-bottom:var(--g-space-24)}@media (min-width:calc(820 / 16 * 1rem)){md-renderer{grid-column:span 7;margin-top:0}}::-moz-selection{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}::selection{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}summary::-webkit-details-marker{display:none}summary::marker{display:none}.c-stack{display:flex;flex-direction:column;justify-content:flex-start}.c-stack>*+*{margin-top:var(--g-space-4)}.c-inline{align-items:center;display:inline-flex;gap:var(--g-space-3)}.c-between{align-items:center;display:flex;justify-content:space-between}.c-center{box-sizing:border-box;margin-left:auto;margin-right:auto;max-width:var(--g-breakpoint-max);padding-left:var(--g-space-4);padding-right:var(--g-space-4)}@media (min-width:calc(640 / 16 * 1rem)){.c-center{padding-left:var(--g-space-10);padding-right:var(--g-space-10)}}.c-full-screen{align-items:center;display:flex;flex-direction:column;grid-column:1/-1;height:100%;justify-content:center;margin-top:var(--g-space-10);padding-bottom:var(--g-space-24);width:100%}.c-reel{display:flex;overflow:scroll}.c-icon{flex-shrink:0;height:1.15em;width:1.15em}.c-with-icon{align-items:flex-start;display:inline-flex}.c-with-icon .c-icon,.c-with-icon--inline .c-icon{margin-left:.3em;margin-right:.3em;margin-top:.15em}.c-with-icon--inline{display:inline-block}.c-with-icon--inline>*{vertical-align:middle}.c-with-icon--inline .c-icon{margin-top:0}.c-view-grid{display:flex;flex-direction:column}@media (min-width:calc(640 / 16 * 1rem)){.c-view-grid{-moz-column-gap:var(--g-space-8);column-gap:var(--g-space-8);flex-direction:row}}@media (min-width:calc(820 / 16 * 1rem)){.c-view-grid{display:grid;grid-template-columns:var(--g-grid-10);grid-column-gap:var(--g-space-20);-moz-column-gap:var(--g-space-20);column-gap:var(--g-space-20)}}@media (min-width:calc(1366 / 16 * 1rem)){.c-view-grid{-moz-column-gap:var(--g-space-32);column-gap:var(--g-space-32)}}.c-toggle-btn>input{display:none}.c-toggle-btn label{visibility:hidden}.c-toggle-btn input:checked+label{visibility:visible}.c-readme-view,.c-realm-view{--cr-px-base:var(--g-px-base);--cr-space-mult:1;--cr-space-base:calc(1em/var(--g-space-mult)*var(--cr-space-mult));--cr-space-0:0;--cr-space-0-5:calc(var(--cr-space-base)*0.5);--cr-space-1:var(--cr-space-base);--cr-space-2:calc(var(--cr-space-base)*2);--cr-space-3:calc(var(--cr-space-base)*3);--cr-space-4:calc(var(--cr-space-base)*4);--cr-space-5:calc(var(--cr-space-base)*5);--cr-space-7:calc(var(--cr-space-base)*7);--cr-space-8:calc(var(--cr-space-base)*8);--cr-space-24:calc(var(--cr-space-base)*24);--cr-color-brand-default:var(--s-color-text-link);display:block;font-size:calc(var(--cr-px-base)*1px);padding-top:var(--g-space-4);word-break:break-word}.c-readme-view:empty,.c-realm-view:empty{display:none}.c-realm-view:has(.b-btn:only-child){display:none}.c-readme-view:has(.b-btn:only-child){display:none}@media (min-width:calc(820 / 16 * 1rem)){.c-readme-view,.c-realm-view{grid-row-start:1;padding-top:var(--g-space-6)}}.c-readme-view a,.c-realm-view a{color:var(--cr-color-brand-default);display:inline-block;font-weight:inherit;position:relative;text-wrap:balance;vertical-align:top}.c-readme-view a:hover,.c-realm-view a:hover{-webkit-text-decoration:underline;text-decoration:underline}.c-realm-view a:has(>img){vertical-align:middle}.c-readme-view a:has(>img){vertical-align:middle}.c-readme-view a>span,.c-realm-view a>span{margin-bottom:.1em}.c-readme-view a>.tooltip+.tooltip,.c-realm-view a>.tooltip+.tooltip{margin-left:.2em}.c-readme-view a>.tooltip:last-of-type,.c-realm-view a>.tooltip:last-of-type{margin-right:.2em}.c-realm-view a:has(>img:first-child):has(.tooltip:last-child):not(:has(>:nth-child(3)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip:last-child):not(:has(>:nth-child(3)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-realm-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-realm-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip:first-of-type{bottom:var(--g-space-2);left:var(--g-space-7);position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip:first-of-type{bottom:var(--g-space-2);left:var(--g-space-7);position:absolute}.c-readme-view h1+h2,.c-readme-view h2+h3,.c-readme-view h3+h4,.c-realm-view h1+h2,.c-realm-view h2+h3,.c-realm-view h3+h4{margin-top:var(--cr-space-4)}.c-readme-view h1,.c-readme-view h2,.c-readme-view h3,.c-readme-view h4,.c-realm-view h1,.c-realm-view h2,.c-realm-view h3,.c-realm-view h4{color:var(--s-color-text-primary);line-height:var(--g-line-height-tight);margin-top:var(--cr-space-4)}.c-readme-view h1,.c-realm-view h1{font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);margin-bottom:var(--cr-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h1,.c-realm-view h1{font-size:var(--g-font-size-800)}}.c-readme-view h2,.c-realm-view h2{font-size:var(--g-font-size-500);font-weight:var(--g-font-bold)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h2,.c-realm-view h2{font-size:var(--g-font-size-600)}}.c-readme-view h2 *,.c-realm-view h2 *{font-weight:var(--g-font-bold)}.c-readme-view h3,.c-readme-view h4,.c-realm-view h3,.c-realm-view h4{color:var(--s-color-text-secondary);font-weight:var(--g-font-semibold)}.c-readme-view h3,.c-realm-view h3{font-size:var(--g-font-size-400);margin-top:var(--cr-space-4)}.c-readme-view h4,.c-realm-view h4{font-size:var(--g-font-size-300);margin-top:var(--cr-space-3)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h4,.c-realm-view h4{font-size:var(--g-font-size-300)}}.c-readme-view h3 *,.c-readme-view h4 *,.c-realm-view h3 *,.c-realm-view h4 *{font-weight:var(--g-font-semibold)}.c-readme-view h5,.c-readme-view h6,.c-realm-view h5,.c-realm-view h6{font-size:var(--g-font-size-300);font-weight:var(--g-font-bold);margin-bottom:var(--cr-space-0);margin-top:var(--cr-space-0)}.c-readme-view h5+p,.c-readme-view h6+p,.c-realm-view h5+p,.c-realm-view h6+p{margin-top:var(--cr-space-0)}.c-readme-view img,.c-realm-view img{border:1px solid var(--s-color-bg-surface-primary);border-radius:var(--g-border-radius-sm);margin-bottom:var(--cr-space-2);margin-top:var(--cr-space-2);max-width:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none}.c-readme-view figure,.c-realm-view figure{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3);text-align:center}.c-readme-view figcaption,.c-realm-view figcaption{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100)}.c-readme-view video,.c-realm-view video{margin-bottom:var(--g-space-4);margin-top:var(--g-space-4);max-width:100%}.c-readme-view p,.c-realm-view p{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3)}.c-realm-view p:has(>a:only-child>img){margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4)}.c-readme-view p:has(>a:only-child>img){margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4)}.c-realm-view p:has(>a:only-child>img) img{margin-bottom:0;margin-top:0}.c-readme-view p:has(>a:only-child>img) img{margin-bottom:0;margin-top:0}.c-readme-view strong,.c-readme-view strong *,.c-realm-view strong,.c-realm-view strong *{font-weight:var(--g-font-bold)}.c-readme-view em,.c-realm-view em{font-style:var(--g-italic)}.c-readme-view blockquote,.c-realm-view blockquote{border-left:solid var(--g-space-0-5) var(--s-color-border-tertiary);color:var(--s-color-text-secondary);margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4);padding-left:var(--g-space-3)}.c-readme-view blockquote>blockquote,.c-realm-view blockquote>blockquote{margin-bottom:var(--cr-space-7);margin-top:var(--cr-space-7)}.c-readme-view caption,.c-realm-view caption{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);margin-top:var(--cr-space-2);text-align:left}.c-readme-view q,.c-realm-view q{quotes:"“" "”"}.c-readme-view q:before,.c-realm-view q:before{content:open-quote}.c-readme-view q:after,.c-realm-view q:after{content:close-quote}.c-readme-view details,.c-realm-view details{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3)}.c-readme-view summary,.c-realm-view summary{cursor:pointer;font-weight:var(--g-font-bold)}.c-readme-view math,.c-realm-view math{font-family:var(--g-font-family-mono)}.c-readme-view small,.c-realm-view small{font-size:var(--g-font-size-100)}.c-readme-view del,.c-realm-view del{-webkit-text-decoration:line-through;text-decoration:line-through}.c-readme-view sub,.c-realm-view sub{font-size:var(--g-font-size-50);vertical-align:sub}.c-readme-view sup,.c-realm-view sup{font-size:var(--g-font-size-50);padding-left:var(--space-px);vertical-align:middle}.c-readme-view sup>a,.c-realm-view sup>a{vertical-align:middle}.c-readme-view ol,.c-readme-view ul,.c-realm-view ol,.c-realm-view ul{margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4);padding-left:var(--g-space-4)}.c-readme-view ul,.c-realm-view ul{list-style:disc}.c-readme-view ol,.c-realm-view ol{list-style:decimal}.c-readme-view ol ol,.c-readme-view ol ul,.c-readme-view ul ol,.c-readme-view ul ul,.c-realm-view ol ol,.c-realm-view ol ul,.c-realm-view ul ol,.c-realm-view ul ul{margin-bottom:var(--cr-space-2);margin-top:var(--cr-space-2);padding-left:var(--g-space-4)}.c-readme-view li,.c-realm-view li{margin-bottom:var(--cr-space-1);margin-top:var(--cr-space-1)}.c-readme-view code,.c-readme-view pre,.c-realm-view code,.c-realm-view pre{font-family:var(--g-font-family-mono)}.c-readme-view pre,.c-readme-view pre.chroma-chroma,.c-realm-view pre,.c-realm-view pre.chroma-chroma{background-color:var(--s-color-bg-surface-primary);border-radius:var(--g-border-radius-sm);margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3);overflow-x:auto;padding:var(--cr-space-4)}.c-readme-view :not(pre)>code,.c-realm-view :not(pre)>code{background-color:var(--s-color-bg-surface-secondary);border-radius:var(--g-border-radius-sm);font-size:.96em;padding:var(--cr-space-0-5) var(--cr-space-1)}.c-readme-view a code,.c-realm-view a code{color:inherit}.c-readme-view hr,.c-realm-view hr{border-top:var(--s-border-secondary);margin-bottom:var(--cr-space-8);margin-top:var(--cr-space-8)}.c-readme-view table,.c-realm-view table{border-collapse:collapse;display:block;margin-bottom:var(--cr-space-5);margin-top:var(--cr-space-5);max-width:100%;width:100%}.c-readme-view td,.c-readme-view th,.c-realm-view td,.c-realm-view th{border:var(--s-border);padding:var(--cr-space-2) var(--cr-space-4);white-space:normal;word-break:break-word}.c-readme-view th,.c-realm-view th{background-color:var(--s-color-bg-surface-secondary);font-weight:var(--g-font-bold)}.c-readme-view button,.c-readme-view input,.c-readme-view select,.c-readme-view textarea,.c-realm-view button,.c-realm-view input,.c-realm-view select,.c-realm-view textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--s-color-bg-input);border:var(--s-border);padding:var(--cr-space-2) var(--cr-space-4)}.c-readme-view>.realm-view__btns:first-child+*,.c-readme-view>:first-child:not(.realm-view__btns),.c-realm-view>.realm-view__btns:first-child+*,.c-realm-view>:first-child:not(.realm-view__btns){margin-top:0!important}.c-readme-view .footnote-backref,.c-readme-view h1:not(.does-not-exist),.c-readme-view h2:not(.does-not-exist),.c-readme-view h3:not(.does-not-exist),.c-readme-view h4:not(.does-not-exist),.c-readme-view sup:not(.does-not-exist),.c-realm-view .footnote-backref,.c-realm-view h1:not(.does-not-exist),.c-realm-view h2:not(.does-not-exist),.c-realm-view h3:not(.does-not-exist),.c-realm-view h4:not(.does-not-exist),.c-realm-view sup:not(.does-not-exist){scroll-margin-top:var(--cr-space-24)}.c-readme-view .b-btn,.c-realm-view .b-btn{color:var(--s-color-text-secondary);display:inline-flex}.c-readme-view .b-btn:hover,.c-realm-view .b-btn:hover{-webkit-text-decoration:none;text-decoration:none}.c-readme-view .b-btn:first-child,.c-realm-view .b-btn:first-child{float:right;margin-top:var(--g-space-4)}.c-readme-view>.b-btn:first-child+*,.c-readme-view>:first-child:not(.b-btn),.c-realm-view>.b-btn:first-child+*,.c-realm-view>:first-child:not(.b-btn){margin-top:0}.c-readme-view{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius);margin-bottom:var(--g-space-6);padding:var(--g-space-6) var(--g-space-4) var(--g-space-4);width:100%}@media (min-width:calc(820 / 16 * 1rem)){.c-readme-view{grid-row-start:auto}}.b-gnome .hat,.b-logo .hat{fill:var(--s-logo-hat)}.b-gnome .beard,.b-logo .beard{fill:var(--s-logo-beard)}.b-header{background-color:var(--s-color-bg-base);border-bottom:var(--s-border);font-size:var(--g-font-size-100);position:sticky;top:0;z-index:var(--g-z-max)}.b-header nav{align-items:stretch;height:auto}.b-header .main-nav{align-items:stretch;display:flex;flex:1 1 auto;gap:var(--g-space-1);height:100%;min-width:0;padding-bottom:var(--g-space-2);padding-top:var(--g-space-2);width:100%}@media (min-width:calc(820 / 16 * 1rem)){.b-header .main-nav{grid-column:span 7}}.b-header .main-nav--explorer{grid-column:span 10}.b-header .user-picture{border:var(--s-border-secondary);border-radius:var(--s-rounded);cursor:pointer;flex-shrink:0;height:var(--g-space-10);width:var(--g-space-10)}.b-header .user-picture>svg{height:100%;width:100%}.b-main-navigation{color:var(--s-color-text-quaternary);height:auto;position:relative;width:100%}.b-main-navigation>.inner{align-items:center;background-color:var(--s-color-bg-surface-secondary);border:var(--s-border-secondary);border-radius:var(--s-rounded);height:100%;padding-left:var(--g-space-1-5);padding-right:var(--g-space-1-5);position:relative}@media (min-width:calc(640 / 16 * 1rem)){.b-main-navigation>.inner{padding-right:var(--g-space-8)}}.b-main-navigation>.inner:has([data-role=header-input-search]:focus-within){border-color:var(--s-color-border-tertiary)}.b-main-navigation .searchbar{bottom:0;color:var(--s-color-text-secondary);font-size:var(--g-font-size-200);font-weight:var(--g-font-medium);left:0;padding:var(--g-space-1-5);padding-right:var(--g-space-8);position:absolute;right:0;top:0}.b-main-navigation .searchbar>input{background-color:transparent;height:100%;outline:none;width:100%}.b-main-navigation .searchbar:focus-within+.b-breadcrumb{display:none}.b-main-navigation .network-toggle{align-items:center;background-color:var(--g-color-transparent);border-radius:var(--g-border-radius);cursor:pointer;display:none;height:calc(100% - 2px);justify-content:center;padding:var(--g-space-1-5);position:absolute;right:1px;top:1px;z-index:var(--g-z-max)}@media (min-width:calc(640 / 16 * 1rem)){.b-main-navigation .network-toggle{display:flex}}.b-main-navigation .network-toggle>svg{color:var(--s-color-text-tertiary);height:var(--g-space-5);width:var(--g-space-5)}.b-main-navigation .network-toggle:hover>svg{color:var(--s-color-text-tertiary-hover)}.b-main-navigation .b-popup-dialog>.inner{color:var(--s-color-text-tertiary);width:var(--g-space-72)}.b-main-navigation .b-popup-dialog header>span{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold)}.b-main-navigation .b-popup-dialog .item{display:flex;gap:var(--g-space-1)}.b-main-navigation .b-popup-dialog .item>svg{height:var(--g-space-4);width:var(--g-space-4)}.b-main-navigation .b-popup-dialog .item-content{display:flex;flex-direction:column}.b-main-navigation .b-popup-dialog .item-label{font-size:var(--g-font-size-50)}.b-main-navigation .b-popup-dialog .item-value{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold)}.b-main-menu{display:flex;flex:0 0 auto;grid-column:span 3;height:var(--g-space-12)}@media (min-width:calc(640 / 16 * 1rem)){.b-main-menu{height:auto}}.b-main-menu .menu-toggle{align-items:center;cursor:pointer;display:flex;margin-left:auto;order:3}.b-main-menu .menu-toggle>svg{height:var(--g-space-5);margin-left:var(--g-space-4);width:var(--g-space-5)}@media (min-width:calc(820 / 16 * 1rem)){.b-main-menu .menu-toggle>svg{margin-left:var(--g-space-2)}}.b-main-menu .menu-toggle-input~.menu-dev{display:none}.b-main-menu .menu-toggle-input:checked~.menu-dev{display:flex}.b-main-menu .menu-toggle-input:checked~.menu-general{display:none}.b-main-menu .menu-dev,.b-main-menu .menu-general{display:flex;height:100%;justify-content:flex-end}.b-menu-link:last-child,.b-menu-link:last-child .link{margin-right:0}.b-menu-link .link{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold);gap:var(--g-space-1);height:100%;margin-right:var(--g-space-3);position:relative}.b-menu-link .link:hover{color:var(--s-color-text-tertiary-hover)}.b-menu-link .link:after{background-color:var(--s-color-bg-brand-default);border-radius:var(--s-rounded) var(--s-rounded) 0 0;bottom:0;content:"";height:var(--g-space-1);left:0;position:absolute;transition:width var(--g-transition-fast);width:0}.b-menu-link .link>svg{flex-shrink:0;height:var(--g-space-5);min-width:var(--g-space-2);width:var(--g-space-5)}@media (min-width:calc(1020 / 16 * 1rem)){.b-menu-link .link>svg{display:none}}@media (min-width:calc(1366 / 16 * 1rem)){.b-menu-link .link>svg{display:inline-block;height:var(--g-space-4-5);width:var(--g-space-4-5)}}@media (min-width:calc(640 / 16 * 1rem)){.b-menu-link .link{font-weight:var(--g-font-bold)}}@media (min-width:calc(1366 / 16 * 1rem)){.b-menu-link .link{margin-right:var(--g-space-6);padding-right:var(--g-space-1)}}@media (min-width:calc(640 / 16 * 1rem)){.b-menu-link .link-label{display:none}}@media (min-width:calc(1020 / 16 * 1rem)){.b-menu-link .link-label{display:inline}}.b-menu-link .link--icon{font-weight:var(--g-font-regular);margin-right:var(--g-space-4)}@media (min-width:calc(480 / 16 * 1rem)){.b-menu-link .link--icon{margin-right:var(--g-space-6)}}.b-menu-link .link--is-active{color:var(--s-color-text-secondary)}.b-menu-link .link--is-active:after{width:100%}.b-menu-link .link--is-active>svg{color:var(--s-color-bg-brand-default)}.menu-general .link{color:var(--s-color-text-secondary)}.menu-general .link:hover{color:var(--s-color-text-link-hover)}.b-breadcrumb{display:flex}.b-breadcrumb,.b-breadcrumb:after{background-color:var(--s-color-bg-surface-secondary)}.b-breadcrumb:after{bottom:0;content:"";display:block;left:0;pointer-events:none;position:absolute;right:0;top:0}.b-breadcrumb>ol{color:var(--s-color-text-primary);display:flex;font-weight:var(--g-font-semibold);line-height:var(--g-line-height-snug)}.b-breadcrumb .argument,.b-breadcrumb .element,.b-breadcrumb .query{align-items:center;display:flex;white-space:nowrap;z-index:var(--g-z-1)}.b-breadcrumb .argument:not(:first-child):before,.b-breadcrumb .element:not(:first-child):before,.b-breadcrumb .query:not(:first-child):before{color:var(--s-color-text-tertiary);content:"/";line-height:var(--g-line-height-normal);padding-left:.18rem;padding-right:.18rem;padding-top:var(--g-space-px)}.b-breadcrumb .argument a,.b-breadcrumb .element a,.b-breadcrumb .query a{background-color:var(--s-color-bg-base);border:1px solid var(--s-color-border-transparent);border-radius:var(--s-rounded-sm);display:inline-block;min-width:var(--g-space-4);padding:var(--g-space-0-5);text-align:center}.b-breadcrumb .argument a:hover,.b-breadcrumb .element a:hover,.b-breadcrumb .query a:hover{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}.b-breadcrumb .argument:not(:first-child):before{content:":"}.b-breadcrumb .argument a{background-color:var(--s-color-bg-surface-quaternary);color:var(--s-color-text-base)}.b-breadcrumb .query:not(:first-child):before{content:"&"}.b-breadcrumb .query:nth-child(1 of .query):before{content:"?"}.b-breadcrumb .query label{background-color:var(--s-color-bg-surface-primary);border:var(--s-border);border-radius:var(--s-rounded-sm);color:var(--s-color-text-secondary);cursor:text;display:inline-flex;height:100%;min-width:var(--g-space-4);padding:var(--g-space-0-5) var(--g-space-1);position:relative;text-align:center;width:100%}.b-breadcrumb .query label:focus-within{border-color:var(--s-color-border-quaternary)}.b-breadcrumb .query label:hover{border-color:var(--s-color-border-quaternary)}.b-breadcrumb .query input{background-color:var(--s-color-bg-surface-primary);max-width:10ch;order:3;outline:none;field-sizing:content}@supports not (field-sizing:content){.b-breadcrumb .query input{width:5rem!important}}.b-breadcrumb .query input::-moz-placeholder{opacity:0}.b-breadcrumb .query input::placeholder{opacity:0}.b-breadcrumb .query input:-moz-placeholder{width:var(--g-space-px)}.b-breadcrumb .query input:placeholder-shown{width:var(--g-space-px)}.b-breadcrumb .query input:placeholder-shown::-moz-placeholder{color:var(--g-color-transparent)}.b-breadcrumb .query input:-moz-placeholder::placeholder{color:var(--g-color-transparent)}.b-breadcrumb .query input:placeholder-shown::placeholder{color:var(--g-color-transparent)}.b-footer{border-top:var(--s-border);font-size:var(--g-font-size-100);padding-bottom:var(--g-space-4);padding-top:var(--g-space-4);width:100%}.b-footer>nav{flex-direction:column;row-gap:var(--g-space-8)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer>nav{flex-wrap:wrap}}.b-footer .logo{color:var(--s-color-text-primary);grid-column:1/-1;width:var(--g-space-44)}.b-footer .logo:hover{color:var(--s-color-text-primary);-webkit-text-decoration:none;text-decoration:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .logo{align-self:center;grid-column:1/3;grid-row:1/1;width:60%}}.b-footer .nav-primary{display:flex;gap:var(--g-space-10);grid-column:1/-1;grid-row:2/3}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-primary{align-items:center;flex:1 0 0%;flex-direction:row;gap:var(--g-space-6);justify-content:space-between}}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .nav-primary{grid-column:2/8;grid-row:1/1}}.b-footer .nav-primary>ul{display:flex;flex:1;flex-direction:column;flex-wrap:wrap;gap:var(--g-space-1) var(--g-space-3)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-primary>ul{flex:initial;flex-direction:row}.b-footer .nav-social{margin-left:auto}}@media (min-width:calc(820 / 16 * 1rem)){.b-footer .nav-social{grid-column:span 3;justify-self:end;margin-left:0}}.b-footer .nav-theme{align-items:center;display:flex;gap:var(--g-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-theme{flex-basis:100%}}@media (min-width:calc(820 / 16 * 1rem)){.b-footer .nav-theme{grid-column:span 3}}.b-footer .nav-theme .nav-theme-label{color:var(--s-color-text-secondary)}.b-footer .nav-theme:has([data-theme-target=sun]:not(.u-hidden)) .nav-theme-label:before{content:"Light"}.b-footer .nav-theme:has([data-theme-target=moon]:not(.u-hidden)) .nav-theme-label:before{content:"Dark"}.b-footer .nav-theme:has([data-theme-target=system]:not(.u-hidden)) +:root{--g-px-base:16;--g-space-mult:4;--g-space-base:calc(1rem/var(--g-space-mult));--g-breakpoint-max:calc(1580/var(--g-px-base)*1rem);--g-z-min:-1;--g-z-1:1;--g-z-max:9999;--g-duration-75:75ms;--g-duration-150:150ms;--g-opacity-50:0.5;--g-grid-1:repeat(1,minmax(0,1fr));--g-grid-10:repeat(10,minmax(0,1fr));--g-space-px:1px;--g-space-0-5:calc(var(--g-space-base)*0.5);--g-space-1:var(--g-space-base);--g-space-1-5:calc(var(--g-space-base)*1.5);--g-space-2:calc(var(--g-space-base)*2);--g-space-2-5:calc(var(--g-space-base)*2.5);--g-space-3:calc(var(--g-space-base)*3);--g-space-4:calc(var(--g-space-base)*4);--g-space-4-5:calc(var(--g-space-base)*4.5);--g-space-5:calc(var(--g-space-base)*5);--g-space-6:calc(var(--g-space-base)*6);--g-space-7:calc(var(--g-space-base)*7);--g-space-8:calc(var(--g-space-base)*8);--g-space-10:calc(var(--g-space-base)*10);--g-space-12:calc(var(--g-space-base)*12);--g-space-14:calc(var(--g-space-base)*14);--g-space-20:calc(var(--g-space-base)*20);--g-space-24:calc(var(--g-space-base)*24);--g-space-28:calc(var(--g-space-base)*28);--g-space-32:calc(var(--g-space-base)*32);--g-space-36:calc(var(--g-space-base)*36);--g-space-44:calc(var(--g-space-base)*44);--g-space-48:calc(var(--g-space-base)*48);--g-space-52:calc(var(--g-space-base)*52);--g-space-72:calc(var(--g-space-base)*72);--g-space-96:calc(var(--g-space-base)*96);--g-font-size-50:calc(12/var(--g-px-base)*1rem);--g-font-size-100:calc(14/var(--g-px-base)*1rem);--g-font-size-200:calc(16/var(--g-px-base)*1rem);--g-font-size-300:calc(18/var(--g-px-base)*1rem);--g-font-size-400:calc(20/var(--g-px-base)*1rem);--g-font-size-500:calc(22/var(--g-px-base)*1rem);--g-font-size-600:calc(24/var(--g-px-base)*1rem);--g-font-size-700:calc(32/var(--g-px-base)*1rem);--g-font-size-800:calc(38/var(--g-px-base)*1rem);--g-font-family-mono:"Roboto",'Menlo, Consolas, "Ubuntu Mono", "Roboto Mono", "DejaVu Sans Mono", monospace';--g-font-family-inter-var:"Inter",'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif';--g-font-normal:400;--g-font-medium:500;--g-font-semibold:600;--g-font-bold:700;--g-italic:oblique 14deg;--g-line-height-tight:1.25;--g-line-height-snug:1.375;--g-line-height-normal:1.5;--g-border-radius-sm:calc(4/var(--g-px-base)*1rem);--g-border-radius:calc(6/var(--g-px-base)*1rem);--g-border-radius-full:9999px;--g-color-light:#fff;--g-color-transparent:transparent;--g-color-gray-50:#f0f0f0;--g-color-gray-100:#e2e2e2;--g-color-gray-200:#bdbdbd;--g-color-gray-300:#999;--g-color-gray-400:#7c7c7c;--g-color-gray-500:#696969;--g-color-gray-600:#585858;--g-color-gray-700:#292929;--g-color-gray-750:#1f1f1f;--g-color-gray-800:#141414;--g-color-gray-850:#0e0e0e;--g-color-gray-900:#090909;--g-color-green-50:#e7efed;--g-color-green-400:#60ab96;--g-color-green-500:#277b63;--g-color-green-600:#226c57;--g-color-green-900:#144134;--g-color-green-950:#002c20;--g-color-blue-400:#49afeb;--g-color-blue-600:#3e96c9;--g-color-blue-900:#21506b;--g-color-yellow-50:#fff7eb;--g-color-yellow-400:#facc32;--g-color-yellow-600:#fbbf24;--g-color-yellow-900:#7b4807;--g-color-yellow-950:#362600;--g-color-red-400:#eb6c49;--g-color-red-600:#c95c3e;--g-color-red-900:#6b2521;--g-color-purple-400:#7f49eb;--g-color-purple-600:#6c3ec9;--g-color-purple-900:#39216b}@supports (color:color(display-p3 0 0 0%)){:root{--g-color-green-950:#002c20;--g-color-yellow-50:#fff7eb;--g-color-yellow-950:#362600}@media (color-gamut:p3){:root{--g-color-green-950:color(display-p3 0.04602 0.17026 0.1277);--g-color-yellow-50:color(display-p3 0.99709 0.97106 0.92232);--g-color-yellow-950:color(display-p3 0.2031 0.15112 0.01811)}}}:root{--s-color-bg-base:var(--g-color-light,#fff);--s-color-bg-base-dev:var(--g-color-gray-50,#f0f0f0);--s-color-bg-surface-primary:var(--g-color-gray-50,#f0f0f0);--s-color-bg-surface-primary-hover:var(--g-color-gray-100,#f0f0f0);--s-color-bg-surface-secondary:var(--g-color-gray-100,#e2e2e2);--s-color-bg-surface-quaternary:var(--g-color-gray-400,#7c7c7c);--s-color-bg-brand-default:var(--g-color-green-600,#226c57);--s-color-bg-brand-weak:var(--g-color-green-50,#f0f9ff);--s-color-bg-success-default:var(--g-color-green-600,#144134);--s-color-bg-info-default:var(--g-color-blue-600,#21506b);--s-color-bg-warning-default:var(--g-color-yellow-600,#665100);--s-color-bg-warning-weak:var(--g-color-yellow-50,#f9d985);--s-color-bg-warning-action:var(--g-color-yellow-400,#f9d985);--s-color-bg-caution-default:var(--g-color-red-600,#610);--s-color-bg-tip-default:var(--g-color-purple-600,#49216b);--s-color-bg-note-default:var(--g-color-gray-600,#21506b);--s-color-bg-input:var(--g-color-light,#fff);--s-color-text-base:var(--g-color-light,#fff);--s-color-text-primary:var(--g-color-gray-900,#080809);--s-color-text-secondary:var(--g-color-gray-600,#454a4e);--s-color-text-tertiary:var(--g-color-gray-400,#f0f0f0);--s-color-text-tertiary-hover:var(--g-color-gray-600,#e2e2e2);--s-color-text-quaternary:var(--g-color-gray-100,#f0f0f0);--s-color-text-brand-default:var(--g-color-light,#fff);--s-color-text-link:var(--g-color-green-600,#226c57);--s-color-text-link-hover:var(--g-color-green-600,#226c57);--s-color-text-success:var(--g-color-green-900,#144134);--s-color-text-info:var(--g-color-blue-900,#21506b);--s-color-text-warning:var(--g-color-yellow-900,#665100);--s-color-text-caution:var(--g-color-red-900,#610);--s-color-text-tip:var(--g-color-purple-900,#49216b);--s-color-border-primary:var(--g-color-gray-200,#bdbdbd);--s-color-border-secondary:var(--g-color-gray-100,#e2e2e2);--s-color-border-tertiary:var(--g-color-gray-300,#999);--s-color-border-quaternary:var(--g-color-gray-400,#7c7c7c);--s-color-border-transparent:var(--g-color-transparent,transparent);--s-color-border-input:var(--g-color-gray-300,#999);--s-color-border-brand-default:var(--g-color-green-600,#226c57);--s-color-border-success:var(--g-color-green-600,#144134);--s-color-border-info:var(--g-color-blue-600,#21506b);--s-color-border-warning:var(--g-color-yellow-600,#665100);--s-color-border-error:var(--g-color-red-600,#610);--s-color-border-tip:var(--g-color-purple-600,#49216b);--s-color-border-note:var(--g-color-gray-600,#21506b);--s-rounded-sm:var(--g-border-radius-sm,4px);--s-rounded:var(--g-border-radius,6px);--s-rounded-full:var(--g-border-radius-full,9999px);--s-border:var(--g-space-px,1px) solid var(--s-color-border-primary);--s-border-secondary:var(--g-space-px,1px) solid var(--s-color-border-secondary);--s-logo-hat:var(--g-color-green-600,#226c57);--s-logo-beard:var(--g-color-gray-300,#999)}[data-theme=dark]{--s-color-bg-base:var(--g-color-gray-850);--s-color-bg-base-dev:var(--g-color-gray-800);--s-color-bg-surface-primary:var(--g-color-gray-800);--s-color-bg-surface-primary-hover:var(--g-color-gray-750);--s-color-bg-surface-secondary:var(--g-color-gray-750);--s-color-bg-surface-quaternary:var(--g-color-gray-600);--s-color-bg-brand-weak:var(--g-color-green-950);--s-color-bg-warning-weak:var(--g-color-yellow-950);--s-color-bg-input:var(--g-color-gray-800);--s-color-text-primary:var(--g-color-gray-100);--s-color-text-secondary:var(--g-color-gray-200);--s-color-text-tertiary:var(--g-color-gray-400);--s-color-text-tertiary-hover:var(--g-color-gray-300);--s-color-text-quaternary:var(--g-color-gray-500);--s-color-text-brand-default:var(--g-color-light);--s-color-text-link:var(--g-color-green-500);--s-color-text-link-hover:var(--g-color-green-400);--s-color-text-success:var(--g-color-green-400);--s-color-text-info:var(--g-color-blue-400);--s-color-text-warning:var(--g-color-yellow-400);--s-color-text-caution:var(--g-color-red-400);--s-color-text-tip:var(--g-color-purple-400);--s-color-border-primary:var(--g-color-gray-700);--s-color-border-secondary:var(--g-color-gray-750);--s-color-border-tertiary:var(--g-color-gray-600);--s-color-border-quaternary:var(--g-color-gray-500);--s-color-border-input:var(--g-color-gray-700);--s-color-border-brand-default:var(--g-color-green-600);--s-color-border-success:var(--g-color-green-400);--s-color-border-info:var(--g-color-blue-400);--s-color-border-warning:var(--g-color-yellow-400);--s-color-border-error:var(--g-color-red-400);--s-color-border-tip:var(--g-color-purple-400);--s-color-border-note:var(--g-color-gray-600);--s-logo-hat:#fff;--s-logo-beard:grey}*,::backdrop,::file-selector-button,:after,:before{border:0 solid;box-sizing:border-box;margin:0;padding:0}html{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}h1,h2,h3{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub{bottom:-.25em;font-size:75%;line-height:0;position:relative;vertical-align:baseline}table{border-collapse:collapse;border-color:inherit;text-indent:0}summary{display:list-item}menu,ol,ul{list-style:none}embed,img,object,svg{display:block;vertical-align:middle}img{height:auto;max-width:100%}::file-selector-button,button,input,select,textarea{background-color:transparent;border-radius:0;color:inherit;font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;opacity:1}::file-selector-button{margin-right:4px}::-moz-placeholder{opacity:1}::placeholder{opacity:1}@supports (not (-webkit-appearance:-apple-pay-button)) or (contain-intrinsic-size:1px){::-moz-placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}::-webkit-calendar-picker-indicator{line-height:1}::file-selector-button,button,input:where([type=button],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}@font-face{font-display:swap;font-family:Roboto;font-style:normal;font-weight:900;src:url(fonts/roboto/roboto-mono-normal.woff2) format("woff2"),url(fonts/roboto/roboto-mono-normal.woff) format("woff")}@font-face{font-display:block;font-family:Inter;font-style:oblique 0deg 10deg;font-variant:normal;font-weight:100 900;src:url(fonts/intervar/Intervar.woff2) format("woff2")}html{background-color:var(--s-color-bg-base);color:var(--s-color-text-secondary);font-family:var(--g-font-family-inter-var);font-feature-settings:"kern" on,"liga" on,"calt" off,"zero" on,contextual common-ligatures,"kern";-webkit-font-feature-settings:"kern" on,"liga" on,"calt" off,"zero" on;font-size:calc(var(--g-px-base)*1px);line-height:var(--g-line-height-normal);-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-kerning:normal;font-variant-ligatures:contextual common-ligatures;text-rendering:optimizeLegibility}body{display:flex;flex-direction:column;min-height:100vh}main{background-color:var(--s-color-bg-base);flex-grow:2;width:100%}main.dev-mode{background-color:var(--s-color-bg-base-dev)}main>section{display:grid;grid-auto-flow:dense;grid-template-columns:var(--g-grid-1);grid-column-gap:var(--g-space-20);-moz-column-gap:var(--g-space-20);column-gap:var(--g-space-20);min-height:100%;padding-left:var(--g-space-4);padding-right:var(--g-space-4)}@media (min-width:calc(640 / 16 * 1rem)){main>section{padding-left:var(--g-space-10);padding-right:var(--g-space-10)}}@media (min-width:calc(820 / 16 * 1rem)){main>section{grid-template-columns:var(--g-grid-10)}}@media (min-width:calc(1366 / 16 * 1rem)){main>section{-moz-column-gap:var(--g-space-32);column-gap:var(--g-space-32)}}svg{max-height:100%;max-width:100%}form{margin-bottom:0;margin-top:0}code{font-family:var(--g-font-mono)}summary{cursor:pointer}md-renderer{margin-top:var(--g-space-4);padding-bottom:var(--g-space-24)}@media (min-width:calc(820 / 16 * 1rem)){md-renderer{grid-column:span 7;margin-top:0}}::-moz-selection{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}::selection{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}summary::-webkit-details-marker{display:none}summary::marker{display:none}.c-stack{display:flex;flex-direction:column;justify-content:flex-start}.c-stack>*+*{margin-top:var(--g-space-4)}.c-inline{align-items:center;display:inline-flex;gap:var(--g-space-3)}.c-between{align-items:center;display:flex;justify-content:space-between}.c-center{box-sizing:border-box;margin-left:auto;margin-right:auto;max-width:var(--g-breakpoint-max);padding-left:var(--g-space-4);padding-right:var(--g-space-4)}@media (min-width:calc(640 / 16 * 1rem)){.c-center{padding-left:var(--g-space-10);padding-right:var(--g-space-10)}}.c-full-screen{align-items:center;display:flex;flex-direction:column;grid-column:1/-1;height:100%;justify-content:center;margin-top:var(--g-space-10);padding-bottom:var(--g-space-24);width:100%}.c-reel{display:flex;overflow:scroll}.c-icon{flex-shrink:0;height:1.15em;width:1.15em}.c-with-icon{align-items:flex-start;display:inline-flex}.c-with-icon .c-icon,.c-with-icon--inline .c-icon{margin-left:.3em;margin-right:.3em;margin-top:.15em}.c-with-icon--inline{display:inline-block}.c-with-icon--inline>*{vertical-align:middle}.c-with-icon--inline .c-icon{margin-top:0}.c-view-grid{display:flex;flex-direction:column}@media (min-width:calc(640 / 16 * 1rem)){.c-view-grid{-moz-column-gap:var(--g-space-8);column-gap:var(--g-space-8);flex-direction:row}}@media (min-width:calc(820 / 16 * 1rem)){.c-view-grid{display:grid;grid-template-columns:var(--g-grid-10);grid-column-gap:var(--g-space-20);-moz-column-gap:var(--g-space-20);column-gap:var(--g-space-20)}}@media (min-width:calc(1366 / 16 * 1rem)){.c-view-grid{-moz-column-gap:var(--g-space-32);column-gap:var(--g-space-32)}}.c-toggle-btn>input{display:none}.c-toggle-btn label{visibility:hidden}.c-toggle-btn input:checked+label{visibility:visible}.c-readme-view,.c-realm-view{--cr-px-base:var(--g-px-base);--cr-space-mult:1;--cr-space-base:calc(1em/var(--g-space-mult)*var(--cr-space-mult));--cr-space-0:0;--cr-space-0-5:calc(var(--cr-space-base)*0.5);--cr-space-1:var(--cr-space-base);--cr-space-2:calc(var(--cr-space-base)*2);--cr-space-3:calc(var(--cr-space-base)*3);--cr-space-4:calc(var(--cr-space-base)*4);--cr-space-5:calc(var(--cr-space-base)*5);--cr-space-7:calc(var(--cr-space-base)*7);--cr-space-8:calc(var(--cr-space-base)*8);--cr-space-24:calc(var(--cr-space-base)*24);--cr-color-brand-default:var(--s-color-text-link);display:block;font-size:calc(var(--cr-px-base)*1px);padding-top:var(--g-space-4);word-break:break-word}.c-readme-view:empty,.c-realm-view:empty{display:none}.c-realm-view:has(.b-btn:only-child){display:none}.c-readme-view:has(.b-btn:only-child){display:none}@media (min-width:calc(820 / 16 * 1rem)){.c-readme-view,.c-realm-view{grid-row-start:1;padding-top:var(--g-space-6)}}.c-readme-view a,.c-realm-view a{color:var(--cr-color-brand-default);display:inline-block;font-weight:inherit;position:relative;text-wrap:balance;vertical-align:top}.c-readme-view a:hover,.c-realm-view a:hover{-webkit-text-decoration:underline;text-decoration:underline}.c-realm-view a:has(>img){vertical-align:middle}.c-readme-view a:has(>img){vertical-align:middle}.c-readme-view a>span,.c-realm-view a>span{margin-bottom:.1em}.c-readme-view a>.tooltip+.tooltip,.c-realm-view a>.tooltip+.tooltip{margin-left:.2em}.c-readme-view a>.tooltip:last-of-type,.c-realm-view a>.tooltip:last-of-type{margin-right:.2em}.c-realm-view a:has(>img:first-child):has(.tooltip:last-child):not(:has(>:nth-child(3)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip:last-child):not(:has(>:nth-child(3)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-realm-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-realm-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip:first-of-type{bottom:var(--g-space-2);left:var(--g-space-7);position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip:first-of-type{bottom:var(--g-space-2);left:var(--g-space-7);position:absolute}.c-readme-view h1+h2,.c-readme-view h2+h3,.c-readme-view h3+h4,.c-realm-view h1+h2,.c-realm-view h2+h3,.c-realm-view h3+h4{margin-top:var(--cr-space-4)}.c-readme-view h1,.c-readme-view h2,.c-readme-view h3,.c-readme-view h4,.c-realm-view h1,.c-realm-view h2,.c-realm-view h3,.c-realm-view h4{color:var(--s-color-text-primary);line-height:var(--g-line-height-tight);margin-top:var(--cr-space-4)}.c-readme-view h1,.c-realm-view h1{font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);margin-bottom:var(--cr-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h1,.c-realm-view h1{font-size:var(--g-font-size-800)}}.c-readme-view h2,.c-realm-view h2{font-size:var(--g-font-size-500);font-weight:var(--g-font-bold)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h2,.c-realm-view h2{font-size:var(--g-font-size-600)}}.c-readme-view h2 *,.c-realm-view h2 *{font-weight:var(--g-font-bold)}.c-readme-view h3,.c-readme-view h4,.c-realm-view h3,.c-realm-view h4{color:var(--s-color-text-secondary);font-weight:var(--g-font-semibold)}.c-readme-view h3,.c-realm-view h3{font-size:var(--g-font-size-400);margin-top:var(--cr-space-4)}.c-readme-view h4,.c-realm-view h4{font-size:var(--g-font-size-300);margin-top:var(--cr-space-3)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h4,.c-realm-view h4{font-size:var(--g-font-size-300)}}.c-readme-view h3 *,.c-readme-view h4 *,.c-realm-view h3 *,.c-realm-view h4 *{font-weight:var(--g-font-semibold)}.c-readme-view h5,.c-readme-view h6,.c-realm-view h5,.c-realm-view h6{font-size:var(--g-font-size-300);font-weight:var(--g-font-bold);margin-bottom:var(--cr-space-0);margin-top:var(--cr-space-0)}.c-readme-view h5+p,.c-readme-view h6+p,.c-realm-view h5+p,.c-realm-view h6+p{margin-top:var(--cr-space-0)}.c-readme-view img,.c-realm-view img{border:1px solid var(--s-color-bg-surface-primary);border-radius:var(--g-border-radius-sm);margin-bottom:var(--cr-space-2);margin-top:var(--cr-space-2);max-width:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none}.c-readme-view figure,.c-realm-view figure{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3);text-align:center}.c-readme-view figcaption,.c-realm-view figcaption{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100)}.c-readme-view video,.c-realm-view video{margin-bottom:var(--g-space-4);margin-top:var(--g-space-4);max-width:100%}.c-readme-view p,.c-realm-view p{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3)}.c-realm-view p:has(>a:only-child>img){margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4)}.c-readme-view p:has(>a:only-child>img){margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4)}.c-realm-view p:has(>a:only-child>img) img{margin-bottom:0;margin-top:0}.c-readme-view p:has(>a:only-child>img) img{margin-bottom:0;margin-top:0}.c-readme-view strong,.c-readme-view strong *,.c-realm-view strong,.c-realm-view strong *{font-weight:var(--g-font-bold)}.c-readme-view em,.c-realm-view em{font-style:var(--g-italic)}.c-readme-view blockquote,.c-realm-view blockquote{border-left:solid var(--g-space-0-5) var(--s-color-border-tertiary);color:var(--s-color-text-secondary);margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4);padding-left:var(--g-space-3)}.c-readme-view blockquote>blockquote,.c-realm-view blockquote>blockquote{margin-bottom:var(--cr-space-7);margin-top:var(--cr-space-7)}.c-readme-view caption,.c-realm-view caption{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);margin-top:var(--cr-space-2);text-align:left}.c-readme-view q,.c-realm-view q{quotes:"“" "”"}.c-readme-view q:before,.c-realm-view q:before{content:open-quote}.c-readme-view q:after,.c-realm-view q:after{content:close-quote}.c-readme-view details,.c-realm-view details{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3)}.c-readme-view summary,.c-realm-view summary{cursor:pointer;font-weight:var(--g-font-bold)}.c-readme-view math,.c-realm-view math{font-family:var(--g-font-family-mono)}.c-readme-view small,.c-realm-view small{font-size:var(--g-font-size-100)}.c-readme-view del,.c-realm-view del{-webkit-text-decoration:line-through;text-decoration:line-through}.c-readme-view sub,.c-realm-view sub{font-size:var(--g-font-size-50);vertical-align:sub}.c-readme-view sup,.c-realm-view sup{font-size:var(--g-font-size-50);padding-left:var(--space-px);vertical-align:middle}.c-readme-view sup>a,.c-realm-view sup>a{vertical-align:middle}.c-readme-view ol,.c-readme-view ul,.c-realm-view ol,.c-realm-view ul{margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4);padding-left:var(--g-space-4)}.c-readme-view ul,.c-realm-view ul{list-style:disc}.c-readme-view ol,.c-realm-view ol{list-style:decimal}.c-readme-view ol ol,.c-readme-view ol ul,.c-readme-view ul ol,.c-readme-view ul ul,.c-realm-view ol ol,.c-realm-view ol ul,.c-realm-view ul ol,.c-realm-view ul ul{margin-bottom:var(--cr-space-2);margin-top:var(--cr-space-2);padding-left:var(--g-space-4)}.c-readme-view li,.c-realm-view li{margin-bottom:var(--cr-space-1);margin-top:var(--cr-space-1)}.c-readme-view code,.c-readme-view pre,.c-realm-view code,.c-realm-view pre{font-family:var(--g-font-family-mono)}.c-readme-view pre,.c-readme-view pre.chroma-chroma,.c-realm-view pre,.c-realm-view pre.chroma-chroma{background-color:var(--s-color-bg-surface-primary);border-radius:var(--g-border-radius-sm);margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3);overflow-x:auto;padding:var(--cr-space-4)}.c-readme-view :not(pre)>code,.c-realm-view :not(pre)>code{background-color:var(--s-color-bg-surface-secondary);border-radius:var(--g-border-radius-sm);font-size:.96em;padding:var(--cr-space-0-5) var(--cr-space-1)}.c-readme-view a code,.c-realm-view a code{color:inherit}.c-readme-view hr,.c-realm-view hr{border-top:var(--s-border-secondary);margin-bottom:var(--cr-space-8);margin-top:var(--cr-space-8)}.c-readme-view table,.c-realm-view table{border-collapse:collapse;display:block;margin-bottom:var(--cr-space-5);margin-top:var(--cr-space-5);max-width:100%;width:100%}.c-readme-view td,.c-readme-view th,.c-realm-view td,.c-realm-view th{border:var(--s-border);padding:var(--cr-space-2) var(--cr-space-4);white-space:normal;word-break:break-word}.c-readme-view th,.c-realm-view th{background-color:var(--s-color-bg-surface-secondary);font-weight:var(--g-font-bold)}.c-readme-view button,.c-readme-view input,.c-readme-view select,.c-readme-view textarea,.c-realm-view button,.c-realm-view input,.c-realm-view select,.c-realm-view textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--s-color-bg-input);border:var(--s-border);padding:var(--cr-space-2) var(--cr-space-4)}.c-readme-view>.realm-view__btns:first-child+*,.c-readme-view>:first-child:not(.realm-view__btns),.c-realm-view>.realm-view__btns:first-child+*,.c-realm-view>:first-child:not(.realm-view__btns){margin-top:0!important}.c-readme-view .footnote-backref,.c-readme-view h1:not(.does-not-exist),.c-readme-view h2:not(.does-not-exist),.c-readme-view h3:not(.does-not-exist),.c-readme-view h4:not(.does-not-exist),.c-readme-view sup:not(.does-not-exist),.c-realm-view .footnote-backref,.c-realm-view h1:not(.does-not-exist),.c-realm-view h2:not(.does-not-exist),.c-realm-view h3:not(.does-not-exist),.c-realm-view h4:not(.does-not-exist),.c-realm-view sup:not(.does-not-exist){scroll-margin-top:var(--cr-space-24)}.c-readme-view .b-btn,.c-realm-view .b-btn{color:var(--s-color-text-secondary);display:inline-flex}.c-readme-view .b-btn:hover,.c-realm-view .b-btn:hover{-webkit-text-decoration:none;text-decoration:none}.c-readme-view .b-btn:first-child,.c-realm-view .b-btn:first-child{float:right;margin-top:var(--g-space-4)}.c-readme-view>.b-btn:first-child+*,.c-readme-view>:first-child:not(.b-btn),.c-realm-view>.b-btn:first-child+*,.c-realm-view>:first-child:not(.b-btn){margin-top:0}.c-readme-view{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius);margin-bottom:var(--g-space-6);padding:var(--g-space-6) var(--g-space-4) var(--g-space-4);width:100%}@media (min-width:calc(820 / 16 * 1rem)){.c-readme-view{grid-row-start:auto}}.b-gnome .hat,.b-logo .hat{fill:var(--s-logo-hat)}.b-gnome .beard,.b-logo .beard{fill:var(--s-logo-beard)}.b-banner{align-items:center;background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base);display:flex;font-size:var(--g-font-size-50);font-weight:var(--g-font-semibold);justify-content:center;padding:var(--g-space-1-5) var(--g-space-4);text-align:center;-webkit-text-decoration:none;text-decoration:none;width:100%}@media (min-width:calc(640 / 16 * 1rem)){.b-banner{font-size:var(--g-font-size-100)}}a.b-banner:hover{opacity:.9;-webkit-text-decoration:underline;text-decoration:underline}.b-header{background-color:var(--s-color-bg-base);border-bottom:var(--s-border);font-size:var(--g-font-size-100);position:sticky;top:0;z-index:var(--g-z-max)}.b-header nav{align-items:stretch;height:auto}.b-header .main-nav{align-items:stretch;display:flex;flex:1 1 auto;gap:var(--g-space-1);height:100%;min-width:0;padding-bottom:var(--g-space-2);padding-top:var(--g-space-2);width:100%}@media (min-width:calc(820 / 16 * 1rem)){.b-header .main-nav{grid-column:span 7}}.b-header .main-nav--explorer{grid-column:span 10}.b-header .user-picture{border:var(--s-border-secondary);border-radius:var(--s-rounded);cursor:pointer;flex-shrink:0;height:var(--g-space-10);width:var(--g-space-10)}.b-header .user-picture>svg{height:100%;width:100%}.b-main-navigation{color:var(--s-color-text-quaternary);height:auto;position:relative;width:100%}.b-main-navigation>.inner{align-items:center;background-color:var(--s-color-bg-surface-secondary);border:var(--s-border-secondary);border-radius:var(--s-rounded);height:100%;padding-left:var(--g-space-1-5);padding-right:var(--g-space-1-5);position:relative}@media (min-width:calc(640 / 16 * 1rem)){.b-main-navigation>.inner{padding-right:var(--g-space-8)}}.b-main-navigation>.inner:has([data-role=header-input-search]:focus-within){border-color:var(--s-color-border-tertiary)}.b-main-navigation .searchbar{bottom:0;color:var(--s-color-text-secondary);font-size:var(--g-font-size-200);font-weight:var(--g-font-medium);left:0;padding:var(--g-space-1-5);padding-right:var(--g-space-8);position:absolute;right:0;top:0}.b-main-navigation .searchbar>input{background-color:transparent;height:100%;outline:none;width:100%}.b-main-navigation .searchbar:focus-within+.b-breadcrumb{display:none}.b-main-navigation .network-toggle{align-items:center;background-color:var(--g-color-transparent);border-radius:var(--g-border-radius);cursor:pointer;display:none;height:calc(100% - 2px);justify-content:center;padding:var(--g-space-1-5);position:absolute;right:1px;top:1px;z-index:var(--g-z-max)}@media (min-width:calc(640 / 16 * 1rem)){.b-main-navigation .network-toggle{display:flex}}.b-main-navigation .network-toggle>svg{color:var(--s-color-text-tertiary);height:var(--g-space-5);width:var(--g-space-5)}.b-main-navigation .network-toggle:hover>svg{color:var(--s-color-text-tertiary-hover)}.b-main-navigation .b-popup-dialog>.inner{color:var(--s-color-text-tertiary);width:var(--g-space-72)}.b-main-navigation .b-popup-dialog header>span{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold)}.b-main-navigation .b-popup-dialog .item{display:flex;gap:var(--g-space-1)}.b-main-navigation .b-popup-dialog .item>svg{height:var(--g-space-4);width:var(--g-space-4)}.b-main-navigation .b-popup-dialog .item-content{display:flex;flex-direction:column}.b-main-navigation .b-popup-dialog .item-label{font-size:var(--g-font-size-50)}.b-main-navigation .b-popup-dialog .item-value{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold)}.b-main-menu{display:flex;flex:0 0 auto;grid-column:span 3;height:var(--g-space-12)}@media (min-width:calc(640 / 16 * 1rem)){.b-main-menu{height:auto}}.b-main-menu .menu-toggle{align-items:center;cursor:pointer;display:flex;margin-left:auto;order:3}.b-main-menu .menu-toggle>svg{height:var(--g-space-5);margin-left:var(--g-space-4);width:var(--g-space-5)}@media (min-width:calc(820 / 16 * 1rem)){.b-main-menu .menu-toggle>svg{margin-left:var(--g-space-2)}}.b-main-menu .menu-toggle-input~.menu-dev{display:none}.b-main-menu .menu-toggle-input:checked~.menu-dev{display:flex}.b-main-menu .menu-toggle-input:checked~.menu-general{display:none}.b-main-menu .menu-dev,.b-main-menu .menu-general{display:flex;height:100%;justify-content:flex-end}.b-menu-link:last-child,.b-menu-link:last-child .link{margin-right:0}.b-menu-link .link{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold);gap:var(--g-space-1);height:100%;margin-right:var(--g-space-3);position:relative}.b-menu-link .link:hover{color:var(--s-color-text-tertiary-hover)}.b-menu-link .link:after{background-color:var(--s-color-bg-brand-default);border-radius:var(--s-rounded) var(--s-rounded) 0 0;bottom:0;content:"";height:var(--g-space-1);left:0;position:absolute;transition:width var(--g-transition-fast);width:0}.b-menu-link .link>svg{flex-shrink:0;height:var(--g-space-5);min-width:var(--g-space-2);width:var(--g-space-5)}@media (min-width:calc(1020 / 16 * 1rem)){.b-menu-link .link>svg{display:none}}@media (min-width:calc(1366 / 16 * 1rem)){.b-menu-link .link>svg{display:inline-block;height:var(--g-space-4-5);width:var(--g-space-4-5)}}@media (min-width:calc(640 / 16 * 1rem)){.b-menu-link .link{font-weight:var(--g-font-bold)}}@media (min-width:calc(1366 / 16 * 1rem)){.b-menu-link .link{margin-right:var(--g-space-6);padding-right:var(--g-space-1)}}@media (min-width:calc(640 / 16 * 1rem)){.b-menu-link .link-label{display:none}}@media (min-width:calc(1020 / 16 * 1rem)){.b-menu-link .link-label{display:inline}}.b-menu-link .link--icon{font-weight:var(--g-font-regular);margin-right:var(--g-space-4)}@media (min-width:calc(480 / 16 * 1rem)){.b-menu-link .link--icon{margin-right:var(--g-space-6)}}.b-menu-link .link--is-active{color:var(--s-color-text-secondary)}.b-menu-link .link--is-active:after{width:100%}.b-menu-link .link--is-active>svg{color:var(--s-color-bg-brand-default)}.menu-general .link{color:var(--s-color-text-secondary)}.menu-general .link:hover{color:var(--s-color-text-link-hover)}.b-breadcrumb{display:flex}.b-breadcrumb,.b-breadcrumb:after{background-color:var(--s-color-bg-surface-secondary)}.b-breadcrumb:after{bottom:0;content:"";display:block;left:0;pointer-events:none;position:absolute;right:0;top:0}.b-breadcrumb>ol{color:var(--s-color-text-primary);display:flex;font-weight:var(--g-font-semibold);line-height:var(--g-line-height-snug)}.b-breadcrumb .argument,.b-breadcrumb .element,.b-breadcrumb .query{align-items:center;display:flex;white-space:nowrap;z-index:var(--g-z-1)}.b-breadcrumb .argument:not(:first-child):before,.b-breadcrumb .element:not(:first-child):before,.b-breadcrumb .query:not(:first-child):before{color:var(--s-color-text-tertiary);content:"/";line-height:var(--g-line-height-normal);padding-left:.18rem;padding-right:.18rem;padding-top:var(--g-space-px)}.b-breadcrumb .argument a,.b-breadcrumb .element a,.b-breadcrumb .query a{background-color:var(--s-color-bg-base);border:1px solid var(--s-color-border-transparent);border-radius:var(--s-rounded-sm);display:inline-block;min-width:var(--g-space-4);padding:var(--g-space-0-5);text-align:center}.b-breadcrumb .argument a:hover,.b-breadcrumb .element a:hover,.b-breadcrumb .query a:hover{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}.b-breadcrumb .argument:not(:first-child):before{content:":"}.b-breadcrumb .argument a{background-color:var(--s-color-bg-surface-quaternary);color:var(--s-color-text-base)}.b-breadcrumb .query:not(:first-child):before{content:"&"}.b-breadcrumb .query:nth-child(1 of .query):before{content:"?"}.b-breadcrumb .query label{background-color:var(--s-color-bg-surface-primary);border:var(--s-border);border-radius:var(--s-rounded-sm);color:var(--s-color-text-secondary);cursor:text;display:inline-flex;height:100%;min-width:var(--g-space-4);padding:var(--g-space-0-5) var(--g-space-1);position:relative;text-align:center;width:100%}.b-breadcrumb .query label:focus-within{border-color:var(--s-color-border-quaternary)}.b-breadcrumb .query label:hover{border-color:var(--s-color-border-quaternary)}.b-breadcrumb .query input{background-color:var(--s-color-bg-surface-primary);max-width:10ch;order:3;outline:none;field-sizing:content}@supports not (field-sizing:content){.b-breadcrumb .query input{width:5rem!important}}.b-breadcrumb .query input::-moz-placeholder{opacity:0}.b-breadcrumb .query input::placeholder{opacity:0}.b-breadcrumb .query input:-moz-placeholder{width:var(--g-space-px)}.b-breadcrumb .query input:placeholder-shown{width:var(--g-space-px)}.b-breadcrumb .query input:placeholder-shown::-moz-placeholder{color:var(--g-color-transparent)}.b-breadcrumb .query input:-moz-placeholder::placeholder{color:var(--g-color-transparent)}.b-breadcrumb .query input:placeholder-shown::placeholder{color:var(--g-color-transparent)}.b-footer{border-top:var(--s-border);font-size:var(--g-font-size-100);padding-bottom:var(--g-space-4);padding-top:var(--g-space-4);width:100%}.b-footer>nav{flex-direction:column;row-gap:var(--g-space-8)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer>nav{flex-wrap:wrap}}.b-footer .logo{color:var(--s-color-text-primary);grid-column:1/-1;width:var(--g-space-44)}.b-footer .logo:hover{color:var(--s-color-text-primary);-webkit-text-decoration:none;text-decoration:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .logo{align-self:center;grid-column:1/3;grid-row:1/1;width:60%}}.b-footer .nav-primary{display:flex;gap:var(--g-space-10);grid-column:1/-1;grid-row:2/3}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-primary{align-items:center;flex:1 0 0%;flex-direction:row;gap:var(--g-space-6);justify-content:space-between}}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .nav-primary{grid-column:2/8;grid-row:1/1}}.b-footer .nav-primary>ul{display:flex;flex:1;flex-direction:column;flex-wrap:wrap;gap:var(--g-space-1) var(--g-space-3)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-primary>ul{flex:initial;flex-direction:row}.b-footer .nav-social{margin-left:auto}}@media (min-width:calc(820 / 16 * 1rem)){.b-footer .nav-social{grid-column:span 3;justify-self:end;margin-left:0}}.b-footer .nav-theme{align-items:center;display:flex;gap:var(--g-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-theme{flex-basis:100%}}@media (min-width:calc(820 / 16 * 1rem)){.b-footer .nav-theme{grid-column:span 3}}.b-footer .nav-theme .nav-theme-label{color:var(--s-color-text-secondary)}.b-footer .nav-theme:has([data-theme-target=sun]:not(.u-hidden)) .nav-theme-label:before{content:"Light"}.b-footer .nav-theme:has([data-theme-target=moon]:not(.u-hidden)) .nav-theme-label:before{content:"Dark"}.b-footer .nav-theme:has([data-theme-target=system]:not(.u-hidden)) .nav-theme-label:before{content:"System"}.b-footer .legal{color:var(--s-color-text-tertiary);font-size:var(--g-font-size-50);margin-top:var(--g-space-3);padding-top:var(--g-space-3)}.b-footer .legal>nav{color:var(--s-color-text-secondary);display:flex;flex-direction:column;flex-wrap:wrap;gap:var(--g-space-1) var(--g-space-3);margin-top:var(--g-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .legal>nav{flex-direction:row}.b-footer .legal>nav>a+a:before{color:var(--s-color-text-quaternary);content:"|";margin-right:var(--g-space-3)}}.b-footer .legal>nav:nth-child(3){grid-column:span 2/span 2}.b-footer .legal>:last-child:not(ul),.b-footer .legal>nav li{margin-bottom:var(--g-space-2);margin-top:var(--g-space-2)}.b-footer .legal>:last-child:not(ul){flex-basis:100%}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .legal>:last-child:not(ul){flex-basis:auto;grid-column:span 1/span 1}}.b-footer a:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}.b-content-header{display:flex;flex-direction:column;gap:var(--g-space-3);grid-row:span 1/span 1;margin-bottom:var(--g-space-6);margin-top:var(--g-space-10)}@media (min-width:calc(820 / 16 * 1rem)){.b-content-header{grid-column:span 7/span 7;grid-row-start:1;justify-content:space-between;margin-top:var(--g-space-10)}}@media (min-width:calc(1020 / 16 * 1rem)){.b-content-header{align-items:center;flex-direction:row}}.b-content-header .title{align-items:center;display:flex;gap:var(--g-space-3)}.b-content-header .header-info{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);gap:var(--g-space-12);justify-content:space-between}.b-content-header .b-inline-btn>span{display:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-content-header .b-inline-btn>span{display:inline}}.b-content-h1{font-size:var(--g-font-size-600);text-align:center}.b-content-h1,.b-content-h2{color:var(--s-color-text-primary);font-weight:var(--g-font-bold)}.b-content-h2{font-size:var(--g-font-size-400);margin-bottom:var(--g-space-4)}.b-btns{align-items:center;display:flex;gap:var(--g-space-1)}@media (min-width:calc(1020 / 16 * 1rem)){.b-btns{gap:var(--g-space-2)}}.b-btn{border:var(--s-border);border-radius:var(--s-rounded-sm);cursor:pointer;display:inline-flex;gap:var(--g-space-1-5);min-width:-moz-max-content;min-width:max-content;padding:var(--g-space-1) var(--g-space-2)}.b-btn:hover{background-color:var(--s-color-bg-surface-primary-hover)}.b-btn .c-icon{margin-left:0;margin-right:0}.b-btn--secondary:hover{background-color:var(--s-color-bg-surface-primary)}.b-inline-btn{color:var(--s-color-text-tertiary);cursor:pointer}.b-inline-btn:hover{color:var(--s-color-text-tertiary-hover)}.b-switch input,.b-switch label:last-child{display:none}.b-switch input+label,.b-switch input:checked~label:last-child{display:block}.b-switch input:checked+label{display:none}.b-block-form,.b-inline-form{color:var(--s-color-text-tertiary);display:flex;flex-direction:column;gap:var(--g-space-2) var(--g-space-3)}@media (min-width:calc(820 / 16 * 1rem)){.b-block-form,.b-inline-form{flex-direction:row}}.b-block-form{align-items:stretch}@media (min-width:calc(820 / 16 * 1rem)){.b-block-form{flex-direction:column}}.b-input{border:var(--s-border);border-radius:var(--s-rounded-sm);color:var(--s-color-text-secondary);display:flex;font-size:var(--g-font-size-100);min-width:var(--g-space-48);overflow:hidden;position:relative}.b-input>svg{height:var(--g-space-4);pointer-events:none;position:absolute;top:50%;transform:translateY(-50%);width:var(--g-space-4)}.b-input>svg:first-child{left:var(--g-space-2)}.b-input>svg:last-child{right:var(--g-space-2)}.b-input:hover,.b-input>input:focus,.b-input>input:hover{border-color:var(--s-color-border-tertiary)}.b-input:has(input:focus),.b-input:hover,.b-input>input:focus,.b-input>input:hover{border-color:var(--s-color-border-tertiary)}.b-input:hover>label{background-color:var(--s-color-bg-surface-primary)}.b-input:has(input:focus)>label,.b-input:hover>label{background-color:var(--s-color-bg-surface-primary)}.b-input>label{align-items:center;background-color:var(--s-color-bg-surface-secondary);gap:var(--g-space-3);white-space:nowrap}.b-input>input,.b-input>label,.b-input>select{display:flex;padding:var(--g-space-1-5) var(--g-space-3)}.b-input>input,.b-input>select{color:inherit;outline:none;width:100%}@media (min-width:calc(820 / 16 * 1rem)){.b-input>input,.b-input>select{padding:var(--g-space-1-5) var(--g-space-2)}}.b-input>select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--s-color-bg-surface-secondary);cursor:pointer}.b-input>select:hover{background-color:var(--s-color-bg-surface-primary)}.b-input>input{background-color:var(--s-color-bg-base);border-left:none}.b-input>label+input{border-left:var(--s-border)}.b-list{margin-bottom:var(--g-space-10)}.b-list>li{border-bottom:var(--s-border);color:var(--s-color-text-tertiary)}.b-list>li:first-child{border-top:var(--s-border)}.b-list>li>a{align-items:center;display:flex;justify-content:space-between;padding:var(--g-space-2)}.b-list>li>a:hover{background-color:var(--s-color-bg-surface-primary-hover)}.b-list>li>a .c-icon{margin-left:0}.b-list .name{display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;color:var(--s-color-text-secondary);margin-left:var(--g-space-1);max-width:100%;overflow:hidden;text-overflow:ellipsis}.b-user-sidebar{margin-top:var(--g-space-4)}.b-user-sidebar>*+*{margin-top:var(--g-space-8)}.b-user-sidebar .user-avatar{border:var(--s-border);border-radius:var(--s-rounded);height:var(--g-space-24);width:var(--g-space-24)}@media (min-width:calc(640 / 16 * 1rem)){.b-user-sidebar .user-avatar{height:var(--g-space-36);width:var(--g-space-36)}}.b-user-sidebar .user-avatar img,.b-user-sidebar .user-avatar svg{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.b-user-sidebar .user-info{align-items:flex-start;display:flex;gap:var(--g-space-6)}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar .user-info{flex-direction:column}}.b-user-sidebar .user-info>div:last-child{align-self:flex-end}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar .user-info>div:last-child{align-self:flex-start}}.b-user-sidebar .title{color:var(--s-color-text-primary);display:bock;font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);line-height:var(--g-line-height-tight);text-transform:capitalize;word-break:break-all}@media (min-width:calc(640 / 16 * 1rem)){.b-user-sidebar .title{font-size:var(--g-font-size-800)}}.b-user-sidebar .subtitle{color:var(--s-color-text-secondary);display:block;font-size:var(--g-font-size-100);line-height:var(--g-line-height-tight);margin-top:var(--g-space-2)}.b-user-sidebar>a{align-items:center;display:flex;justify-content:center}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar>a{display:inline-flex}}.b-sidebar{border-bottom:var(--s-border);grid-column:span 1/span 1;padding-bottom:var(--g-space-10);position:relative}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar{border-bottom:none;grid-column:span 3/span 3;grid-row:span 2/span 2;grid-row-start:1;height:100%;margin-bottom:0;order:2;padding-bottom:0}.b-sidebar+md-renderer:empty+*{grid-row-start:1;padding-top:var(--g-space-6)}.b-sidebar+md-renderer:empty+*,.b-sidebar+md-renderer:has(.b-btn:only-child)+*{grid-row-start:1;padding-top:var(--g-space-6)}}.b-sidebar:first-child{margin-top:var(--g-space-8)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar:first-child{margin-top:0}}.b-sidebar>div{padding-top:var(--g-space-2);position:sticky;top:var(--g-space-14)}.b-sidebar>div:has(.inner):not(:has(nav li)){display:none}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar>div{padding-bottom:var(--g-space-2)}}.b-sidebar .inner{background-color:var(--s-color-bg-surface-primary);border-radius:var(--s-rounded-sm);max-height:100vh;overflow:scroll;scrollbar-width:none}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .inner{background-color:var(--g-color-transparent)}}.b-sidebar .inner>nav{display:none;font-size:var(--g-font-size-100);margin-top:var(--g-space-2);padding:var(--g-space-2) var(--g-space-4) var(--g-space-6)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .inner>nav{display:block;margin-top:0;padding-bottom:var(--g-space-28);padding-left:0;padding-right:0}.b-sidebar .inner>nav>*{padding-left:0}}.b-sidebar .b-expend-btn{align-items:center;background-color:var(--s-color-bg-base);border:var(--s-border);border-radius:var(--s-rounded-sm);cursor:pointer;display:flex;font-size:var(--g-font-size-100);justify-content:space-between;padding:var(--g-space-2) var(--g-space-4)}.b-sidebar .b-expend-btn:hover{background-color:var(--s-color-bg-surface-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .b-expend-btn{border:none;cursor:default;font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);margin-top:var(--g-space-10);padding:0}.b-sidebar .b-expend-btn,.b-sidebar .b-expend-btn:hover{background-color:var(--g-color-transparent)}}.b-sidebar .b-expend-btn:has(#toc-expend:checked)+nav{display:block}.b-sidebar .b-expend-btn>input{display:none}.b-sidebar .b-expend-btn>input:checked+.wrapper-icon:before{content:"close"}.b-sidebar .b-expend-btn>input:checked+.wrapper-icon>svg{transform:rotate(180deg)}.b-sidebar .wrapper-icon{align-items:center;display:flex;gap:var(--g-space-1-5)}.b-sidebar .wrapper-icon:before{content:"open"}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .wrapper-icon{display:none}}.dev-mode .b-sidebar .b-expend-btn{background-color:var(--s-color-bg-surface-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.dev-mode .b-sidebar .b-expend-btn{background-color:var(--g-color-transparent)}}.dev-mode .b-sidebar .b-expend-btn:hover{background-color:var(--s-color-bg-surface-primary)}.b-source-code{font-family:var(--g-font-mono)}.b-source-code>pre{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded);font-size:var(--g-font-size-100);overflow:scroll;padding:var(--g-space-4) var(--g-space-1)}@media (min-width:calc(640 / 16 * 1rem)){.b-source-code>pre{font-size:var(--g-font-size-200);padding:var(--g-space-8) var(--g-space-3)}}.b-source-code>pre a:hover{-webkit-text-decoration:none;text-decoration:none}[data-theme=dark] .b-source-code>pre{background-color:var(--s-color-bg-base)}.b-toc{list-style:none;margin-top:var(--g-space-2)}.b-toc>*+*{margin-bottom:var(--g-space-1-5);margin-top:var(--g-space-1-5)}.b-toc .b-toc{border-left:1px solid var(--s-color-border-secondary);margin-bottom:var(--g-space-4);padding-left:var(--g-space-4)}.b-toc a>span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-overflow:ellipsis}.b-toc a:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}main.dev-mode .b-toc a{word-break:break-all}.b-source-toc>.b-toc{margin-bottom:var(--g-space-4)}.b-source-toc>*+*{margin-top:var(--g-space-1-5)}.b-source-toc .accordion summary>svg{transform:rotate(-90deg)}.b-source-toc .accordion summary:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}.b-source-toc .accordion[open] summary>svg{transform:rotate(0deg)}.b-source-toc .accordion>.b-toc{padding-left:var(--g-space-5)}.b-source-toc .accordion h3{font-size:var(--g-font-size-100);font-weight:var(--g-font-medium);margin-top:0}.b-action-overview{margin-bottom:var(--g-space-12)}.b-action-overview>p{font-size:var(--g-font-size-200)}.b-action-function{background-color:var(--s-color-bg-surface-secondary);border-radius:var(--s-rounded);margin-bottom:var(--g-space-3);padding:var(--g-space-4)}.b-action-function .title{align-items:baseline;display:flex;flex-wrap:wrap;font-size:var(--g-font-size-50);gap:var(--g-space-1) var(--g-space-4);margin-bottom:var(--g-space-1)}.b-action-function>header{align-items:flex-start;display:flex;font-size:var(--g-font-size-100);justify-content:space-between;margin-bottom:var(--g-space-4)}.b-action-function>header .signature>code{color:var(--s--text-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.b-action-function>header .signature{font-size:var(--g-font-size-50)}}.b-action-function>header h2{color:var(--s-color-text-primary);font-size:var(--g-font-size-300);font-weight:var(--g-font-semibold);line-height:var(--g-line-height-tight)}.b-action-function .description{color:var(--s-color-text-secondary);font-size:var(--g-font-size-200)}.b-action-function .params{align-items:stretch;color:var(--s-color-text-tertiary);display:flex;flex-direction:column;font-size:var(--g-font-size-100);gap:var(--g-space-1);margin-bottom:var(--g-space-1);margin-top:var(--g-space-6);width:100%}.b-action-function .params label{background-color:var(--s-color-bg-surface-primary)}.b-action-function .params .b-input:has(input:focus) label{background-color:var(--s-color-bg-surface-secondary)}.b-action-function .params .b-input:has(input:hover) label{background-color:var(--s-color-bg-surface-secondary)}.b-action-function .b-alert{background-color:var(--s-color-bg-warning-weak);border-left:var(--g-space-1) solid var(--s-color-border-tertiary);border-left-color:var(--s-color-border-warning);border-radius:var(--s-rounded);color:var(--s-color-text-secondary);color:var(--s-color-text-warning);margin-bottom:var(--g-space-10);margin-top:var(--g-space-5);padding:var(--g-space-3) var(--g-space-4)}.b-action-function .b-alert>h1:first-child,.b-action-function .b-alert>h2:first-child,.b-action-function .b-alert>h3:first-child{font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);margin-bottom:var(--g-space-2)}.b-action-function .b-alert .b-btn,.b-action-function .b-alert label{background-color:var(--s-color-bg-warning-action);border:none;color:var(--s-color-bg-warning-weak);cursor:pointer}.b-action-function .b-alert .b-btn{margin-top:var(--g-space-4)}.b-code{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded);font-size:var(--g-font-size-100);position:relative}.b-code pre{color:var(--s-color-text-secondary);padding:var(--g-space-4);padding-right:var(--g-space-10);white-space:pre-wrap}.b-code .btn-copy{background-color:var(--g-color-transparent);color:var(--s-color-text-tertiary);cursor:pointer;padding:0;position:absolute;right:var(--g-space-2);top:var(--g-space-2)}.b-code .btn-copy:hover{color:var(--s-color-text-primary)}.b-packages{min-height:var(--g-space-96);padding-bottom:var(--g-space-24);scroll-margin-block-start:var(--g-space-24)}@media (min-width:calc(820 / 16 * 1rem)){.b-packages{grid-column:span 7/span 7}}.b-packages .title{color:var(--s-color-text-primary);display:block;font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);margin-bottom:var(--g-space-6)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .title{font-size:var(--g-font-size-800)}}.b-packages nav{display:grid;grid-template-columns:repeat(4,1fr);grid-gap:var(--g-space-3);gap:var(--g-space-3);margin-bottom:var(--g-space-6)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages nav{border-bottom:var(--s-border);padding-bottom:var(--g-space-2)}}.b-packages .packages-tabs{border-bottom:var(--s-border);color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);gap:var(--g-space-4);grid-column:span 4/span 4;padding-bottom:var(--g-space-2);width:auto}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-tabs{border-bottom:none;font-size:var(--g-font-size-100);grid-column:span 2/span 2;padding-bottom:0;width:100%}}@media (min-width:calc(1020 / 16 * 1rem)){.b-packages .packages-tabs{gap:var(--g-space-6);margin-left:0;width:100%}}.b-packages .packages-tabs label{align-items:center;cursor:pointer;display:flex;gap:var(--g-space-1);position:relative}.b-packages .packages-tabs label:hover{color:var(--s-color-text-tertiary-hover)}.b-packages .packages-tabs label .b-tag--secondary{display:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-packages .packages-tabs label .b-tag--secondary{display:inline}}.b-packages .packages-filters{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);gap:var(--g-space-2);grid-column:span 2/span 2}@media (min-width:calc(480 / 16 * 1rem)){.b-packages .packages-filters{grid-column:span 1/span 1}}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-filters{justify-content:flex-end}}.b-packages .packages-filters>div{display:grid}.b-packages .packages-filters label{align-items:center;cursor:pointer;display:flex;gap:var(--g-space-0-5);grid-column:1/1;grid-row:1/1;justify-content:space-between}.b-packages .packages-filters label:hover>*{color:var(--s-color-text-tertiary-hover)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-filters label span{display:none}}@media (min-width:calc(1366 / 16 * 1rem)){.b-packages .packages-filters label span{display:inline}}.b-packages .packages-search{display:flex;font-size:var(--g-font-size-100);grid-column:span 2/span 2;position:relative}@media (min-width:calc(480 / 16 * 1rem)){.b-packages .packages-search{grid-column:span 3/span 3}}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-search{grid-column:span 1/span 1}}.b-packages .range{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-gap:var(--g-space-2);color:var(--s-color-text-tertiary);font-size:var(--g-font-size-100);gap:var(--g-space-2)}.b-packages .range:before{color:var(--s-color-text-tertiary);display:none;font-size:var(--g-font-size-200);font-weight:var(--g-font-weight-bold);grid-column:1/-1;padding-bottom:var(--g-space-2);padding-top:var(--g-space-2);text-align:center;width:100%}.b-packages .range:after{content:"Add a package to your namespace to get started";display:none;font-size:var(--g-font-size-100);grid-column:1/-1;text-align:center}.b-packages .range:empty:before{content:"No packages found";display:block}.b-packages .range:empty:after{content:"Add a package to your namespace to get started";display:block}.b-packages article{background-color:var(--s-color-bg-surface-primary);border-radius:var(--s-rounded);display:flex;flex-direction:column;gap:var(--g-space-6);padding:var(--g-space-1)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages article{gap:var(--g-space-2)}}.b-packages article .article-content{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded-sm);display:flex;flex-direction:column;height:100%;padding:var(--g-space-2);width:100%}.b-packages article .article-content .title{align-items:center;display:flex;gap:var(--g-space-2);margin-bottom:var(--g-space-1);overflow:hidden;width:100%}.b-packages article .article-content h3{font-size:var(--g-font-size-200);font-weight:var(--g-font-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.b-packages article .article-content h3>a{color:var(--s-color-text-link-hover)}.b-packages article .article-content h3>a:hover{-webkit-text-decoration:underline;text-decoration:underline}.b-packages article .article-content>p{overflow:hidden;text-overflow:ellipsis;width:100%}.b-packages article .article-content>p>a:hover{-webkit-text-decoration:underline;text-decoration:underline}.b-packages article footer{display:flex;font-size:var(--g-font-size-50);gap:var(--g-space-1);justify-content:space-between;padding-bottom:var(--g-space-1);padding-left:var(--g-space-2);padding-right:var(--g-space-2)}.b-packages article footer time{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.b-packages article footer .size{text-align:right}.b-packages article,.b-packages li{display:none}.b-packages:has(input[value=packages]:checked) li{display:flex}.b-packages:has(input[value=packages]:checked) article{display:flex}.b-packages:has(input[value=realms]:checked) li[data-list-type-value=realm]{display:flex}.b-packages:has(input[value=realms]:checked) article[data-list-type-value=realm]{display:flex}.b-packages:has(input[value=pures]:checked) From 93f289d72957770020e6b50ca29bdd4a823cbf5a Mon Sep 17 00:00:00 2001 From: Nemanja Aleksic Date: Thu, 26 Mar 2026 16:02:32 +0100 Subject: [PATCH 25/92] chore: update static page content (#5367) Update the content on the static pages Related to #5355 (cherry picked from commit 27ee20897f8f4bde64b5ef6be2d0a5f9c83c43a3) --- misc/deployments/home-alias/pages/contribute.md | 12 ++++++++---- misc/deployments/home-alias/pages/ecosystem.md | 13 ++----------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/misc/deployments/home-alias/pages/contribute.md b/misc/deployments/home-alias/pages/contribute.md index d8fa600a83a..26645a35252 100644 --- a/misc/deployments/home-alias/pages/contribute.md +++ b/misc/deployments/home-alias/pages/contribute.md @@ -22,10 +22,14 @@ If you are interested in contributing to Gno.land, you can jump in on our [GitHub monorepo](https://github.com/gnolang/gno/blob/master/CONTRIBUTING.md) \- where most development happens. -A good place to start are the issues tagged -["good first issue"](https://github.com/gnolang/gno/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22). -They should allow you to make some impact on the Gno repository while you're -still exploring the details of how everything works. +### Resources for contributors + +- [Write Gno in the browser](https://play.gno.land) +- [Read about the Gno Language](/gnolang) +- [Visit the official documentation](https://docs.gno.land) +- [Efficient local development for Gno](https://docs.gno.land/builders/local-dev-with-gnodev) +- [Get testnet GNOTs](https://faucet.gno.land) +- [Discover demo packages](https://github.com/gnolang/gno/tree/master/examples) ## Gno Bounties diff --git a/misc/deployments/home-alias/pages/ecosystem.md b/misc/deployments/home-alias/pages/ecosystem.md index 940cfe467f9..b5e5f151005 100644 --- a/misc/deployments/home-alias/pages/ecosystem.md +++ b/misc/deployments/home-alias/pages/ecosystem.md @@ -9,15 +9,6 @@ execute functions in your code using the repo. Visit the playground at [play.gno.land](https://play.gno.land)! -### [Gno Studio Connect](https://gno.studio/connect) - -Gno Studio Connect provides seamless access to realms, making it simple to -explore, interact, and engage with gno.land's smart contracts through function -calls. Connect focuses on function calls, enabling users to interact with any -realm's exposed function(s) on gno.land. - -See your realm interactions in [Gno Studio Connect](https://gno.studio/connect) - ### [Gnoscan](https://gnoscan.io) Developed by the Onbloc team, Gnoscan is gno.land's blockchain explorer. Anyone @@ -35,9 +26,9 @@ an emphasis on UX, Adena is built to handle millions of realms and tokens with a high-quality interface, support for NFTs and custom tokens, and seamless integration. Install Adena via the [official website](https://www.adena.app/) -### Gnoswap +### GnoSwap -Gnoswap is currently under development and led by the Onbloc team. Gnoswap will +GnoSwap is currently under development and led by the Onbloc team. GnoSwap will be the first DEX on gno.land and is an automated market maker (AMM) protocol written in Gno that allows for permissionless token exchanges on the platform. From e24a7690ed88b36ad155d40f926244d4a662a795 Mon Sep 17 00:00:00 2001 From: Morgan Date: Mon, 30 Mar 2026 14:57:25 +0200 Subject: [PATCH 26/92] fix(stdlibs/chain): make Coins.AmountOf panic for duplicate denoms (#5099) Duplicate denoms in Coins is generally invalid. Currently, it simply returns the first encountered Coin, but I think the more correct behaviour is to panic so that execution doesn't continue with multiple coins of the same value. #5096 modifies NewCoins and Coins.Add to implement a behaviour I'm sure some users will expect (joining coins together). --------- Co-authored-by: David <60177543+Davphla@users.noreply.github.com> (cherry picked from commit 81d9f806c5e379f8e4cb042cce666b9144a8c4fb) --- gnovm/stdlibs/chain/coins.gno | 12 ++++++++--- gnovm/stdlibs/chain/coins_test.gno | 33 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/gnovm/stdlibs/chain/coins.gno b/gnovm/stdlibs/chain/coins.gno index 7dfbb339603..4480d392ca1 100644 --- a/gnovm/stdlibs/chain/coins.gno +++ b/gnovm/stdlibs/chain/coins.gno @@ -163,15 +163,21 @@ func (cz Coins) String() string { return res } -// AmountOf returns the amount of a specific coin from the Coins set +// AmountOf returns the amount of a specific coin from the Coins set. +// Returns 0 if the denom is not found. Panics if there are duplicate coins with +// the given denom. func (cz Coins) AmountOf(denom string) int64 { + amt, set := int64(0), false for _, c := range cz { if c.Denom == denom { - return c.Amount + if set { + panic("duplicate denom " + denom + " in coin set") + } + amt, set = c.Amount, true } } - return 0 + return amt } // Add adds the given Coins to the set. diff --git a/gnovm/stdlibs/chain/coins_test.gno b/gnovm/stdlibs/chain/coins_test.gno index 36e45634dcf..a01863bb7fe 100644 --- a/gnovm/stdlibs/chain/coins_test.gno +++ b/gnovm/stdlibs/chain/coins_test.gno @@ -70,6 +70,39 @@ func TestCoinsIsZero(t *testing.T) { } } +func TestCoins_AmountOf(t *testing.T) { + coins := chain.Coins{ + chain.NewCoin("denom1", 1), + chain.NewCoin("denom2", 2), + } + + amount := coins.AmountOf("denom1") + if amount != 1 { + t.Fatalf("expected amount=1 for denom1, got %d", amount) + } + + amount = coins.AmountOf("denom2") + if amount != 2 { + t.Fatalf("expected amount=2 for denom2, got %d", amount) + } + + amount = coins.AmountOf("denom3") + if amount != 0 { + t.Fatalf("expected amount=0 for denom3, got %d", amount) + } + + coins = append(coins, chain.NewCoin("denom1", 1)) + + func() { + defer func() { + if err := recover(); err == nil { + t.Fatal("expected panic when getting duplicate denom") + } + }() + coins.AmountOf("denom1") + }() +} + const maxInt64 int64 = (1 << 63) - 1 func shouldPanic(t *testing.T, f func()) { From fe88fbbcc7a97f5a02e77af675939ce5184190b3 Mon Sep 17 00:00:00 2001 From: ltzmaxwell Date: Wed, 1 Apr 2026 21:51:21 +0800 Subject: [PATCH 27/92] fix(gnovm): enforce int type for untyped variadic arguments in make (#5249) Co-authored-by: Morgan Bazalgette (cherry picked from commit 2d7f1936286f20b3d028275fd5c8d779fb73d162) --- gnovm/pkg/gnolang/preprocess.go | 98 +++++++++++++++++++++++++--- gnovm/pkg/gnolang/preprocess_test.go | 38 +++++++++++ gnovm/pkg/gnolang/type_check.go | 9 +++ gnovm/pkg/gnolang/uverse.go | 4 ++ gnovm/tests/files/types/varg_0.gno | 13 ++++ gnovm/tests/files/types/varg_1.gno | 10 +++ gnovm/tests/files/types/varg_10.gno | 11 ++++ gnovm/tests/files/types/varg_11.gno | 15 +++++ gnovm/tests/files/types/varg_12.gno | 11 ++++ gnovm/tests/files/types/varg_13.gno | 11 ++++ gnovm/tests/files/types/varg_2.gno | 13 ++++ gnovm/tests/files/types/varg_3.gno | 14 ++++ gnovm/tests/files/types/varg_4.gno | 12 ++++ gnovm/tests/files/types/varg_5.gno | 14 ++++ gnovm/tests/files/types/varg_6.gno | 17 +++++ gnovm/tests/files/types/varg_7.gno | 13 ++++ gnovm/tests/files/types/varg_8.gno | 14 ++++ gnovm/tests/files/types/varg_9.gno | 11 ++++ 18 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 gnovm/pkg/gnolang/preprocess_test.go create mode 100644 gnovm/tests/files/types/varg_0.gno create mode 100644 gnovm/tests/files/types/varg_1.gno create mode 100644 gnovm/tests/files/types/varg_10.gno create mode 100644 gnovm/tests/files/types/varg_11.gno create mode 100644 gnovm/tests/files/types/varg_12.gno create mode 100644 gnovm/tests/files/types/varg_13.gno create mode 100644 gnovm/tests/files/types/varg_2.gno create mode 100644 gnovm/tests/files/types/varg_3.gno create mode 100644 gnovm/tests/files/types/varg_4.gno create mode 100644 gnovm/tests/files/types/varg_5.gno create mode 100644 gnovm/tests/files/types/varg_6.gno create mode 100644 gnovm/tests/files/types/varg_7.gno create mode 100644 gnovm/tests/files/types/varg_8.gno create mode 100644 gnovm/tests/files/types/varg_9.gno diff --git a/gnovm/pkg/gnolang/preprocess.go b/gnovm/pkg/gnolang/preprocess.go index e88a12f75a9..cf6672e7f40 100644 --- a/gnovm/pkg/gnolang/preprocess.go +++ b/gnovm/pkg/gnolang/preprocess.go @@ -1717,9 +1717,10 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { // NOTE: these appear to be actually special cases in go. // In general, a string is not assignable to []bytes // without conversion. - if cx, ok := n.Func.(*ConstExpr); ok { + if cx, ok := n.Func.(*ConstExpr); ok && cx.GetFunc().PkgPath == uversePkgPath { fv := cx.GetFunc() - if fv.PkgPath == uversePkgPath && fv.Name == "append" { + switch fv.Name { + case "append": if n.Varg && len(n.Args) == 2 { // If the second argument is a string, // convert to byteslice. @@ -1760,7 +1761,7 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { n.Args[i+1] = Preprocess(nil, last, arg1).(Expr) } } - } else if fv.PkgPath == uversePkgPath && fv.Name == "copy" { + case "copy": if len(n.Args) == 2 { // If the second argument is a string, // convert to byteslice. @@ -1772,9 +1773,84 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { n.Args[1] = args1 } } - } else if fv.PkgPath == uversePkgPath && fv.Name == "cross" { - panic("cross(fn)(...) syntax is deprecated, use fn(cross,...)") - } else if fv.PkgPath == uversePkgPath && fv.Name == "_cross_gno0p0" { + case "make": + // Self-contained handling for make() builtin. + // Validate argument count based on target type, + // check size arguments are integers, resolve generics, + // then skip the general case via TRANS_CONTINUE. + // NOTE: If the general *FuncType call-expr path below changes + // (e.g. embedded-call expansion, generic Specify semantics, or + // checkOrConvertType behaviour), this block may need updating. + ft := bnft + n.NumArgs = countNumArgs(store, last, n) + + if n.Varg { + panic("make does not accept variadic spread (...)") + } + if len(n.Args) == 0 { + panic("missing argument to make") + } + + // Validate arg count per target type. + tt := evalStaticType(store, last, n.Args[0]) + switch baseOf(tt).(type) { + case *SliceType: + if len(n.Args) < 2 || len(n.Args) > 3 { + panic(fmt.Sprintf( + "invalid operation: make(%s) expects 2 or 3 arguments; found %d", + tt, len(n.Args))) + } + case *MapType: + if len(n.Args) > 2 { + panic(fmt.Sprintf( + "invalid operation: make(%s) expects 1 or 2 arguments; found %d", + tt, len(n.Args))) + } + case *ChanType: + panic("channel type is not yet supported") + default: + panic(fmt.Sprintf( + "invalid argument: cannot make %s; type must be slice, map", tt)) + } + + // Specify function param/result generics. + argTVs := evalStaticTypedValues(store, last, n.Args...) + isVarg := n.Varg + sft := ft.Specify(store, n, argTVs, isVarg) + spts := sft.Params + srts := FieldTypeList(sft.Results).Types() + + // Update func attributes with specified types. + n.Func.SetAttribute(ATTR_TYPEOF_VALUE, sft) + cx := n.Func.(*ConstExpr) + fv2 := cx.V.(*FuncValue).Copy(nilAllocator) + fv2.Type = sft + cx.T = sft + cx.V = fv2 + n.SetAttribute(ATTR_TYPEOF_VALUE, &tupleType{Elts: srts}) + + // Type-check arguments. + // First arg is the type -- check against resolved param type. + checkOrConvertType(store, last, n, &n.Args[0], spts[0].Type) + + // make's variadic params are declared as Vrd(AnyT()), so untyped + // constants won't be automatically coerced to int; enforce it here. + for i := 1; i < len(n.Args); i++ { + expectedType := spts[len(spts)-1].Type.Elem() + at := evalStaticTypeOf(store, last, n.Args[i]) + switch { + case isUntyped(at): + expectedType = IntType + case !isInteger(at): + panic(fmt.Sprintf( + "invalid argument: index %v (variable of type %v) must be integer", + n.Args[i], at)) + } + checkOrConvertType(store, last, n, &n.Args[i], expectedType) + } + + return n, TRANS_CONTINUE + case "_cross_gno0p0": if ctxpn.GetAttribute(ATTR_FIX_FROM) == GnoVerMissing { // This is only backwards compatibility for the gno 0.9 // transpiler/fixer. cross() is no longer used. @@ -1790,11 +1866,13 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { // only way _cross_gno0p0 appears is panic("_cross_gno0p0 is reserved") } - } else if fv.PkgPath == uversePkgPath && fv.Name == "crossing" { + case "cross": + panic("cross(fn)(...) syntax is deprecated, use fn(cross,...)") + case "crossing": if ctxpn.GetAttribute(ATTR_FIX_FROM) != GnoVerMissing { panic("crossing() is reserved and deprecated") } - } else if fv.PkgPath == uversePkgPath && fv.Name == "attach" { + case "attach": // reserve attach() so we can support it later. panic("attach() not yet supported") } @@ -2013,8 +2091,7 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { } checkOrConvertType(store, last, n, &n.Args[i], spts[i].Type) } else { - checkOrConvertType(store, last, n, &n.Args[i], - spts[len(spts)-1].Type.Elem()) + checkOrConvertType(store, last, n, &n.Args[i], spts[len(spts)-1].Type.Elem()) } } else { checkOrConvertType(store, last, n, &n.Args[i], spts[i].Type) @@ -4292,6 +4369,7 @@ func checkOrConvertType(store Store, last BlockNode, n Node, x *Expr, t Type) { // Convert untyped to typed. checkOrConvertType(store, last, n, &bx.Left, t) + bx.SetAttribute(ATTR_TYPEOF_VALUE, t) // propagate converted type from left operand to shift expr. } else { mustAssignableTo(n, xt, t) } diff --git a/gnovm/pkg/gnolang/preprocess_test.go b/gnovm/pkg/gnolang/preprocess_test.go new file mode 100644 index 00000000000..fbedf198d8a --- /dev/null +++ b/gnovm/pkg/gnolang/preprocess_test.go @@ -0,0 +1,38 @@ +package gnolang + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestShiftExprAttrTypeOfValue(t *testing.T) { + t.Parallel() + + m := NewMachine("test", nil) + c := `package test +func main() { + var a uint = 1 + b := make([]byte, 1< (variable of type string) must be integer + +// TypeCheckError: +// main/varg_3.gno:5:20: invalid argument: index i (variable of type string) must be integer diff --git a/gnovm/tests/files/types/varg_4.gno b/gnovm/tests/files/types/varg_4.gno new file mode 100644 index 00000000000..7d5442a0182 --- /dev/null +++ b/gnovm/tests/files/types/varg_4.gno @@ -0,0 +1,12 @@ +package main + +func main() { + + i := -2 + b := make([]byte, 1, i) + + println(b) +} + +// Error: +// makeslice: cap out of range diff --git a/gnovm/tests/files/types/varg_5.gno b/gnovm/tests/files/types/varg_5.gno new file mode 100644 index 00000000000..74e6899be41 --- /dev/null +++ b/gnovm/tests/files/types/varg_5.gno @@ -0,0 +1,14 @@ +package main + +func bar(s ...int) { + var s1 []int + s1 = append(s1, s...) + println(s1) +} +func main() { + var a uint = 2 + bar(1.0< Date: Tue, 21 Apr 2026 18:28:04 +0200 Subject: [PATCH 28/92] fixup(gnovm): rename isInteger(*apd.Decimal) to isDecimalInteger to avoid collision --- gnovm/pkg/gnolang/preprocess.go | 2 +- gnovm/pkg/gnolang/values_conversions.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gnovm/pkg/gnolang/preprocess.go b/gnovm/pkg/gnolang/preprocess.go index cf6672e7f40..8aebdee0b4c 100644 --- a/gnovm/pkg/gnolang/preprocess.go +++ b/gnovm/pkg/gnolang/preprocess.go @@ -1538,7 +1538,7 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { // Out of bounds errors are usually handled during evalConst(). if isIntNum(ct) { if bd, ok := arg0.TypedValue.V.(BigdecValue); ok { - if !isInteger(bd.V) { + if !isDecimalInteger(bd.V) { panic(fmt.Sprintf( "cannot convert %s to integer type", arg0)) diff --git a/gnovm/pkg/gnolang/values_conversions.go b/gnovm/pkg/gnolang/values_conversions.go index 6fabc3eafd9..861a53a3efc 100644 --- a/gnovm/pkg/gnolang/values_conversions.go +++ b/gnovm/pkg/gnolang/values_conversions.go @@ -1386,7 +1386,7 @@ func ConvertUntypedBigdecTo(dst *TypedValue, bdv BigdecValue, t Type) { bd := bdv.V switch k { case BigintKind: - if !isInteger(bd) { + if !isDecimalInteger(bd) { panic(fmt.Sprintf( "cannot convert untyped bigdec to integer -- %s not an exact integer", bd.String(), @@ -1406,7 +1406,7 @@ func ConvertUntypedBigdecTo(dst *TypedValue, bdv BigdecValue, t Type) { case IntKind, Int8Kind, Int16Kind, Int32Kind, Int64Kind: fallthrough case UintKind, Uint8Kind, Uint16Kind, Uint32Kind, Uint64Kind: - if !isInteger(bd) { + if !isDecimalInteger(bd) { panic(fmt.Sprintf( "cannot convert untyped bigdec to integer -- %s not an exact integer", bd.String(), @@ -1451,7 +1451,7 @@ func ConvertUntypedBigdecTo(dst *TypedValue, bdv BigdecValue, t Type) { // ---------------------------------------- // apd.Decimal utility -func isInteger(d *apd.Decimal) bool { +func isDecimalInteger(d *apd.Decimal) bool { d2 := apd.New(0, 0) res, err := apd.BaseContext.RoundToIntegralExact(d2, d) if err != nil { @@ -1483,7 +1483,7 @@ func toBigInt(d *apd.Decimal) *big.Int { // underlying value has no fractional component. func IsExactBigDec(v Value) bool { if bd, ok := v.(BigdecValue); ok { - return isInteger(bd.V) + return isDecimalInteger(bd.V) } return false } From f9a5d96714a89a37517a2c85000673ac0a9b8b24 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:49:21 +0200 Subject: [PATCH 29/92] feat: add new govdao-scripts (#5375) Related with #5374 --------- Signed-off-by: moul <94029+moul@users.noreply.github.com> (cherry picked from commit 0886378db7a41c8dcae619be8187bc4701ff4645) --- .../gnoland1/govdao-scripts/README.md | 3 + .../govdao-scripts/restrict-account.sh | 76 ++++++++++++++++ .../gnoland1/govdao-scripts/set-cla.sh | 89 +++++++++++++++++++ .../govdao-scripts/set-valoper-minfee.sh | 67 ++++++++++++++ .../govdao-scripts/unrestrict-account.sh | 4 + 5 files changed, 239 insertions(+) create mode 100755 misc/deployments/gnoland1/govdao-scripts/restrict-account.sh create mode 100755 misc/deployments/gnoland1/govdao-scripts/set-cla.sh create mode 100755 misc/deployments/gnoland1/govdao-scripts/set-valoper-minfee.sh diff --git a/misc/deployments/gnoland1/govdao-scripts/README.md b/misc/deployments/gnoland1/govdao-scripts/README.md index e63c01c5049..88f969de206 100644 --- a/misc/deployments/gnoland1/govdao-scripts/README.md +++ b/misc/deployments/gnoland1/govdao-scripts/README.md @@ -10,4 +10,7 @@ All scripts default to `GNOKEY_NAME=moul`, `CHAIN_ID=gnoland1`, and `REMOTE=http ./rm-validator.sh ADDR # remove a validator ./extend-govdao-t1.sh # add 6 T1 members to govDAO (one-time bootstrap) ./unrestrict-account.sh ADDR [ADDR...] # allow address(es) to transfer ugnot +./restrict-account.sh ADDR [ADDR...] # re-restrict account(s) from transferring ugnot +./set-cla.sh URL # set/update CLA document via govDAO proposal +./set-valoper-minfee.sh AMOUNT # update valoper registration minimum fee ``` diff --git a/misc/deployments/gnoland1/govdao-scripts/restrict-account.sh b/misc/deployments/gnoland1/govdao-scripts/restrict-account.sh new file mode 100755 index 00000000000..7b7528f761b --- /dev/null +++ b/misc/deployments/gnoland1/govdao-scripts/restrict-account.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Re-restrict an account so it can no longer transfer ugnot when bank is locked. +# +# Usage: +# ./restrict-account.sh ADDR [ADDR...] +# +# Example: +# ./restrict-account.sh g1abc...123 +# ./restrict-account.sh g1abc...123 g1def...456 +# +# Environment: +# GNOKEY_NAME - gnokey key name (default: moul) +# CHAIN_ID - chain ID (default: gnoland1) +# REMOTE - RPC endpoint (default: https://rpc.betanet.testnets.gno.land:443) +# GAS_WANTED - gas limit (default: 50000000) +# GAS_FEE - gas fee (default: 1000000ugnot) +set -eo pipefail + +if [ $# -eq 0 ]; then + echo "Usage: $0 ADDR [ADDR...]" >&2 + exit 1 +fi + +GNOKEY_NAME="${GNOKEY_NAME:-moul}" +CHAIN_ID="${CHAIN_ID:-gnoland1}" +REMOTE="${REMOTE:-https://rpc.betanet.testnets.gno.land:443}" +GAS_WANTED="${GAS_WANTED:-50000000}" +GAS_FEE="${GAS_FEE:-1000000ugnot}" + +# Build address list for the Gno code. +ADDR_ARGS="" +for addr in "$@"; do + ADDR_ARGS="${ADDR_ARGS} address(\"${addr}\"), +" +done + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/restrict.gno" <&2 + echo " $0 \"\" # disable CLA enforcement" >&2 + exit 1 +fi + +CLA_URL="$1" + +GNOKEY_NAME="${GNOKEY_NAME:-moul}" +CHAIN_ID="${CHAIN_ID:-gnoland1}" +REMOTE="${REMOTE:-https://rpc.betanet.testnets.gno.land:443}" +GAS_WANTED="${GAS_WANTED:-50000000}" +GAS_FEE="${GAS_FEE:-1000000ugnot}" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | cut -d' ' -f1 + else + echo "Error: no sha256 tool found (install coreutils or perl)" >&2 + return 1 + fi +} + +if [ -z "$CLA_URL" ]; then + CLA_HASH="" + echo "Disabling CLA enforcement" +else + echo "Fetching CLA from: $CLA_URL" + wget -q -O "$TMPDIR/cla.md" "$CLA_URL" + CLA_HASH=$(sha256_file "$TMPDIR/cla.md") + echo " sha256: $CLA_HASH" +fi + +cat >"$TMPDIR/set_cla.gno" < +# ./set-valoper-minfee.sh 0 # disable registration fee +# ./set-valoper-minfee.sh 20000000 # set to 20 GNOT +# +# Environment: +# GNOKEY_NAME - gnokey key name (default: moul) +# CHAIN_ID - chain ID (default: gnoland1) +# REMOTE - RPC endpoint (default: https://rpc.betanet.testnets.gno.land:443) +# GAS_WANTED - gas limit (default: 50000000) +# GAS_FEE - gas fee (default: 1000000ugnot) +set -eo pipefail + +if [ $# -ne 1 ]; then + echo "Usage: $0 " >&2 + echo " $0 0 # disable registration fee" >&2 + echo " $0 20000000 # set to 20 GNOT" >&2 + exit 1 +fi + +MIN_FEE="$1" + +GNOKEY_NAME="${GNOKEY_NAME:-moul}" +CHAIN_ID="${CHAIN_ID:-gnoland1}" +REMOTE="${REMOTE:-https://rpc.betanet.testnets.gno.land:443}" +GAS_WANTED="${GAS_WANTED:-50000000}" +GAS_FEE="${GAS_FEE:-1000000ugnot}" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/set_minfee.gno" < Date: Thu, 2 Apr 2026 09:47:06 +0200 Subject: [PATCH 30/92] fix(gnovm): implement Go-compliant variable initialization order (#5247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: [dashboard.hackenproof.com/manager/companies/newtendermint/gno-dot-land/reports/NEWTENDG-68](https://dashboard.hackenproof.com/manager/companies/newtendermint/gno-dot-land/reports/NEWTENDG-68). Alternative to #5153. The previous initialization algorithm used recursive depth-first traversal of variable dependencies, which could produce incorrect initialization order and was susceptible to non-determinism (map iteration over dependency sets). The Go specification mandates: > Within a package, package-level variable initialization proceeds stepwise, with each step selecting the variable earliest in declaration order which has no dependencies on uninitialized variables. ## Changes ### Initialization order rewrite **`preprocess.go`**: Replace the post-preprocessing `findDependentNames` AST walker with a dependency-tracking approach that runs as a dedicated coda pass (`codaInitOrderDeps`) after `preprocess1`: - `addDependencyToTopDecl` — called from `codaInitOrderDeps` whenever a `NameExpr` or `SelectorExpr` resolves to a package-level name in the same package. It walks up the node stack to find the enclosing top-level `ValueDecl` or `FuncDecl` and records the dependency as a `Name` in an `ATTR_DECL_DEPS` attribute on that node. Method dependencies are encoded as `"Type.Method"` so receiver bodies are tracked without re-walking the AST. - `resolveDeclDep` — given a dependency name (plain or `"Type.Method"`), looks up the corresponding `Decl` using `GetLocalIndex` + `NameSources` for correct, panic-safe `*DeclaredType` resolution. - `resolveEffectiveDeps` — memoized DFS from all declarations, following `ATTR_DECL_DEPS` edges and collapsing `FuncDecl` bodies transitively to discover indirect `*ValueDecl` dependencies. O(V+E). Detects circular variable dependencies and panics with the full chain (e.g. `circular dependency: A -> f -> B -> A`). **`machine.go`**: Replace the recursive `runDeclarationFor` + `loopfindr` approach with a two-phase iterative algorithm directly implementing the Go spec: 1. Build an ordered `[]Decl` pending list from all non-`FuncDecl` declarations across all files, preserving source declaration order. 2. Kahn's topological sort with a min-heap keyed on declaration index to always pick the earliest-in-declaration-order ready entry. O(V + E + V log V). This eliminates non-determinism from map iteration and incorrect ordering from depth-first traversal. **`nodes.go`**: Remove the now-unused `findDependentNames`, `GetExternNames`, `addExternName`, and `isFile` helpers. The `Externs` field on `StaticBlock` is retained for amino serialization backward-compatibility but is no longer populated. ### Composite literal non-const key fix **`preprocess.go`**: The old code allowed non-constant variables as slice/array composite literal keys (e.g. `[]int{a: b, c: d}` where `a` and `c` are runtime variables). This is invalid per the Go spec, which requires constant index expressions. The new code panics with `"slice/array literals may not contain non-const keys"`, matching Go's `go/types` error. **`uverse_test.go`**: `TestIssue1337PrintNilSliceAsUndefined`'s "print composite slice" case used `a, b, c, d := 1, 2, 3, 4` as composite literal keys. Updated to `const a, b, c, d = 1, 2, 3, 4` since these must be constant expressions. ### Tests - Add `var_initorder{1–26}.gno` filetests covering declaration chains, multi-name specs, blank identifiers, method-receiver dependencies (value, pointer, auto-addressed), cross-file transitive deps, cross-package method guards, interface dispatch, recursive methods, circular deps, embedded struct method promotion (value and pointer receiver), func-literal-var circular deps, multiple blank decls with side-effects, deep transitive FuncDecl→ValueDecl chains, variable shadowing in function bodies, and doubly-nested closures. - Add `var_initorder_crossfn.gno` and `var_initorder_xpkgmethod.gno` for cross-file and cross-package cases. - Add `TestInitOrderDeterminism` in `preprocess_test.go` which runs a complex dependency graph 100 times to verify stable output. - Add `TestCircDepDeterminism` which verifies circular-dependency error messages are deterministic. - Add post-Kahn's completeness assertion in `runFileDecls` that panics if any declaration was not processed (guards against missed cycles or reverse-dep notification gaps). - Update `recursive10.gno`, `recursive11.gno`, and `closure.gno` for the new circular-dependency error format. - Update `composite15.gno` expected output for the new non-const key error. ### Integration test updates `atomicswap.txtar`: Gas amounts changed slightly (e.g. 454000 → 453800) as a side effect of the new initialization order affecting the number of VM operations executed during package loading. --------- Co-authored-by: ltzmaxwell (cherry picked from commit 50ee56e643a9099e2c4a498a5225b5767f1fbc29) --- .../pkg/integration/testdata/atomicswap.txtar | 6 +- gnovm/adr/pr5247_initialization_order.md | 161 ++++++++ gnovm/pkg/gnolang/machine.go | 141 ++++--- gnovm/pkg/gnolang/nodes.go | 32 +- gnovm/pkg/gnolang/preprocess.go | 380 ++++++++++++------ gnovm/pkg/gnolang/preprocess_test.go | 144 ++++++- gnovm/pkg/gnolang/uverse_test.go | 2 +- gnovm/tests/files/closure.gno | 2 +- gnovm/tests/files/composite15.gno | 6 +- .../files/extern/initorder_crossfn/a.gno | 7 + .../files/extern/initorder_crossfn/b.gno | 5 + .../files/extern/initorder_xpkgmethod/pkg.gno | 13 + gnovm/tests/files/recursive10.gno | 2 +- gnovm/tests/files/recursive11.gno | 2 +- gnovm/tests/files/var_initorder.gno | 21 + gnovm/tests/files/var_initorder10.gno | 26 ++ gnovm/tests/files/var_initorder11.gno | 23 ++ gnovm/tests/files/var_initorder12.gno | 19 + gnovm/tests/files/var_initorder13.gno | 23 ++ gnovm/tests/files/var_initorder14.gno | 28 ++ gnovm/tests/files/var_initorder15.gno | 31 ++ gnovm/tests/files/var_initorder16.gno | 30 ++ gnovm/tests/files/var_initorder17.gno | 23 ++ gnovm/tests/files/var_initorder18.gno | 17 + gnovm/tests/files/var_initorder19.gno | 24 ++ gnovm/tests/files/var_initorder2.gno | 43 ++ gnovm/tests/files/var_initorder20.gno | 22 + gnovm/tests/files/var_initorder21.gno | 20 + gnovm/tests/files/var_initorder22.gno | 17 + gnovm/tests/files/var_initorder23.gno | 21 + gnovm/tests/files/var_initorder24.gno | 20 + gnovm/tests/files/var_initorder25.gno | 22 + gnovm/tests/files/var_initorder26.gno | 16 + gnovm/tests/files/var_initorder3.gno | 18 + gnovm/tests/files/var_initorder4.gno | 17 + gnovm/tests/files/var_initorder5.gno | 22 + gnovm/tests/files/var_initorder6.gno | 24 ++ gnovm/tests/files/var_initorder7.gno | 21 + gnovm/tests/files/var_initorder8.gno | 23 ++ gnovm/tests/files/var_initorder9.gno | 22 + .../tests/files/var_initorder_circ_multi.gno | 18 + gnovm/tests/files/var_initorder_crossfn.gno | 15 + .../tests/files/var_initorder_xpkgmethod.gno | 21 + 43 files changed, 1315 insertions(+), 235 deletions(-) create mode 100644 gnovm/adr/pr5247_initialization_order.md create mode 100644 gnovm/tests/files/extern/initorder_crossfn/a.gno create mode 100644 gnovm/tests/files/extern/initorder_crossfn/b.gno create mode 100644 gnovm/tests/files/extern/initorder_xpkgmethod/pkg.gno create mode 100644 gnovm/tests/files/var_initorder.gno create mode 100644 gnovm/tests/files/var_initorder10.gno create mode 100644 gnovm/tests/files/var_initorder11.gno create mode 100644 gnovm/tests/files/var_initorder12.gno create mode 100644 gnovm/tests/files/var_initorder13.gno create mode 100644 gnovm/tests/files/var_initorder14.gno create mode 100644 gnovm/tests/files/var_initorder15.gno create mode 100644 gnovm/tests/files/var_initorder16.gno create mode 100644 gnovm/tests/files/var_initorder17.gno create mode 100644 gnovm/tests/files/var_initorder18.gno create mode 100644 gnovm/tests/files/var_initorder19.gno create mode 100644 gnovm/tests/files/var_initorder2.gno create mode 100644 gnovm/tests/files/var_initorder20.gno create mode 100644 gnovm/tests/files/var_initorder21.gno create mode 100644 gnovm/tests/files/var_initorder22.gno create mode 100644 gnovm/tests/files/var_initorder23.gno create mode 100644 gnovm/tests/files/var_initorder24.gno create mode 100644 gnovm/tests/files/var_initorder25.gno create mode 100644 gnovm/tests/files/var_initorder26.gno create mode 100644 gnovm/tests/files/var_initorder3.gno create mode 100644 gnovm/tests/files/var_initorder4.gno create mode 100644 gnovm/tests/files/var_initorder5.gno create mode 100644 gnovm/tests/files/var_initorder6.gno create mode 100644 gnovm/tests/files/var_initorder7.gno create mode 100644 gnovm/tests/files/var_initorder8.gno create mode 100644 gnovm/tests/files/var_initorder9.gno create mode 100644 gnovm/tests/files/var_initorder_circ_multi.gno create mode 100644 gnovm/tests/files/var_initorder_crossfn.gno create mode 100644 gnovm/tests/files/var_initorder_xpkgmethod.gno diff --git a/gno.land/pkg/integration/testdata/atomicswap.txtar b/gno.land/pkg/integration/testdata/atomicswap.txtar index 164892fe569..b31215e587d 100644 --- a/gno.land/pkg/integration/testdata/atomicswap.txtar +++ b/gno.land/pkg/integration/testdata/atomicswap.txtar @@ -20,12 +20,12 @@ gnokey maketx call -pkgpath gno.land/r/demo/defi/atomicswap -func NewCoinSwap -g stdout '(1 int)' stdout ".*$test2_user_addr.*$test3_user_addr.*12345ugnot.*" stdout 'OK!' -stdout 'EVENTS: \[.*"fee_delta":\{"denom":"ugnot","amount":454000\}.*\]' +stdout 'EVENTS: \[.*"fee_delta":\{"denom":"ugnot","amount":453800\}.*\]' gnokey query vm/qrender --data 'gno.land/r/demo/defi/atomicswap:' gnokey query auth/accounts/$test2_user_addr -stdout 'coins.*:.*1008533655ugnot' +stdout 'coins.*:.*1008533855ugnot' gnokey query auth/accounts/$test3_user_addr stdout 'coins.*:.*1010000000ugnot' @@ -34,6 +34,6 @@ stdout 'OK!' stdout 'EVENTS: \[.*"fee_delta":\{"denom":"ugnot","amount":500\}.*\]' gnokey query auth/accounts/$test2_user_addr -stdout 'coins.*:.*1008533655ugnot' +stdout 'coins.*:.*1008533855ugnot' gnokey query auth/accounts/$test3_user_addr stdout 'coins.*:.*1009011845ugnot' diff --git a/gnovm/adr/pr5247_initialization_order.md b/gnovm/adr/pr5247_initialization_order.md new file mode 100644 index 00000000000..ba9dccaa76c --- /dev/null +++ b/gnovm/adr/pr5247_initialization_order.md @@ -0,0 +1,161 @@ +# PR5247: Go-Compliant Variable Initialization Order + +## Context + +The Go specification mandates that package-level variables are initialized +stepwise, with each step selecting the variable earliest in declaration order +whose dependencies are all satisfied. GnoVM's original implementation used a +recursive depth-first `runDeclarationFor` function that computed dependencies +via `findDependentNames` (an AST walker) at initialization time. This had +several problems: + +1. **Incorrect ordering.** The depth-first recursive approach did not implement + the Go spec's "earliest in declaration order" rule. It processed variables + in file-iteration order and recursively resolved deps depth-first, which + could produce a different order than Go. + +2. **Non-determinism.** Dependency sets were stored in `map[Name]struct{}` + and iterated with `for dep := range deps`, making initialization order + depend on Go's non-deterministic map iteration. + +3. **Missed method dependencies.** `findDependentNames` relied on the + `Externs` mechanism (`GetExternNames`) to discover names referenced from + inside function bodies. However, method names were not tracked as externs, + so their transitive dependencies were invisible. For example: + + ```go + type T struct{} + func (T) GetB() int { return B } + var A = T{}.GetB() // dependency on B was not discovered + var B = 42 + ``` + +4. **Shallow `Externs` tracking.** More broadly, `findDependentNames` depended + on the `Externs` implementation on `StaticBlock`, which did not descend + into function bodies. It only tracked names that crossed block boundaries + during `GetPathForName`, not all names referenced within a function. This + meant transitive dependencies through function calls could be missed. + +## Decision + +The fix has two parts: (A) how dependencies are syntactically recorded, and +(B) how the initialization order is computed from those dependencies. + +### Part A: Syntactic Dependency Recording via `codaInitOrderDeps` + +Replace the post-hoc `findDependentNames` AST walker with a single dedicated +coda pass: `codaInitOrderDeps`. + +The pass runs after `preprocess1` (all `NameExpr` paths are filled) and before +`codaPackageSelectors` (which replaces `NameExpr`s with `SelectorExpr`s). It +uses `TranscribeB` to traverse the full AST of each file, with the ancestor +node stack always available. + +For each `*NameExpr` at `TRANS_LEAVE`: if the path type is `VPBlock`, the name +is not blank, not a package reference, not the LHS of a declaration, and is +defined at package level (not a type declaration), the name is recorded via +`addDependencyToTopDecl(ns, name)` as an entry in `ATTR_DECL_DEPS` on the +nearest enclosing `*ValueDecl` or `*FuncDecl`. + +For each `*SelectorExpr` with a method path (`VPValMethod`, `VPPtrMethod`, +`VPDerefValMethod`, `VPDerefPtrMethod`): the cached `ATTR_TYPEOF_VALUE` is +read from the receiver expression (unwrapping auto-generated `RefExpr` +wrappers), and if the result is a `*DeclaredType` from the current package, +`"TypeName.MethodName"` is recorded as a dep. This allows the resolution phase +to transitively discover variables referenced inside method bodies. + +### Part B: Initialization Order via Memoized DFS + Kahn's Algorithm + +**`resolveEffectiveDeps`** (memoized DFS, O(V+E)): For every declaration +reachable from the pending list, computes the set of `*ValueDecl` dependencies +by collapsing `FuncDecl` edges. FuncDecls are transparent pass-throughs: their +effective `*ValueDecl` deps are inherited by callers. Each `Decl` is visited at +most once thanks to a shared `cache` map, so total work is O(V+E) regardless +of the number of declarations. Circular variable dependencies are detected +during this DFS (via an `onStack` set) and produce a panic with the full +dependency chain. + +**[Kahn's topological sort][kahn]** (in `runFileDecls`): Builds a +reverse-dependency index and unsatisfied-count array from the effective deps. +A min-heap keyed on declaration index ensures the Go spec's "earliest in +declaration order" tiebreaking. Each declaration enters and leaves the heap at +most once, giving O(V + E + V log V) total. + +[kahn]: https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm + +### Removed Code + +- `findDependentNames`: recursive AST walker that needed a case for every node + type, replaced by `codaInitOrderDeps` (which uses the existing `TranscribeB` + infrastructure to walk the full AST generically). +- `GetExternNames` / `addExternName` / `isFile`: the `Externs` tracking on + `StaticBlock` was only used by `findDependentNames` via `FuncLitExpr` and + `FuncDecl`. The `Externs` field is retained for amino serialization + backward-compatibility but is no longer populated. +- `runDeclarationFor` / `loopfindr`: the recursive initialization loop in + `runFileDecls`, replaced by Kahn's algorithm. + +## Alternatives Considered + +### Iteration 1: Rewrite `findDependentNames` + stepwise loop + +The first approach kept the `findDependentNames` AST walker but rewrote +`runFileDecls` to use a stepwise "find earliest ready" loop instead of +recursive DFS. This fixed the ordering but kept the incomplete walker and did +not address the method dependency problem or the non-determinism from map +iteration. + +### Iteration 2: Full rewrite with inline dep recording + +Rewrote `findDependentNames` from scratch to work on the preprocessed AST +(using filled `NameExpr` paths instead of raw names) and moved dependency +recording inline into `preprocess1`. This fixed many issues but failed to +cover dependencies through methods: the `SelectorExpr` handling required +knowing the receiver's declared type to look up the method's `FuncDecl`, but +the inline approach did not reliably have this information for all receiver +patterns (value vs pointer, auto-addressed, etc.). + +### Iteration 3: `codaInitOrderDeps` + per-decl `findUnresolvedDeps` + +Moved dep recording to a dedicated post-preprocess coda pass +(`codaInitOrderDeps`) using `TranscribeB`, fixing the method dependency issue +by reading `ATTR_TYPEOF_VALUE` from the already-preprocessed receiver +expression. Used `findUnresolvedDeps` (a per-declaration DFS) to resolve +transitive deps and a stepwise scanning loop to find the earliest ready +variable. This was correct but O(n²): `findUnresolvedDeps` was called +independently for each declaration with no shared state, re-traversing the +same `FuncDecl` bodies repeatedly, and the ready-variable loop scanned all +pending entries each iteration. + +### Other alternatives not pursued + +**Patch `predefineRecursively` to pass the outer `ns`**: would require +threading `ns` through many call sites and would not fix the analogous problem +for other isolated `Preprocess` calls. + +**Record deps during `findUndefinedV`**: already has access to the full +context but walks un-preprocessed ASTs; mixing dep-recording there would +conflate two concerns. + +## Consequences + +- Variable initialization order is now correct and deterministic, matching + the Go specification's stepwise algorithm. +- O(V+E) dependency resolution (memoized DFS) and O(V + E + V log V) + initialization (Kahn's with min-heap) replace O(n²) algorithms. Packages + with thousands of top-level declarations are no longer a performance concern. +- `ATTR_TYPEOF_VALUE` must continue to be set on receiver expressions during + `preprocess1` (currently guaranteed by the `evalStaticTypeOf` call at the + SelectorExpr `TRANS_LEAVE`). +- Interface dispatch (`Getter(T{}).GetB()`) does not trace into the concrete + method body, since the static receiver type is `*InterfaceType`. This is + spec-compliant but diverges from gc's behavior in simple cases. + +## Key Files + +- `gnovm/pkg/gnolang/preprocess.go`: `codaInitOrderDeps`, + `addDependencyToTopDecl`, `resolveEffectiveDeps`, `resolveDeclDep` +- `gnovm/pkg/gnolang/machine.go`: `initHeap`, Kahn's loop in `runFileDecls` +- `gnovm/tests/files/var_initorder*.gno`: 19 filetests +- `gnovm/pkg/gnolang/preprocess_test.go`: `TestInitOrderDeterminism`, + `TestCircDepDeterminism` diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index d8e6bfeddb8..bfc85264b40 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -1,6 +1,7 @@ package gnolang import ( + "container/heap" "fmt" "io" "path" @@ -524,6 +525,23 @@ func (m *Machine) PreprocessFiles(pkgName, pkgPath string, fset *FileSet, save, return pn, pv } +// initHeap is a min-heap of pending-declaration indices, used by runFileDecls +// to always pick the earliest-in-declaration-order ready entry. +type initHeap []int + +func (h initHeap) Len() int { return len(h) } +func (h initHeap) Less(i, j int) bool { return h[i] < h[j] } +func (h initHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *initHeap) Push(x any) { *h = append(*h, x.(int)) } + +func (h *initHeap) Pop() any { + old := *h + n := len(old) + x := old[n-1] + *h = old[:n-1] + return x +} + // Add files to the package's *FileSet and run decls in them. // This will also run each init function encountered. // Returns the updated typed values of package. @@ -594,71 +612,86 @@ func (m *Machine) runFileDecls(withOverrides bool, fns ...*FileNode) []TypedValu // Get new values across all files in package. updates := pn.PrepareNewValues(m.Alloc, pv) - // to detect loops in var declarations. - loopfindr := []Name{} - // recursive function for var declarations. - var runDeclarationFor func(fn *FileNode, decl Decl) - runDeclarationFor = func(fn *FileNode, decl Decl) { - // get fileblock of fn. - // fb := pv.GetFileBlock(nil, fn.FileName) - // get dependencies of decl. - deps := make(map[Name]struct{}) - findDependentNames(decl, deps) - for dep := range deps { - // if dep already defined as import, skip. - if _, ok := fn.GetLocalIndex(dep); ok { - continue - } - // if dep already in fdeclared, skip. - if _, ok := fdeclared[dep]; ok { + // To initialize package variables, Go's spec says the following: + // Within a package, package-level variable initialization proceeds + // stepwise, with each step selecting the variable earliest in declaration + // order which has no dependencies on uninitialized variables. + // + // Implementation: Kahn's topological sort with declaration-order tiebreaking + // via a min-heap keyed on declaration index. + // + // Phase 1: Collect all non-FuncDecl declarations in source order and compute + // effective deps (collapsing FuncDecl edges) once for all declarations. + // Phase 2: Build reverse-dep index and unsatisfied counts, then use a + // min-heap to always pick the earliest-in-declaration-order ready entry. + + // Build ordered pending list from all non-FuncDecl decls, preserving source + // declaration order. declFiles tracks which FileNode each decl belongs to. + var pending []Decl + var declFiles []*FileNode + for _, fn := range fns { + for _, decl := range fn.Decls { + if _, ok := decl.(*FuncDecl); ok { continue } - fn, depdecl, exists := pn.FileSet.GetDeclForSafe(dep) - // special case: if doesn't exist: - if !exists { - if isUverseName(dep) { // then is reserved keyword in uverse. - continue - } else { // is an undefined dependency. - panic(fmt.Sprintf( - "%s/%s:%s: dependency %s not defined in fileset with files %v", - pv.PkgPath, fn.FileName, decl.GetPos().String(), dep, fs.FileNames())) - } - } - // if dep already in loopfindr, abort. - if slices.Contains(loopfindr, dep) { - if _, ok := (*depdecl).(*FuncDecl); ok { - // recursive function dependencies - // are OK with func decls. - continue - } else { - panic(fmt.Sprintf( - "%s/%s:%s: loop in variable initialization: dependency trail %v circularly depends on %s", - pv.PkgPath, fn.FileName, decl.GetPos().String(), loopfindr, dep)) - } - } - // run dependency declaration - loopfindr = append(loopfindr, dep) - runDeclarationFor(fn, *depdecl) - loopfindr = loopfindr[:len(loopfindr)-1] + pending = append(pending, decl) + declFiles = append(declFiles, fn) } - // run declaration - fb := pv.GetFileBlock(m.Store, fn.FileName) + } + + // Compute effective deps for all decls at once (memoized DFS, O(V+E)). + effectiveDeps := resolveEffectiveDeps(pending, pn, fdeclared) + + // Build reverse deps and unsatisfied counts. reverseDeps maps a ValueDecl + // to the indices of pending entries that depend on it. + unsatisfied := make([]int, len(pending)) + reverseDeps := map[*ValueDecl][]int{} + for i, decl := range pending { + deps := effectiveDeps[decl] + unsatisfied[i] = len(deps) + for _, dep := range deps { + reverseDeps[dep] = append(reverseDeps[dep], i) + } + } + + // Seed heap with zero-dep entries. + ready := &initHeap{} + for i := range pending { + if unsatisfied[i] == 0 { + heap.Push(ready, i) + } + } + + // Kahn's loop: always pop the earliest-in-declaration-order ready entry. + for ready.Len() > 0 { + idx := heap.Pop(ready).(int) + decl := pending[idx] + fb := pv.GetFileBlock(m.Store, declFiles[idx].FileName) m.PushBlock(fb) m.runDeclaration(decl) m.PopBlock() for _, n := range decl.GetDeclNames() { fdeclared[n] = struct{}{} } + // Notify dependents; enqueue newly-ready ones. + if vd, ok := decl.(*ValueDecl); ok { + for _, depIdx := range reverseDeps[vd] { + unsatisfied[depIdx]-- + if unsatisfied[depIdx] == 0 { + heap.Push(ready, depIdx) + } + } + } } - // Declarations (and variable initializations). This must happen - // after all files are preprocessed, because value decl may be out of - // order and depend on other files. - - // Run declarations. - for _, fn := range fns { - for _, decl := range fn.Decls { - runDeclarationFor(fn, decl) + // Sanity check: all entries must have been processed. If any remain, + // it means resolveEffectiveDeps missed a cycle or the reverse-dep + // notification has a gap. + for i, decl := range pending { + if unsatisfied[i] > 0 { + panic(fmt.Sprintf( + "incomplete initialization: %v still has %d unsatisfied deps", + decl.GetDeclNames(), unsatisfied[i])) } } diff --git a/gnovm/pkg/gnolang/nodes.go b/gnovm/pkg/gnolang/nodes.go index c5098ff5932..4beaa95b1e5 100644 --- a/gnovm/pkg/gnolang/nodes.go +++ b/gnovm/pkg/gnolang/nodes.go @@ -142,6 +142,8 @@ const ( ATTR_PACKAGE_PATH GnoAttribute = "ATTR_PACKAGE_PATH" // if name expr refers to package. ATTR_FIX_FROM GnoAttribute = "ATTR_FIX_FROM" // gno fix this version. ATTR_LOOPVAR_SKIP GnoAttribute = "ATTR_LOOPVAR_SKIP" // temp only + // For top level declarations, a map[Name]struct{} of other dependencies + ATTR_DECL_DEPS GnoAttribute = "ATTR_DECL_DEPS" ) // Embedded in each Node. @@ -1547,7 +1549,6 @@ type BlockNode interface { Define2(bool, Name, Type, TypedValue, NameSource) GetPathForName(Store, Name) ValuePath GetBlockNames() []Name - GetExternNames() []Name GetNumNames() uint16 GetIsConst(Store, Name) bool GetIsConstAt(Store, ValuePath) bool @@ -1606,7 +1607,7 @@ type StaticBlock struct { HeapItems []bool UnassignableNames []Name Consts []Name // TODO consider merging with Names. - Externs []Name + Externs []Name // TODO: remove, this only exists for amino backward-compat. Parent BlockNode // temporary storage for rolling back redefinitions. @@ -1697,7 +1698,6 @@ func (sb *StaticBlock) InitStaticBlock(source BlockNode, parent BlockNode) { sb.NameSources = make([]NameSource, 0, 16) sb.HeapItems = make([]bool, 0, 16) sb.Consts = make([]Name, 0, 16) - sb.Externs = make([]Name, 0, 16) sb.Parent = parent } @@ -1722,20 +1722,6 @@ func (sb *StaticBlock) GetBlockNames() (ns []Name) { return sb.Names } -// Implements BlockNode. -// NOTE: Extern names may also be local, if declared after usage as an extern -// (thus shadowing the extern name). -func (sb *StaticBlock) GetExternNames() (ns []Name) { - return sb.Externs -} - -func (sb *StaticBlock) addExternName(n Name) { - if slices.Contains(sb.Externs, n) { - return - } - sb.Externs = append(sb.Externs, n) -} - // Implements BlockNode. func (sb *StaticBlock) GetNumNames() (nn uint16) { return sb.NumNames @@ -1761,7 +1747,6 @@ func (sb *StaticBlock) GetParentNode(store Store) BlockNode { } // Implements BlockNode. -// As a side effect, notes externally defined names. // Slow, for precompile only. func (sb *StaticBlock) GetPathForName(store Store, n Name) ValuePath { if n == blankIdentifier { @@ -1773,14 +1758,6 @@ func (sb *StaticBlock) GetPathForName(store Store, n Name) ValuePath { return NewValuePathBlock(uint8(gen), idx, n) } sn := sb.GetSource(store) - // Register as extern. - // NOTE: uverse names are externs too. - // NOTE: externs may also be shadowed later in the block. Thus, usages - // before the declaration will have depth > 1; following it, depth == 1, - // matching the two different identifiers they refer to. - if !isFile(sn) { - sb.GetStaticBlock().addExternName(n) - } // Check ancestors. gen++ fauxChild := 0 @@ -1795,9 +1772,6 @@ func (sb *StaticBlock) GetPathForName(store Store, n Name) ValuePath { } return NewValuePathBlock(uint8(gen-fauxChild), idx, n) } else { - if !isFile(sn) { - sn.GetStaticBlock().addExternName(n) - } gen++ if fauxChildBlockNode(sn) { fauxChild++ diff --git a/gnovm/pkg/gnolang/preprocess.go b/gnovm/pkg/gnolang/preprocess.go index 8aebdee0b4c..b715fec33f2 100644 --- a/gnovm/pkg/gnolang/preprocess.go +++ b/gnovm/pkg/gnolang/preprocess.go @@ -2,6 +2,7 @@ package gnolang import ( "fmt" + "maps" "math" "math/big" "reflect" @@ -673,6 +674,12 @@ func Preprocess(store Store, ctx BlockNode, n Node) Node { // XXX check node lines and locations checkNodeLinesLocations("XXXpkgPath", "XXXfileName", n) + // Record package-level initialization order dependencies. + // Must run before codaPackageSelectors replaces NameExprs. + if fn, ok := n.(*FileNode); ok { + codaInitOrderDeps(packageOf(ctx), fn) + } + // "coda" means "conclusion". // NOTE: need to use Transcribe() here instead of `bn, ok := n.(BlockNode)` // because say n may be a *CallExpr containing an anonymous function. @@ -1249,17 +1256,7 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { cx := evalConst(store, last, n) return cx, TRANS_CONTINUE } - // If name refers to a package, and this is not in - // the context of a selector, fail. Packages cannot - // be used as a value, for go compatibility but also - // to preserve the security expectation regarding imports. - nt := evalStaticTypeOf(store, last, n) - if nt.Kind() == PackageKind { - panic(fmt.Sprintf( - "package %s cannot only be referred to in a selector expression", - n.Name)) - } - return n, TRANS_CONTINUE + panic("slice/array literals may not contain non-const keys") } } // specific and general cases @@ -2378,6 +2375,7 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { // bound method or underlying. // TODO check for unexported fields. n.Path = tr[len(tr)-1] + // n.Path = cxt.GetPathForName(n.Sel) case *PackageType: var pv *PackageValue @@ -5410,14 +5408,6 @@ func fillNameExprPath(last BlockNode, nx *NameExpr, isDefineLHS bool) { nx.Path = last.GetPathForName(nil, nx.Name) } -func isFile(n BlockNode) bool { - if _, ok := n.(*FileNode); ok { - return true - } else { - return false - } -} - func skipFile(n BlockNode) BlockNode { if fn, ok := n.(*FileNode); ok { return packageOf(fn) @@ -5527,134 +5517,258 @@ func countNumArgs(store Store, last BlockNode, n *CallExpr) (numArgs int) { } } -// This is to be run *after* preprocessing is done, -// to determine the order of var decl execution -// (which may include functions which may refer to package vars). -func findDependentNames(n Node, dst map[Name]struct{}) { - switch cn := n.(type) { - case *NameExpr: - dst[cn.Name] = struct{}{} - case *BasicLitExpr: - case *BinaryExpr: - findDependentNames(cn.Left, dst) - findDependentNames(cn.Right, dst) - case *SelectorExpr: - findDependentNames(cn.X, dst) - case *SliceExpr: - findDependentNames(cn.X, dst) - if cn.Low != nil { - findDependentNames(cn.Low, dst) - } - if cn.High != nil { - findDependentNames(cn.High, dst) +// codaInitOrderDeps records ATTR_DECL_DEPS on package-level *ValueDecl and +// *FuncDecl nodes by scanning for all references to other package-level names. +// It must run after preprocess1 (so all NameExpr paths are filled) and before +// codaPackageSelectors (which replaces NameExprs with SelectorExprs). +// +// This is implemented as a separate post-preprocess pass (rather than inline +// within preprocess1) because preprocess1 recursively calls Preprocess for +// inner function literal bodies with a fresh context, losing the enclosing +// declaration. By iterating fn.Decls and transcribing each one, the target +// declaration is always known from the outer loop. +func codaInitOrderDeps(pn *PackageNode, fn *FileNode) { + if pn.PkgPath == ".uverse" { + return + } + for _, decl := range fn.Decls { + switch decl.(type) { + case *FuncDecl, *ValueDecl: + default: + continue } - if cn.Max != nil { - findDependentNames(cn.Max, dst) + + var deps map[Name]struct{} + addDep := func(name Name) { + if deps == nil { + deps = make(map[Name]struct{}) + } + deps[name] = struct{}{} } - case *StarExpr: - findDependentNames(cn.X, dst) - case *RefExpr: - findDependentNames(cn.X, dst) - case *TypeAssertExpr: - findDependentNames(cn.X, dst) - findDependentNames(cn.Type, dst) - case *UnaryExpr: - findDependentNames(cn.X, dst) - case *CompositeLitExpr: - findDependentNames(cn.Type, dst) - ct := getType(cn.Type) - switch ct.Kind() { - case ArrayKind, SliceKind, MapKind: - for _, kvx := range cn.Elts { - if kvx.Key != nil { - findDependentNames(kvx.Key, dst) + + _ = TranscribeB(fn, decl, func( + ns []Node, + stack []BlockNode, + last BlockNode, + ftype TransField, + index int, + n Node, + stage TransStage, + ) (Node, TransCtrl) { + switch stage { + case TRANS_ENTER: + // Skip function literal bodies that were not preprocessed; + // same guard as the main coda Transcribe in Preprocess. + if flx, ok := n.(*FuncLitExpr); ok { + if flx.GetAttribute(ATTR_PREPROCESS_SKIPPED) == AttrPreprocessFuncLitExpr { + return n, TRANS_SKIP + } + } + case TRANS_LEAVE: + switch n := n.(type) { + case *NameExpr: + // Only track names resolved via the block hierarchy. + if n.Path.Type != VPBlock { + return n, TRANS_CONTINUE + } + // Ignore blank identifiers. + if n.Name == blankIdentifier { + return n, TRANS_CONTINUE + } + // Ignore package-name references (e.g. `fmt` in `fmt.Println`). + if n.GetAttribute(ATTR_PACKAGE_REF) != nil { + return n, TRANS_CONTINUE + } + // Ignore the declaration name itself (LHS of var/const decl). + if ftype == TRANS_VAR_NAME { + return n, TRANS_CONTINUE + } + // Check that the name is defined at package level. + dbn := last.GetBlockNodeForPath(nil, n.Path) + if dbn != pn { + return n, TRANS_CONTINUE + } + // Ignore type declarations; they have no runtime + // initialization order. + if li, ok := pn.GetLocalIndex(n.Name); ok { + if pn.NameSources[li].Type == NSTypeDecl { + return n, TRANS_CONTINUE + } + } + addDep(n.Name) + case *SelectorExpr: + // Track same-package method calls so that resolveEffectiveDeps + // can transitively discover vars referenced in method bodies. + // e.g. `A = T{}.GetB()` records "T.GetB" as a dep of A; + // resolveEffectiveDeps then walks GetB's body to find B. + switch n.Path.Type { + case VPValMethod, VPPtrMethod, VPDerefValMethod, VPDerefPtrMethod: + // Get the receiver type from the cached ATTR_TYPEOF_VALUE. + // Two cases for RefExpr: + // (a) user-written &T{}: n.X is the RefExpr with type *T + // stored directly on the RefExpr node. + // (b) auto-generated &x (pointer-receiver auto-address): + // preprocessing wraps the original expression in a + // RefExpr AFTER caching the type on the inner node, + // so n.X (the RefExpr) has no cached type but n.X.X + // does. + xt, ok := n.X.GetAttribute(ATTR_TYPEOF_VALUE).(Type) + if !ok { + if re, ok2 := n.X.(*RefExpr); ok2 { + xt, ok = re.X.GetAttribute(ATTR_TYPEOF_VALUE).(Type) + } + } + if !ok { + break + } + // Dereference pointer receiver types. + if pt, ok2 := xt.(*PointerType); ok2 { + xt = pt.Elt + } + dt, ok := xt.(*DeclaredType) + if !ok || dt.PkgPath != pn.PkgPath { + break + } + addDep(dt.Name + "." + n.Sel) + } } - findDependentNames(kvx.Value, dst) - } - case StructKind: - for _, kvx := range cn.Elts { - findDependentNames(kvx.Value, dst) } - default: - panic(fmt.Sprintf( - "unexpected composite lit type %s", - ct.String())) - } - case *FieldTypeExpr: - findDependentNames(cn.Type, dst) - case *ArrayTypeExpr: - findDependentNames(cn.Elt, dst) - if cn.Len != nil { - findDependentNames(cn.Len, dst) - } - case *SliceTypeExpr: - findDependentNames(cn.Elt, dst) - case *InterfaceTypeExpr: - for i := range cn.Methods { - findDependentNames(&cn.Methods[i], dst) - } - case *ChanTypeExpr: - findDependentNames(cn.Value, dst) - case *FuncTypeExpr: - for i := range cn.Params { - findDependentNames(&cn.Params[i], dst) - } - for i := range cn.Results { - findDependentNames(&cn.Results[i], dst) + return n, TRANS_CONTINUE + }) + + if deps != nil { + decl.SetAttribute(ATTR_DECL_DEPS, deps) } - case *MapTypeExpr: - findDependentNames(cn.Key, dst) - findDependentNames(cn.Value, dst) - case *StructTypeExpr: - for i := range cn.Fields { - findDependentNames(&cn.Fields[i], dst) + } +} + +// resolveDeclDep resolves a dependency name (as stored in ATTR_DECL_DEPS) to +// the corresponding Decl in pn. Method dependencies are encoded as "Type.Method". +func resolveDeclDep(name Name, pn *PackageNode) Decl { + id, sel, isMethod := strings.Cut(string(name), ".") + if isMethod { + li, found := pn.GetLocalIndex(Name(id)) + if !found || pn.NameSources[li].Type != NSTypeDecl { + panic(fmt.Sprintf("type %s not found in package %s", id, pn.PkgName)) } - case *CallExpr: - findDependentNames(cn.Func, dst) - for i := range cn.Args { - findDependentNames(cn.Args[i], dst) + dt, ok := pn.Types[li].(*DeclaredType) + if !ok { + panic(fmt.Sprintf("type %s is not a *DeclaredType in package %s", id, pn.PkgName)) } - case *IndexExpr: - findDependentNames(cn.X, dst) - findDependentNames(cn.Index, dst) - case *FuncLitExpr: - findDependentNames(&cn.Type, dst) - for _, n := range cn.GetExternNames() { - dst[n] = struct{}{} + idx := slices.IndexFunc(dt.Methods, func(m TypedValue) bool { + return m.V.(*FuncValue).Name == Name(sel) + }) + if idx < 0 { + panic(fmt.Sprintf("method %s not found in type %s", sel, id)) } - case *constTypeExpr: - case *ConstExpr: - case *ImportDecl: - case *ValueDecl: - if cn.Type != nil { - findDependentNames(cn.Type, dst) + return dt.Methods[idx].V.(*FuncValue).Source.(*FuncDecl) + } + li, found := pn.GetLocalIndex(name) + if !found { + panic(fmt.Sprintf("name %s not found in package %s", name, pn.PkgName)) + } + return pn.NameSources[li].Origin.(Decl) +} + +// resolveEffectiveDeps computes, for every Decl reachable from the given +// declarations, the set of *ValueDecl dependencies obtained by collapsing +// FuncDecl edges (FuncDecls are transparent pass-throughs). +// +// The result is a shared cache: cache[d] = list of *ValueDecl that d +// (transitively through FuncDecls) depends on. Each Decl is visited at most +// once across all calls, so total work is O(V+E). +// +// Circular variable dependencies are detected and cause a panic with a +// descriptive chain. +func resolveEffectiveDeps(decls []Decl, pn *PackageNode, fdeclared map[Name]struct{}) map[Decl][]*ValueDecl { + cache := map[Decl][]*ValueDecl{} // fully resolved + onStack := map[Decl]bool{} // grey: currently in DFS path + + // inFDeclared reports whether all names declared by d are already in + // fdeclared, meaning d has already been initialized. + inFDeclared := func(d *ValueDecl) bool { + for _, n := range d.GetDeclNames() { + if _, ok := fdeclared[n]; !ok { + return false + } } - for _, vx := range cn.Values { - findDependentNames(vx, dst) + return true + } + + var walk func(d Decl, path []Name) []*ValueDecl + walk = func(d Decl, path []Name) []*ValueDecl { + if res, ok := cache[d]; ok { + return res } - case *TypeDecl: - findDependentNames(cn.Type, dst) - case *FuncDecl: - findDependentNames(&cn.Type, dst) - if cn.IsMethod { - findDependentNames(&cn.Recv, dst) - for _, n := range cn.GetExternNames() { - dst[n] = struct{}{} - } - } else { - for _, n := range cn.GetExternNames() { - if n == cn.Name { - // top-level function referring to itself - } else { - dst[n] = struct{}{} + onStack[d] = true + m, _ := d.GetAttribute(ATTR_DECL_DEPS).(map[Name]struct{}) + // Sort dependency names for deterministic DFS traversal order. + names := slices.Collect(maps.Keys(m)) + slices.Sort(names) + + var result []*ValueDecl + for _, name := range names { + dep := resolveDeclDep(name, pn) + switch dep := dep.(type) { + case *FuncDecl: + if onStack[dep] { + // Mutually recursive functions are fine; skip. + continue + } + // Collapse: inherit effective deps from the FuncDecl. + for _, vd := range walk(dep, append(path, name)) { + if !slices.Contains(result, vd) { + result = append(result, vd) + } + } + case *ValueDecl: + if onStack[dep] { + bld := strings.Builder{} + if fn, sourceDecl, ok := pn.FileSet.GetDeclForSafe(path[0]); ok { + fmt.Fprintf(&bld, "%s/%s:%s: ", pn.PkgPath, fn.FileName, (*sourceDecl).GetSpan().Pos.String()) + } + bld.WriteString("circular dependency: ") + for _, n := range path { + bld.WriteString(string(n)) + bld.WriteString(" -> ") + } + bld.WriteString(string(name)) + panic(bld.String()) + } + // Skip already-initialized decls entirely. + if len(fdeclared) > 0 && inFDeclared(dep) { + continue + } + if !slices.Contains(result, dep) { + result = append(result, dep) } + // Recurse into ValueDecl deps to detect cycles and to + // discover transitive deps through FuncDecl chains rooted + // in this ValueDecl. Kahn's handles direct ValueDecl→ValueDecl + // transitivity, but we still need to walk through for cycle + // detection and for FuncDecl collapse. + walk(dep, append(path, name)) + default: + panic(fmt.Sprintf("unexpected gnolang.Decl: %#v", dep)) } } - default: - panic(fmt.Sprintf( - "unexpected node: %v (%v)", - n, reflect.TypeOf(n))) + delete(onStack, d) + cache[d] = result + return result + } + + for _, d := range decls { + if _, ok := cache[d]; ok { + continue + } + rootNames := d.GetDeclNames() + rootName := Name("_") + if len(rootNames) > 0 { + rootName = rootNames[0] + } + walk(d, []Name{rootName}) } + return cache } // A name is locally defined on a block node diff --git a/gnovm/pkg/gnolang/preprocess_test.go b/gnovm/pkg/gnolang/preprocess_test.go index fbedf198d8a..8fb2bef4793 100644 --- a/gnovm/pkg/gnolang/preprocess_test.go +++ b/gnovm/pkg/gnolang/preprocess_test.go @@ -1,16 +1,142 @@ -package gnolang +package gnolang_test import ( + "fmt" + "io" + "path/filepath" + "strings" "testing" + gno "github.com/gnolang/gno/gnovm/pkg/gnolang" + "github.com/gnolang/gno/gnovm/pkg/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// TestInitOrderDeterminism verifies that package-level variable initialization +// always produces the same result across many runs, regardless of Go's +// non-deterministic map iteration over internal dependency sets. +// It runs a program with complex cross-variable dependencies 100 times and +// checks that the initialization order is always Go-spec-compliant. +func TestInitOrderDeterminism(t *testing.T) { + // This source has vars that all depend (transitively, through emit) on + // 'events', but Z also depends on A,B,C,D,E. The Go spec mandates that + // the earliest-in-source-order ready variable is initialized next. + // Expected order: events, B, A, C, G, D, E, Z (emit("L")), F. + const src = `package main + +var events []string +func emit(s string) string { events = append(events, s); return s } +var ( + Z = A + "-" + B + "-" + C + "-" + emit("L") + D + "-" + E + B = emit("B") + A = emit("A") + C = emit("C") + G = emit("G") + D = emit("D") + E = emit("E") + F = emit("F") +) +func main() { + for _, e := range events { println(e) } +} + +// Output: +// B +// A +// C +// G +// D +// E +// L +// F +` + rootDir, err := filepath.Abs("../../../") + require.NoError(t, err) + + newOpts := func() *test.TestOptions { + opts := test.NewTestOptions(rootDir, io.Discard, io.Discard, nil) + return opts + } + sharedOpts := newOpts() + + const iters = 100 + for i := 0; i < iters; i++ { + t.Run(fmt.Sprintf("iter%d", i), func(t *testing.T) { + _, _, err := sharedOpts.RunFiletest("init_order_det.gno", []byte(src), sharedOpts.TestStore) + require.NoError(t, err, "init order non-determinism or mismatch detected on iteration %d", i) + }) + } +} + +// TestCircDepDeterminism verifies that circular-dependency error messages are +// produced in a deterministic order even when a declaration has multiple +// function dependencies (each of which independently reads the same variable). +// +// Without sorting: `var a = B() + C()` causes a's ATTR_DECL_DEPS to hold +// {B, C}. Because Go map iteration is non-deterministic, the DFS in +// findUnresolvedDeps could traverse B first ("circular dependency: a -> B") +// or C first ("circular dependency: a -> C"), making the panic message flaky. +// +// After the fix (sorting ATTR_DECL_DEPS keys before DFS), the message is +// always deterministic ("a -> B" because "B" < "C" lexicographically). +func TestCircDepDeterminism(t *testing.T) { + const src = `package main + +var a = B() + C() + +func B() int { return a } + +func C() int { return a } + +func main() {}` + + rootDir, err := filepath.Abs("../../../") + require.NoError(t, err) + + opts := test.NewTestOptions(rootDir, io.Discard, io.Discard, nil) + + const iters = 100 + var seen []string + for i := 0; i < iters; i++ { + _, _, runErr := opts.RunFiletest("circ_dep_det.gno", []byte(src), opts.TestStore) + // A circular dep error is expected; extract just the "circular dependency: ..." line. + if runErr == nil { + t.Fatalf("iteration %d: expected circular dep error, got none", i) + } + errMsg := runErr.Error() + idx := strings.Index(errMsg, "circular dependency: ") + if idx < 0 { + // May be a TypeCheckError mismatch with no circular dep in the message; skip. + continue + } + end := strings.IndexByte(errMsg[idx:], '\n') + var circdep string + if end >= 0 { + circdep = errMsg[idx : idx+end] + } else { + circdep = errMsg[idx:] + } + found := false + for _, s := range seen { + if s == circdep { + found = true + break + } + } + if !found { + seen = append(seen, circdep) + } + } + if len(seen) > 1 { + t.Errorf("circular dependency error message is non-deterministic across %d runs: %v", iters, seen) + } +} + func TestShiftExprAttrTypeOfValue(t *testing.T) { t.Parallel() - m := NewMachine("test", nil) + m := gno.NewMachine("test", nil) c := `package test func main() { var a uint = 1 @@ -20,19 +146,19 @@ func main() { n := m.MustParseFile("main.go", c) m.RunFiles(n) - fn := n.Decls[0].(*FuncDecl) - assignStmt := fn.Body[1].(*AssignStmt) - callExpr := assignStmt.Rhs[0].(*CallExpr) + fn := n.Decls[0].(*gno.FuncDecl) + assignStmt := fn.Body[1].(*gno.AssignStmt) + callExpr := assignStmt.Rhs[0].(*gno.CallExpr) // The shift expression (1< b -> a // TypeCheckError: // main/closure.gno:7:5: initialization cycle for a; main/closure.gno:7:5: a refers to b; main/closure.gno:11:5: b refers to a diff --git a/gnovm/tests/files/composite15.gno b/gnovm/tests/files/composite15.gno index de8cac0d09d..0bc2f2273a7 100644 --- a/gnovm/tests/files/composite15.gno +++ b/gnovm/tests/files/composite15.gno @@ -9,8 +9,8 @@ func main() { println(x) } -// Output: -// slice[(0 int),(2 int),(0 int),(4 int)] - // TypeCheckError: // main/composite15.gno:6:3: index a must be integer constant; main/composite15.gno:7:3: index c must be integer constant + +// Error: +// main/composite15.gno:6:3-4: slice/array literals may not contain non-const keys diff --git a/gnovm/tests/files/extern/initorder_crossfn/a.gno b/gnovm/tests/files/extern/initorder_crossfn/a.gno new file mode 100644 index 00000000000..63e2d29251f --- /dev/null +++ b/gnovm/tests/files/extern/initorder_crossfn/a.gno @@ -0,0 +1,7 @@ +package initorder_crossfn + +// A depends on GetB which is defined in b.gno. +// findDependentNames must recurse into GetB's body (via GetExternNames) to +// discover that A transitively depends on B, even across file boundaries. + +var A = GetB() diff --git a/gnovm/tests/files/extern/initorder_crossfn/b.gno b/gnovm/tests/files/extern/initorder_crossfn/b.gno new file mode 100644 index 00000000000..a863ed6c382 --- /dev/null +++ b/gnovm/tests/files/extern/initorder_crossfn/b.gno @@ -0,0 +1,5 @@ +package initorder_crossfn + +var B = 42 + +func GetB() int { return B } diff --git a/gnovm/tests/files/extern/initorder_xpkgmethod/pkg.gno b/gnovm/tests/files/extern/initorder_xpkgmethod/pkg.gno new file mode 100644 index 00000000000..91f6217126c --- /dev/null +++ b/gnovm/tests/files/extern/initorder_xpkgmethod/pkg.gno @@ -0,0 +1,13 @@ +package initorder_xpkgmethod + +// B is a package-level variable. GetB references it so that any caller of +// GetB transitively depends on B. A is declared after GetB here to stress +// that the intra-package init order is also correct (B before A). + +var B = 42 + +type T struct{} + +func (T) GetB() int { return B } + +var A = T{}.GetB() diff --git a/gnovm/tests/files/recursive10.gno b/gnovm/tests/files/recursive10.gno index 19789fdd57b..2cf16a55085 100644 --- a/gnovm/tests/files/recursive10.gno +++ b/gnovm/tests/files/recursive10.gno @@ -11,7 +11,7 @@ func main() { } // Error: -// main/recursive10.gno:3:1: loop in variable initialization: dependency trail [b B a A] circularly depends on b +// main/recursive10.gno:6:5: circular dependency: a -> A -> b -> B -> a // TypeCheckError: // main/recursive10.gno:6:5: initialization cycle for a; main/recursive10.gno:6:5: a refers to A; main/recursive10.gno:3:6: A refers to b; main/recursive10.gno:7:5: b refers to B; main/recursive10.gno:4:6: B refers to a diff --git a/gnovm/tests/files/recursive11.gno b/gnovm/tests/files/recursive11.gno index 756796f5f22..7650f511e94 100644 --- a/gnovm/tests/files/recursive11.gno +++ b/gnovm/tests/files/recursive11.gno @@ -7,7 +7,7 @@ func main() { } // Error: -// main/recursive11.gno:3:5: loop in variable initialization: dependency trail [B A] circularly depends on B +// main/recursive11.gno:3:5: circular dependency: A -> B -> A // TypeCheckError: // main/recursive11.gno:3:5: initialization cycle for A; main/recursive11.gno:3:5: A refers to B; main/recursive11.gno:4:5: B refers to A diff --git a/gnovm/tests/files/var_initorder.gno b/gnovm/tests/files/var_initorder.gno new file mode 100644 index 00000000000..5257bc653dc --- /dev/null +++ b/gnovm/tests/files/var_initorder.gno @@ -0,0 +1,21 @@ +package main + +// Order: +// B has no deps -> initialized first (incr->counter=1, B=1). +// A has no deps -> initialized second (incr->counter=2, A=2). +// C depends on B and A -> initialized last (C=B+A=3). + +var counter int + +func incr() int { counter++; return counter } + +var ( + C = B + A + B = incr() + A = incr() +) + +func main() { println(A, B, C) } + +// Output: +// 2 1 3 diff --git a/gnovm/tests/files/var_initorder10.gno b/gnovm/tests/files/var_initorder10.gno new file mode 100644 index 00000000000..856b4c9ec89 --- /dev/null +++ b/gnovm/tests/files/var_initorder10.gno @@ -0,0 +1,26 @@ +package main + +// Tests that method bodies are analysed for package-level variable dependencies. +// A = T{}.GetB() has no direct syntactic dependency on B, but GetB's body +// reads B. Compared to initorder9, this test ensures that a dependency on GetB +// does not recurse infinitely. +// Init order: T (type, no deps), B (no deps), A (deps T and B via GetB). + +type T struct{} + +func (T) GetB(i int) int { + if i < 3 { + return T{}.GetB(i + 1) + } + return B +} + +var ( + A = T{}.GetB(0) + B = 42 +) + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder11.gno b/gnovm/tests/files/var_initorder11.gno new file mode 100644 index 00000000000..12bdcb639ec --- /dev/null +++ b/gnovm/tests/files/var_initorder11.gno @@ -0,0 +1,23 @@ +package main + +// Tests that init order correctly handles transitive deps discovered through a +// pointer-receiver method body. A = (&T{}).Get() has no direct syntactic +// dependency on B, but Get's body reads B. This exercises the *PointerType +// branch in findDependentNames' SelectorExpr case: +// +// Contrast with var_initorder10.gno which uses a value receiver T{}.GetB(). +// Init order: T (type, no deps), B (no deps), A (deps T and B via *T.Get). + +type T struct{} + +func (t *T) Get() int { return B } + +var ( + A = (&T{}).Get() + B = 42 +) + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder12.gno b/gnovm/tests/files/var_initorder12.gno new file mode 100644 index 00000000000..f22caf8929b --- /dev/null +++ b/gnovm/tests/files/var_initorder12.gno @@ -0,0 +1,19 @@ +package main + +// Tests that function call arguments are analysed as init-order constraints. +// A = double(B) passes B directly as an argument; without queuing CallExpr.Args, +// findDependentNames would not discover the dependency on B and could initialize +// A before B, producing double(0)=0 instead of double(21)=42. +// Init order: B (no deps), A (depends on B via argument expression). + +func double(x int) int { return x * 2 } + +var ( + A = double(B) + B = 21 +) + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder13.gno b/gnovm/tests/files/var_initorder13.gno new file mode 100644 index 00000000000..14979c892b9 --- /dev/null +++ b/gnovm/tests/files/var_initorder13.gno @@ -0,0 +1,23 @@ +package main + +// Tests that function call arguments are analysed as init-order constraints. +// A = incr(B) passes B directly as an argument, but the dependency on B is +// created even if B is not used in the body of incr. +// Init order: B (no deps), A (depends on B via argument expression). + +var counter int + +func incr(arg int) int { + counter += 1 + return counter +} + +var ( + A = incr(B) + B = incr(0) +) + +func main() { println(A) } + +// Output: +// 2 diff --git a/gnovm/tests/files/var_initorder14.gno b/gnovm/tests/files/var_initorder14.gno new file mode 100644 index 00000000000..a5a7cd99c25 --- /dev/null +++ b/gnovm/tests/files/var_initorder14.gno @@ -0,0 +1,28 @@ +package main + +// Tests transitive dependency through chained method calls. +// A = T{}.MethodA(), MethodA calls MethodB, MethodB reads B. +// If findDependentNames only traces one level of method body, +// A's dependency on B (through MethodA→MethodB) might be missed, +// causing wrong init order. + +type T struct{} + +func (T) MethodA() int { + t := T{} + return t.MethodB() +} + +func (T) MethodB() int { + return B +} + +var ( + A = T{}.MethodA() + B = 42 +) + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder15.gno b/gnovm/tests/files/var_initorder15.gno new file mode 100644 index 00000000000..73dc6474302 --- /dev/null +++ b/gnovm/tests/files/var_initorder15.gno @@ -0,0 +1,31 @@ +package main + +// Tests that init-order analysis through interface dispatch is not performed. +// A = Getter(T{}).GetB() converts T{} to the Getter interface before calling +// GetB; at that point the static receiver type is *InterfaceType, not +// *DeclaredType, so the dependency on T.GetB (and transitively on B) cannot +// be inferred. A is therefore treated as having no deps and is initialized +// before B, so A = B (zero) = 0. +// +// Note: the real Go compiler performs a more sophisticated analysis that can +// see through interface conversions in simple cases like this and produces 42. +// That behaviour is not required by the Go spec, which only mandates stepwise +// initialization; Gno's result of 0 is spec-compliant. + +type Getter interface { + GetB() int +} + +type T struct{} + +func (T) GetB() int { return B } + +var ( + A = Getter(T{}).GetB() + B = 42 +) + +func main() { println(A) } + +// Output: +// 0 diff --git a/gnovm/tests/files/var_initorder16.gno b/gnovm/tests/files/var_initorder16.gno new file mode 100644 index 00000000000..62646722208 --- /dev/null +++ b/gnovm/tests/files/var_initorder16.gno @@ -0,0 +1,30 @@ +package main + +// Tests that init-order analysis is not performed through interface dispatch, +// even when both variables call the same method via the same interface. +// Neither A nor B can have their dependency on T.Get (which reads B) inferred, +// because the static receiver is the Getter interface, not a *DeclaredType. +// Both are treated as ready immediately; they are initialized in declaration +// order (A first, then B), so A = B (zero) = 0. +// +// Note: the real Go compiler sees through the interface conversion and produces +// a different result. That behaviour is not required by the Go spec; Gno's +// result of 0 is spec-compliant. + +type Getter interface { + Get() int +} + +type T struct{} + +func (T) Get() int { return B } + +var ( + A = Getter(T{}).Get() + B = Getter(T{}).Get() +) + +func main() { println(A) } + +// Output: +// 0 diff --git a/gnovm/tests/files/var_initorder17.gno b/gnovm/tests/files/var_initorder17.gno new file mode 100644 index 00000000000..3e8b2d383e3 --- /dev/null +++ b/gnovm/tests/files/var_initorder17.gno @@ -0,0 +1,23 @@ +package main + +// Tests that a method call through a package-level receiver variable with a +// pointer receiver is correctly ordered. t.GetB() calls (*T).GetB which reads +// B, so A's dependency on B is inferred via the method body. +// Also checks that t (the receiver variable) is itself treated as a dep of A, +// and t has no deps, so init order is: T (type), t (no deps), B (no deps), A. + +type T struct{} + +func (*T) GetB() int { return B } + +var t T + +var ( + A = t.GetB() + B = 42 +) + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder18.gno b/gnovm/tests/files/var_initorder18.gno new file mode 100644 index 00000000000..93508757862 --- /dev/null +++ b/gnovm/tests/files/var_initorder18.gno @@ -0,0 +1,17 @@ +package main + +// Tests that a self-referencing variable through its own function-literal +// initializer is detected as a circular dependency. B's init expression +// assigns to B, creating B -> B. + +var B = func() int { + B = 1 +}() + +func main() { println(B) } + +// Error: +// main/var_initorder18.gno:7:5: circular dependency: B -> B + +// TypeCheckError: +// main/var_initorder18.gno:9:1: missing return; main/var_initorder18.gno:7:5: initialization cycle: B refers to itself diff --git a/gnovm/tests/files/var_initorder19.gno b/gnovm/tests/files/var_initorder19.gno new file mode 100644 index 00000000000..b39f8e5bada --- /dev/null +++ b/gnovm/tests/files/var_initorder19.gno @@ -0,0 +1,24 @@ +package main + +// Tests that a dependency inside a function literal's body (single nesting +// level) is correctly attributed to the enclosing package-level ValueDecl. +// The inner `var local = B` is a local declaration; codaInitOrderDeps must +// walk past it and attribute the B reference to ValueDecl(A), not to the +// local ValueDecl. + +var A = func() int { + var ( + local = B + _ = 123 // just to avoid `fmt` simplifying this into :=. + ) + return local +}() + +var B = 42 + +func main() { + println(A) +} + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder2.gno b/gnovm/tests/files/var_initorder2.gno new file mode 100644 index 00000000000..74cf9f709ce --- /dev/null +++ b/gnovm/tests/files/var_initorder2.gno @@ -0,0 +1,43 @@ +package main + +// Tests init order with multiple vars all depending on the same function, +// which itself depends on a package-level var (transitive dep through function). +// Vars without unsatisfied deps are initialized in declaration order. +// Detailed order: +// - events: no deps, initialized first (zero value, no emit call). +// - B, A, C, G, D, E: each only calls emit(events), all ready after events. +// Initialized in declaration order: B, A, C, G, D, E. +// - Z: depends directly on A, B, C, D, E — all now satisfied. Z is placed +// earlier in declaration order than F, so Z runs next (emits "L"). +// - F: last remaining, initialized after Z. + +var events []string + +func emit(s string) string { events = append(events, s); return s } + +var ( + Z = A + "-" + B + "-" + C + "-" + emit("L") + D + "-" + E + B = emit("B") + A = emit("A") + C = emit("C") + G = emit("G") + D = emit("D") + E = emit("E") + F = emit("F") +) + +func main() { + for _, e := range events { + println(e) + } +} + +// Output: +// B +// A +// C +// G +// D +// E +// L +// F diff --git a/gnovm/tests/files/var_initorder20.gno b/gnovm/tests/files/var_initorder20.gno new file mode 100644 index 00000000000..dcdd686205f --- /dev/null +++ b/gnovm/tests/files/var_initorder20.gno @@ -0,0 +1,22 @@ +package main + +// Tests that init-order analysis correctly handles promoted methods from +// embedded structs. Outer embeds Inner, and Inner.GetB reads B. The call +// Outer{}.GetB() should be traced through to Inner.GetB's body, discovering +// the transitive dependency on B. + +type Inner struct{} + +func (Inner) GetB() int { return B } + +type Outer struct{ Inner } + +var ( + A = Outer{}.GetB() + B = 42 +) + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder21.gno b/gnovm/tests/files/var_initorder21.gno new file mode 100644 index 00000000000..e35c2dcfc77 --- /dev/null +++ b/gnovm/tests/files/var_initorder21.gno @@ -0,0 +1,20 @@ +package main + +// Tests promoted pointer-receiver method through embedding. +// Outer embeds *Inner (pointer embed), Inner has a pointer receiver method. + +type Inner struct{} + +func (*Inner) GetB() int { return B } + +type Outer struct{ *Inner } + +var ( + A = Outer{Inner: &Inner{}}.GetB() + B = 42 +) + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder22.gno b/gnovm/tests/files/var_initorder22.gno new file mode 100644 index 00000000000..e7b8222d680 --- /dev/null +++ b/gnovm/tests/files/var_initorder22.gno @@ -0,0 +1,17 @@ +package main + +// Tests that a circular dependency through a function-literal variable +// (ValueDecl, not FuncDecl) is correctly detected. Unlike a FuncDecl, +// a var holding a func literal participates in initialization ordering, +// so f -> B -> f is a real cycle. + +var f = func() int { return B } +var B = f() + +func main() {} + +// Error: +// main/var_initorder22.gno:8:5: circular dependency: f -> B -> f + +// TypeCheckError: +// main/var_initorder22.gno:8:5: initialization cycle for f; main/var_initorder22.gno:8:5: f refers to B; main/var_initorder22.gno:9:5: B refers to f diff --git a/gnovm/tests/files/var_initorder23.gno b/gnovm/tests/files/var_initorder23.gno new file mode 100644 index 00000000000..f854e50b135 --- /dev/null +++ b/gnovm/tests/files/var_initorder23.gno @@ -0,0 +1,21 @@ +package main + +// Tests that multiple blank-identifier declarations are initialized in +// source order and their side effects are visible to later declarations. +// Blank decls produce no names in fdeclared but still occupy their +// position in the pending list. + +var counter int + +func incr() int { counter++; return counter } + +var ( + _ = incr() // counter=1 + _ = incr() // counter=2 + A = counter // should be 2 +) + +func main() { println(A) } + +// Output: +// 2 diff --git a/gnovm/tests/files/var_initorder24.gno b/gnovm/tests/files/var_initorder24.gno new file mode 100644 index 00000000000..842a502caeb --- /dev/null +++ b/gnovm/tests/files/var_initorder24.gno @@ -0,0 +1,20 @@ +package main + +// Tests a 3-level transitive dependency chain through alternating +// FuncDecl and ValueDecl nodes: A -> f() -> B -> g() -> C. +// resolveEffectiveDeps collapses FuncDecl edges; Kahn's algorithm +// handles ValueDecl→ValueDecl transitivity. + +func f() int { return B } +func g() int { return C } + +var ( + A = f() + B = g() + C = 42 +) + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder25.gno b/gnovm/tests/files/var_initorder25.gno new file mode 100644 index 00000000000..34a3d3fd4c1 --- /dev/null +++ b/gnovm/tests/files/var_initorder25.gno @@ -0,0 +1,22 @@ +package main + +// Tests that a local variable shadowing a package-level name does NOT +// create a false dependency. f() declares a local B that shadows the +// package-level B; the NameExpr for the local B resolves to f's block, +// not the package block, so A should have no dependency on B. +// Init order: A and B are independent; A is declared first so it runs first. + +func f() int { + B := 99 + return B +} + +var ( + A = f() + B = 42 +) + +func main() { println(A, B) } + +// Output: +// 99 42 diff --git a/gnovm/tests/files/var_initorder26.gno b/gnovm/tests/files/var_initorder26.gno new file mode 100644 index 00000000000..b2ba80be74b --- /dev/null +++ b/gnovm/tests/files/var_initorder26.gno @@ -0,0 +1,16 @@ +package main + +// Tests that dependencies are correctly discovered through doubly-nested +// function literals. The NameExpr for B is two FuncLitExprs deep; +// addDependencyToTopDecl must walk past both to find ValueDecl(A). + +var A = func() int { + return func() int { return B }() +}() + +var B = 42 + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder3.gno b/gnovm/tests/files/var_initorder3.gno new file mode 100644 index 00000000000..4bc02dc5253 --- /dev/null +++ b/gnovm/tests/files/var_initorder3.gno @@ -0,0 +1,18 @@ +package main + +// Tests transitive dependency resolution through a chain of functions: +// A = f() depends on g() which depends on B. +// B must be initialized before A even though A is declared first. + +func f() int { return g() } +func g() int { return B } + +var ( + A = f() + B = 42 +) + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder4.gno b/gnovm/tests/files/var_initorder4.gno new file mode 100644 index 00000000000..69a62acdd33 --- /dev/null +++ b/gnovm/tests/files/var_initorder4.gno @@ -0,0 +1,17 @@ +package main + +// Tests chained dependencies where the source order is the reverse of the +// initialization order. +// C depends on B, B depends on A; source order is C, B, A. +// Init order must be: A (no deps), B (deps A), C (deps B). + +var ( + C = B + 2 + B = A + 1 + A = 1 +) + +func main() { println(A, B, C) } + +// Output: +// 1 2 4 diff --git a/gnovm/tests/files/var_initorder5.gno b/gnovm/tests/files/var_initorder5.gno new file mode 100644 index 00000000000..596a9ed2cfe --- /dev/null +++ b/gnovm/tests/files/var_initorder5.gno @@ -0,0 +1,22 @@ +package main + +// Tests a single multi-name var spec where both names share the same +// initializer dependencies. A and B are declared by ONE ValueDecl so they +// are treated as a single initialization step; within the step values are +// evaluated left-to-right. +// Init order: counter (no deps), then {A,B} spec (deps counter via incr), +// then C (deps A and B). + +var counter int + +func incr() int { counter++; return counter } + +var ( + A, B = incr(), incr() + C = A + B +) + +func main() { println(A, B, C) } + +// Output: +// 1 2 3 diff --git a/gnovm/tests/files/var_initorder6.gno b/gnovm/tests/files/var_initorder6.gno new file mode 100644 index 00000000000..1f6d060d338 --- /dev/null +++ b/gnovm/tests/files/var_initorder6.gno @@ -0,0 +1,24 @@ +package main + +// Tests that a multi-name spec initialized by a multi-return function is +// treated as one atomic step. X and Y are ONE ValueDecl; Z must wait for +// both even though it could theoretically proceed after X alone. + +func pair() (int, int) { return 10 + incr(), 20 + incr() } + +var counter int + +func incr() int { + counter++ + return counter +} + +var ( + X, Y = pair() + Z = X + incr() +) + +func main() { println(X, Y, Z) } + +// Output: +// 11 22 14 diff --git a/gnovm/tests/files/var_initorder7.gno b/gnovm/tests/files/var_initorder7.gno new file mode 100644 index 00000000000..bdf4301d426 --- /dev/null +++ b/gnovm/tests/files/var_initorder7.gno @@ -0,0 +1,21 @@ +package main + +// Tests that a blank-identifier var spec (_) runs for its side effects in +// source order before a named var that shares the same dependencies. +// GetDeclNames() returns [] for _, so it is never registered in fdeclared, +// but it still occupies its source position in the pending list. +// Init order: counter, _ (side effect only), A. + +var counter int + +func incr() int { counter++; return counter } + +var ( + _ = incr() // runs first; counter becomes 1; contributes nothing to fdeclared + A = incr() // runs second; counter becomes 2; A = 2 +) + +func main() { println(A) } + +// Output: +// 2 diff --git a/gnovm/tests/files/var_initorder8.gno b/gnovm/tests/files/var_initorder8.gno new file mode 100644 index 00000000000..e2b41911212 --- /dev/null +++ b/gnovm/tests/files/var_initorder8.gno @@ -0,0 +1,23 @@ +package main + +// Tests that a method call through a package-level receiver variable is +// correctly ordered. The SelectorExpr `obj.Val()` makes A syntactically +// depend on `obj`, and the struct literal `T{val: B}` makes obj depend on B. +// So the transitive chain B -> obj -> A is inferred without needing to +// inspect the method body. +// Init order: T (type, no deps), B (no deps), obj (deps T and B), A (deps obj). + +type T struct{ val int } + +func (t T) Val() int { return t.val } + +var ( + B = 42 + obj = T{val: B} + A = obj.Val() +) + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder9.gno b/gnovm/tests/files/var_initorder9.gno new file mode 100644 index 00000000000..1116fd9b6cf --- /dev/null +++ b/gnovm/tests/files/var_initorder9.gno @@ -0,0 +1,22 @@ +package main + +// Tests that method bodies are analysed for package-level variable dependencies. +// A = T{}.GetB() has no direct syntactic dependency on B, but GetB's body +// reads B. The init-order analysis must trace into the method body (via +// selectorRecvTypeName + findDependentNames recursion) to discover that A +// transitively depends on B. +// Init order: T (type, no deps), B (no deps), A (deps T and B via GetB). + +type T struct{} + +func (T) GetB() int { return B } + +var ( + A = T{}.GetB() + B = 42 +) + +func main() { println(A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder_circ_multi.gno b/gnovm/tests/files/var_initorder_circ_multi.gno new file mode 100644 index 00000000000..7c88e0c8e09 --- /dev/null +++ b/gnovm/tests/files/var_initorder_circ_multi.gno @@ -0,0 +1,18 @@ +package main + +// var a depends on both B() and C(), both of which read a. +// The cycle error should always name B first (B < C lexicographically). + +var a = B() + C() + +func B() int { return a } + +func C() int { return a } + +func main() {} + +// Error: +// main/var_initorder_circ_multi.gno:6:5: circular dependency: a -> B -> a + +// TypeCheckError: +// main/var_initorder_circ_multi.gno:6:5: initialization cycle for a; main/var_initorder_circ_multi.gno:6:5: a refers to B; main/var_initorder_circ_multi.gno:8:6: B refers to a diff --git a/gnovm/tests/files/var_initorder_crossfn.gno b/gnovm/tests/files/var_initorder_crossfn.gno new file mode 100644 index 00000000000..64e73edae84 --- /dev/null +++ b/gnovm/tests/files/var_initorder_crossfn.gno @@ -0,0 +1,15 @@ +package main + +// Tests cross-file transitive dependency through a function. +// extern/initorder_crossfn: a.gno has A = GetB(), b.gno has B = 42 and +// func GetB() int { return B }. +// During preprocessing of b.gno, addDependencyToTopDecl records that GetB +// uses B, so A's ATTR_DECL_DEPS includes both GetB and (transitively) B. +// Init order within the package: B (no deps), then A (deps GetB and B). + +import "filetests/extern/initorder_crossfn" + +func main() { println(initorder_crossfn.A) } + +// Output: +// 42 diff --git a/gnovm/tests/files/var_initorder_xpkgmethod.gno b/gnovm/tests/files/var_initorder_xpkgmethod.gno new file mode 100644 index 00000000000..725640bff20 --- /dev/null +++ b/gnovm/tests/files/var_initorder_xpkgmethod.gno @@ -0,0 +1,21 @@ +package main + +// Tests that addDependencyToTopDecl does not record dependencies into method +// bodies defined in imported packages. initorder_xpkgmethod.T.GetB references +// that package's variable B; without the same-package guard (dt.PkgPath == +// ctxpn.PkgPath), the dep would be recorded with pn=main and resolveDeclDep +// would panic trying to find "B" in main's fileset. With the guard, the +// foreign method body is skipped. +// +// Also verifies that the intra-package init order of the imported package +// itself is correct: B must be initialised before A inside +// initorder_xpkgmethod (A = T{}.GetB() = 42). + +import "filetests/extern/initorder_xpkgmethod" + +func main() { + println(initorder_xpkgmethod.A) +} + +// Output: +// 42 From 8dbbae195620dd0a000b5ab3b175741b98173fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jer=C3=B3nimo=20Albi?= <894299+jeronimoalbi@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:30:34 +0200 Subject: [PATCH 31/92] chore(boards2): change unauthorized message to add more context (#5397) (cherry picked from commit f4fa16c6c3dcabc8f9582a2f845115948b918bb6) --- .../p/gnoland/boards/exts/permissions/permissions.gno | 2 +- .../p/gnoland/boards/exts/permissions/permissions_test.gno | 4 ++-- .../boards2/v1/filetests/z_accept_invite_03_filetest.gno | 2 +- .../r/gnoland/boards2/v1/filetests/z_ban_02_filetest.gno | 2 +- .../v1/filetests/z_change_member_role_07_filetest.gno | 2 +- .../boards2/v1/filetests/z_create_board_07_filetest.gno | 2 +- .../boards2/v1/filetests/z_create_reply_07_filetest.gno | 2 +- .../boards2/v1/filetests/z_create_repost_02_filetest.gno | 2 +- .../boards2/v1/filetests/z_create_thread_02_filetest.gno | 2 +- .../boards2/v1/filetests/z_delete_reply_05_filetest.gno | 2 +- .../boards2/v1/filetests/z_delete_reply_08_filetest.gno | 2 +- .../boards2/v1/filetests/z_delete_thread_03_filetest.gno | 2 +- .../boards2/v1/filetests/z_delete_thread_06_filetest.gno | 2 +- .../boards2/v1/filetests/z_edit_thread_05_filetest.gno | 2 +- .../boards2/v1/filetests/z_flag_reply_05_filetest.gno | 2 +- .../boards2/v1/filetests/z_flag_thread_02_filetest.gno | 2 +- .../boards2/v1/filetests/z_invite_member_06_filetest.gno | 2 +- .../boards2/v1/filetests/z_lock_realm_02_filetest.gno | 2 +- .../boards2/v1/filetests/z_remove_member_01_filetest.gno | 2 +- .../boards2/v1/filetests/z_rename_board_09_filetest.gno | 2 +- .../boards2/v1/filetests/z_revoke_invite_02_filetest.gno | 2 +- .../boards2/v1/filetests/z_set_permissions_01_filetest.gno | 2 +- .../boards2/v1/filetests/z_set_realm_notice_02_filetest.gno | 2 +- .../r/gnoland/boards2/v1/filetests/z_unban_02_filetest.gno | 2 +- examples/gno.land/r/gnoland/boards2/v1/public.gno | 6 ------ 25 files changed, 25 insertions(+), 31 deletions(-) diff --git a/examples/gno.land/p/gnoland/boards/exts/permissions/permissions.gno b/examples/gno.land/p/gnoland/boards/exts/permissions/permissions.gno index a385e5c363c..88c74e2841d 100644 --- a/examples/gno.land/p/gnoland/boards/exts/permissions/permissions.gno +++ b/examples/gno.land/p/gnoland/boards/exts/permissions/permissions.gno @@ -244,7 +244,7 @@ func (ps Permissions) IterateUsers(start, count int, fn boards.UsersIterFn) (sto // If a permission validation function exists it's called before calling the callback. func (ps *Permissions) WithPermission(user address, p boards.Permission, args boards.Args, cb func()) { if !ps.HasPermission(user, p) { - panic("unauthorized") + panic("unauthorized, user " + user.String() + " doesn't have the required permission") } // Execute custom validation before calling the callback diff --git a/examples/gno.land/p/gnoland/boards/exts/permissions/permissions_test.gno b/examples/gno.land/p/gnoland/boards/exts/permissions/permissions_test.gno index cd6eb766f09..53120067527 100644 --- a/examples/gno.land/p/gnoland/boards/exts/permissions/permissions_test.gno +++ b/examples/gno.land/p/gnoland/boards/exts/permissions/permissions_test.gno @@ -55,7 +55,7 @@ func TestBasicPermissionsWithPermission(t *testing.T) { perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") return perms }, - err: "unauthorized", + err: "unauthorized, user g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5 doesn't have the required permission", }, { name: "is not a DAO member", @@ -64,7 +64,7 @@ func TestBasicPermissionsWithPermission(t *testing.T) { setup: func() *Permissions { return New() }, - err: "unauthorized", + err: "unauthorized, user g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5 doesn't have the required permission", }, } diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_accept_invite_03_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_accept_invite_03_filetest.gno index 2101f599759..85e668638aa 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_accept_invite_03_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_accept_invite_03_filetest.gno @@ -31,4 +31,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1vh7krmmzfua5xjmkatvmx09z37w34lsvd2mxa5 doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_ban_02_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_ban_02_filetest.gno index e99ccbdabe0..dcff2b5596b 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_ban_02_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_ban_02_filetest.gno @@ -28,4 +28,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1vh7krmmzfua5xjmkatvmx09z37w34lsvd2mxa5 doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_change_member_role_07_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_change_member_role_07_filetest.gno index 90f9f063cdb..d6f3274d605 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_change_member_role_07_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_change_member_role_07_filetest.gno @@ -15,4 +15,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_board_07_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_board_07_filetest.gno index fd81445f760..237626d66ba 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_board_07_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_board_07_filetest.gno @@ -15,4 +15,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_reply_07_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_reply_07_filetest.gno index 44fbce430d6..27095620dae 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_reply_07_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_reply_07_filetest.gno @@ -31,4 +31,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_repost_02_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_repost_02_filetest.gno index 5edd5c965c3..dcd467b71d4 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_repost_02_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_repost_02_filetest.gno @@ -34,4 +34,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_thread_02_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_thread_02_filetest.gno index 09a03c6e925..e5d87beae77 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_thread_02_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_thread_02_filetest.gno @@ -27,4 +27,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_reply_05_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_reply_05_filetest.gno index 0cd1c10bd7c..8fda5a51caf 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_reply_05_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_reply_05_filetest.gno @@ -33,4 +33,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_reply_08_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_reply_08_filetest.gno index 3eed77046e7..6dd46971388 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_reply_08_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_reply_08_filetest.gno @@ -44,4 +44,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g125t352u4pmdrr57emc4pe04y40sknr5ztng5mt doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_thread_03_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_thread_03_filetest.gno index ee2299ca4f2..23a36501b92 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_thread_03_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_thread_03_filetest.gno @@ -32,4 +32,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_thread_06_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_thread_06_filetest.gno index 67a92aac28c..c65b993a165 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_thread_06_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_delete_thread_06_filetest.gno @@ -43,4 +43,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g125t352u4pmdrr57emc4pe04y40sknr5ztng5mt doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_edit_thread_05_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_edit_thread_05_filetest.gno index 7909e006dbb..a9d54da0916 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_edit_thread_05_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_edit_thread_05_filetest.gno @@ -31,4 +31,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_flag_reply_05_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_flag_reply_05_filetest.gno index fd71d10b7a9..fbf9be42964 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_flag_reply_05_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_flag_reply_05_filetest.gno @@ -32,4 +32,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_flag_thread_02_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_flag_thread_02_filetest.gno index 89b027ebc96..70522f22ac9 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_flag_thread_02_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_flag_thread_02_filetest.gno @@ -32,4 +32,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_invite_member_06_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_invite_member_06_filetest.gno index d6cf2a5c54a..6f994791b42 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_invite_member_06_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_invite_member_06_filetest.gno @@ -15,4 +15,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_lock_realm_02_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_lock_realm_02_filetest.gno index f41628d95a5..fedac8b5c26 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_lock_realm_02_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_lock_realm_02_filetest.gno @@ -16,4 +16,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_remove_member_01_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_remove_member_01_filetest.gno index 1a76eb29529..816fa230793 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_remove_member_01_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_remove_member_01_filetest.gno @@ -15,4 +15,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_rename_board_09_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_rename_board_09_filetest.gno index 4ce7c7d72fd..e3dbbbff83d 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_rename_board_09_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_rename_board_09_filetest.gno @@ -24,4 +24,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_revoke_invite_02_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_revoke_invite_02_filetest.gno index c21a4b3b396..e4e53d7e9d8 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_revoke_invite_02_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_revoke_invite_02_filetest.gno @@ -31,4 +31,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1vh7krmmzfua5xjmkatvmx09z37w34lsvd2mxa5 doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_permissions_01_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_permissions_01_filetest.gno index 9307b0b477a..0a13eacf668 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_permissions_01_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_permissions_01_filetest.gno @@ -28,4 +28,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_02_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_02_filetest.gno index 5adf5c56f74..08874c545cc 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_02_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_02_filetest.gno @@ -16,4 +16,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_unban_02_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_unban_02_filetest.gno index a5561af41cc..d793336d5bc 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_unban_02_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_unban_02_filetest.gno @@ -29,4 +29,4 @@ func main() { } // Error: -// unauthorized +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/public.gno b/examples/gno.land/r/gnoland/boards2/v1/public.gno index b8bd3134a51..e630324cad6 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/public.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/public.gno @@ -652,12 +652,6 @@ func assertUserAddressIsValid(user address) { } } -func assertHasPermission(perms boards.Permissions, user address, p boards.Permission) { - if !perms.HasPermission(user, p) { - panic("unauthorized") - } -} - func assertBoardExists(id boards.ID) { if id == 0 { // ID zero is used to refer to the realm return From c8b7f8bd568ffb9f2f836d9e49652ceab268ab85 Mon Sep 17 00:00:00 2001 From: Morgan Date: Thu, 2 Apr 2026 18:13:59 +0200 Subject: [PATCH 32/92] fix(amino): return error instead of panic for malformed type_url (#5399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typeURLtoFullname() panicked on a type_url with no slash, which propagated through BaseApp.DeliverTx before runTx's recover block was entered. On the DeliverTx path the panic reached the consensus receiveRoutine, logged "CONSENSUS FAILURE!!!", and terminated the goroutine — halting the chain with a single malformed transaction. Fix the root cause by converting typeURLtoFullname to return (string, error). Registration-time callers (RegisterConcrete, registerTypeInfoWLocked) still panic since a bad type_url there is a programming error caught at startup. The decode-time caller (getTypeInfoFromTypeURLRLock) now propagates the error so amino.Unmarshal returns it normally. As defence-in-depth, add an unmarshalTx helper in BaseApp that wraps amino.Unmarshal in a defer/recover, so any future codec panic in CheckTx or DeliverTx is caught and returned as ErrTxDecode rather than crashing the node. Add TestMalformedTypeURL_ConsensusDoesNotHalt to verify the full consensus path survives a block containing the malformed tx. tm2/pkg/sdk/helpers.go modifies the helpers so instead of passing a tx directly, they have to first encode the tx. This means that the test in contribs/gnodev/pkg/packages/resolver_test.go previously used GenerateTestingGenesisState, which referenced the MemPackage directly and actually allowed MemPackage to modify the gno mod in-place within the same project. By forcing encoding and decoding to always happen, the effects of AddPkg aren't seen on the original mempkg, so the test had to be updated. (cherry picked from commit 8b8e1f8e7de28db5174923781e84774f4dbccfd7) --- contribs/gnodev/pkg/packages/resolver_test.go | 5 +- .../pkg/integration/malformed_typeurl_test.go | 158 ++++++++++++++++++ tm2/pkg/amino/codec.go | 24 ++- tm2/pkg/sdk/baseapp.go | 57 +++---- tm2/pkg/sdk/baseapp_test.go | 32 +++- tm2/pkg/sdk/helpers.go | 20 ++- 6 files changed, 243 insertions(+), 53 deletions(-) create mode 100644 gno.land/pkg/integration/malformed_typeurl_test.go diff --git a/contribs/gnodev/pkg/packages/resolver_test.go b/contribs/gnodev/pkg/packages/resolver_test.go index 2463deec63e..850dfc6f244 100644 --- a/contribs/gnodev/pkg/packages/resolver_test.go +++ b/contribs/gnodev/pkg/packages/resolver_test.go @@ -253,7 +253,10 @@ func TestResolver_ResolveRemote(t *testing.T) { pkg, err := remoteResolver.Resolve(token.NewFileSet(), mempkg.Path) require.NoError(t, err) require.NotNil(t, pkg) - assert.Equal(t, mempkg, pkg.MemPackage) + // The files will be slightly different, because addpkg adds information + // to the gnomod.toml about the creator. + assert.Equal(t, mempkg.Name, pkg.MemPackage.Name) + assert.Equal(t, mempkg.Path, pkg.MemPackage.Path) }) t.Run("invalid package", func(t *testing.T) { diff --git a/gno.land/pkg/integration/malformed_typeurl_test.go b/gno.land/pkg/integration/malformed_typeurl_test.go new file mode 100644 index 00000000000..4e2c5f56a37 --- /dev/null +++ b/gno.land/pkg/integration/malformed_typeurl_test.go @@ -0,0 +1,158 @@ +package integration + +// Tests for the malformed amino type_url attack path: a transaction whose +// type_url field contains no forward slash used to trigger a hard panic in +// typeURLtoFullname() before the runTx recover block was ever entered. +// +// After the fix, typeURLtoFullname returns an error instead of panicking, and +// BaseApp.CheckTx / DeliverTx recover from any remaining codec panics. Both +// paths must return a tx-decode error rather than crashing. + +import ( + "bytes" + "io" + "log/slog" + "testing" + "time" + + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + "github.com/gnolang/gno/tm2/pkg/amino" + bfttypes "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/events" + "github.com/gnolang/gno/tm2/pkg/sdk/bank" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/stretchr/testify/require" +) + +// buildMalformedTxBytes encodes a valid bank.MsgSend transaction and flips the +// leading '/' (0x2F) in the amino type_url to '}' (0x7D). The result passes +// the IsASCIIText check in the binary decode path but contains no slash, +// which previously triggered a panic in typeURLtoFullname(). +func buildMalformedTxBytes(t *testing.T) []byte { + t.Helper() + tx := std.Tx{ + Msgs: []std.Msg{ + bank.MsgSend{ + FromAddress: crypto.Address{}, + ToAddress: crypto.Address{}, + Amount: std.NewCoins(std.NewCoin("ugnot", 1)), + }, + }, + Fee: std.NewFee(100000, std.NewCoin("ugnot", 1)), + } + validBz, err := amino.Marshal(tx) + require.NoError(t, err) + + typeURL := amino.GetTypeURL(bank.MsgSend{}) + idx := bytes.Index(validBz, []byte(typeURL)) + require.True(t, idx >= 0, "type_url not found in binary payload") + + mutated := make([]byte, len(validBz)) + copy(mutated, validBz) + mutated[idx] = '}' // '/' (0x2F) → '}' (0x7D): no slash, previously caused panic + return mutated +} + +// TestMalformedTypeURL_ConsensusDoesNotHalt verifies that a block containing a +// transaction with a malformed amino type_url (no slash) does not halt the +// consensus goroutine. The transaction must be rejected with a decode error and +// the node must continue processing subsequent blocks normally. +// +// Before the fix, the panic in typeURLtoFullname() propagated through: +// +// BaseApp.DeliverTx → amino.Unmarshal → typeURLtoFullname (panic) +// → localClient.DeliverTxAsync (no recover) +// → execBlockOnProxyApp → ApplyBlock → finalizeCommit +// → receiveRoutine defer/recover → logs CONSENSUS FAILURE!!! → onExit() +// +// A single malicious proposer could deterministically halt every validator by +// including one such transaction in a block. +func TestMalformedTypeURL_ConsensusDoesNotHalt(t *testing.T) { + t.Parallel() + + rootdir := gnoenv.RootDir() + config := TestingMinimalNodeConfig(rootdir) + // Disable empty blocks so the node stays in enterNewRound at heights > 1 + // with an empty mempool, giving a clean window to inject our proposal. + config.TMConfig.Consensus.CreateEmptyBlocks = false + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + node, _ := TestingInMemoryNode(t, logger, config) + defer node.Stop() + + mutatedBz := buildMalformedTxBytes(t) + cs := node.ConsensusState() + pv := node.PrivValidator() + + // consensusDead is closed by cs.Wait() if receiveRoutine ever exits — the + // definitive signal that the consensus goroutine has terminated. + consensusDead := make(chan struct{}) + go func() { cs.Wait(); close(consensusDead) }() + + // Wait for height ≥ 2 with no existing proposal. Height 1 always commits + // automatically (needProofBlock(1)=true). At height 2+ with + // CreateEmptyBlocks=false and an empty mempool, the node idles in + // enterNewRound — cs.Proposal remains nil — providing a clean injection + // window. + var ( + targetHeight int64 + targetCommit *bfttypes.Commit + ) + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + rs := cs.GetRoundState() + if rs.Height < 2 || rs.Proposal != nil { + time.Sleep(20 * time.Millisecond) + continue + } + if c := cs.LoadCommit(rs.Height - 1); c != nil { + targetHeight = rs.Height + targetCommit = c + break + } + time.Sleep(20 * time.Millisecond) + } + require.NotZero(t, targetHeight, "timed out waiting for injectable consensus height") + + state := cs.GetState() + + // Subscribe before injection so we don't miss the commit event. + newBlockSub := events.SubscribeToEvent(node.EventSwitch(), "test-malformed-typeurl", bfttypes.EventNewBlock{}) + + // Build a block that passes structural validation but contains the + // amino-malformed transaction. state.MakeBlock uses MedianTime for + // block.Time, which is exactly what ValidateBlock expects. + proposerAddr := pv.PubKey().Address() + block, blockParts := state.MakeBlock( + targetHeight, + []bfttypes.Tx{bfttypes.Tx(mutatedBz)}, + targetCommit, + proposerAddr, + ) + + proposal := bfttypes.NewProposal( + targetHeight, 0, -1, + bfttypes.BlockID{Hash: block.Hash(), PartsHeader: blockParts.Header()}, + ) + require.NoError(t, pv.SignProposal(state.ChainID, proposal)) + + // Inject the signed proposal. receiveRoutine picks it up via peerMsgQueue. + // With the fix applied, DeliverTx returns a decode error; the block is + // committed with the tx marked as errored and consensus continues. + require.NoError(t, cs.SetProposalAndBlock(proposal, block, blockParts, "attacker")) + + // Wait for an EventNewBlock at targetHeight: this fires only after the + // block is fully committed, proving consensus is still alive. If the fix + // is absent, consensusDead closes first. + select { + case <-consensusDead: + t.Fatal("consensus goroutine terminated — malformed tx caused an unrecovered panic") + case ev := <-newBlockSub: + got := ev.(bfttypes.EventNewBlock) + require.GreaterOrEqual(t, got.Block.Height, targetHeight) + t.Logf("OK: consensus survived, committed block %d with malformed type_url transaction", got.Block.Height) + case <-time.After(15 * time.Second): + t.Fatal("timed out waiting for consensus to commit block with malformed type_url transaction") + } +} diff --git a/tm2/pkg/amino/codec.go b/tm2/pkg/amino/codec.go index 01726e673cf..9d866ad4625 100644 --- a/tm2/pkg/amino/codec.go +++ b/tm2/pkg/amino/codec.go @@ -447,7 +447,10 @@ func (cdc *Codec) registerTypeInfoWLocked(info *TypeInfo, primary bool) { // Everybody's dooing a brand-new dance, now // Come on baby, doo the registration! - fullname := typeURLtoFullname(info.TypeURL) + fullname, err := typeURLtoFullname(info.TypeURL) + if err != nil { + panic(err) + } existing, ok := cdc.fullnameToTypeInfo[fullname] if primary { if ok { @@ -508,7 +511,10 @@ func (cdc *Codec) getTypeInfoWLocked(rt reflect.Type) (info *TypeInfo, err error } func (cdc *Codec) getTypeInfoFromTypeURLRLock(typeURL string, fopts FieldOptions) (info *TypeInfo, err error) { - fullname := typeURLtoFullname(typeURL) + fullname, err := typeURLtoFullname(typeURL) + if err != nil { + return nil, err + } return cdc.getTypeInfoFromFullnameRLock(fullname, fopts) } @@ -778,16 +784,22 @@ func parseFieldOptions(field reflect.StructField) (skip bool, fopts FieldOptions // ---------------------------------------- // Misc. -func typeURLtoFullname(typeURL string) (fullname string) { +func typeURLtoFullname(typeURL string) (string, error) { parts := strings.Split(typeURL, "/") if len(parts) == 1 { - panic(fmt.Sprintf("invalid type_url \"%v\", must contain at least one slash and be followed by the full name", typeURL)) + return "", fmt.Errorf("invalid type_url %q: must contain at least one slash and be followed by the full name", typeURL) } - return parts[len(parts)-1] + return parts[len(parts)-1], nil } +// typeURLtoShortname is only called during type registration (startup), so +// panicking on a malformed typeURL is appropriate: it is a programming error, +// not a runtime input. func typeURLtoShortname(typeURL string) (name string) { - fullname := typeURLtoFullname(typeURL) + fullname, err := typeURLtoFullname(typeURL) + if err != nil { + panic(err) + } parts := strings.Split(fullname, ".") if len(parts) == 1 { panic(fmt.Sprintf("invalid type_url \"%v\", full name must contain dot", typeURL)) diff --git a/tm2/pkg/sdk/baseapp.go b/tm2/pkg/sdk/baseapp.go index 4adebaa1840..ce288800a09 100644 --- a/tm2/pkg/sdk/baseapp.go +++ b/tm2/pkg/sdk/baseapp.go @@ -427,14 +427,7 @@ func handleQueryApp(app *BaseApp, path []string, req abci.RequestQuery) (res abc switch path[1] { case "simulate": - txBytes := req.Data - var tx Tx - err := amino.Unmarshal(txBytes, &tx) - if err != nil { - res.Error = ABCIError(std.ErrTxDecode(err.Error())) - } else { - result = app.Simulate(txBytes, tx) - } + result = app.Simulate(req.Data) res.Height = req.Height @@ -612,38 +605,22 @@ func (app *BaseApp) BeginBlock(req abci.RequestBeginBlock) (res abci.ResponseBeg // // NOTE:CheckTx does not run the actual Msg handler function(s). func (app *BaseApp) CheckTx(req abci.RequestCheckTx) (res abci.ResponseCheckTx) { - var tx Tx - err := amino.Unmarshal(req.Tx, &tx) - if err != nil { - res.Error = ABCIError(std.ErrTxDecode(err.Error())) - return - } else { - ctx := app.getContextForTx(RunTxModeCheck, req.Tx) - - result := app.runTx(ctx, tx) - res.ResponseBase = result.ResponseBase - res.GasWanted = result.GasWanted - res.GasUsed = result.GasUsed - return - } + ctx := app.getContextForTx(RunTxModeCheck, req.Tx) + result := app.runTx(ctx, req.Tx) + res.ResponseBase = result.ResponseBase + res.GasWanted = result.GasWanted + res.GasUsed = result.GasUsed + return } // DeliverTx implements the ABCI interface. func (app *BaseApp) DeliverTx(req abci.RequestDeliverTx) (res abci.ResponseDeliverTx) { - var tx Tx - err := amino.Unmarshal(req.Tx, &tx) - if err != nil { - res.Error = ABCIError(std.ErrTxDecode(err.Error())) - return - } else { - ctx := app.getContextForTx(RunTxModeDeliver, req.Tx) - - result := app.runTx(ctx, tx) - res.ResponseBase = result.ResponseBase - res.GasWanted = result.GasWanted - res.GasUsed = result.GasUsed - return - } + ctx := app.getContextForTx(RunTxModeDeliver, req.Tx) + result := app.runTx(ctx, req.Tx) + res.ResponseBase = result.ResponseBase + res.GasWanted = result.GasWanted + res.GasUsed = result.GasUsed + return } // validateBasicTxMsgs executes basic validator calls for messages. @@ -768,7 +745,7 @@ func (app *BaseApp) cacheTxContext(ctx Context) (Context, store.MultiStore) { // anteHandler. The provided txBytes may be nil in some cases, eg. in tests. For // further details on transaction execution, reference the BaseApp SDK // documentation. -func (app *BaseApp) runTx(ctx Context, tx Tx) (result Result) { +func (app *BaseApp) runTx(ctx Context, txBytes []byte) (result Result) { var ( // NOTE: GasWanted should be returned by the AnteHandler. GasUsed is // determined by the GasMeter. We need access to the context to get the gas @@ -845,6 +822,12 @@ func (app *BaseApp) runTx(ctx Context, tx Tx) (result Result) { } }() + var tx Tx + if err := amino.Unmarshal(txBytes, &tx); err != nil { + result.Error = ABCIError(std.ErrTxDecode(err.Error())) + return + } + msgs := tx.GetMsgs() if err := validateBasicTxMsgs(msgs); err != nil { result.Error = ABCIError(err) diff --git a/tm2/pkg/sdk/baseapp_test.go b/tm2/pkg/sdk/baseapp_test.go index 5e140ae3a29..33ed560cc86 100644 --- a/tm2/pkg/sdk/baseapp_test.go +++ b/tm2/pkg/sdk/baseapp_test.go @@ -593,6 +593,32 @@ func TestCheckTx(t *testing.T) { require.Nil(t, storedBytes) } +// TestCheckTxMalformedTypeURL verifies that CheckTx returns a decode error +// (and does not panic) when given a transaction whose amino type_url contains +// no slash — the bug that previously caused a consensus-halting panic. +func TestCheckTxMalformedTypeURL(t *testing.T) { + t.Parallel() + + app := setupBaseApp(t) + app.InitChain(abci.RequestInitChain{ChainID: "test-chain"}) + + // Build a valid tx and corrupt its type_url to have no slash. + tx := newTxCounter(0, 0) + validBz, err := amino.Marshal(tx) + require.NoError(t, err) + + typeURL := amino.GetTypeURL(msgCounter{}) + idx := bytes.Index(validBz, []byte(typeURL)) + require.True(t, idx >= 0, "type_url not found in encoded tx") + + mutated := make([]byte, len(validBz)) + copy(mutated, validBz) + mutated[idx] = '}' // strip leading '/' so type_url has no slash + + res := app.CheckTx(abci.RequestCheckTx{Tx: mutated}) + require.False(t, res.IsOK(), "expected a decode error, got OK") +} + // Test that successive DeliverTx can see each others' effects // on the store, both within and across blocks. func TestDeliverTx(t *testing.T) { @@ -656,7 +682,7 @@ func TestGasUsedBetweenSimulateAndDeliver(t *testing.T) { txBytes, err := amino.Marshal(tx) require.Nil(t, err) - simulateRes := app.Simulate(txBytes, tx) + simulateRes := app.Simulate(txBytes) require.True(t, simulateRes.IsOK(), fmt.Sprintf("%v", simulateRes)) require.Greater(t, simulateRes.GasUsed, int64(0)) // gas used should be greater than 0 @@ -767,12 +793,12 @@ func TestSimulateTx(t *testing.T) { require.Nil(t, err) // simulate a message, check gas reported - result := app.Simulate(txBytes, tx) + result := app.Simulate(txBytes) require.True(t, result.IsOK(), result.Log) require.Equal(t, gasConsumed, result.GasUsed) // simulate again, same result - result = app.Simulate(txBytes, tx) + result = app.Simulate(txBytes) require.True(t, result.IsOK(), result.Log) require.Equal(t, gasConsumed, result.GasUsed) diff --git a/tm2/pkg/sdk/helpers.go b/tm2/pkg/sdk/helpers.go index ea5cdfea2da..b54bee60536 100644 --- a/tm2/pkg/sdk/helpers.go +++ b/tm2/pkg/sdk/helpers.go @@ -4,24 +4,32 @@ import ( "fmt" "regexp" + "github.com/gnolang/gno/tm2/pkg/amino" abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" + "github.com/gnolang/gno/tm2/pkg/std" ) var isAlphaNumeric = regexp.MustCompile(`^[a-zA-Z0-9]+$`).MatchString func (app *BaseApp) Check(tx Tx) (result Result) { + txBytes, err := amino.Marshal(tx) + if err != nil { + return ABCIResultFromError(std.ErrTxDecode(err.Error())) + } ctx := app.getContextForTx(RunTxModeCheck, nil) - - return app.runTx(ctx, tx) + return app.runTx(ctx, txBytes) } -func (app *BaseApp) Simulate(txBytes []byte, tx Tx) (result Result) { +func (app *BaseApp) Simulate(txBytes []byte) (result Result) { ctx := app.getContextForTx(RunTxModeSimulate, txBytes) - - return app.runTx(ctx, tx) + return app.runTx(ctx, txBytes) } func (app *BaseApp) Deliver(tx Tx, ctxFns ...ContextFn) (result Result) { + txBytes, err := amino.Marshal(tx) + if err != nil { + return ABCIResultFromError(std.ErrTxDecode(err.Error())) + } ctx := app.getContextForTx(RunTxModeDeliver, nil) for _, ctxFn := range ctxFns { @@ -32,7 +40,7 @@ func (app *BaseApp) Deliver(tx Tx, ctxFns ...ContextFn) (result Result) { ctx = ctxFn(ctx) } - return app.runTx(ctx, tx) + return app.runTx(ctx, txBytes) } // ContextFn is the custom execution context builder. From d0e7a35f291c0ebc8fe960500028dff4b3f82095 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:56:22 +0200 Subject: [PATCH 33/92] fix(examples): prevent markdown injection in Render outputs (#5418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes markdown injection vulnerability (GHSA-q5xx-v955-c7vg) where user-supplied strings in `Render()` outputs could inject phishing links. ### Defense in depth — input validation + output escaping **Input validation** (`grc20reg.Register`): Slugs are restricted to alphanumeric characters, dashes, and underscores. Invalid slugs panic at registration time. **Output escaping** (`p/moul/md`, `p/nt/fqname`): All markdown helpers escape dangerous characters. If you use `md.Link()`, you're safe by default. ### Changes | File | What | |------|------| | `p/moul/md` | Added `EscapeURL()` (escapes `()` → `%28`/`%29`). `Link()`, `Image()`, `UserLink()` now escape URLs too. | | `p/nt/fqname/v0` | `RenderLink()` escapes `[]()` in slugs via `escapeMarkdown()`. | | `r/demo/defi/grc20reg` | `Register()` validates slug is alphanumeric + dash + underscore. Render uses `md.Bold()`, `md.EscapeText()`, `md.Link()`. | | `r/matijamarjanovic/tokenhub` | Fixed `RegisterToken` which was double-wrapping slug in `fqname.Construct` (same fix as security advisory PR). | | `r/gov/dao/v3/impl` | Escaped proposal titles with `md.EscapeText()`. | | `r/nt/commondao/v0` | Escaped proposal titles with `md.EscapeText()`. | ### Attack prevented A malicious realm could register a GRC20 token with slug `) [Claim Airdrop](https://evil.com` which would break out of the markdown link and render a clickable phishing link in gnoweb. Now: 1. `Register()` panics on the invalid slug (`)` not allowed) 2. Even if it got through, `RenderLink()` would escape it 3. Even if that failed, `md.Link()` would escape it ### Design principle **Use `p/moul/md` helpers and you're safe by default.** `Link(text, url)` escapes both text and URL. Realms that build markdown manually should use `md.EscapeText()` for display text and `md.EscapeURL()` for URLs.
Contributors' checklist - [x] Added new tests, or not needed, or not feasible - [x] Provided an example (e.g. screenshot) to aid review or the PR is self-explanatory - [x] Updated the official documentation or not needed - [x] No breaking changes were made, or a `BREAKING CHANGE: xxx` message was included in the description - [x] Added `benchmarks` label to the PR or not needed
--------- Co-authored-by: Jerónimo Albi (cherry picked from commit 7cb2e9b12cf7c4d4712fd892451e6d8e76066e62) --- examples/gno.land/p/moul/md/md.gno | 19 +++++--- examples/gno.land/p/nt/fqname/v0/fqname.gno | 17 ++++++- .../r/demo/defi/grc20reg/grc20reg.gno | 24 ++++++++-- .../r/demo/defi/grc20reg/grc20reg_test.gno | 44 +++++++++++++++++++ .../proposal/filetests/z_1_filetest.gno | 2 +- .../gno.land/r/gov/dao/v3/impl/render.gno | 5 ++- .../r/matijamarjanovic/tokenhub/tokenhub.gno | 15 ++----- .../tokenhub/tokenhub_test.gno | 12 ++--- .../gno.land/r/nt/commondao/v0/render.gno | 4 +- 9 files changed, 110 insertions(+), 32 deletions(-) diff --git a/examples/gno.land/p/moul/md/md.gno b/examples/gno.land/p/moul/md/md.gno index d7b1d6d7d6f..854dab7baec 100644 --- a/examples/gno.land/p/moul/md/md.gno +++ b/examples/gno.land/p/moul/md/md.gno @@ -183,7 +183,7 @@ func HorizontalRule() string { // Link returns a hyperlink for markdown. // Example: Link("foo", "http://example.com") => "[foo](http://example.com)" func Link(text, url string) string { - return "[" + EscapeText(text) + "](" + url + ")" + return "[" + EscapeText(text) + "](" + EscapeURL(url) + ")" } // UserLink returns a user profile link for markdown. @@ -192,21 +192,21 @@ func Link(text, url string) string { // Example: UserLink("g1blah") => "[g1blah](/u/g1blah)" func UserLink(user string) string { if strings.HasPrefix(user, "g1") { - return "[" + EscapeText(user) + "](/u/" + user + ")" + return "[" + EscapeText(user) + "](/u/" + EscapeURL(user) + ")" } - return "[@" + EscapeText(user) + "](/u/" + user + ")" + return "[@" + EscapeText(user) + "](/u/" + EscapeURL(user) + ")" } // InlineImageWithLink creates an inline image wrapped in a hyperlink for markdown. // Example: InlineImageWithLink("alt text", "image-url", "link-url") => "[![alt text](image-url)](link-url)" func InlineImageWithLink(altText, imageUrl, linkUrl string) string { - return "[" + Image(altText, imageUrl) + "](" + linkUrl + ")" + return "[" + Image(altText, imageUrl) + "](" + EscapeURL(linkUrl) + ")" } // Image returns an image for markdown. // Example: Image("foo", "http://example.com") => "![foo](http://example.com)" func Image(altText, url string) string { - return "![" + EscapeText(altText) + "](" + url + ")" + return "![" + EscapeText(altText) + "](" + EscapeURL(url) + ")" } // Footnote returns a footnote for markdown. @@ -234,6 +234,15 @@ func CollapsibleSection(title, content string) string { return "
" + EscapeText(title) + "\n\n" + content + "\n
\n" } +// EscapeURL escapes characters in a URL that would break markdown link syntax. +func EscapeURL(url string) string { + r := strings.NewReplacer( + "(", `%28`, + ")", `%29`, + ) + return r.Replace(url) +} + // EscapeText escapes special Markdown characters in regular text where needed. func EscapeText(text string) string { replacer := strings.NewReplacer( diff --git a/examples/gno.land/p/nt/fqname/v0/fqname.gno b/examples/gno.land/p/nt/fqname/v0/fqname.gno index 66be07d8887..3cbb6dd5a4b 100644 --- a/examples/gno.land/p/nt/fqname/v0/fqname.gno +++ b/examples/gno.land/p/nt/fqname/v0/fqname.gno @@ -63,15 +63,28 @@ func RenderLink(pkgPath, slug string) string { if strings.HasPrefix(pkgPath, "gno.land") { pkgLink := strings.TrimPrefix(pkgPath, "gno.land") if slug != "" { - return "[" + pkgPath + "](" + pkgLink + ")." + slug + safeSlug := escapeMarkdown(slug) + return "[" + pkgPath + "](" + pkgLink + ")." + safeSlug } return "[" + pkgPath + "](" + pkgLink + ")" } if slug != "" { - return pkgPath + "." + slug + safeSlug := escapeMarkdown(slug) + return pkgPath + "." + safeSlug } return pkgPath } + +// escapeMarkdown escapes characters that could break markdown link syntax. +func escapeMarkdown(s string) string { + r := strings.NewReplacer( + "[", `\[`, + "]", `\]`, + "(", `\(`, + ")", `\)`, + ) + return r.Replace(s) +} diff --git a/examples/gno.land/r/demo/defi/grc20reg/grc20reg.gno b/examples/gno.land/r/demo/defi/grc20reg/grc20reg.gno index b9cbdf260d9..1f7f6268c27 100644 --- a/examples/gno.land/r/demo/defi/grc20reg/grc20reg.gno +++ b/examples/gno.land/r/demo/defi/grc20reg/grc20reg.gno @@ -5,6 +5,7 @@ import ( "chain/runtime" "gno.land/p/demo/tokens/grc20" + "gno.land/p/moul/md" "gno.land/p/nt/avl/v0" "gno.land/p/nt/avl/v0/rotree" "gno.land/p/nt/fqname/v0" @@ -13,6 +14,9 @@ import ( var registry = avl.NewTree() // rlmPath[.slug] -> *Token (slug is optional) func Register(cur realm, token *grc20.Token, slug string) { + if slug != "" { + validateSlug(slug) + } rlmPath := runtime.PreviousRealm().PkgPath() key := fqname.Construct(rlmPath, slug) registry.Set(key, token) @@ -51,7 +55,7 @@ func Render(path string) string { rlmPath, slug := fqname.Parse(key) rlmLink := fqname.RenderLink(rlmPath, slug) infoLink := "/r/demo/grc20reg:" + key - s += ufmt.Sprintf("- **%s** - %s - [info](%s)\n", token.GetName(), rlmLink, infoLink) + s += "- " + md.Bold(md.EscapeText(token.GetName())) + " - " + rlmLink + " - " + md.Link("info", infoLink) + "\n" return false }) if count == 0 { @@ -63,8 +67,8 @@ func Render(path string) string { token := MustGet(key) rlmPath, slug := fqname.Parse(key) rlmLink := fqname.RenderLink(rlmPath, slug) - s := ufmt.Sprintf("# %s\n", token.GetName()) - s += ufmt.Sprintf("- symbol: **%s**\n", token.GetSymbol()) + s := ufmt.Sprintf("# %s\n", md.EscapeText(token.GetName())) + s += "- symbol: " + md.Bold(md.EscapeText(token.GetSymbol())) + "\n" s += ufmt.Sprintf("- realm: %s\n", rlmLink) s += ufmt.Sprintf("- decimals: %d\n", token.GetDecimals()) s += ufmt.Sprintf("- total supply: %d\n", token.TotalSupply()) @@ -77,3 +81,17 @@ const registerEvent = "register" func GetRegistry() *rotree.ReadOnlyTree { return rotree.Wrap(registry, nil) } + +// validateSlug panics if the slug contains non-alphanumeric characters. +// Only letters, digits, dashes, and underscores are allowed. +func validateSlug(slug string) { + for _, c := range slug { + if !isAlphanumeric(c) && c != '_' && c != '-' { + panic("grc20reg: invalid slug character: " + string(c)) + } + } +} + +func isAlphanumeric(c rune) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') +} diff --git a/examples/gno.land/r/demo/defi/grc20reg/grc20reg_test.gno b/examples/gno.land/r/demo/defi/grc20reg/grc20reg_test.gno index d5d83f7f59f..625486aae4f 100644 --- a/examples/gno.land/r/demo/defi/grc20reg/grc20reg_test.gno +++ b/examples/gno.land/r/demo/defi/grc20reg/grc20reg_test.gno @@ -53,3 +53,47 @@ func TestRegistry(t *testing.T) { got = Render("gno.land/r/demo/foo.mySlug") urequire.Equal(t, expected, got) } + +func TestValidateSlug(t *testing.T) { + // Valid slugs — should not panic + valid := []string{"mytoken", "my-token", "my_token", "Token123", "a", "A-B_c"} + for _, slug := range valid { + validateSlug(slug) // no panic = pass + } +} + +func TestValidateSlugPanicsOnSpace(t *testing.T) { + defer func() { recover() }() + validateSlug("has space") + t.Errorf("should have panicked") +} + +func TestValidateSlugPanicsOnDot(t *testing.T) { + defer func() { recover() }() + validateSlug("has.dot") + t.Errorf("should have panicked") +} + +func TestValidateSlugPanicsOnSlash(t *testing.T) { + defer func() { recover() }() + validateSlug("has/slash") + t.Errorf("should have panicked") +} + +func TestValidateSlugPanicsOnBrackets(t *testing.T) { + defer func() { recover() }() + validateSlug("[brackets]") + t.Errorf("should have panicked") +} + +func TestValidateSlugPanicsOnParens(t *testing.T) { + defer func() { recover() }() + validateSlug("(parens)") + t.Errorf("should have panicked") +} + +func TestValidateSlugPanicsOnInjection(t *testing.T) { + defer func() { recover() }() + validateSlug(`) [Claim](https://evil.com`) + t.Errorf("should have panicked") +} diff --git a/examples/gno.land/r/gnops/valopers/proposal/filetests/z_1_filetest.gno b/examples/gno.land/r/gnops/valopers/proposal/filetests/z_1_filetest.gno index 24bfbb33525..9c08f835515 100644 --- a/examples/gno.land/r/gnops/valopers/proposal/filetests/z_1_filetest.gno +++ b/examples/gno.land/r/gnops/valopers/proposal/filetests/z_1_filetest.gno @@ -50,7 +50,7 @@ func main() { // ## Members // [> Go to Memberstore <](/r/gov/dao/v3/memberstore) // ## Proposals -// ### [Prop #0 - Add valoper test-1 to the valset](/r/gov/dao:0) +// ### [Prop #0 - Add valoper test\-1 to the valset](/r/gov/dao:0) // Author: g1vuch2um9wf047h6lta047h6lta047h6l2ewm6w // // Status: ACTIVE diff --git a/examples/gno.land/r/gov/dao/v3/impl/render.gno b/examples/gno.land/r/gov/dao/v3/impl/render.gno index cdc97e20b07..461a1ddd925 100644 --- a/examples/gno.land/r/gov/dao/v3/impl/render.gno +++ b/examples/gno.land/r/gov/dao/v3/impl/render.gno @@ -6,6 +6,7 @@ import ( "strings" "gno.land/p/moul/helplink" + "gno.land/p/moul/md" "gno.land/p/nt/avl/v0/pager" "gno.land/p/nt/mux/v0" "gno.land/p/nt/seqid/v0" @@ -92,7 +93,7 @@ func (ren *render) renderProposalPage(sPid string, d *GovDAO) string { } ps := d.pss.GetStatus(dao.ProposalID(pid)) - out := ufmt.Sprintf("## Prop #%v - %v\n", pid, p.Title()) + out := ufmt.Sprintf("## Prop #%v - %v\n", pid, md.EscapeText(p.Title())) out += "Author: " + tryResolveAddr(p.Author()) + "\n\n" out += p.Description() @@ -132,7 +133,7 @@ func (ren *render) renderProposalListItem(sPid string, d *GovDAO) string { } ps := d.pss.GetStatus(dao.ProposalID(pid)) - out := ufmt.Sprintf("### [Prop #%v - %v](%v:%v)\n", pid, p.Title(), ren.relativeRealmPath, pid) + out := ufmt.Sprintf("### [Prop #%v - %v](%v:%v)\n", pid, md.EscapeText(p.Title()), ren.relativeRealmPath, pid) out += ufmt.Sprintf("Author: %s\n\n", tryResolveAddr(p.Author())) out += "Status: " + getPropStatus(ps) diff --git a/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub.gno b/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub.gno index d71a7f98acd..ce520b0c3e6 100644 --- a/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub.gno +++ b/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub.gno @@ -32,18 +32,11 @@ func init() { } // RegisterToken is a function that uses gno.land/r/demo/defi/grc20reg to register a token -// It uses the slug to construct a key and then registers the token in the registry -// The logic is the same as in grc20reg, but it's done here so the key path is callers pkgpath and not of this realm -// After doing so, the token hub realm uses grc20reg's registry as a read-only avl.Tree -// -// Note: register token returns the key path that can be used to retrieve the token +// RegisterToken registers a token in grc20reg with the given slug. +// Returns the registry key that can be used to retrieve the token. func RegisterToken(cur realm, token *grc20.Token, slug string) string { - rlmPath := runtime.PreviousRealm().PkgPath() - key := fqname.Construct(rlmPath, slug) - - grc20reg.Register(cross, token, key) - - return fqname.Construct(runtime.CurrentRealm().PkgPath(), key) + grc20reg.Register(cross, token, slug) + return fqname.Construct(runtime.CurrentRealm().PkgPath(), slug) } // RegisterNFT is a function that registers an NFT in an avl.Tree diff --git a/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub_test.gno b/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub_test.gno index 58b1f7b7b27..c59d096c7e9 100644 --- a/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub_test.gno +++ b/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub_test.gno @@ -22,7 +22,7 @@ func TestTokenRegistration(t *testing.T) { token, _ := grc20.NewToken("Test Token", "TEST", 6) RegisterToken(cross, token, "test_token") - retrievedToken := GetToken("gno.land/r/matijamarjanovic/tokenhub.gno.land/r/matijamarjanovic/testrealm.test_token") + retrievedToken := GetToken("gno.land/r/matijamarjanovic/tokenhub.test_token") urequire.True(t, retrievedToken != nil, "Should retrieve registered token") uassert.Equal(t, "Test Token", retrievedToken.GetName(), "Token name should match") @@ -76,7 +76,7 @@ func TestBalanceRetrieval(t *testing.T) { balances := GetUserTokenBalances(runtime.CurrentRealm().Address().String()) uassert.True(t, strings.Contains(balances, - "Token:gno.land/r/matijamarjanovic/tokenhub."+testRealmPkgPath+".test_token:1000"), "Should show correct GRC20 balance") + "Token:gno.land/r/matijamarjanovic/tokenhub.test_token:1000"), "Should show correct GRC20 balance") nft := grc721.NewBasicNFT("Test NFT", "TNFT") nft.Mint(runtime.CurrentRealm().Address(), grc721.TokenID("1")) @@ -94,7 +94,7 @@ func TestBalanceRetrieval(t *testing.T) { nonZeroBalances := GetUserTokenBalancesNonZero(runtime.CurrentRealm().Address().String()) uassert.True(t, strings.Contains(nonZeroBalances, - "Token:gno.land/r/matijamarjanovic/tokenhub."+testRealmPkgPath+".test_token:1000"), "Should show non-zero GRC20 balance") + "Token:gno.land/r/matijamarjanovic/tokenhub.test_token:1000"), "Should show non-zero GRC20 balance") } func TestErrorCases(t *testing.T) { @@ -122,7 +122,7 @@ func TestTokenListingFunctions(t *testing.T) { RegisterToken(cross, grc20Token, "listing_token") grc20List := GetAllTokens() - uassert.True(t, strings.Contains(grc20List, "Token:gno.land/r/matijamarjanovic/tokenhub."+testRealmPkgPath+".listing_token"), + uassert.True(t, strings.Contains(grc20List, "Token:gno.land/r/matijamarjanovic/tokenhub.listing_token"), "GetAllGRC20Tokens should list registered token") nftToken := grc721.NewBasicNFT("Listing NFT", "LNFT") @@ -143,7 +143,7 @@ func TestTokenListingFunctions(t *testing.T) { completeList := GetAllRegistered() uassert.True(t, strings.Contains(completeList, "NFT:"+testRealmPkgPath+".listing_nft.1"), "GetAllTokens should list NFTs") - uassert.True(t, strings.Contains(completeList, "Token:gno.land/r/matijamarjanovic/tokenhub."+testRealmPkgPath+".listing_token"), + uassert.True(t, strings.Contains(completeList, "Token:gno.land/r/matijamarjanovic/tokenhub.listing_token"), "GetAllTokens should list GRC20 tokens") uassert.True(t, strings.Contains(completeList, "MultiToken:"+testRealmPkgPath+".listing_mt"), "GetAllTokens should list multi-tokens") @@ -155,7 +155,7 @@ func TestMustGetFunctions(t *testing.T) { token, _ := grc20.NewToken("Must Token", "MUST", 6) RegisterToken(cross, token, "must_token") - retrievedToken := MustGetToken("gno.land/r/matijamarjanovic/tokenhub." + testRealmPkgPath + ".must_token") + retrievedToken := MustGetToken("gno.land/r/matijamarjanovic/tokenhub.must_token") uassert.Equal(t, "Must Token", retrievedToken.GetName(), "Token name should match") defer func() { diff --git a/examples/gno.land/r/nt/commondao/v0/render.gno b/examples/gno.land/r/nt/commondao/v0/render.gno index 12ed44ab856..ee38781610f 100644 --- a/examples/gno.land/r/nt/commondao/v0/render.gno +++ b/examples/gno.land/r/nt/commondao/v0/render.gno @@ -346,7 +346,7 @@ func renderProposalsListItem(res *mux.ResponseWriter, dao *commondao.CommonDAO, o := getOptions(dao.ID()) // Render title - res.Write(ufmt.Sprintf("**[#%d %s](%s)** \n", p.ID(), def.Title(), proposalURL(dao.ID(), p.ID()))) + res.Write(ufmt.Sprintf("**[#%d %s](%s)** \n", p.ID(), md.EscapeText(def.Title()), proposalURL(dao.ID(), p.ID()))) // Render details res.Write(ufmt.Sprintf("Created by %s \n", userLink(p.Creator()))) @@ -389,7 +389,7 @@ func renderProposal(res *mux.ResponseWriter, req *mux.Request) { def := p.Definition() // Render header - res.Write(md.H1("#" + strconv.FormatUint(p.ID(), 10) + " " + def.Title())) + res.Write(md.H1("#" + strconv.FormatUint(p.ID(), 10) + " " + md.EscapeText(def.Title()))) // Render main menu items := []string{goToDAOLink(dao.ID())} From ddb885ccae5abbf832b717b5cdc89aa9a47ceb6d Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:59:02 +0200 Subject: [PATCH 34/92] fix(tm2/bft): add nil checks for block and block meta retrievals (#5137) Add nil guards for block/block-meta lookups across TM2's RPC handlers, consensus reactor, replay, and block store to prevent nil-pointer panics when blocks or metadata are missing. Includes tests for each guarded path. (cherry picked from commit 786f06ba20639566ac15064362d5353cccd2c8b5) --- tm2/pkg/bft/consensus/reactor.go | 8 ++ tm2/pkg/bft/consensus/replay.go | 9 ++ tm2/pkg/bft/rpc/core/blocks.go | 21 +++- tm2/pkg/bft/rpc/core/blocks_test.go | 181 ++++++++++++++++++++++++++++ tm2/pkg/bft/rpc/core/mock_test.go | 88 +++++++++++++- tm2/pkg/bft/rpc/core/status.go | 3 + tm2/pkg/bft/rpc/core/status_test.go | 78 ++++++++++++ tm2/pkg/bft/rpc/core/tx.go | 3 + tm2/pkg/bft/rpc/core/tx_test.go | 48 ++++++++ tm2/pkg/bft/store/store.go | 5 + tm2/pkg/bft/store/store_test.go | 23 ++++ 11 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 tm2/pkg/bft/rpc/core/status_test.go diff --git a/tm2/pkg/bft/consensus/reactor.go b/tm2/pkg/bft/consensus/reactor.go index 624096c4f08..e63fcd7b59c 100644 --- a/tm2/pkg/bft/consensus/reactor.go +++ b/tm2/pkg/bft/consensus/reactor.go @@ -635,6 +635,10 @@ OUTER_LOOP: // Load the block commit for prs.Height, // which contains precommit signatures for prs.Height. commit := conR.conS.blockStore.LoadBlockCommit(prs.Height) + if commit == nil { + logger.Warn("Failed to load block commit for catchup", "height", prs.Height) + continue OUTER_LOOP + } if ps.PickSendVote(commit) { logger.Debug("Picked Catchup commit to send", "height", prs.Height) continue OUTER_LOOP @@ -783,6 +787,10 @@ OUTER_LOOP: prs := ps.GetRoundState() if prs.CatchupCommitRound != -1 && 0 < prs.Height && prs.Height <= conR.conS.blockStore.Height() { commit := conR.conS.LoadCommit(prs.Height) + if commit == nil { + logger.Warn("Failed to load commit for queryMaj23", "height", prs.Height) + continue OUTER_LOOP + } peer.TrySend(StateChannel, amino.MustMarshalAny(&VoteSetMaj23Message{ Height: prs.Height, Round: commit.Round(), diff --git a/tm2/pkg/bft/consensus/replay.go b/tm2/pkg/bft/consensus/replay.go index 262da291cf2..013d90a2e31 100644 --- a/tm2/pkg/bft/consensus/replay.go +++ b/tm2/pkg/bft/consensus/replay.go @@ -435,6 +435,9 @@ func (h *Handshaker) replayBlocks(state sm.State, proxyApp appconn.AppConns, app for i := appBlockHeight + 1; i <= finalBlock; i++ { h.logger.Info("Applying block", "height", i) block := h.store.LoadBlock(i) + if block == nil { + return nil, fmt.Errorf("block not found for height %d", i) + } // Extra check to ensure the app was not changed in a way it shouldn't have. if len(appHash) > 0 { assertAppHashEqualsOneFromBlock(appHash, block) @@ -464,7 +467,13 @@ func (h *Handshaker) replayBlocks(state sm.State, proxyApp appconn.AppConns, app // ApplyBlock on the proxyApp with the last block. func (h *Handshaker) replayBlock(state sm.State, height int64, proxyApp appconn.Consensus) (sm.State, error) { block := h.store.LoadBlock(height) + if block == nil { + return sm.State{}, fmt.Errorf("block not found for height %d", height) + } meta := h.store.LoadBlockMeta(height) + if meta == nil { + return sm.State{}, fmt.Errorf("block meta not found for height %d", height) + } blockExec := sm.NewBlockExecutor(h.stateDB, h.logger, proxyApp, mock.Mempool{}) blockExec.SetEventSwitch(h.evsw) diff --git a/tm2/pkg/bft/rpc/core/blocks.go b/tm2/pkg/bft/rpc/core/blocks.go index c00dd27909a..cf24b741876 100644 --- a/tm2/pkg/bft/rpc/core/blocks.go +++ b/tm2/pkg/bft/rpc/core/blocks.go @@ -88,6 +88,9 @@ func BlockchainInfo(ctx *rpctypes.Context, minHeight, maxHeight int64) (*ctypes. blockMetas := []*types.BlockMeta{} for height := maxHeight; height >= minHeight; height-- { blockMeta := blockStore.LoadBlockMeta(height) + if blockMeta == nil { + return nil, fmt.Errorf("block meta not found for height %d", height) + } blockMetas = append(blockMetas, blockMeta) } @@ -247,7 +250,13 @@ func Block(ctx *rpctypes.Context, heightPtr *int64) (*ctypes.ResultBlock, error) } blockMeta := blockStore.LoadBlockMeta(height) + if blockMeta == nil { + return nil, fmt.Errorf("block meta not found for height %d", height) + } block := blockStore.LoadBlock(height) + if block == nil { + return nil, fmt.Errorf("block not found for height %d", height) + } return &ctypes.ResultBlock{BlockMeta: blockMeta, Block: block}, nil } @@ -339,17 +348,27 @@ func Commit(ctx *rpctypes.Context, heightPtr *int64) (*ctypes.ResultCommit, erro return nil, err } - header := blockStore.LoadBlockMeta(height).Header + blockMeta := blockStore.LoadBlockMeta(height) + if blockMeta == nil { + return nil, fmt.Errorf("block meta not found for height %d", height) + } + header := blockMeta.Header // If the next block has not been committed yet, // use a non-canonical commit if height == storeHeight { commit := blockStore.LoadSeenCommit(height) + if commit == nil { + return nil, fmt.Errorf("seen commit not found for height %d", height) + } return ctypes.NewResultCommit(&header, commit, false), nil } // Return the canonical commit (comes from the block at height+1) commit := blockStore.LoadBlockCommit(height) + if commit == nil { + return nil, fmt.Errorf("block commit not found for height %d", height) + } return ctypes.NewResultCommit(&header, commit, true), nil } diff --git a/tm2/pkg/bft/rpc/core/blocks_test.go b/tm2/pkg/bft/rpc/core/blocks_test.go index dd55784ada0..bc75aa06a7a 100644 --- a/tm2/pkg/bft/rpc/core/blocks_test.go +++ b/tm2/pkg/bft/rpc/core/blocks_test.go @@ -3,7 +3,12 @@ package core import ( "fmt" "testing" + "time" + rpctypes "github.com/gnolang/gno/tm2/pkg/bft/rpc/lib/types" + "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/log" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -92,3 +97,179 @@ func TestGetHeight(t *testing.T) { func int64Ptr(v int64) *int64 { return &v } + +func TestBlockchainInfoHandler(t *testing.T) { + t.Run("nil block meta in range", func(t *testing.T) { + var storeHeight int64 = 10 + + SetLogger(log.NewNoopLogger()) + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return storeHeight }, + loadBlockMetaFn: func(h int64) *types.BlockMeta { + if h == 8 { + return nil // simulate missing block meta at height 8 + } + return &types.BlockMeta{Header: types.Header{Height: h, Time: time.Now()}} + }, + }) + + result, err := BlockchainInfo(&rpctypes.Context{}, 5, 10) + require.Nil(t, result) + assert.ErrorContains(t, err, "block meta not found for height 8") + }) + + t.Run("all block metas present", func(t *testing.T) { + var storeHeight int64 = 5 + + SetLogger(log.NewNoopLogger()) + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return storeHeight }, + loadBlockMetaFn: func(h int64) *types.BlockMeta { + return &types.BlockMeta{Header: types.Header{Height: h, Time: time.Now()}} + }, + }) + + result, err := BlockchainInfo(&rpctypes.Context{}, 1, 5) + require.NoError(t, err) + require.NotNil(t, result) + assert.Len(t, result.BlockMetas, 5) + }) +} + +func TestBlockHandler(t *testing.T) { + t.Run("nil block meta", func(t *testing.T) { + var height int64 = 5 + + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return height }, + loadBlockMetaFn: func(int64) *types.BlockMeta { return nil }, + }) + + result, err := Block(&rpctypes.Context{}, &height) + require.Nil(t, result) + assert.ErrorContains(t, err, "block meta not found for height 5") + }) + + t.Run("nil block", func(t *testing.T) { + var height int64 = 5 + + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return height }, + loadBlockMetaFn: func(int64) *types.BlockMeta { + return &types.BlockMeta{Header: types.Header{Height: height}} + }, + loadBlockFn: func(int64) *types.Block { return nil }, + }) + + result, err := Block(&rpctypes.Context{}, &height) + require.Nil(t, result) + assert.ErrorContains(t, err, "block not found for height 5") + }) + + t.Run("success", func(t *testing.T) { + var height int64 = 5 + + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return height }, + loadBlockMetaFn: func(int64) *types.BlockMeta { + return &types.BlockMeta{Header: types.Header{Height: height}} + }, + loadBlockFn: func(int64) *types.Block { + return &types.Block{Header: types.Header{Height: height}} + }, + }) + + result, err := Block(&rpctypes.Context{}, &height) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, height, result.BlockMeta.Header.Height) + assert.Equal(t, height, result.Block.Header.Height) + }) +} + +func TestCommitHandler(t *testing.T) { + t.Run("nil block meta", func(t *testing.T) { + var height int64 = 5 + + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return height }, + loadBlockMetaFn: func(int64) *types.BlockMeta { return nil }, + }) + + result, err := Commit(&rpctypes.Context{}, &height) + require.Nil(t, result) + assert.ErrorContains(t, err, "block meta not found for height 5") + }) + + t.Run("nil seen commit", func(t *testing.T) { + var height int64 = 5 + + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return height }, // storeHeight == height + loadBlockMetaFn: func(int64) *types.BlockMeta { + return &types.BlockMeta{Header: types.Header{Height: height}} + }, + loadSeenCommitFn: func(int64) *types.Commit { return nil }, + }) + + result, err := Commit(&rpctypes.Context{}, &height) + require.Nil(t, result) + assert.ErrorContains(t, err, "seen commit not found for height 5") + }) + + t.Run("nil block commit", func(t *testing.T) { + var ( + height int64 = 5 + storeHeight int64 = 10 + ) + + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return storeHeight }, + loadBlockMetaFn: func(int64) *types.BlockMeta { + return &types.BlockMeta{Header: types.Header{Height: height}} + }, + loadBlockCommitFn: func(int64) *types.Commit { return nil }, + }) + + result, err := Commit(&rpctypes.Context{}, &height) + require.Nil(t, result) + assert.ErrorContains(t, err, "block commit not found for height 5") + }) + + t.Run("success canonical commit", func(t *testing.T) { + var ( + height int64 = 5 + storeHeight int64 = 10 + ) + + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return storeHeight }, + loadBlockMetaFn: func(int64) *types.BlockMeta { + return &types.BlockMeta{Header: types.Header{Height: height}} + }, + loadBlockCommitFn: func(int64) *types.Commit { return &types.Commit{} }, + }) + + result, err := Commit(&rpctypes.Context{}, &height) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.CanonicalCommit) + }) + + t.Run("success non-canonical commit", func(t *testing.T) { + var height int64 = 10 + + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return height }, // storeHeight == height + loadBlockMetaFn: func(int64) *types.BlockMeta { + return &types.BlockMeta{Header: types.Header{Height: height}} + }, + loadSeenCommitFn: func(int64) *types.Commit { return &types.Commit{} }, + }) + + result, err := Commit(&rpctypes.Context{}, &height) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.CanonicalCommit) + }) +} diff --git a/tm2/pkg/bft/rpc/core/mock_test.go b/tm2/pkg/bft/rpc/core/mock_test.go index a6ffe948d00..717d067a559 100644 --- a/tm2/pkg/bft/rpc/core/mock_test.go +++ b/tm2/pkg/bft/rpc/core/mock_test.go @@ -1,6 +1,12 @@ package core -import "github.com/gnolang/gno/tm2/pkg/bft/types" +import ( + cnscfg "github.com/gnolang/gno/tm2/pkg/bft/consensus/config" + cstypes "github.com/gnolang/gno/tm2/pkg/bft/consensus/types" + sm "github.com/gnolang/gno/tm2/pkg/bft/state" + "github.com/gnolang/gno/tm2/pkg/bft/types" + p2pTypes "github.com/gnolang/gno/tm2/pkg/p2p/types" +) type ( heightDelegate func() int64 @@ -76,3 +82,83 @@ func (m *mockBlockStore) SaveBlock(block *types.Block, blockParts *types.PartSet m.saveBlockFn(block, blockParts, seenCommit) } } + +// mockConsensus implements the Consensus interface for testing. +type mockConsensus struct { + getConfigDeepCopyFn func() *cnscfg.ConsensusConfig + getStateFn func() sm.State + getValidatorsFn func() (int64, []*types.Validator) + getLastHeightFn func() int64 + getRoundStateDeepCopyFn func() *cstypes.RoundState + getRoundStateSimpleFn func() cstypes.RoundStateSimple +} + +func (m *mockConsensus) GetConfigDeepCopy() *cnscfg.ConsensusConfig { + if m.getConfigDeepCopyFn != nil { + return m.getConfigDeepCopyFn() + } + return nil +} + +func (m *mockConsensus) GetState() sm.State { + if m.getStateFn != nil { + return m.getStateFn() + } + return sm.State{} +} + +func (m *mockConsensus) GetValidators() (int64, []*types.Validator) { + if m.getValidatorsFn != nil { + return m.getValidatorsFn() + } + return 0, nil +} + +func (m *mockConsensus) GetLastHeight() int64 { + if m.getLastHeightFn != nil { + return m.getLastHeightFn() + } + return 0 +} + +func (m *mockConsensus) GetRoundStateDeepCopy() *cstypes.RoundState { + if m.getRoundStateDeepCopyFn != nil { + return m.getRoundStateDeepCopyFn() + } + return nil +} + +func (m *mockConsensus) GetRoundStateSimple() cstypes.RoundStateSimple { + if m.getRoundStateSimpleFn != nil { + return m.getRoundStateSimpleFn() + } + return cstypes.RoundStateSimple{} +} + +// mockTransport implements the transport interface for testing. +type mockTransport struct { + listenersFn func() []string + isListeningFn func() bool + nodeInfoFn func() p2pTypes.NodeInfo +} + +func (m *mockTransport) Listeners() []string { + if m.listenersFn != nil { + return m.listenersFn() + } + return nil +} + +func (m *mockTransport) IsListening() bool { + if m.isListeningFn != nil { + return m.isListeningFn() + } + return false +} + +func (m *mockTransport) NodeInfo() p2pTypes.NodeInfo { + if m.nodeInfoFn != nil { + return m.nodeInfoFn() + } + return p2pTypes.NodeInfo{} +} diff --git a/tm2/pkg/bft/rpc/core/status.go b/tm2/pkg/bft/rpc/core/status.go index e6ac4aebef6..5aee307eb50 100644 --- a/tm2/pkg/bft/rpc/core/status.go +++ b/tm2/pkg/bft/rpc/core/status.go @@ -106,6 +106,9 @@ func Status(ctx *rpctypes.Context, heightGtePtr *int64) (*ctypes.ResultStatus, e ) if latestHeight != 0 { latestBlockMeta = blockStore.LoadBlockMeta(latestHeight) + if latestBlockMeta == nil { + return nil, fmt.Errorf("block meta not found for height %d", latestHeight) + } latestBlockHash = latestBlockMeta.BlockID.Hash latestAppHash = latestBlockMeta.Header.AppHash latestBlockTimeNano = latestBlockMeta.Header.Time.UnixNano() diff --git a/tm2/pkg/bft/rpc/core/status_test.go b/tm2/pkg/bft/rpc/core/status_test.go new file mode 100644 index 00000000000..8496535425b --- /dev/null +++ b/tm2/pkg/bft/rpc/core/status_test.go @@ -0,0 +1,78 @@ +package core + +import ( + "testing" + + rpctypes "github.com/gnolang/gno/tm2/pkg/bft/rpc/lib/types" + "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/crypto/ed25519" + p2pTypes "github.com/gnolang/gno/tm2/pkg/p2p/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupStatusGlobals sets the global consensus/transport/pubkey state +// needed by the Status handler's success path. +func setupStatusGlobals() { + SetGetFastSync(func() bool { return true }) + SetConsensusState(&mockConsensus{ + getValidatorsFn: func() (int64, []*types.Validator) { + return 0, nil + }, + }) + SetP2PTransport(&mockTransport{ + nodeInfoFn: func() p2pTypes.NodeInfo { + return p2pTypes.NodeInfo{} + }, + }) + SetPubKey(ed25519.GenPrivKey().PubKey()) +} + +func TestStatusHandler(t *testing.T) { + t.Run("nil block meta", func(t *testing.T) { + var height int64 = 10 + + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return height }, + loadBlockMetaFn: func(int64) *types.BlockMeta { return nil }, + }) + SetGetFastSync(func() bool { return true }) + + result, err := Status(&rpctypes.Context{}, nil) + require.Nil(t, result) + assert.ErrorContains(t, err, "block meta not found for height 10") + }) + + t.Run("success with block meta", func(t *testing.T) { + var height int64 = 10 + + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return height }, + loadBlockMetaFn: func(int64) *types.BlockMeta { + return &types.BlockMeta{Header: types.Header{Height: height}} + }, + }) + setupStatusGlobals() + + result, err := Status(&rpctypes.Context{}, nil) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, height, result.SyncInfo.LatestBlockHeight) + }) + + t.Run("height zero skips block meta load", func(t *testing.T) { + SetBlockStore(&mockBlockStore{ + heightFn: func() int64 { return 0 }, + loadBlockMetaFn: func(int64) *types.BlockMeta { + t.Fatal("LoadBlockMeta should not be called when height is 0") + return nil + }, + }) + setupStatusGlobals() + + result, err := Status(&rpctypes.Context{}, nil) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, int64(0), result.SyncInfo.LatestBlockHeight) + }) +} diff --git a/tm2/pkg/bft/rpc/core/tx.go b/tm2/pkg/bft/rpc/core/tx.go index eb1bef79874..1023e5aef1f 100644 --- a/tm2/pkg/bft/rpc/core/tx.go +++ b/tm2/pkg/bft/rpc/core/tx.go @@ -29,6 +29,9 @@ func Tx(ctx *rpctypes.Context, hash []byte) (*ctypes.ResultTx, error) { // Load the block block := blockStore.LoadBlock(height) + if block == nil { + return nil, fmt.Errorf("block not found for height %d", height) + } numTxs := len(block.Txs) if int(resultIndex.TxIndex) > numTxs || numTxs == 0 { diff --git a/tm2/pkg/bft/rpc/core/tx_test.go b/tm2/pkg/bft/rpc/core/tx_test.go index 924a72f6bd5..011a94e13f7 100644 --- a/tm2/pkg/bft/rpc/core/tx_test.go +++ b/tm2/pkg/bft/rpc/core/tx_test.go @@ -220,4 +220,52 @@ func TestTxHandler(t *testing.T) { assert.ErrorContains(t, err, "unable to load block results") }) + + t.Run("nil block", func(t *testing.T) { + var ( + height = int64(10) + + stdTx = &std.Tx{ + Memo: "example tx", + } + + txResultIndex = state.TxResultIndex{ + BlockNum: height, + TxIndex: 0, + } + ) + + // Prepare the transaction + marshalledTx, err := amino.Marshal(stdTx) + require.NoError(t, err) + + tx := types.Tx(marshalledTx) + + // Prepare the DB + sdb := memdb.NewMemDB() + + // Save the result index to the DB + sdb.Set(state.CalcTxResultKey(tx.Hash()), txResultIndex.Bytes()) + + // Set the GLOBALLY referenced db + SetStateDB(sdb) + + // Set the GLOBALLY referenced blockstore that returns nil block + blockStore := &mockBlockStore{ + heightFn: func() int64 { + return height + }, + loadBlockFn: func(h int64) *types.Block { + return nil + }, + } + + SetBlockStore(blockStore) + + // Load the result + loadedTxResult, err := Tx(&rpctypes.Context{}, tx.Hash()) + require.Nil(t, loadedTxResult) + + assert.ErrorContains(t, err, "block not found for height 10") + }) } diff --git a/tm2/pkg/bft/store/store.go b/tm2/pkg/bft/store/store.go index 8e414ea8205..3242976fe70 100644 --- a/tm2/pkg/bft/store/store.go +++ b/tm2/pkg/bft/store/store.go @@ -61,6 +61,11 @@ func (bs *BlockStore) LoadBlock(height int64) *types.Block { buf := []byte{} for i := range blockMeta.BlockID.PartsHeader.Total { part := bs.LoadBlockPart(height, i) + // If the part is missing (e.g. since it has been deleted after we + // loaded the block meta) we consider the whole block to be missing. + if part == nil { + return nil + } buf = append(buf, part.Bytes...) } err := amino.UnmarshalSized(buf, block) diff --git a/tm2/pkg/bft/store/store_test.go b/tm2/pkg/bft/store/store_test.go index 7fe638b5e44..da8464f5793 100644 --- a/tm2/pkg/bft/store/store_test.go +++ b/tm2/pkg/bft/store/store_test.go @@ -425,6 +425,29 @@ func TestBlockFetchAtHeight(t *testing.T) { require.Nil(t, blockAtHeightPlus2, "expecting an unsuccessful load of Height()+2") } +func TestLoadBlockWithMissingPart(t *testing.T) { + t.Parallel() + + state, bs, cleanup := makeStateAndBlockStore(log.NewNoopLogger()) + defer cleanup() + + block := makeBlock(bs.Height()+1, state, new(types.Commit)) + partSet := block.MakePartSet(2) + seenCommit := makeTestCommit(10, tmtime.Now()) + bs.SaveBlock(block, partSet, seenCommit) + + // Delete a block part from the DB to simulate a missing part + height := block.Header.Height + require.True(t, partSet.Total() > 1, "need multiple parts for this test") + + // Delete the second part key + bs.db.Delete(calcBlockPartKey(height, 1)) + + // LoadBlock should return nil when a part is missing + loaded := bs.LoadBlock(height) + require.Nil(t, loaded, "LoadBlock should return nil when a block part is missing") +} + func doFn(fn func() (any, error)) (res any, err error, panicErr error) { defer func() { if r := recover(); r != nil { From 3032cbb620d5c38b5e932d8114603880c12ebd73 Mon Sep 17 00:00:00 2001 From: Jeff Thompson Date: Fri, 3 Apr 2026 12:11:09 +0200 Subject: [PATCH 35/92] chore(govdao): Allow GovDAO to register names without restrictions from controllers (#5293) Fixes https://github.com/gnolang/gno/issues/4414 * From the public `RegisterUser`, `UpdateName` and `Delete`, factor out private `registerUser`, `updateName` and `delete` which don't check for whitelisted controllers * Update the public `RegisterUser`, `UpdateName` and `Delete` to continue checking for whitelisted controllers, and call the private versions to do the work * Add `ProposeRegisterUser`, `ProposeUpdateName` and `ProposeDeleteUser` which call the private versions (bypassing the check for whitelisted controllers) * `store_test.gno`: Add `TestRegisterNotWhitelisted` to confirm the check in the public `RegisterUser` * `users_test.gno`: Add `TestProposeErrors` to test the error messages * Add `govdao_proposal_users_register_user.txtar` to test that `ProposeRegisterUser`, `ProposeUpdateName` and `ProposeDeleteUser` work using DAO proposals from a non-whitelisted controller This PR recreates PR https://github.com/gnolang/gno/pull/4415 . Please see that PR for previous review comments. --------- Signed-off-by: Jeff Thompson Co-authored-by: leohhhn Co-authored-by: Antoine Eddi <5222525+aeddi@users.noreply.github.com> Co-authored-by: moul <94029+moul@users.noreply.github.com> (cherry picked from commit 334c773e2708ee8c8361969a480269f4c16295b9) --- examples/gno.land/r/sys/users/admin.gno | 58 ++++++++ examples/gno.land/r/sys/users/store.gno | 61 ++++++--- examples/gno.land/r/sys/users/store_test.gno | 6 + examples/gno.land/r/sys/users/users_test.gno | 34 +++++ .../govdao_proposal_users_register_user.txtar | 125 ++++++++++++++++++ 5 files changed, 266 insertions(+), 18 deletions(-) create mode 100644 gno.land/pkg/integration/testdata/govdao_proposal_users_register_user.txtar diff --git a/examples/gno.land/r/sys/users/admin.gno b/examples/gno.land/r/sys/users/admin.gno index f0d159ee6eb..b1e55e51b6e 100644 --- a/examples/gno.land/r/sys/users/admin.gno +++ b/examples/gno.land/r/sys/users/admin.gno @@ -87,6 +87,64 @@ func ProposeControllerAdditionAndRemoval(toAdd, toRemove address) dao.ProposalRe return dao.NewProposalRequest("Add and Remove Whitelisted Callers From \"sys/users\" Realm", desc, dao.NewSimpleExecutor(cb, "")) } +// ProposeRegisterUser allows GovDAO to register a name without checking controllers +func ProposeRegisterUser(name string, addr address) dao.ProposalRequest { + // Validate the name and address now, even though registerUser will validate again + if err := validateName(name); err != nil { + panic(err.Error()) + } + if !addr.IsValid() { + panic(ErrInvalidAddress) + } + + cb := func(cur realm) error { + return registerUser(cur, name, addr) + } + + desc := "This proposal registers " + name + " with address " + addr.String() + " in `sys/users`." + return dao.NewProposalRequest("Register User to \"sys/users\" Realm", desc, dao.NewSimpleExecutor(cb, "")) +} + +// ProposeUpdateName allows GovDAO to update a name with an alias without checking controllers +func ProposeUpdateName(addr address, newName string) dao.ProposalRequest { + if !addr.IsValid() { + panic(ErrInvalidAddress) + } + if err := validateName(newName); err != nil { + panic(err.Error()) + } + + cb := func(cur realm) error { + data := ResolveAddress(addr) + if data == nil { + return ErrUserNotExistOrDeleted + } + return data.updateName(newName) + } + + desc := "This proposal updates address " + addr.String() + " with alias " + newName + " in `sys/users`." + return dao.NewProposalRequest("Update Name Alias in \"sys/users\" Realm", desc, dao.NewSimpleExecutor(cb, "")) +} + +// ProposeDeleteUser allows GovDAO to delete a user without checking controllers +func ProposeDeleteUser(addr address) dao.ProposalRequest { + if !addr.IsValid() { + panic(ErrInvalidAddress) + } + + cb := func(cur realm) error { + data := ResolveAddress(addr) + if data == nil { + return ErrUserNotExistOrDeleted + } + return data.delete() + } + + desc := "This proposal deletes the user with address " + addr.String() + " in `sys/users`." + return dao.NewProposalRequest("Delete User in \"sys/users\" Realm", desc, dao.NewSimpleExecutor(cb, "")) +} + + // Helpers func deleteFromWhitelist(addr address) error { diff --git a/examples/gno.land/r/sys/users/store.gno b/examples/gno.land/r/sys/users/store.gno index e18293ef857..89ed1e9902a 100644 --- a/examples/gno.land/r/sys/users/store.gno +++ b/examples/gno.land/r/sys/users/store.gno @@ -51,14 +51,8 @@ func (u UserData) RenderLink(linkText string) string { return ufmt.Sprintf("[%s](/u/%s)", linkText, u.username) } -// RegisterUser adds a new user to the system. -func RegisterUser(cur realm, name string, address_XXX address) error { - // At genesis (height 0), allow any caller to register users. - // After genesis, only whitelisted controllers can register. - if runtime.ChainHeight() > 0 && !controllers.Has(runtime.PreviousRealm().Address()) { - return NewErrNotWhitelisted() - } - +// registerUser adds a new user to the system without checking controllers +func registerUser(cur realm, name string, address_XXX address) error { // Validate name if err := validateName(name); err != nil { return err @@ -103,19 +97,25 @@ func RegisterUser(cur realm, name string, address_XXX address) error { return nil } -// UpdateName adds a name that is associated with a specific address +// RegisterUser adds a new user to the system. +func RegisterUser(cur realm, name string, address_XXX address) error { + // At genesis (height 0), allow any caller to register users. + // After genesis, only whitelisted controllers can register. + if runtime.ChainHeight() > 0 && !controllers.Has(runtime.PreviousRealm().Address()) { + return NewErrNotWhitelisted() + } + + return registerUser(cur, name, address_XXX) +} + +// updateName adds a name that is associated with a specific address without checking controllers // All previous names are preserved and resolvable. // The new name is the default value returned for address lookups. -func (u *UserData) UpdateName(newName string) error { - if u == nil { // either doesnt exists or was deleted +func (u *UserData) updateName(newName string) error { + if u == nil { // either doesn't exist or was deleted return ErrUserNotExistOrDeleted } - // Validate caller - if !controllers.Has(runtime.CurrentRealm().Address()) { - return NewErrNotWhitelisted() - } - // Validate name if err := validateName(newName); err != nil { return err @@ -136,8 +136,10 @@ func (u *UserData) UpdateName(newName string) error { return nil } -// Delete marks a user and all their aliases as deleted. -func (u *UserData) Delete() error { +// UpdateName adds a name that is associated with a specific address +// All previous names are preserved and resolvable. +// The new name is the default value returned for address lookups. +func (u *UserData) UpdateName(newName string) error { if u == nil { return ErrUserNotExistOrDeleted } @@ -147,12 +149,35 @@ func (u *UserData) Delete() error { return NewErrNotWhitelisted() } + return u.updateName(newName) +} + +// delete marks a user and all their aliases as deleted without checking controllers. +func (u *UserData) delete() error { + if u == nil { + return ErrUserNotExistOrDeleted + } + u.deleted = true chain.Emit(DeleteUserEvent, "address", u.addr.String()) return nil } +// Delete marks a user and all their aliases as deleted. +func (u *UserData) Delete() error { + if u == nil { + return ErrUserNotExistOrDeleted + } + + // Validate caller + if !controllers.Has(runtime.CurrentRealm().Address()) { + return NewErrNotWhitelisted() + } + + return u.delete() +} + // Validate validates username and address passed in // Most of the validation is done in the controllers // This provides more flexibility down the line diff --git a/examples/gno.land/r/sys/users/store_test.gno b/examples/gno.land/r/sys/users/store_test.gno index cb2c147b4ef..fa634c4b022 100644 --- a/examples/gno.land/r/sys/users/store_test.gno +++ b/examples/gno.land/r/sys/users/store_test.gno @@ -216,6 +216,12 @@ func TestDelete(t *testing.T) { }) } +func TestRegisterNotWhitelisted(t *testing.T) { + t.Run("register_not_whitelisted", func(t *testing.T) { + uassert.ErrorContains(t, RegisterUser(cross, alice, aliceAddr), "does not exist in whitelist") + }) +} + // cleanStore should not be needed, as vm store should be reset after each test. // Reference: https://github.com/gnolang/gno/issues/1982 func cleanStore(t *testing.T) { diff --git a/examples/gno.land/r/sys/users/users_test.gno b/examples/gno.land/r/sys/users/users_test.gno index 857a8fb20a1..91ac762b49b 100644 --- a/examples/gno.land/r/sys/users/users_test.gno +++ b/examples/gno.land/r/sys/users/users_test.gno @@ -201,6 +201,40 @@ func TestResolveAny(t *testing.T) { }) } +func TestProposeErrors(t *testing.T) { + t.Run("propose_register_user_errors", func(t *testing.T) { + urequire.PanicsWithMessage(t, ErrInvalidUsername.Error(), func() { + ProposeRegisterUser("bad name", aliceAddr) + }) + urequire.PanicsWithMessage(t, ErrInvalidAddress.Error(), func() { + ProposeRegisterUser(alice, "badaddress") + }) + }) + + t.Run("propose_update_name_errors", func(t *testing.T) { + cleanStore(t) + + urequire.PanicsWithMessage(t, ErrInvalidAddress.Error(), func() { + ProposeUpdateName("badaddress", "alice1") + }) + urequire.PanicsWithMessage(t, ErrInvalidUsername.Error(), func() { + ProposeUpdateName(aliceAddr, "bad name") + }) + // Note: unregistered user is not checked at proposal creation time. + // The callback handles it at execution time. + }) + + t.Run("propose_delete_user_errors", func(t *testing.T) { + cleanStore(t) + + urequire.PanicsWithMessage(t, ErrInvalidAddress.Error(), func() { + ProposeDeleteUser("badaddress") + }) + // Note: unregistered user is not checked at proposal creation time. + // The callback handles it at execution time. + }) +} + // TODO Uncomment after gnoweb /u/ page. //func TestUserRenderLink(t *testing.T) { // testing.SetOriginCaller(whitelistedCallerAddr) diff --git a/gno.land/pkg/integration/testdata/govdao_proposal_users_register_user.txtar b/gno.land/pkg/integration/testdata/govdao_proposal_users_register_user.txtar new file mode 100644 index 00000000000..d41d7f5051f --- /dev/null +++ b/gno.land/pkg/integration/testdata/govdao_proposal_users_register_user.txtar @@ -0,0 +1,125 @@ +loadpkg gno.land/r/gov/dao +loadpkg gno.land/r/gov/dao/v3/impl +loadpkg gno.land/r/gov/dao/v3/init +loadpkg gno.land/r/sys/users + +adduserfrom user1 'source bonus chronic canvas draft south burst lottery vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast' 1 +stdout 'g18e22n23g462drp4pyszyl6e6mwxkaylthgeeq4' + +gnoland start + +# Initialize GovDAO members +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 95000000 -broadcast -chainid=tendermint_test test1 $WORK/run/init_govdao.gno +stdout OK! + +# Render GovDAO to check it's working +gnokey query vm/qrender --data 'gno.land/r/gov/dao:' +stdout 'data: # GovDAO' + +# Create proposal to register @user1 +gnokey maketx run -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test test1 $WORK/run/create_register_user_proposal.gno +stdout OK! + +# Check proposal exists +gnokey query vm/qrender --data 'gno.land/r/gov/dao:0' +stdout 'Register User to \"sys/users\" Realm' + +# Vote on proposal +gnokey maketx call -pkgpath gno.land/r/gov/dao -func MustVoteOnProposalSimple -gas-fee 1000000ugnot -gas-wanted 10000000 -args 0 -args YES -broadcast -chainid=tendermint_test test1 +stdout OK! + +# Execute proposal +gnokey maketx call -pkgpath gno.land/r/gov/dao -func ExecuteProposal -gas-fee 1000000ugnot -gas-wanted 20000000 -args 0 -broadcast -chainid=tendermint_test test1 +stdout OK! + +# Check user is registered +gnokey query vm/qeval --data "gno.land/r/sys/users.ResolveName(\"user1\")" +stdout 'g18e22n23g462drp4pyszyl6e6mwxkaylthgeeq4' + +# Create proposal for name alias @user1alias +gnokey maketx run -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test test1 $WORK/run/create_update_name_proposal.gno +stdout OK! + +# Check proposal exists +gnokey query vm/qrender --data 'gno.land/r/gov/dao:1' +stdout 'Update Name Alias in \"sys/users\" Realm' + +# Vote on proposal +gnokey maketx call -pkgpath gno.land/r/gov/dao -func MustVoteOnProposalSimple -gas-fee 1000000ugnot -gas-wanted 10000000 -args 1 -args YES -broadcast -chainid=tendermint_test test1 +stdout OK! + +# Execute proposal +gnokey maketx call -pkgpath gno.land/r/gov/dao -func ExecuteProposal -gas-fee 1000000ugnot -gas-wanted 20000000 -args 1 -broadcast -chainid=tendermint_test test1 +stdout OK! + +# Check alias is registered +gnokey query vm/qeval --data "gno.land/r/sys/users.ResolveName(\"user1alias\")" +stdout 'g18e22n23g462drp4pyszyl6e6mwxkaylthgeeq4' + +# Create proposal for to delete @user1 +gnokey maketx run -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test test1 $WORK/run/create_delete_user_proposal.gno +stdout OK! + +# Check proposal exists +gnokey query vm/qrender --data 'gno.land/r/gov/dao:2' +stdout 'Delete User in \"sys/users\" Realm' + +# Vote on proposal +gnokey maketx call -pkgpath gno.land/r/gov/dao -func MustVoteOnProposalSimple -gas-fee 1000000ugnot -gas-wanted 10000000 -args 2 -args YES -broadcast -chainid=tendermint_test test1 +stdout OK! + +# Execute proposal +gnokey maketx call -pkgpath gno.land/r/gov/dao -func ExecuteProposal -gas-fee 1000000ugnot -gas-wanted 20000000 -args 2 -broadcast -chainid=tendermint_test test1 +stdout OK! + +# Check user is deleted +gnokey query vm/qeval --data "gno.land/r/sys/users.ResolveName(\"user1\")" +stdout '(nil \*gno\.land/r/sys/users\.UserData)' + +-- run/init_govdao.gno -- +package main + +import dao "gno.land/r/gov/dao/v3/init" + +func main() { + dao.InitWithUsers("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") +} + +-- run/create_register_user_proposal.gno -- +package main + +import ( + "gno.land/r/sys/users" + "gno.land/r/gov/dao" +) + +func main() { + r := users.ProposeRegisterUser("user1", "g18e22n23g462drp4pyszyl6e6mwxkaylthgeeq4") + dao.MustCreateProposal(cross, r) +} + +-- run/create_update_name_proposal.gno -- +package main + +import ( + "gno.land/r/sys/users" + "gno.land/r/gov/dao" +) + +func main() { + r := users.ProposeUpdateName("g18e22n23g462drp4pyszyl6e6mwxkaylthgeeq4", "user1alias") + dao.MustCreateProposal(cross, r) +} + +-- run/create_delete_user_proposal.gno -- +package main + +import ( + "gno.land/r/sys/users" + "gno.land/r/gov/dao" +) + +func main() { + r := users.ProposeDeleteUser("g18e22n23g462drp4pyszyl6e6mwxkaylthgeeq4") + dao.MustCreateProposal(cross, r) +} From d41d8eaf86d2338fbd40eae0822eca025f52c446 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:43:23 +0200 Subject: [PATCH 36/92] fix(gnovm): reject `chan` type at preprocess/runtime (#5238) fix #5233 `make(chan T)` previously passed deployment but panicked with 'not yet implemented' at runtime, leaving a implicit panic on-chain. Reject it during preprocessing instead, producing a clean deployment error. Breaking change: Package with `chan` type should be deactivated to avoid further issue before upgrade Can be mitigated by: https://github.com/gnolang/gno/pull/5384 --------- Co-authored-by: ltzmaxwell Co-authored-by: Morgan Bazalgette (cherry picked from commit 4bcd9828e8f5ce86ad1a357d28766ddb14ca2f58) --- .../integration/testdata/addpkg_private.txtar | 1 - .../integration/testdata/addpkg_public.txtar | 1 - gnovm/pkg/benchops/gno/opcodes/opcode.gno | 22 +++++++------- gnovm/pkg/gnolang/go2gno.go | 25 +++++++--------- gnovm/pkg/gnolang/machine.go | 12 +++----- gnovm/pkg/gnolang/misc.go | 2 +- gnovm/pkg/gnolang/op_eval.go | 4 --- gnovm/pkg/gnolang/op_types.go | 13 -------- gnovm/pkg/gnolang/op_unary.go | 4 --- gnovm/pkg/gnolang/preprocess.go | 30 +------------------ gnovm/pkg/gnolang/realm.go | 10 +------ gnovm/pkg/gnolang/type_check.go | 4 +-- gnovm/pkg/gnolang/uverse.go | 7 ----- gnovm/pkg/gnolang/values.go | 4 +-- .../unnamedtype7_filetest.gno | 10 +++---- gnovm/tests/files/chan_make0.gno | 11 +++++++ gnovm/tests/files/chan_select0.gno | 9 ++++++ gnovm/tests/files/chan_type0.gno | 10 +++++++ gnovm/tests/files/chan_type1.gno | 10 +++++++ gnovm/tests/files/chan_type2.gno | 12 ++++++++ gnovm/tests/files/chan_type3.gno | 12 ++++++++ gnovm/tests/files/extern/net/http/http.gno | 4 --- gnovm/tests/files/recurse0.gno | 8 ++--- 23 files changed, 104 insertions(+), 121 deletions(-) create mode 100644 gnovm/tests/files/chan_make0.gno create mode 100644 gnovm/tests/files/chan_select0.gno create mode 100644 gnovm/tests/files/chan_type0.gno create mode 100644 gnovm/tests/files/chan_type1.gno create mode 100644 gnovm/tests/files/chan_type2.gno create mode 100644 gnovm/tests/files/chan_type3.gno diff --git a/gno.land/pkg/integration/testdata/addpkg_private.txtar b/gno.land/pkg/integration/testdata/addpkg_private.txtar index c34607c877e..44f7e4c3015 100644 --- a/gno.land/pkg/integration/testdata/addpkg_private.txtar +++ b/gno.land/pkg/integration/testdata/addpkg_private.txtar @@ -531,7 +531,6 @@ var savedInterfaceMap map[string]interface{} var savedInterface interface{} var savedArray [3]*avl.Tree var savedArray2 [3]interface{} -var savedChannel chan *avl.Tree type PublicInterface interface { DoSomething() interface{} diff --git a/gno.land/pkg/integration/testdata/addpkg_public.txtar b/gno.land/pkg/integration/testdata/addpkg_public.txtar index 1f779a79cda..69ed449c106 100644 --- a/gno.land/pkg/integration/testdata/addpkg_public.txtar +++ b/gno.land/pkg/integration/testdata/addpkg_public.txtar @@ -213,7 +213,6 @@ var savedSlice []string var savedMap map[string]int var savedInterface interface{} var savedArray [3]*avl.Tree -var savedChannel chan *avl.Tree type PublicStruct struct { Name string diff --git a/gnovm/pkg/benchops/gno/opcodes/opcode.gno b/gnovm/pkg/benchops/gno/opcodes/opcode.gno index ac08a2443ca..8f94b99c321 100644 --- a/gnovm/pkg/benchops/gno/opcodes/opcode.gno +++ b/gnovm/pkg/benchops/gno/opcodes/opcode.gno @@ -986,8 +986,8 @@ func OpForLoop() { } /* -OpEval, struct { a [](const-type string), b map[(const-type string)] (const-type string), c <-chan (const-type string), d func(), e interface { } }{} -OpEval, struct { a [](const-type string), b map[(const-type string)] (const-type string), c <-chan (const-type string), d func(), e interface { } } +OpEval, struct { a [](const-type string), b map[(const-type string)] (const-type string), c *(const-type string), d func(), e interface { } }{} +OpEval, struct { a [](const-type string), b map[(const-type string)] (const-type string), c *(const-type string), d func(), e interface { } } OpEval, a [](const-type string) OpEval, [](const-type string) OpEval, (const-type string) @@ -999,11 +999,11 @@ OpEval, (const-type string) OpEval, (const-type string) OpMapType, (typeval{string} type{}) OpFieldType, b map[(const-type string)] (const-type string) -OpEval, c <-chan (const-type string) -OpEval, <-chan (const-type string) +OpEval, c *(const-type string) +OpEval, *(const-type string) OpEval, (const-type string) -OpChanType, <-chan (const-type string) -OpFieldType, c <-chan (const-type string) +OpStarType, *(const-type string) +OpFieldType, c *(const-type string) OpEval, d func() OpEval, func() OpFuncType, func() @@ -1012,10 +1012,10 @@ OpEval, e interface { } OpEval, interface { } OpInterfaceType, interface { } OpFieldType, e interface { } -OpStructType, struct { a [](const-type string), b map[(const-type string)] (const-type string), c <-chan (const-type string), d func(), e interface { } } -OpCompositeLit, struct { a [](const-type string), b map[(const-type string)] (const-type string), c <-chan (const-type string), d func(), e interface { } }{} -OpStructLit, struct { a [](const-type string), b map[(const-type string)] (const-type string), c <-chan (const-type string), d func(), e interface { } }{} -OpDefine, t := struct { a [](const-type string), b map[(const-type string)] (const-type string), c <-chan (const-type string), d func(), e interface +OpStructType, struct { a [](const-type string), b map[(const-type string)] (const-type string), c *(const-type string), d func(), e interface { } } +OpCompositeLit, struct { a [](const-type string), b map[(const-type string)] (const-type string), c *(const-type string), d func(), e interface { } }{} +OpStructLit, struct { a [](const-type string), b map[(const-type string)] (const-type string), c *(const-type string), d func(), e interface { } }{} +OpDefine, t := struct { a [](const-type string), b map[(const-type string)] (const-type string), c *(const-type string), d func(), e interface OpExec, bodyStmt[0/0/1]=(end) OpExec, return OpReturnFromBlock, [FRAME FUNC:OpTypes RECV:(undefined) (0 args) 1/0/0/0/1 LASTPKG:main LASTRLM:Realm(nil)] @@ -1025,7 +1025,7 @@ func OpTypes() { t := struct { a []string b map[string]string - c chan string + c *string d func() e any }{} diff --git a/gnovm/pkg/gnolang/go2gno.go b/gnovm/pkg/gnolang/go2gno.go index 2117135ca7d..1d8148128b5 100644 --- a/gnovm/pkg/gnolang/go2gno.go +++ b/gnovm/pkg/gnolang/go2gno.go @@ -312,11 +312,14 @@ func Go2Gno(fs *token.FileSet, gon ast.Node) (n Node) { Type: toExpr(fs, gon.Type), } case *ast.UnaryExpr: - if gon.Op == token.AND { + switch gon.Op { + case token.AND: return &RefExpr{ X: toExpr(fs, gon.X), } - } else { + case token.ARROW: + panicWithPos("channel receive is not permitted") + default: return &UnaryExpr{ X: toExpr(fs, gon.X), Op: toWord(gon.Op), @@ -386,18 +389,6 @@ func Go2Gno(fs *token.FileSet, gon ast.Node) (n Node) { return &InterfaceTypeExpr{ Methods: toFieldsFromList(fs, gon.Methods), } - case *ast.ChanType: - var dir ChanDir - if gon.Dir&ast.SEND > 0 { - dir |= SEND - } - if gon.Dir&ast.RECV > 0 { - dir |= RECV - } - return &ChanTypeExpr{ - Dir: dir, - Value: toExpr(fs, gon.Value), - } case *ast.FuncType: return &FuncTypeExpr{ Params: toFieldsFromList(fs, gon.Params), @@ -578,8 +569,14 @@ func Go2Gno(fs *token.FileSet, gon ast.Node) (n Node) { panicWithPos("invalid operation: more than one index") } panicWithPos("invalid operation: indexList is not permitted in Gno") + case *ast.ChanType: + panicWithPos("channels are not permitted") case *ast.GoStmt: panicWithPos("goroutines are not permitted") + case *ast.SendStmt: + panicWithPos("send statements are not permitted") + case *ast.SelectStmt: + panicWithPos("select statements are not permitted") default: panicWithPos("unknown Go type %v: %s\n", reflect.TypeOf(gon), diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index bfc85264b40..5c751b3071b 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -1361,11 +1361,9 @@ func (m *Machine) Run(st Stage) { m.incrCPU(OpCPUCallDeferNativeBody) m.doOpCallDeferNativeBody() case OpGo: - m.incrCPU(OpCPUGo) - panic("not yet implemented") + panic("goroutines are not yet supported") case OpSelect: - m.incrCPU(OpCPUSelect) - panic("not yet implemented") + panic("select is not yet supported") case OpSwitchClause: m.incrCPU(OpCPUSwitchClause) m.doOpSwitchClause() @@ -1404,8 +1402,7 @@ func (m *Machine) Run(st Stage) { m.incrCPU(OpCPUUxor) m.doOpUxor() case OpUrecv: - m.incrCPU(OpCPUUrecv) - m.doOpUrecv() + panic("channel type is not yet supported") /* Binary operators */ case OpLor: m.incrCPU(OpCPULor) @@ -1533,8 +1530,7 @@ func (m *Machine) Run(st Stage) { m.incrCPU(OpCPUSliceType) m.doOpSliceType() case OpChanType: - m.incrCPU(OpCPUChanType) - m.doOpChanType() + panic("channel type is not yet supported") case OpFuncType: m.incrCPU(OpCPUFuncType) m.doOpFuncType() diff --git a/gnovm/pkg/gnolang/misc.go b/gnovm/pkg/gnolang/misc.go index 67386b2ba58..169e8debfdd 100644 --- a/gnovm/pkg/gnolang/misc.go +++ b/gnovm/pkg/gnolang/misc.go @@ -82,7 +82,7 @@ func word2UnaryOp(w Word) Op { case BAND: panic("unexpected unary operation & - use RefExpr instead") case ARROW: - return OpUrecv + panic("channel type is not yet supported") default: panic("unexpected unary operation") } diff --git a/gnovm/pkg/gnolang/op_eval.go b/gnovm/pkg/gnolang/op_eval.go index 4f8fe8e64c0..49700bd3269 100644 --- a/gnovm/pkg/gnolang/op_eval.go +++ b/gnovm/pkg/gnolang/op_eval.go @@ -400,10 +400,6 @@ func (m *Machine) doOpEval() { // evaluate x m.PushExpr(x.X) m.PushOp(OpEval) - case *ChanTypeExpr: - m.PushOp(OpChanType) - m.PushExpr(x.Value) - m.PushOp(OpEval) // OpEvalType? default: panic(fmt.Sprintf("unexpected expression %#v", x)) } diff --git a/gnovm/pkg/gnolang/op_types.go b/gnovm/pkg/gnolang/op_types.go index 0a26365667d..3e2c03a7c8d 100644 --- a/gnovm/pkg/gnolang/op_types.go +++ b/gnovm/pkg/gnolang/op_types.go @@ -152,19 +152,6 @@ func (m *Machine) doOpInterfaceType() { }) } -func (m *Machine) doOpChanType() { - x := m.PopExpr().(*ChanTypeExpr) - tv := m.PeekValue(1) // re-use as result. - ct := &ChanType{ - Dir: x.Dir, - Elt: tv.GetType(), - } - *tv = TypedValue{ - T: gTypeType, - V: toTypeValue(ct), - } -} - // Evaluate the type of a typed (i.e. not untyped) value. // This function expects const expressions to have been // already swapped for *ConstExpr in the preprocessor. If not, panics. diff --git a/gnovm/pkg/gnolang/op_unary.go b/gnovm/pkg/gnolang/op_unary.go index b597d0dd58e..86e595ffb10 100644 --- a/gnovm/pkg/gnolang/op_unary.go +++ b/gnovm/pkg/gnolang/op_unary.go @@ -120,7 +120,3 @@ func (m *Machine) doOpUxor() { baseOf(xv.T))) } } - -func (m *Machine) doOpUrecv() { - panic("not yet implemented") -} diff --git a/gnovm/pkg/gnolang/preprocess.go b/gnovm/pkg/gnolang/preprocess.go index b715fec33f2..d6381c1b09c 100644 --- a/gnovm/pkg/gnolang/preprocess.go +++ b/gnovm/pkg/gnolang/preprocess.go @@ -313,8 +313,6 @@ func initStaticBlocks1(store Store, ctx BlockNode, nn Node) { nx.Name += ".loopvar" replaceAllLoopvar(last, n, ln) } - case *SendStmt: - panic("not yet implemented") } case *RangeStmt: if n.Op != DEFINE { @@ -962,10 +960,6 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { n.Type.Results[i].Path = n.GetPathForName(nil, name) } - // TRANS_BLOCK ----------------------- - case *SelectCaseStmt: - pushInitBlock(n, &last, &stack) - // TRANS_BLOCK ----------------------- case *SwitchStmt: // create faux block to store .Init/.Varname. @@ -1563,7 +1557,7 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { // check legal type for nil if arg0.IsUndefined() { switch ct.Kind() { // special case for nil conversion check. - case SliceKind, PointerKind, FuncKind, MapKind, InterfaceKind, ChanKind: + case SliceKind, PointerKind, FuncKind, MapKind, InterfaceKind: convertConst(store, last, n, arg0, ct) default: panic(fmt.Sprintf( @@ -2469,10 +2463,6 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { case *InterfaceTypeExpr: evalStaticType(store, last, n) - // TRANS_LEAVE ----------------------- - case *ChanTypeExpr: - evalStaticType(store, last, n) - // TRANS_LEAVE ----------------------- case *FuncTypeExpr: evalStaticType(store, last, n) @@ -2766,18 +2756,6 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { } } - // TRANS_LEAVE ----------------------- - case *SendStmt: - // Value consts become default *ConstExprs. - checkOrConvertType(store, last, n, &n.Value, nil) - - // TRANS_LEAVE ----------------------- - case *SelectCaseStmt: - // maybe receive defines. - // if as, ok := n.Comm.(*AssignStmt); ok { - // handled by case *AssignStmt. - // } - // TRANS_LEAVE ----------------------- case *SwitchStmt: // Ensure type switch cases are unique. @@ -2866,8 +2844,6 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { *dstT = *(tmp.(*SliceType)) case *InterfaceType: *dstT = *(tmp.(*InterfaceType)) - case *ChanType: - *dstT = *(tmp.(*ChanType)) case *MapType: *dstT = *(tmp.(*MapType)) case *StructType: @@ -4746,8 +4722,6 @@ func findUndefinedAny(store Store, last BlockNode, x Expr, stack []Name, definin return } } - case *ChanTypeExpr: - return findUndefinedT(store, last, cx.Value, stack, defining, isalias, astype && isalias) case *FuncTypeExpr: for i := range cx.Params { un, directR = findUndefinedT(store, last, &cx.Params[i], stack, defining, isalias, astype && isalias) @@ -5094,8 +5068,6 @@ func tryPredefine(store Store, pkg *PackageNode, last BlockNode, d Decl, stack [ t = &SliceType{} case *InterfaceTypeExpr: t = &InterfaceType{} - case *ChanTypeExpr: - t = &ChanType{} case *MapTypeExpr: t = &MapType{} case *StructTypeExpr: diff --git a/gnovm/pkg/gnolang/realm.go b/gnovm/pkg/gnolang/realm.go index d5e1cf49082..c87761bef4c 100644 --- a/gnovm/pkg/gnolang/realm.go +++ b/gnovm/pkg/gnolang/realm.go @@ -1006,7 +1006,7 @@ func (rlm *Realm) assertTypeIsPublic(store Store, t Type, visited map[TypeID]str } case FieldType: rlm.assertTypeIsPublic(store, tt.Type, visited) - case *SliceType, *ArrayType, *ChanType, *PointerType: + case *SliceType, *ArrayType, *PointerType: rlm.assertTypeIsPublic(store, tt.Elem(), visited) case *tupleType: for _, et := range tt.Elts { @@ -1287,11 +1287,6 @@ func copyTypeWithRefs(typ Type) Type { return dt case *PackageType: return &PackageType{} - case *ChanType: - return &ChanType{ - Dir: ct.Dir, - Elt: refOrCopyType(ct.Elt), - } case blockType: return blockType{} case *tupleType: @@ -1550,9 +1545,6 @@ func fillType(store Store, typ Type) Type { } case *PackageType: return ct // nothing to do - case *ChanType: - ct.Elt = fillType(store, ct.Elt) - return ct case blockType: return ct // nothing to do case *tupleType: diff --git a/gnovm/pkg/gnolang/type_check.go b/gnovm/pkg/gnolang/type_check.go index 764a39b009c..8168ab9a8ae 100644 --- a/gnovm/pkg/gnolang/type_check.go +++ b/gnovm/pkg/gnolang/type_check.go @@ -168,7 +168,7 @@ func isIntegerKind(k Kind) bool { func mayBeNil(t Type) bool { switch baseOf(t).(type) { - case *SliceType, *FuncType, *MapType, *InterfaceType, *PointerType, *ChanType: // we don't have unsafePointer + case *SliceType, *FuncType, *MapType, *InterfaceType, *PointerType: // we don't have unsafePointer return true default: return false @@ -611,7 +611,7 @@ func checkAssignableTo(n Node, xt, dt Type) (err error) { panic("should not happen") case *DeclaredType: panic("should not happen") - case *FuncType, *StructType, *PackageType, *ChanType, *TypeType: + case *FuncType, *StructType, *PackageType, *TypeType: if xt.TypeID() == cdt.TypeID() { return nil // ok } diff --git a/gnovm/pkg/gnolang/uverse.go b/gnovm/pkg/gnolang/uverse.go index f4eee93700c..be2634fc0ec 100644 --- a/gnovm/pkg/gnolang/uverse.go +++ b/gnovm/pkg/gnolang/uverse.go @@ -871,13 +871,6 @@ func makeUverseNode() { default: panic("make() of map type takes 1 or 2 arguments") } - case *ChanType: - switch vargsl { - case 0, 1: - panic("not yet implemented") - default: - panic("make() of chan type takes 1 or 2 arguments") - } default: panic(fmt.Sprintf( "cannot make type %s kind %v", diff --git a/gnovm/pkg/gnolang/values.go b/gnovm/pkg/gnolang/values.go index 75014e003bd..cc24cdb2e76 100644 --- a/gnovm/pkg/gnolang/values.go +++ b/gnovm/pkg/gnolang/values.go @@ -999,7 +999,7 @@ func (tv *TypedValue) IsTypedNil() bool { } if tv.T != nil { switch tv.T.Kind() { - case SliceKind, FuncKind, MapKind, InterfaceKind, PointerKind, ChanKind: + case SliceKind, FuncKind, MapKind, InterfaceKind, PointerKind: return true } } @@ -1664,7 +1664,7 @@ func (tv *TypedValue) ComputeMapKey(store Store, omitType bool) (key MapKey, isN } bz = append(bz, '}') case *ChanType: - panic("runtime error: not yet implemented") + panic("channel type is not yet supported") default: panic(fmt.Sprintf( "unexpected map key type %s", diff --git a/gnovm/tests/files/assign_unnamed_type/unnamedtype7_filetest.gno b/gnovm/tests/files/assign_unnamed_type/unnamedtype7_filetest.gno index 939428bd192..dc80934802a 100644 --- a/gnovm/tests/files/assign_unnamed_type/unnamedtype7_filetest.gno +++ b/gnovm/tests/files/assign_unnamed_type/unnamedtype7_filetest.gno @@ -1,15 +1,15 @@ package main -type mychan chan int +type myptr *int -// chan int is unmamed +// *int is unnamed func main() { - var n mychan = nil - var u chan int = nil + var n myptr = nil + var u *int = nil n = u println(n) } // Output: -// (nil main.mychan) +// (nil main.myptr) diff --git a/gnovm/tests/files/chan_make0.gno b/gnovm/tests/files/chan_make0.gno new file mode 100644 index 00000000000..4f0bbbb9d60 --- /dev/null +++ b/gnovm/tests/files/chan_make0.gno @@ -0,0 +1,11 @@ +// https://github.com/gnolang/gno/issues/5233 +// make(chan T) is rejected because channel type is not yet supported. +package main + +func main() { + ch := make(chan int) + _ = ch +} + +// Error: +// chan_make0.gno:6:13: channels are not permitted diff --git a/gnovm/tests/files/chan_select0.gno b/gnovm/tests/files/chan_select0.gno new file mode 100644 index 00000000000..3c718f90dcd --- /dev/null +++ b/gnovm/tests/files/chan_select0.gno @@ -0,0 +1,9 @@ +// select statement is rejected at parse time. +package main + +func main() { + select {} +} + +// Error: +// chan_select0.gno:5:2: select statements are not permitted diff --git a/gnovm/tests/files/chan_type0.gno b/gnovm/tests/files/chan_type0.gno new file mode 100644 index 00000000000..f2b41111bc9 --- /dev/null +++ b/gnovm/tests/files/chan_type0.gno @@ -0,0 +1,10 @@ +// channel type in variable declaration is rejected at parse time. +package main + +func main() { + var ch chan int + _ = ch +} + +// Error: +// chan_type0.gno:5:9: channels are not permitted diff --git a/gnovm/tests/files/chan_type1.gno b/gnovm/tests/files/chan_type1.gno new file mode 100644 index 00000000000..ff70a16cd42 --- /dev/null +++ b/gnovm/tests/files/chan_type1.gno @@ -0,0 +1,10 @@ +// named channel type declaration is rejected at parse time. +package main + +type C chan int + +func main() { +} + +// Error: +// chan_type1.gno:4:8: channels are not permitted diff --git a/gnovm/tests/files/chan_type2.gno b/gnovm/tests/files/chan_type2.gno new file mode 100644 index 00000000000..0a9fe15c156 --- /dev/null +++ b/gnovm/tests/files/chan_type2.gno @@ -0,0 +1,12 @@ +// channel type in function signature is rejected at parse time. +package main + +func foo(ch chan int) { +} + +func main() { + foo(nil) +} + +// Error: +// chan_type2.gno:4:13: channels are not permitted diff --git a/gnovm/tests/files/chan_type3.gno b/gnovm/tests/files/chan_type3.gno new file mode 100644 index 00000000000..a5cc41a2865 --- /dev/null +++ b/gnovm/tests/files/chan_type3.gno @@ -0,0 +1,12 @@ +// channel type in struct field is rejected at parse time. +package main + +type S struct { + ch chan int +} + +func main() { +} + +// Error: +// chan_type3.gno:5:5: channels are not permitted diff --git a/gnovm/tests/files/extern/net/http/http.gno b/gnovm/tests/files/extern/net/http/http.gno index 7dec7c90923..e9d1b5f4d0b 100644 --- a/gnovm/tests/files/extern/net/http/http.gno +++ b/gnovm/tests/files/extern/net/http/http.gno @@ -117,10 +117,6 @@ type ResponseWriter interface { WriteHeader(statusCode int) } -type CloseNotifier interface { - CloseNotify() <-chan bool -} - // XXX dummy type Server struct { // Addr optionally specifies the TCP address for the server to listen on, diff --git a/gnovm/tests/files/recurse0.gno b/gnovm/tests/files/recurse0.gno index a6786bbaaed..88b97073ea5 100644 --- a/gnovm/tests/files/recurse0.gno +++ b/gnovm/tests/files/recurse0.gno @@ -5,8 +5,6 @@ type T struct { b []*T c map[string]T d map[string]*T - e chan T - f chan *T h *T i func(T) T j func(*T) *T @@ -18,8 +16,6 @@ type U struct { l []*T m map[string]T n map[string]*T - o chan T - p chan *T q *T r func(T) T s func(*T) *T @@ -33,5 +29,5 @@ func main() { } // Output: -// (struct{(nil []main.T),(nil []*main.T),(nil map[string]main.T),(nil map[string]*main.T),(nil chan main.T),(nil chan *main.T),(nil *main.T),(nil func(main.T) main.T),(nil func(*main.T) *main.T),(struct{(nil []main.T),(nil []*main.T),(nil map[string]main.T),(nil map[string]*main.T),(nil chan main.T),(nil chan *main.T),(nil *main.T),(nil func(main.T) main.T),(nil func(*main.T) *main.T)} main.U)} main.T) -// (struct{(nil []main.T),(nil []*main.T),(nil map[string]main.T),(nil map[string]*main.T),(nil chan main.T),(nil chan *main.T),(nil *main.T),(nil func(main.T) main.T),(nil func(*main.T) *main.T)} main.U) +// (struct{(nil []main.T),(nil []*main.T),(nil map[string]main.T),(nil map[string]*main.T),(nil *main.T),(nil func(main.T) main.T),(nil func(*main.T) *main.T),(struct{(nil []main.T),(nil []*main.T),(nil map[string]main.T),(nil map[string]*main.T),(nil *main.T),(nil func(main.T) main.T),(nil func(*main.T) *main.T)} main.U)} main.T) +// (struct{(nil []main.T),(nil []*main.T),(nil map[string]main.T),(nil map[string]*main.T),(nil *main.T),(nil func(main.T) main.T),(nil func(*main.T) *main.T)} main.U) From 558d5fedee57aaab3e2bc95112ea0d2be4c222c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jer=C3=B3nimo=20Albi?= <894299+jeronimoalbi@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:14:20 +0200 Subject: [PATCH 37/92] chore(boards2): add original thread ID to `hub` realm thread reposts (#5398) Adds the ID of the original thread to reposts to Boards2 `hub` realm (cherry picked from commit fcb732bda92f5535801efb453ed3f1856c9619dd) --- .../v1/hub/filetests/z_1_a_filetest.gno | 2 ++ .../v1/hub/filetests/z_1_b_filetest.gno | 4 ++- .../v1/hub/filetests/z_1_c_filetest.gno | 11 +++++-- .../r/gnoland/boards2/v1/hub/thread.gno | 32 +++++++++++-------- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_a_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_a_filetest.gno index 1a6fd28ac16..d9913e8d998 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_a_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_a_filetest.gno @@ -30,6 +30,7 @@ func main() { println(thread.ID) println(thread.OriginalBoardID) + println(thread.OriginalThreadID) println(thread.BoardID) println(thread.Title) println(thread.Body) @@ -46,6 +47,7 @@ func main() { // Output: // 1 // 0 +// 0 // 1 // Title // Body diff --git a/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_b_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_b_filetest.gno index 39bc9b335ba..9620d1371ec 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_b_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_b_filetest.gno @@ -45,7 +45,8 @@ func main() { } println(thread.ID) - println(thread.OriginalBoardID) // Only reposts have an original board ID + println(thread.OriginalBoardID) // Only reposts have an original board ID + println(thread.OriginalThreadID) // Only reposts have an original thread ID println(thread.BoardID) println(thread.Title) println(thread.Body) @@ -62,6 +63,7 @@ func main() { // Output: // 1 // 0 +// 0 // 1 // Foo // Bar diff --git a/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_c_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_c_filetest.gno index 266a79bfeca..c27cf5a7709 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_c_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/hub/filetests/z_1_c_filetest.gno @@ -18,7 +18,12 @@ var ( func init() { testing.SetRealm(testing.NewUserRealm("g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh")) srcBoardID := boards2.CreateBoard(cross, "origin123", false, false) - srcThreadID := boards2.CreateThread(cross, srcBoardID, "Title", "Body") + + // Create two threads where the second is the one to repost + boards2.CreateThread(cross, srcBoardID, "Title1", "Body1") + srcThreadID := boards2.CreateThread(cross, srcBoardID, "Title2", "Body2") // ID = 2 + + // Create repost boardID = boards2.CreateBoard(cross, "test123", false, false) threadID = boards2.CreateRepost(cross, srcBoardID, srcThreadID, boardID, "Title", "Body") @@ -44,13 +49,14 @@ func main() { println(thread.ID) println(thread.OriginalBoardID) + println(thread.OriginalThreadID) println(thread.BoardID) println(thread.Title) println(thread.Body) println(thread.Hidden) println(thread.Readonly) println(thread.CommentCount) - println(thread.RepostCount) // Reposts can't be reposted + println(thread.RepostCount) // Reposts can't be reposted, so count must be 0 println(thread.FlagCount) println(thread.Creator) println(thread.CreatedAt) @@ -61,6 +67,7 @@ func main() { // 1 // 1 // 2 +// 2 // Foo // Bar // true diff --git a/examples/gno.land/r/gnoland/boards2/v1/hub/thread.gno b/examples/gno.land/r/gnoland/boards2/v1/hub/thread.gno index 720289445a2..7a7949d70a8 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/hub/thread.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/hub/thread.gno @@ -23,6 +23,9 @@ type Thread struct { // OriginalBoardID contains the board ID of the original thread when current is a repost. OriginalBoardID uint64 + // OriginalThreadID contains the ID of the original thread when current is a repost. + OriginalThreadID uint64 + // BoardID is the board ID where thread is created. BoardID uint64 @@ -87,19 +90,20 @@ func NewSafeThread(ref *boards.Post) Thread { } return Thread{ - ref: ref, - ID: uint64(ref.ID), - OriginalBoardID: uint64(ref.OriginalBoardID), - BoardID: uint64(ref.Board.ID), - Title: ref.Title, - Body: ref.Body, - Hidden: ref.Hidden, - Readonly: ref.Readonly, - CommentCount: ref.Replies.Size(), - RepostCount: ref.Reposts.Size(), - FlagCount: ref.Flags.Size(), - Creator: ref.Creator, - CreatedAt: timeToUnix(ref.CreatedAt), - UpdatedAt: timeToUnix(ref.UpdatedAt), + ref: ref, + ID: uint64(ref.ID), + OriginalBoardID: uint64(ref.OriginalBoardID), + OriginalThreadID: uint64(ref.ParentID), + BoardID: uint64(ref.Board.ID), + Title: ref.Title, + Body: ref.Body, + Hidden: ref.Hidden, + Readonly: ref.Readonly, + CommentCount: ref.Replies.Size(), + RepostCount: ref.Reposts.Size(), + FlagCount: ref.Flags.Size(), + Creator: ref.Creator, + CreatedAt: timeToUnix(ref.CreatedAt), + UpdatedAt: timeToUnix(ref.UpdatedAt), } } From 74585ea6e2e0d1aeba44af99e81c4756b9f02cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jer=C3=B3nimo=20Albi?= <894299+jeronimoalbi@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:19:39 +0200 Subject: [PATCH 38/92] feat(boards2): change permissions to use less storage (#5346) Closes #5345 (cherry picked from commit d5ce782e3ef5910add03ad28c3f54755afa4ba0d) --- .../gnoland/boards/exts/permissions/README.md | 2 +- .../filetests/z_readme_filetest.gno | 2 +- .../boards/exts/permissions/permissions.gno | 27 ++-- .../exts/permissions/permissions_test.gno | 115 ++++++++-------- .../p/gnoland/boards/permission_set.gno | 54 ++++++++ .../p/gnoland/boards/permission_set_test.gno | 124 ++++++++++++++++++ .../gno.land/p/gnoland/boards/permissions.gno | 13 +- .../r/gnoland/boards2/v1/permissions.gno | 61 +++++---- 8 files changed, 300 insertions(+), 98 deletions(-) create mode 100644 examples/gno.land/p/gnoland/boards/permission_set.gno create mode 100644 examples/gno.land/p/gnoland/boards/permission_set_test.gno diff --git a/examples/gno.land/p/gnoland/boards/exts/permissions/README.md b/examples/gno.land/p/gnoland/boards/exts/permissions/README.md index 0d8a8bc1d65..c7fcbd70235 100644 --- a/examples/gno.land/p/gnoland/boards/exts/permissions/README.md +++ b/examples/gno.land/p/gnoland/boards/exts/permissions/README.md @@ -28,7 +28,7 @@ const user address = "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5" const RoleExample boards.Role = "example" // Define a permission -const PermissionFoo boards.Permission = "foo" +const PermissionFoo boards.Permission = 42 func main() { // Define a custom foo permission validation function diff --git a/examples/gno.land/p/gnoland/boards/exts/permissions/filetests/z_readme_filetest.gno b/examples/gno.land/p/gnoland/boards/exts/permissions/filetests/z_readme_filetest.gno index 52cf7465e54..36dc8764f90 100644 --- a/examples/gno.land/p/gnoland/boards/exts/permissions/filetests/z_readme_filetest.gno +++ b/examples/gno.land/p/gnoland/boards/exts/permissions/filetests/z_readme_filetest.gno @@ -14,7 +14,7 @@ const user address = "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5" const RoleExample boards.Role = "example" // Define a permission -const PermissionFoo boards.Permission = "foo" +const PermissionFoo boards.Permission = 42 func main() { // Define a custom foo permission validation function diff --git a/examples/gno.land/p/gnoland/boards/exts/permissions/permissions.gno b/examples/gno.land/p/gnoland/boards/exts/permissions/permissions.gno index 88c74e2841d..75cd4fba90d 100644 --- a/examples/gno.land/p/gnoland/boards/exts/permissions/permissions.gno +++ b/examples/gno.land/p/gnoland/boards/exts/permissions/permissions.gno @@ -24,8 +24,8 @@ type ValidatorFunc func(boards.Permissions, boards.Args) error type Permissions struct { superRole boards.Role dao *commondao.CommonDAO - validators *avl.Tree // string(boards.Permission) -> BasicPermissionValidator - public *avl.Tree // string(boards.Permission) -> struct{}{} + public boards.PermissionSet + validators *avl.Tree // string(boards.Permission) -> ValidatorFunc singleUserRole bool } @@ -34,7 +34,6 @@ func New(options ...Option) *Permissions { s := storage.NewMemberStorage() ps := &Permissions{ validators: avl.NewTree(), - public: avl.NewTree(), dao: commondao.New(commondao.WithMemberStorage(s)), } @@ -52,20 +51,17 @@ func (ps Permissions) DAO() *commondao.CommonDAO { // ValidateFunc adds a custom permission validator function. // If an existing permission function exists it's ovewritten by the new one. func (ps *Permissions) ValidateFunc(p boards.Permission, fn ValidatorFunc) { - ps.validators.Set(string(p), fn) + ps.validators.Set(p.String(), fn) } // SetPublicPermissions assigns permissions that are available to anyone. // It removes previous public permissions and assigns the new ones. // By default there are no public permissions. func (ps *Permissions) SetPublicPermissions(permissions ...boards.Permission) { - ps.public = avl.NewTree() - for _, p := range permissions { - ps.public.Set(string(p), struct{}{}) - } + ps.public = boards.NewPermissionSet(permissions...) } -// AddRole add a role with one or more assigned permissions. +// AddRole adds a role with one or more assigned permissions. // If role exists its permissions are overwritten with the new ones. func (ps *Permissions) AddRole(r boards.Role, p boards.Permission, extra ...boards.Permission) { // If role is the super role it already has all permissions @@ -86,7 +82,8 @@ func (ps *Permissions) AddRole(r boards.Role, p boards.Permission, extra ...boar } // Save permissions within the member group overwritting any existing permissions - group.SetMeta(append([]boards.Permission{p}, extra...)) + permissions := append([]boards.Permission{p}, extra...) + group.SetMeta(boards.NewPermissionSet(permissions...)) } // RoleExists checks if a role exists. @@ -120,7 +117,7 @@ func (ps Permissions) HasRole(user address, r boards.Role) bool { // HasPermission checks if a user has a specific permission. func (ps Permissions) HasPermission(user address, perm boards.Permission) bool { - if ps.public.Has(string(perm)) { + if ps.public.Has(perm) { return true } @@ -142,10 +139,8 @@ func (ps Permissions) HasPermission(user address, perm boards.Permission) bool { } meta := group.GetMeta() - for _, p := range meta.([]boards.Permission) { - if p == perm { - return true - } + if perms, ok := meta.(boards.PermissionSet); ok && perms.Has(perm) { + return true } } return false @@ -248,7 +243,7 @@ func (ps *Permissions) WithPermission(user address, p boards.Permission, args bo } // Execute custom validation before calling the callback - v, found := ps.validators.Get(string(p)) + v, found := ps.validators.Get(p.String()) if found { err := v.(ValidatorFunc)(ps, args) if err != nil { diff --git a/examples/gno.land/p/gnoland/boards/exts/permissions/permissions_test.gno b/examples/gno.land/p/gnoland/boards/exts/permissions/permissions_test.gno index 53120067527..3fe019bb56c 100644 --- a/examples/gno.land/p/gnoland/boards/exts/permissions/permissions_test.gno +++ b/examples/gno.land/p/gnoland/boards/exts/permissions/permissions_test.gno @@ -8,6 +8,13 @@ import ( "gno.land/p/nt/urequire/v0" ) +// Test permission constants +const ( + testPermA boards.Permission = iota + testPermB + testPermC +) + var _ boards.Permissions = (*Permissions)(nil) func TestBasicPermissionsWithPermission(t *testing.T) { @@ -23,10 +30,10 @@ func TestBasicPermissionsWithPermission(t *testing.T) { { name: "ok", user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", - permission: "bar", + permission: testPermA, setup: func() *Permissions { perms := New() - perms.AddRole("foo", "bar") + perms.AddRole("foo", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") return perms }, @@ -35,11 +42,11 @@ func TestBasicPermissionsWithPermission(t *testing.T) { { name: "ok with arguments", user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", - permission: "bar", + permission: testPermA, args: boards.Args{"a", "b"}, setup: func() *Permissions { perms := New() - perms.AddRole("foo", "bar") + perms.AddRole("foo", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") return perms }, @@ -48,10 +55,10 @@ func TestBasicPermissionsWithPermission(t *testing.T) { { name: "no permission", user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", - permission: "bar", + permission: testPermA, setup: func() *Permissions { perms := New() - perms.AddRole("foo", "bar") + perms.AddRole("foo", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") return perms }, @@ -60,7 +67,7 @@ func TestBasicPermissionsWithPermission(t *testing.T) { { name: "is not a DAO member", user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", - permission: "bar", + permission: testPermA, setup: func() *Permissions { return New() }, @@ -96,22 +103,22 @@ func TestBasicPermissionsSetPublicPermissions(t *testing.T) { perms := New() // Add a new role with permissions - perms.AddRole("adminRole", "fooPerm", "barPerm", "bazPerm") - urequire.False(t, perms.HasPermission(user, "fooPerm")) - urequire.False(t, perms.HasPermission(user, "barPerm")) - urequire.False(t, perms.HasPermission(user, "bazPerm")) + perms.AddRole("adminRole", testPermA, testPermB, testPermC) + urequire.False(t, perms.HasPermission(user, testPermA)) + urequire.False(t, perms.HasPermission(user, testPermB)) + urequire.False(t, perms.HasPermission(user, testPermC)) // Assign a couple of public permissions - perms.SetPublicPermissions("fooPerm", "bazPerm") - urequire.True(t, perms.HasPermission(user, "fooPerm")) - urequire.False(t, perms.HasPermission(user, "barPerm")) - urequire.True(t, perms.HasPermission(user, "bazPerm")) + perms.SetPublicPermissions(testPermA, testPermC) + urequire.True(t, perms.HasPermission(user, testPermA)) + urequire.False(t, perms.HasPermission(user, testPermB)) + urequire.True(t, perms.HasPermission(user, testPermC)) // Clear all public permissions perms.SetPublicPermissions() - urequire.False(t, perms.HasPermission(user, "fooPerm")) - urequire.False(t, perms.HasPermission(user, "barPerm")) - urequire.False(t, perms.HasPermission(user, "bazPerm")) + urequire.False(t, perms.HasPermission(user, testPermA)) + urequire.False(t, perms.HasPermission(user, testPermB)) + urequire.False(t, perms.HasPermission(user, testPermC)) } func TestBasicPermissionsGetUserRoles(t *testing.T) { @@ -127,7 +134,7 @@ func TestBasicPermissionsGetUserRoles(t *testing.T) { roles: []string{"admin"}, setup: func() *Permissions { perms := New() - perms.AddRole("admin", "x") + perms.AddRole("admin", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin") return perms }, @@ -138,9 +145,9 @@ func TestBasicPermissionsGetUserRoles(t *testing.T) { roles: []string{"admin", "bar", "foo"}, setup: func() *Permissions { perms := New() - perms.AddRole("admin", "x") - perms.AddRole("foo", "x") - perms.AddRole("bar", "x") + perms.AddRole("admin", testPermA) + perms.AddRole("foo", testPermA) + perms.AddRole("bar", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin", "foo", "bar") return perms }, @@ -167,8 +174,8 @@ func TestBasicPermissionsGetUserRoles(t *testing.T) { roles: []string{"admin"}, setup: func() *Permissions { perms := New() - perms.AddRole("admin", "x") - perms.AddRole("bar", "x") + perms.AddRole("admin", testPermA) + perms.AddRole("bar", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin") perms.SetUserRoles("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", "admin") perms.SetUserRoles("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc", "admin", "bar") @@ -204,7 +211,7 @@ func TestBasicPermissionsHasRole(t *testing.T) { role: "admin", setup: func() *Permissions { perms := New() - perms.AddRole("admin", "x") + perms.AddRole("admin", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin") return perms }, @@ -216,8 +223,8 @@ func TestBasicPermissionsHasRole(t *testing.T) { role: "foo", setup: func() *Permissions { perms := New() - perms.AddRole("admin", "x") - perms.AddRole("foo", "x") + perms.AddRole("admin", testPermA) + perms.AddRole("foo", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin", "foo") return perms }, @@ -238,7 +245,7 @@ func TestBasicPermissionsHasRole(t *testing.T) { role: "bar", setup: func() *Permissions { perms := New() - perms.AddRole("foo", "x") + perms.AddRole("foo", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") return perms }, @@ -265,10 +272,10 @@ func TestBasicPermissionsHasPermission(t *testing.T) { { name: "ok", user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", - permission: "bar", + permission: testPermA, setup: func() *Permissions { perms := New() - perms.AddRole("foo", "bar") + perms.AddRole("foo", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") return perms }, @@ -277,10 +284,10 @@ func TestBasicPermissionsHasPermission(t *testing.T) { { name: "ok with multiple users", user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", - permission: "bar", + permission: testPermA, setup: func() *Permissions { perms := New() - perms.AddRole("foo", "bar") + perms.AddRole("foo", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") perms.SetUserRoles("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", "foo") return perms @@ -290,11 +297,11 @@ func TestBasicPermissionsHasPermission(t *testing.T) { { name: "ok with multiple roles", user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", - permission: "other", + permission: testPermB, setup: func() *Permissions { perms := New() - perms.AddRole("foo", "bar") - perms.AddRole("baz", "other") + perms.AddRole("foo", testPermA) + perms.AddRole("baz", testPermB) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo", "baz") return perms }, @@ -303,10 +310,10 @@ func TestBasicPermissionsHasPermission(t *testing.T) { { name: "no permission", user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", - permission: "other", + permission: testPermB, setup: func() *Permissions { perms := New() - perms.AddRole("foo", "bar") + perms.AddRole("foo", testPermA) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo") return perms }, @@ -336,7 +343,7 @@ func TestBasicPermissionsSetUserRoles(t *testing.T) { expectedRoles: []boards.Role{"a"}, setup: func() *Permissions { perms := New() - perms.AddRole("a", "permission1") + perms.AddRole("a", testPermA) return perms }, }, @@ -346,8 +353,8 @@ func TestBasicPermissionsSetUserRoles(t *testing.T) { expectedRoles: []boards.Role{"a", "b"}, setup: func() *Permissions { perms := New() - perms.AddRole("a", "permission1") - perms.AddRole("b", "permission2") + perms.AddRole("a", testPermA) + perms.AddRole("b", testPermB) return perms }, }, @@ -357,7 +364,7 @@ func TestBasicPermissionsSetUserRoles(t *testing.T) { expectedRoles: []boards.Role{"a"}, setup: func() *Permissions { perms := New() - perms.AddRole("a", "permission1") + perms.AddRole("a", testPermA) perms.SetUserRoles("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", "a") perms.SetUserRoles("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc") return perms @@ -369,7 +376,7 @@ func TestBasicPermissionsSetUserRoles(t *testing.T) { expectedRoles: []boards.Role{"a"}, setup: func() *Permissions { perms := New(UseSingleUserRole()) - perms.AddRole("a", "permission1") + perms.AddRole("a", testPermA) return perms }, }, @@ -379,9 +386,9 @@ func TestBasicPermissionsSetUserRoles(t *testing.T) { expectedRoles: []boards.Role{"a", "b"}, setup: func() *Permissions { perms := New() - perms.AddRole("a", "permission1") - perms.AddRole("b", "permission2") - perms.AddRole("c", "permission2") + perms.AddRole("a", testPermA) + perms.AddRole("b", testPermB) + perms.AddRole("c", testPermB) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "c") return perms }, @@ -392,8 +399,8 @@ func TestBasicPermissionsSetUserRoles(t *testing.T) { expectedRoles: []boards.Role{"b"}, setup: func() *Permissions { perms := New(UseSingleUserRole()) - perms.AddRole("a", "permission1") - perms.AddRole("b", "permission2") + perms.AddRole("a", testPermA) + perms.AddRole("b", testPermB) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "a") return perms }, @@ -404,8 +411,8 @@ func TestBasicPermissionsSetUserRoles(t *testing.T) { expectedRoles: []boards.Role{}, setup: func() *Permissions { perms := New() - perms.AddRole("a", "permission1") - perms.AddRole("b", "permission2") + perms.AddRole("a", testPermA) + perms.AddRole("b", testPermB) perms.SetUserRoles("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "a", "b") return perms }, @@ -416,7 +423,7 @@ func TestBasicPermissionsSetUserRoles(t *testing.T) { expectedRoles: []boards.Role{"a", "foo"}, setup: func() *Permissions { perms := New() - perms.AddRole("a", "permission1") + perms.AddRole("a", testPermA) return perms }, err: "invalid role: foo", @@ -427,8 +434,8 @@ func TestBasicPermissionsSetUserRoles(t *testing.T) { expectedRoles: []boards.Role{"a", "b"}, setup: func() *Permissions { perms := New(UseSingleUserRole()) - perms.AddRole("a", "permission1") - perms.AddRole("b", "permission2") + perms.AddRole("a", testPermA) + perms.AddRole("b", testPermB) return perms }, err: "user can only have one role", @@ -511,8 +518,8 @@ func TestBasicPermissionsIterateUsers(t *testing.T) { } perms := New() - perms.AddRole("foo", "perm1") - perms.AddRole("bar", "perm2") + perms.AddRole("foo", testPermA) + perms.AddRole("bar", testPermB) for _, u := range users { perms.SetUserRoles(u.Address, u.Roles...) } diff --git a/examples/gno.land/p/gnoland/boards/permission_set.gno b/examples/gno.land/p/gnoland/boards/permission_set.gno new file mode 100644 index 00000000000..a08e4b9e1ef --- /dev/null +++ b/examples/gno.land/p/gnoland/boards/permission_set.gno @@ -0,0 +1,54 @@ +package boards + +// PermissionSet defines a type to store any number of permissions. +type PermissionSet []uint64 + +// NewPermissionSet creates a new PermissionSet containing the given permissions. +func NewPermissionSet(perms ...Permission) PermissionSet { + if len(perms) == 0 { + return nil + } + + // Find max permission value to calculate slice size. + // This allows any number of permissions to be assigned in any order. + var max Permission + for _, p := range perms { + if p > max { + max = p + } + } + + s := make(PermissionSet, int(max)/64+1) + for _, p := range perms { + // Calculate the index within the set where the permission should be defined. + // Each item in the set can contain 64 permissions, for example: + // - Item 0: permissions 0 to 63 + // - Item 1: permissions 64 to 127 + idx := int(p) / 64 + + // Turn on the bit that matches the permission, ranging from bit 0 to 63 + s[idx] |= 1 << (uint(p) % 64) + } + return s +} + +// Has checks if a permission is in the set. +func (s PermissionSet) Has(p Permission) bool { + idx := int(p) / 64 + if idx >= len(s) { + return false + } + + // Check if the bit for the current permission is on + return s[idx]&(1<<(uint(p)%64)) != 0 +} + +// IsEmpty reports whether the set contains no permissions. +func (s PermissionSet) IsEmpty() bool { + for _, v := range s { + if v != 0 { + return false + } + } + return true +} diff --git a/examples/gno.land/p/gnoland/boards/permission_set_test.gno b/examples/gno.land/p/gnoland/boards/permission_set_test.gno new file mode 100644 index 00000000000..8b784694b45 --- /dev/null +++ b/examples/gno.land/p/gnoland/boards/permission_set_test.gno @@ -0,0 +1,124 @@ +package boards + +import ( + "testing" + + "gno.land/p/nt/uassert/v0" +) + +func TestNewPermissionSet(t *testing.T) { + cases := []struct { + name string + perms []Permission + check []Permission + want bool + }{ + { + name: "empty", + check: []Permission{0}, + want: false, + }, + { + name: "single permission", + perms: []Permission{0}, + check: []Permission{0}, + want: true, + }, + { + name: "multiple permissions", + perms: []Permission{1, 3, 5}, + check: []Permission{1, 3, 5}, + want: true, + }, + { + name: "high permission value", + perms: []Permission{100}, + check: []Permission{100}, + want: true, + }, + { + name: "missing permission", + perms: []Permission{100}, + check: []Permission{0}, + want: false, + }, + { + name: "multiple missing permissions", + perms: []Permission{1, 3, 5}, + check: []Permission{0, 2, 4}, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := NewPermissionSet(tc.perms...) + + for _, p := range tc.check { + uassert.Equal(t, tc.want, s.Has(p)) + } + }) + } +} + +func TestPermissionSetHas(t *testing.T) { + cases := []struct { + name string + set PermissionSet + check Permission + want bool + }{ + { + name: "out of range", + set: NewPermissionSet(0), + check: 100, + want: false, + }, + { + name: "nil set", + check: 0, + want: false, + }, + { + name: "permission present", + set: NewPermissionSet(5), + check: 5, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + uassert.Equal(t, tc.want, tc.set.Has(tc.check)) + }) + } +} + +func TestPermissionSetIsEmpty(t *testing.T) { + cases := []struct { + name string + set PermissionSet + want bool + }{ + { + name: "nil set", + want: true, + }, + { + name: "non-empty set", + set: NewPermissionSet(0), + want: false, + }, + { + name: "empty allocated set", + set: make(PermissionSet, 1), + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + uassert.Equal(t, tc.want, tc.set.IsEmpty()) + }) + } +} diff --git a/examples/gno.land/p/gnoland/boards/permissions.gno b/examples/gno.land/p/gnoland/boards/permissions.gno index 380e8956122..8186f65fea4 100644 --- a/examples/gno.land/p/gnoland/boards/permissions.gno +++ b/examples/gno.land/p/gnoland/boards/permissions.gno @@ -1,9 +1,8 @@ package boards -type ( - // Permission defines the type for permissions. - Permission string +import "strconv" +type ( // Role defines the type for user roles. Role string @@ -59,3 +58,11 @@ type ( IterateUsers(start, count int, fn UsersIterFn) bool } ) + +// Permission defines the type for permissions. +type Permission uint16 + +// String returns the string representation of a permission value. +func (p Permission) String() string { + return strconv.FormatUint(uint64(p), 10) +} diff --git a/examples/gno.land/r/gnoland/boards2/v1/permissions.gno b/examples/gno.land/r/gnoland/boards2/v1/permissions.gno index 89077fc1eb4..6aa3b38073a 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/permissions.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/permissions.gno @@ -5,6 +5,7 @@ import ( "gno.land/p/gnoland/boards/exts/permissions" ) +// List of Boards2 member roles. const ( RoleOwner boards.Role = "owner" RoleAdmin = "admin" @@ -12,30 +13,44 @@ const ( RoleGuest = "guest" ) +// PermissionCustom defines an initial value for custom board permissions. +// When a board defines custom permissions it must starts from a value +// greater or equal than PermissionCustom. +// +// Custom permissions definition example: +// +// const ( +// PermissionCustom1 boards.Permission = iota + boards2.PermissionCustom +// PermissionCustom2 +// PermissionCustom3 +// ) +const PermissionCustom boards.Permission = 200 + +// List of Boards2 permissions. const ( - PermissionBoardCreate boards.Permission = "board:create" - PermissionBoardFlaggingUpdate = "board:flagging-update" - PermissionBoardFreeze = "board:freeze" - PermissionBoardRename = "board:rename" - PermissionMemberInvite = "member:invite" - PermissionMemberInviteRevoke = "member:invite-remove" - PermissionMemberRemove = "member:remove" - PermissionPermissionsUpdate = "permissions:update" - PermissionRealmHelp = "realm:help" - PermissionRealmLock = "realm:lock" - PermissionRealmNotice = "realm:notice" - PermissionReplyCreate = "reply:create" - PermissionReplyDelete = "reply:delete" - PermissionReplyFlag = "reply:flag" - PermissionRoleChange = "role:change" - PermissionThreadCreate = "thread:create" - PermissionThreadDelete = "thread:delete" - PermissionThreadEdit = "thread:edit" - PermissionThreadFlag = "thread:flag" - PermissionThreadFreeze = "thread:freeze" - PermissionThreadRepost = "thread:repost" - PermissionUserBan = "user:ban" - PermissionUserUnban = "user:unban" + PermissionBoardCreate boards.Permission = iota + PermissionBoardFlaggingUpdate + PermissionBoardFreeze + PermissionBoardRename + PermissionMemberInvite + PermissionMemberInviteRevoke + PermissionMemberRemove + PermissionPermissionsUpdate + PermissionRealmHelp + PermissionRealmLock + PermissionRealmNotice + PermissionReplyCreate + PermissionReplyDelete + PermissionReplyFlag + PermissionRoleChange + PermissionThreadCreate + PermissionThreadDelete + PermissionThreadEdit + PermissionThreadFlag + PermissionThreadFreeze + PermissionThreadRepost + PermissionUserBan + PermissionUserUnban ) func createBasicBoardPermissions(owner address) *permissions.Permissions { From 844d2f296049b0b8dac0373faa6429c6ca54d15a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jer=C3=B3nimo=20Albi?= <894299+jeronimoalbi@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:11:20 +0200 Subject: [PATCH 39/92] feat(govdao): allow proposal rejection on execution error (#5261) This allows proposals that fail to be executed to be rejected when state changes or parameter errors makes proposal execution fail. GovDAO proxy's has now a new `ExecuteOrRejectProposal()` public function that rejects proposals on execution errors. (cherry picked from commit 197281f4845a775afc392ca3593a617bbaf090b1) --- examples/gno.land/r/gov/dao/proxy.gno | 58 +++++++++---- examples/gno.land/r/gov/dao/proxy_test.gno | 4 + examples/gno.land/r/gov/dao/types.gno | 5 ++ .../govdao_execute_proposal_00_filetest.gno | 82 +++++++++++++++++++ .../govdao_execute_proposal_01_filetest.gno | 80 ++++++++++++++++++ .../govdao_execute_proposal_02_filetest.gno | 13 +++ .../govdao_execute_proposal_03_filetest.gno | 26 ++++++ .../stringify_proposal_00_filetest.gno} | 0 .../gno.land/r/gov/dao/v3/impl/govdao.gno | 19 +++++ .../govdao_execute_reject_proposal.txtar | 74 +++++++++++++++++ .../testdata/transfer_unlock.txtar | 2 +- .../testdata/transfer_unrestricted.txtar | 4 +- .../testdata/update_storage_params.txtar | 2 +- 13 files changed, 347 insertions(+), 22 deletions(-) create mode 100644 examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_00_filetest.gno create mode 100644 examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_01_filetest.gno create mode 100644 examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_02_filetest.gno create mode 100644 examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_03_filetest.gno rename examples/gno.land/r/gov/dao/v3/impl/{z_stringify_proposal_filetest.gno => filetests/stringify_proposal_00_filetest.gno} (100%) create mode 100644 gno.land/pkg/integration/testdata/govdao_execute_reject_proposal.txtar diff --git a/examples/gno.land/r/gov/dao/proxy.gno b/examples/gno.land/r/gov/dao/proxy.gno index 85055000baf..0b44ce80f82 100644 --- a/examples/gno.land/r/gov/dao/proxy.gno +++ b/examples/gno.land/r/gov/dao/proxy.gno @@ -49,25 +49,19 @@ func MustCreateProposal(cur realm, r ProposalRequest) ProposalID { // If the proposal was denied, it will return false. If the proposal is correctly // executed, it will return true. If something happens this function will panic. func ExecuteProposal(cur realm, pid ProposalID) bool { - if dao == nil { - return false - } - execute, err := dao.PreExecuteProposal(pid) - if err != nil { - panic(err.Error()) - } + return executeProposal(cur, pid, false) +} - if !execute { - return false - } - prop, err := GetProposal(cur, pid) - if err != nil { - panic(err.Error()) - } - if err := prop.executor.Execute(cross); err != nil { - panic(err.Error()) - } - return true +// ExecuteOrRejectProposal executes the proposal with the provided ProposalID or rejects +// it when there is an execution error. +// If the proposal was denied, it will return false. If the proposal is correctly +// executed, it will return true, unless execution fails with an error, in which case +// proposal is rejected with the error as the reason. +// This function allows to finish proposals by rejecting them when there is a state +// change or an error in the proposal parameters that makes execution fail, potentially +// leaving the proposal active forever because it can't be successfully executed. +func ExecuteOrRejectProposal(cur realm, pid ProposalID) bool { + return executeProposal(cur, pid, true) } // CreateProposal will try to create a new proposal, that will be validated by the actual @@ -193,3 +187,31 @@ func InAllowedDAOs(pkg string) bool { } return false } + +func executeProposal(cur realm, pid ProposalID, execErrorRejects bool) bool { + if dao == nil { + return false + } + execute, err := dao.PreExecuteProposal(pid) + if err != nil { + panic(err.Error()) + } + + if !execute { + return false + } + prop, err := GetProposal(cur, pid) + if err != nil { + panic(err.Error()) + } + + err = dao.ExecuteProposal(pid, prop.executor) + if err != nil { + if execErrorRejects { + return false + } + + panic(err.Error()) + } + return true +} diff --git a/examples/gno.land/r/gov/dao/proxy_test.gno b/examples/gno.land/r/gov/dao/proxy_test.gno index bbdcad88479..db4609fd3a1 100644 --- a/examples/gno.land/r/gov/dao/proxy_test.gno +++ b/examples/gno.land/r/gov/dao/proxy_test.gno @@ -128,6 +128,10 @@ func (dd *dummyDao) PreExecuteProposal(pid ProposalID) (bool, error) { return true, nil } +func (dd *dummyDao) ExecuteProposal(pid ProposalID, e Executor) error { + return nil +} + func (dd *dummyDao) Render(pkgpath string, path string) string { return "Render: " + pkgpath + "/" + path } diff --git a/examples/gno.land/r/gov/dao/types.gno b/examples/gno.land/r/gov/dao/types.gno index ba4aa571c11..5f201a5c993 100644 --- a/examples/gno.land/r/gov/dao/types.gno +++ b/examples/gno.land/r/gov/dao/types.gno @@ -241,6 +241,11 @@ type DAO interface { // Is intended to be used to validate who can trigger the proposal execution. PreExecuteProposal(pid ProposalID) (bool, error) + // ExecuteProposal executes the proposal executor and on error changes proposal + // status to denied with the error message being the denial reason. + // It returns the executor error when it fails. + ExecuteProposal(pid ProposalID, e Executor) error + // Render will return a human-readable string in markdown format that // will be used to show new data through the dao proxy entrypoint. Render(pkgpath string, path string) string diff --git a/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_00_filetest.gno b/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_00_filetest.gno new file mode 100644 index 00000000000..25464bdc883 --- /dev/null +++ b/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_00_filetest.gno @@ -0,0 +1,82 @@ +// PKGPATH: gno.land/r/test/govdao +package govdao + +import ( + "errors" + "strconv" + "testing" + + "gno.land/r/gov/dao" + "gno.land/r/gov/dao/v3/impl" + "gno.land/r/gov/dao/v3/memberstore" +) + +const user address = "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5" + +var ( + executor dao.Executor + proposalID dao.ProposalID + renderPath string + govdao = impl.NewGovDAO() +) + +func init() { + // Initialize GovDAO members + memberstore.Get().DeleteAll() + memberstore.Get().SetTier(memberstore.T1) + memberstore.Get().SetMember(memberstore.T1, user, &memberstore.Member{InvitationPoints: 3}) + dao.UpdateImpl(cross, dao.UpdateRequest{DAO: impl.NewGovDAO()}) + + // Create an executor that always fails + cb := func(realm) error { return errors.New("Boom!") } + executor = dao.NewSimpleExecutor(cb, "") + + // Create a proposal request that fails on execution + request := dao.NewProposalRequest("Test", "This proposal always fails on execution", executor) + + // Create the proposal from a realm so GovDAO instance is able to render the proposal + testing.SetRealm(testing.NewUserRealm(user)) + proposalID = dao.MustCreateProposal(cross, request) + renderPath = strconv.FormatUint(uint64(proposalID), 10) + + // Register proposal with the local GovDAO instance + govdao.PostCreateProposal(request, proposalID) +} + +func main() { + // Execute proposal, status should be REJECTED + err := govdao.ExecuteProposal(proposalID, executor) + + println(err.Error()) + println() + println(govdao.Render("gno.land/r/gov/dao/v3/impl", renderPath)) +} + +// Output: +// Boom! +// +// ## Prop #0 - Test +// Author: g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5 +// +// This proposal always fails on execution +// +// +// +// --- +// +// ### Stats +// - **PROPOSAL HAS BEEN DENIED** +// REASON: execution failed: Boom! +// - Tiers eligible to vote: T1, T2, T3 +// - YES PERCENT: 0% +// - NO PERCENT: 0% +// - ABSTAIN PERCENT: 0% +// +// [Detailed voting list](/r/gov/dao/v3/impl:0/votes) +// +// --- +// +// ### Actions +// [Vote YES](/r/gov/dao$help&func=MustVoteOnProposalSimple&option=YES&pid=0) | [Vote NO](/r/gov/dao$help&func=MustVoteOnProposalSimple&option=NO&pid=0) | [Vote ABSTAIN](/r/gov/dao$help&func=MustVoteOnProposalSimple&option=ABSTAIN&pid=0) +// +// WARNING: Please double check transaction data before voting. diff --git a/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_01_filetest.gno b/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_01_filetest.gno new file mode 100644 index 00000000000..74d088da687 --- /dev/null +++ b/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_01_filetest.gno @@ -0,0 +1,80 @@ +// PKGPATH: gno.land/r/test/govdao +package govdao + +import ( + "strconv" + "testing" + + "gno.land/r/gov/dao" + "gno.land/r/gov/dao/v3/impl" + "gno.land/r/gov/dao/v3/memberstore" +) + +const user address = "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5" + +var ( + executor dao.Executor + proposalID dao.ProposalID + renderPath string + govdao = impl.NewGovDAO() +) + +func init() { + // Initialize GovDAO members + memberstore.Get().DeleteAll() + memberstore.Get().SetTier(memberstore.T1) + memberstore.Get().SetMember(memberstore.T1, user, &memberstore.Member{InvitationPoints: 3}) + dao.UpdateImpl(cross, dao.UpdateRequest{DAO: impl.NewGovDAO()}) + + // Create a dummy executor + cb := func(realm) error { return nil } + executor = dao.NewSimpleExecutor(cb, "") + + // Create a proposal request that pass on execution + request := dao.NewProposalRequest("Test", "This proposal always pass on execution", executor) + + // Create the proposal from a realm so GovDAO instance is able to render the proposal + testing.SetRealm(testing.NewUserRealm(user)) + proposalID = dao.MustCreateProposal(cross, request) + renderPath = strconv.FormatUint(uint64(proposalID), 10) + + // Register proposal with the local GovDAO instance + govdao.PostCreateProposal(request, proposalID) +} + +func main() { + // Execute proposal, status should be ACTIVE + err := govdao.ExecuteProposal(proposalID, executor) + + println(err == nil) + println() + println(govdao.Render("gno.land/r/gov/dao/v3/impl", renderPath)) +} + +// Output: +// true +// +// ## Prop #0 - Test +// Author: g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5 +// +// This proposal always pass on execution +// +// +// +// --- +// +// ### Stats +// - **Proposal is open for votes** +// - Tiers eligible to vote: T1, T2, T3 +// - YES PERCENT: 0% +// - NO PERCENT: 0% +// - ABSTAIN PERCENT: 0% +// +// [Detailed voting list](/r/gov/dao/v3/impl:0/votes) +// +// --- +// +// ### Actions +// [Vote YES](/r/gov/dao$help&func=MustVoteOnProposalSimple&option=YES&pid=0) | [Vote NO](/r/gov/dao$help&func=MustVoteOnProposalSimple&option=NO&pid=0) | [Vote ABSTAIN](/r/gov/dao$help&func=MustVoteOnProposalSimple&option=ABSTAIN&pid=0) +// +// WARNING: Please double check transaction data before voting. diff --git a/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_02_filetest.gno b/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_02_filetest.gno new file mode 100644 index 00000000000..b2abe5d912b --- /dev/null +++ b/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_02_filetest.gno @@ -0,0 +1,13 @@ +package main + +import "gno.land/r/gov/dao/v3/impl" + +var govdao = impl.NewGovDAO() + +func main() { + // Try to execute a proposal using a nil executor + govdao.ExecuteProposal(0, nil) +} + +// Error: +// an executor is required to execute the proposal diff --git a/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_03_filetest.gno b/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_03_filetest.gno new file mode 100644 index 00000000000..f19bf2f7693 --- /dev/null +++ b/examples/gno.land/r/gov/dao/v3/impl/filetests/govdao_execute_proposal_03_filetest.gno @@ -0,0 +1,26 @@ +// PKGPATH: gno.land/r/test/govdao +package govdao + +import ( + "gno.land/r/gov/dao" + "gno.land/r/gov/dao/v3/impl" +) + +var ( + executor dao.Executor + govdao = impl.NewGovDAO() +) + +func init() { + // Create a dummy executor + cb := func(realm) error { return nil } + executor = dao.NewSimpleExecutor(cb, "") +} + +func main() { + // Try to execute a proposal that doesn't exist + govdao.ExecuteProposal(404, executor) +} + +// Error: +// proposal not found diff --git a/examples/gno.land/r/gov/dao/v3/impl/z_stringify_proposal_filetest.gno b/examples/gno.land/r/gov/dao/v3/impl/filetests/stringify_proposal_00_filetest.gno similarity index 100% rename from examples/gno.land/r/gov/dao/v3/impl/z_stringify_proposal_filetest.gno rename to examples/gno.land/r/gov/dao/v3/impl/filetests/stringify_proposal_00_filetest.gno diff --git a/examples/gno.land/r/gov/dao/v3/impl/govdao.gno b/examples/gno.land/r/gov/dao/v3/impl/govdao.gno index ee8ac84f826..22155891260 100644 --- a/examples/gno.land/r/gov/dao/v3/impl/govdao.gno +++ b/examples/gno.land/r/gov/dao/v3/impl/govdao.gno @@ -166,6 +166,25 @@ func (g *GovDAO) PreExecuteProposal(pid dao.ProposalID) (bool, error) { return false, errors.New(ufmt.Sprintf("proposal didn't reach supermajority yet: %v", law.Supermajority)) } +func (g *GovDAO) ExecuteProposal(pid dao.ProposalID, e dao.Executor) error { + if e == nil { + panic("an executor is required to execute the proposal") + } + + status := g.pss.GetStatus(pid) + if status == nil { + panic("proposal not found") + } + + err := e.Execute(cross) + if err != nil { + status.Accepted = false + status.Denied = true + status.DeniedReason = "execution failed: " + err.Error() + } + return err +} + func (g *GovDAO) Render(pkgPath string, path string) string { return g.render.Render(pkgPath, path) } diff --git a/gno.land/pkg/integration/testdata/govdao_execute_reject_proposal.txtar b/gno.land/pkg/integration/testdata/govdao_execute_reject_proposal.txtar new file mode 100644 index 00000000000..a21667639be --- /dev/null +++ b/gno.land/pkg/integration/testdata/govdao_execute_reject_proposal.txtar @@ -0,0 +1,74 @@ +loadpkg gno.land/r/gov/dao/v3/init +loadpkg gno.land/r/gov/dao + +gnoland start + +# Init GovDAO members +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 95000000 -broadcast -chainid=tendermint_test test1 $WORK/run/init_govdao_members.gno + +# Deploy the realm that defines the proposal request +gnokey maketx addpkg -pkgdir $WORK/request -pkgpath gno.land/r/test/request -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test test1 +stdout OK! + +# Create proposal that fails on execution +gnokey maketx run -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test test1 $WORK/run/create_proposal.gno +stdout OK! + +# Vote on proposal +gnokey maketx call -pkgpath gno.land/r/gov/dao -func MustVoteOnProposalSimple -gas-fee 1000000ugnot -gas-wanted 10000000 -args 0 -args YES -broadcast -chainid=tendermint_test test1 +stdout OK! + +# Try to execute proposal, it should fail because execution returns an error +! gnokey maketx call -pkgpath gno.land/r/gov/dao -func ExecuteProposal -gas-fee 1000000ugnot -gas-wanted 20000000 -args 0 -broadcast -chainid=tendermint_test test1 +stderr 'Boom!' + +# Execute proposal changing its status to rejected +gnokey maketx call -pkgpath gno.land/r/gov/dao -func ExecuteOrRejectProposal -gas-fee 1000000ugnot -gas-wanted 20000000 -args 0 -broadcast -chainid=tendermint_test test1 +stdout OK! + +# Check that proposal was rejected +gnokey query vm/qeval --data "gno.land/r/gov/dao.Render(\"0\")" +stdout 'Stats\\n- \*\*PROPOSAL HAS BEEN DENIED\*\*\\nREASON: execution failed: Boom!' + +-- run/init_govdao_members.gno -- +package main + +import dao "gno.land/r/gov/dao/v3/init" + +func main() { + dao.InitWithUsers("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") +} + +-- run/create_proposal.gno -- +package main + +import ( + "gno.land/r/gov/dao" + "gno.land/r/test/request" +) + +func main() { + dao.MustCreateProposal(cross, request.NewProposalRequest()) +} + +-- request/gnomod.toml -- +module = "gno.land/r/test/request" +gno = "0.9" + +-- request/request.gno -- +package request + +import ( + "errors" + + "gno.land/r/gov/dao" +) + +func NewProposalRequest() dao.ProposalRequest { + cb := func(realm) error { + return errors.New("Boom!") + } + + e := dao.NewSimpleExecutor(cb, "") + return dao.NewProposalRequest("Test Proposal", "This proposal always fails on execution", e) +} diff --git a/gno.land/pkg/integration/testdata/transfer_unlock.txtar b/gno.land/pkg/integration/testdata/transfer_unlock.txtar index f9448a712d5..2465e3094c1 100644 --- a/gno.land/pkg/integration/testdata/transfer_unlock.txtar +++ b/gno.land/pkg/integration/testdata/transfer_unlock.txtar @@ -88,7 +88,7 @@ import ( ) func main() { - ok := dao.ExecuteProposal(cross, dao.ProposalID(0)) + ok := dao.ExecuteProposal(cross, dao.ProposalID(0)) if ok { println("OK!") } diff --git a/gno.land/pkg/integration/testdata/transfer_unrestricted.txtar b/gno.land/pkg/integration/testdata/transfer_unrestricted.txtar index a09668d7253..f038f701960 100644 --- a/gno.land/pkg/integration/testdata/transfer_unrestricted.txtar +++ b/gno.land/pkg/integration/testdata/transfer_unrestricted.txtar @@ -168,7 +168,7 @@ import ( ) func main() { - ok := dao.ExecuteProposal(cross,dao.ProposalID(0)) + ok := dao.ExecuteProposal(cross,dao.ProposalID(0)) if ok { println("OK!") } @@ -195,7 +195,7 @@ import ( ) func main() { - ok := dao.ExecuteProposal(cross,dao.ProposalID(1)) + ok := dao.ExecuteProposal(cross, dao.ProposalID(1)) if ok { println("OK!") } diff --git a/gno.land/pkg/integration/testdata/update_storage_params.txtar b/gno.land/pkg/integration/testdata/update_storage_params.txtar index c7858d0fc70..ec4ecc89b45 100644 --- a/gno.land/pkg/integration/testdata/update_storage_params.txtar +++ b/gno.land/pkg/integration/testdata/update_storage_params.txtar @@ -102,7 +102,7 @@ import ( ) func main() { - dao.ExecuteProposal(cross,dao.ProposalID(0)) + dao.ExecuteProposal(cross, dao.ProposalID(0)) } From bfd9830f79987d9de1bfc8708155448a172a9a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jer=C3=B3nimo=20Albi?= <894299+jeronimoalbi@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:12:03 +0200 Subject: [PATCH 40/92] feat(boards2): add function to update required amount for open board interactions (#5349) Adds a `SetRequiredAccountAmount()` function to update the amount required for non member accounts to be able to interact in open boards. PR also updates some realm variables to be public. A new `PermissionAccountRequiredAmountChange` has been added. This permission is required to be able to update the required amount, and is not assigned to any realm member role, so only `owners` can update that value. The only `owner` the realm has is the multisig address of the GovDAO T1 members. (cherry picked from commit e9a28cb1c2e06a20e821a8c761df4d34dfe9bb37) --- .../gno.land/r/gnoland/boards2/v1/boards.gno | 32 ++++++++++-------- .../z_set_realm_notice_00_filetest.gno | 12 ++----- .../z_set_realm_notice_01_filetest.gno | 7 +--- .../z_set_realm_notice_03_filetest.gno | 27 +++++++++++++++ ...et_required_account_amount_00_filetest.gno | 20 +++++++++++ ...et_required_account_amount_01_filetest.gno | 20 +++++++++++ ...et_required_account_amount_02_filetest.gno | 18 ++++++++++ .../r/gnoland/boards2/v1/permissions.gno | 3 +- .../v1/permissions_validators_open.gno | 6 ++-- .../gno.land/r/gnoland/boards2/v1/public.gno | 33 +++++++++++++++---- .../gno.land/r/gnoland/boards2/v1/render.gno | 20 +++++------ .../r/gnoland/boards2/v1/uris_board.gno | 8 ++--- .../r/gnoland/boards2/v1/uris_post.gno | 4 +-- 13 files changed, 155 insertions(+), 55 deletions(-) create mode 100644 examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_03_filetest.gno create mode 100644 examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_00_filetest.gno create mode 100644 examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_01_filetest.gno create mode 100644 examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_02_filetest.gno diff --git a/examples/gno.land/r/gnoland/boards2/v1/boards.gno b/examples/gno.land/r/gnoland/boards2/v1/boards.gno index 70a49492ba6..49be99b0e66 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/boards.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/boards.gno @@ -11,11 +11,25 @@ import ( "gno.land/p/nt/avl/v0" ) +var ( + // RealmLink contains Boards2 realm link. + // It can be used to generate board TX links from other realms. + RealmLink = txlink.Realm(runtime.CurrentRealm().PkgPath()) + + // RequiredAccountAmount contains the required account amount for open board interactions. + // The amount requirement is not applied to members that were invited to an open board. + // Amount is defined as ugnot. + RequiredAccountAmount = int64(3_000_000_000) + + // Notice contains an optional message that is displayed globally within the realm. + Notice string + + // Help contains optional Markdown with Boards2 realm help. + Help string +) + // TODO: Refactor globals in favor of a cleaner pattern var ( - gRealmLink txlink.Realm - gNotice string - gHelp string gListedBoardsByID avl.Tree // string(id) -> *boards.Board gInviteRequests avl.Tree // string(board id) -> *avl.Tree(address -> time.Time) gBannedUsers avl.Tree // string(board id) -> *avl.Tree(address -> time.Time) @@ -29,17 +43,9 @@ var ( gBoards = boards.NewStorage() gBoardsSequence = boards.NewIdentifierGenerator() gRealmPath = strings.TrimPrefix(runtime.CurrentRealm().PkgPath(), "gno.land") - gPerms = initRealmPermissions("g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh") // govdao t1 multisig - - // TODO: Allow updating open account amount though a proposal (GovDAO, CommonDAO?) - gOpenAccountAmount = int64(3_000_000_000) // ugnot required for open board actions + gPerms = initRealmPermissions("g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh") // GovDAO T1 multisig ) -func init() { - // Save current realm path so it's available during render calls - gRealmLink = txlink.Realm(runtime.CurrentRealm().PkgPath()) -} - // initRealmPermissions returns the default realm permissions. func initRealmPermissions(owners ...address) boards.Permissions { perms := permissions.New( @@ -139,6 +145,6 @@ func mustGetPermissions(bid boards.ID) boards.Permissions { func parseRealmPath(path string) *realmpath.Request { // Make sure request is using current realm path so paths can be parsed during Render r := realmpath.Parse(path) - r.Realm = string(gRealmLink) + r.Realm = string(RealmLink) return r } diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_00_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_00_filetest.gno index 97f13058449..b28544bcf7f 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_00_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_00_filetest.gno @@ -13,16 +13,8 @@ func main() { boards2.SetRealmNotice(cross, "This is a test realm message") - // Render content must contain the message - println(boards2.Render("")) + println(boards2.Notice) } // Output: -// > [!INFO] Notice -// > This is a test realm message -// # Boards -// [Create Board](/r/gnoland/boards2/v1:create-board) • [List Admin Users](/r/gnoland/boards2/v1:admin-users) • [Help](/r/gnoland/boards2/v1:help) -// -// --- -// ### Currently there are no boards -// Be the first to [create a new board](/r/gnoland/boards2/v1:create-board)! +// This is a test realm message diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_01_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_01_filetest.gno index d88f9e47196..12241827df2 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_01_filetest.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_01_filetest.gno @@ -1,7 +1,6 @@ package main import ( - "strings" "testing" boards2 "gno.land/r/gnoland/boards2/v1" @@ -20,12 +19,8 @@ func main() { boards2.SetRealmNotice(cross, "") - // Render content must contain the message - content := boards2.Render("") - println(strings.HasPrefix(content, "> This is a test realm message\n\n")) - println(strings.HasPrefix(content, "# Boards")) + println(boards2.Notice == "") } // Output: -// false // true diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_03_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_03_filetest.gno new file mode 100644 index 00000000000..8af16f45ad1 --- /dev/null +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_realm_notice_03_filetest.gno @@ -0,0 +1,27 @@ +package main + +import ( + "testing" + + boards2 "gno.land/r/gnoland/boards2/v1" +) + +const owner address = "g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh" + +func main() { + testing.SetRealm(testing.NewUserRealm(owner)) + + boards2.SetRealmNotice(cross, "This is a test realm message") + + println(boards2.Render("")) +} + +// Output: +// > [!INFO] Notice +// > This is a test realm message +// # Boards +// [Create Board](/r/gnoland/boards2/v1:create-board) • [List Admin Users](/r/gnoland/boards2/v1:admin-users) • [Help](/r/gnoland/boards2/v1:help) +// +// --- +// ### Currently there are no boards +// Be the first to [create a new board](/r/gnoland/boards2/v1:create-board)! diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_00_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_00_filetest.gno new file mode 100644 index 00000000000..1fa86eee505 --- /dev/null +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_00_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "testing" + + boards2 "gno.land/r/gnoland/boards2/v1" +) + +const owner address = "g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh" + +func main() { + testing.SetRealm(testing.NewUserRealm(owner)) + + boards2.SetRequiredAccountAmount(cross, 1_000_000) + + println(boards2.RequiredAccountAmount) +} + +// Output: +// 1000000 diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_01_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_01_filetest.gno new file mode 100644 index 00000000000..1f40a6af15d --- /dev/null +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_01_filetest.gno @@ -0,0 +1,20 @@ +package main + +import ( + "testing" + + boards2 "gno.land/r/gnoland/boards2/v1" +) + +const owner address = "g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh" + +func main() { + testing.SetRealm(testing.NewUserRealm(owner)) + + boards2.SetRequiredAccountAmount(cross, 0) // Disable + + println(boards2.RequiredAccountAmount) +} + +// Output: +// 0 diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_02_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_02_filetest.gno new file mode 100644 index 00000000000..159b90f4ae3 --- /dev/null +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_set_required_account_amount_02_filetest.gno @@ -0,0 +1,18 @@ +package main + +import ( + "testing" + + boards2 "gno.land/r/gnoland/boards2/v1" +) + +const owner address = "g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj" // @test2 + +func main() { + testing.SetRealm(testing.NewUserRealm(owner)) + + boards2.SetRequiredAccountAmount(cross, 1_000_000) +} + +// Error: +// unauthorized, user g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj doesn't have the required permission diff --git a/examples/gno.land/r/gnoland/boards2/v1/permissions.gno b/examples/gno.land/r/gnoland/boards2/v1/permissions.gno index 6aa3b38073a..c258c6df2ec 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/permissions.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/permissions.gno @@ -36,9 +36,10 @@ const ( PermissionMemberInviteRevoke PermissionMemberRemove PermissionPermissionsUpdate - PermissionRealmHelp + PermissionRealmHelpChange PermissionRealmLock PermissionRealmNotice + PermissionAccountRequiredAmountChange PermissionReplyCreate PermissionReplyDelete PermissionReplyFlag diff --git a/examples/gno.land/r/gnoland/boards2/v1/permissions_validators_open.gno b/examples/gno.land/r/gnoland/boards2/v1/permissions_validators_open.gno index f9d1fe16c4e..03da6e64627 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/permissions_validators_open.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/permissions_validators_open.gno @@ -120,7 +120,7 @@ func validateOpenThreadCreate(perms boards.Permissions, args boards.Args) error } // Require non members to have some GNOT in their accounts - if err := checkAccountHasAmount(caller, gOpenAccountAmount); err != nil { + if err := checkAccountHasAmount(caller, RequiredAccountAmount); err != nil { return ufmt.Errorf("caller is not allowed to create threads: %s", err) } return nil @@ -147,7 +147,7 @@ func validateOpenReplyCreate(perms boards.Permissions, args boards.Args) error { } // Require non members to have some GNOT in their accounts - if err := checkAccountHasAmount(caller, gOpenAccountAmount); err != nil { + if err := checkAccountHasAmount(caller, RequiredAccountAmount); err != nil { return ufmt.Errorf("caller is not allowed to comment: %s", err) } return nil @@ -156,7 +156,7 @@ func validateOpenReplyCreate(perms boards.Permissions, args boards.Args) error { func checkAccountHasAmount(addr address, amount int64) error { bnk := banker.NewBanker(banker.BankerTypeReadonly) coins := bnk.GetCoins(addr) - if coins.AmountOf("ugnot") < gOpenAccountAmount { + if coins.AmountOf("ugnot") < RequiredAccountAmount { amount = amount / 1_000_000 // ugnot -> GNOT return ufmt.Errorf("account amount is lower than %d GNOT", amount) } diff --git a/examples/gno.land/r/gnoland/boards2/v1/public.gno b/examples/gno.land/r/gnoland/boards2/v1/public.gno index e630324cad6..0cf7321b301 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/public.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/public.gno @@ -36,8 +36,29 @@ func SetHelp(_ realm, content string) { content = strings.TrimSpace(content) caller := runtime.PreviousRealm().Address() args := boards.Args{content} - gPerms.WithPermission(caller, PermissionRealmHelp, args, crossingFn(func() { - gHelp = content + gPerms.WithPermission(caller, PermissionRealmHelpChange, args, crossingFn(func() { + Help = content + })) +} + +// SetRequiredAccountAmount sets the required account amount to interact as a non member with open boards. +// Amount must be given as ugnot. +// The amount requirement is not applied to members that were invited to an open board. +func SetRequiredAccountAmount(_ realm, amount int64) { + if amount < 0 { + panic("invalid amount") + } + + caller := runtime.PreviousRealm().Address() + args := boards.Args{amount} + gPerms.WithPermission(caller, PermissionAccountRequiredAmountChange, args, crossingFn(func() { + RequiredAccountAmount = amount + + chain.Emit( + "RequiredAccountAmountChanged", + "caller", caller.String(), + "amount", strconv.FormatInt(amount, 10), + ) })) } @@ -60,7 +81,7 @@ func SetPermissions(_ realm, boardID boards.ID, p boards.Permissions) { gPerms = p chain.Emit( - "RealmPermissionsUpdated", + "RealmPermissionsChanged", "caller", caller.String(), ) return @@ -71,21 +92,21 @@ func SetPermissions(_ realm, boardID boards.ID, p boards.Permissions) { board.Permissions = p chain.Emit( - "BoardPermissionsUpdated", + "BoardPermissionsChanged", "caller", caller.String(), "boardID", board.ID.String(), ) })) } -// SetRealmNotice sets a notice to be displayed globally by the realm. +// SetRealmNotice sets a notice to be displayed globally within the realm. // An empty message removes the realm notice. func SetRealmNotice(_ realm, message string) { message = strings.TrimSpace(message) caller := runtime.PreviousRealm().Address() args := boards.Args{message} gPerms.WithPermission(caller, PermissionRealmNotice, args, crossingFn(func() { - gNotice = message + Notice = message chain.Emit( "RealmNoticeChanged", diff --git a/examples/gno.land/r/gnoland/boards2/v1/render.gno b/examples/gno.land/r/gnoland/boards2/v1/render.gno index 2a78a56c549..0c323a17cfd 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/render.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/render.gno @@ -61,8 +61,8 @@ func Render(path string) string { } // Render common realm header before resolving render path - if gNotice != "" { - b.WriteString(infoAlert("Notice", gNotice)) + if Notice != "" { + b.WriteString(infoAlert("Notice", Notice)) } // Render view for current path @@ -73,12 +73,12 @@ func Render(path string) string { func renderHelp(res *mux.ResponseWriter, _ *mux.Request) { res.Write(md.H1("Boards Help")) - if gHelp != "" { - res.Write(gHelp) + if Help != "" { + res.Write(Help) return } - link := gRealmLink.Call("SetHelp", "content", "") + link := RealmLink.Call("SetHelp", "content", "") res.Write(md.H3("Help content has not been uploaded")) res.Write("Do you want to " + md.Link("upload boards help", link) + "?") } @@ -224,12 +224,12 @@ func renderMembers(res *mux.ResponseWriter, req *mux.Request) { perms.IterateUsers(p.Offset(), p.PageSize(), func(u boards.User) bool { actions := []string{ - md.Link("remove", gRealmLink.Call( + md.Link("remove", RealmLink.Call( "RemoveMember", "boardID", boardID.String(), "member", u.Address.String(), )), - md.Link("change role", gRealmLink.Call( + md.Link("change role", RealmLink.Call( "ChangeMemberRole", "boardID", boardID.String(), "member", u.Address.String(), @@ -280,12 +280,12 @@ func renderInvites(res *mux.ResponseWriter, req *mux.Request) { res.Write(md.H3("These users have requested to be invited to the board")) requests.ReverseIterateByOffset(p.Offset(), p.PageSize(), func(addr string, v any) bool { actions := []string{ - md.Link("accept", gRealmLink.Call( + md.Link("accept", RealmLink.Call( "AcceptInvite", "boardID", board.ID.String(), "user", addr, )), - md.Link("revoke", gRealmLink.Call( + md.Link("revoke", RealmLink.Call( "RevokeInvite", "boardID", board.ID.String(), "user", addr, @@ -338,7 +338,7 @@ func renderBannedUsers(res *mux.ResponseWriter, req *mux.Request) { table.Append([]string{ userLink(address(addr)), v.(time.Time).Format(dateFormat), - md.Link("unban", gRealmLink.Call( + md.Link("unban", RealmLink.Call( "Unban", "boardID", board.ID.String(), "user", addr, diff --git a/examples/gno.land/r/gnoland/boards2/v1/uris_board.gno b/examples/gno.land/r/gnoland/boards2/v1/uris_board.gno index ba66f5c3292..7fb8c2f6c0e 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/uris_board.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/uris_board.gno @@ -8,19 +8,19 @@ import ( ) func makeBoardURI(b *boards.Board) string { - path := strings.TrimPrefix(string(gRealmLink), "gno.land") + path := strings.TrimPrefix(string(RealmLink), "gno.land") return path + ":" + url.PathEscape(b.Name) } func makeFreezeBoardURI(b *boards.Board) string { - return gRealmLink.Call( + return RealmLink.Call( "FreezeBoard", "boardID", b.ID.String(), ) } func makeUnfreezeBoardURI(b *boards.Board) string { - return gRealmLink.Call( + return RealmLink.Call( "UnfreezeBoard", "boardID", b.ID.String(), "threadID", "", @@ -37,7 +37,7 @@ func makeCreateThreadURI(b *boards.Board) string { } func makeRequestInviteURI(b *boards.Board) string { - return gRealmLink.Call( + return RealmLink.Call( "RequestInvite", "boardID", b.ID.String(), ) diff --git a/examples/gno.land/r/gnoland/boards2/v1/uris_post.gno b/examples/gno.land/r/gnoland/boards2/v1/uris_post.gno index ac1ad5e8958..ed011e77b77 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/uris_post.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/uris_post.gno @@ -30,13 +30,13 @@ func makeCreateRepostURI(p *boards.Post) string { func makeDeletePostURI(p *boards.Post) string { if boards.IsThread(p) { - return gRealmLink.Call( + return RealmLink.Call( "DeleteThread", "boardID", p.Board.ID.String(), "threadID", p.ThreadID.String(), ) } - return gRealmLink.Call( + return RealmLink.Call( "DeleteReply", "boardID", p.Board.ID.String(), "threadID", p.ThreadID.String(), From c1bb44e4e3637f2749eef4d8d6da3ee46e8387fd Mon Sep 17 00:00:00 2001 From: Miguel Victoria Villaquiran Date: Tue, 7 Apr 2026 11:14:53 +0200 Subject: [PATCH 41/92] fix(consensus): error when block header parts are too big (#5246) Linked to https://dashboard.hackenproof.com/manager/companies/newtendermint/gno-dot-land/reports/NEWTENDG-159 (cherry picked from commit f7a23f1ead2ebc5274b90ea8f2586ff69e9dd673) --- tm2/pkg/bft/types/evidence_test.go | 4 ++-- tm2/pkg/bft/types/part_set.go | 4 ++++ tm2/pkg/bft/types/proposal_test.go | 3 +-- tm2/pkg/bft/types/vote_test.go | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tm2/pkg/bft/types/evidence_test.go b/tm2/pkg/bft/types/evidence_test.go index 111c8d065c3..abd76a8320c 100644 --- a/tm2/pkg/bft/types/evidence_test.go +++ b/tm2/pkg/bft/types/evidence_test.go @@ -136,8 +136,8 @@ func TestDuplicateVoteEvidenceValidation(t *testing.T) { t.Parallel() val := NewMockPV() - blockID := makeBlockID(tmhash.Sum([]byte("blockhash")), math.MaxInt64, tmhash.Sum([]byte("partshash"))) - blockID2 := makeBlockID(tmhash.Sum([]byte("blockhash2")), math.MaxInt64, tmhash.Sum([]byte("partshash"))) + blockID := makeBlockID(tmhash.Sum([]byte("blockhash")), 1000, tmhash.Sum([]byte("partshash"))) + blockID2 := makeBlockID(tmhash.Sum([]byte("blockhash2")), 1000, tmhash.Sum([]byte("partshash"))) const chainID = "mychain" testCases := []struct { diff --git a/tm2/pkg/bft/types/part_set.go b/tm2/pkg/bft/types/part_set.go index 4980d13e6e4..5311ade6fa0 100644 --- a/tm2/pkg/bft/types/part_set.go +++ b/tm2/pkg/bft/types/part_set.go @@ -15,6 +15,7 @@ import ( var ( ErrPartSetUnexpectedIndex = errors.New("Error part set unexpected index") ErrPartSetInvalidProof = errors.New("Error part set invalid proof") + ErrPartSetTooBig = errors.New("Error part set too big") ) type Part struct { @@ -76,6 +77,9 @@ func (psh PartSetHeader) ValidateBasic() error { if psh.Total < 0 { return errors.New("Negative Total") } + if psh.Total > MaxBlockPartsCount { + return fmt.Errorf("PartSetHeader total is too big: %d, max: %d: %w", psh.Total, MaxBlockPartsCount, ErrPartSetTooBig) + } // Hash can be empty in case of POLBlockID.PartsHeader in Proposal. if err := ValidateHash(psh.Hash); err != nil { return errors.Wrap(err, "Wrong Hash") diff --git a/tm2/pkg/bft/types/proposal_test.go b/tm2/pkg/bft/types/proposal_test.go index cfcf7487351..dee3995e2b9 100644 --- a/tm2/pkg/bft/types/proposal_test.go +++ b/tm2/pkg/bft/types/proposal_test.go @@ -1,7 +1,6 @@ package types import ( - "math" "testing" "time" @@ -132,7 +131,7 @@ func TestProposalValidateBasic(t *testing.T) { p.Signature = make([]byte, MaxSignatureSize+1) }, true}, } - blockID := makeBlockID(tmhash.Sum([]byte("blockhash")), math.MaxInt64, tmhash.Sum([]byte("partshash"))) + blockID := makeBlockID(tmhash.Sum([]byte("blockhash")), 10, tmhash.Sum([]byte("partshash"))) for _, tc := range testCases { tc := tc diff --git a/tm2/pkg/bft/types/vote_test.go b/tm2/pkg/bft/types/vote_test.go index ee19e55087c..082495dbe9e 100644 --- a/tm2/pkg/bft/types/vote_test.go +++ b/tm2/pkg/bft/types/vote_test.go @@ -36,7 +36,7 @@ func exampleVote(t byte) *Vote { BlockID: BlockID{ Hash: tmhash.Sum([]byte("blockID_hash")), PartsHeader: PartSetHeader{ - Total: 1000000, + Total: 1000, Hash: tmhash.Sum([]byte("blockID_part_set_header_hash")), }, }, @@ -302,7 +302,7 @@ func TestVoteValidateBasic(t *testing.T) { {"Good Vote", func(v *Vote) {}, false}, {"Negative Height", func(v *Vote) { v.Height = -1 }, true}, {"Negative Round", func(v *Vote) { v.Round = -1 }, true}, - {"Invalid BlockID", func(v *Vote) { v.BlockID = BlockID{[]byte{1, 2, 3}, PartSetHeader{111, []byte("blockparts")}} }, true}, + {"Invalid BlockID", func(v *Vote) { v.BlockID = BlockID{[]byte{1, 2, 3}, PartSetHeader{10, []byte("blockparts")}} }, true}, {"Invalid Address", func(v *Vote) { v.ValidatorAddress = crypto.Address{} }, true}, {"Invalid ValidatorIndex", func(v *Vote) { v.ValidatorIndex = -1 }, true}, {"Invalid Signature", func(v *Vote) { v.Signature = nil }, true}, From 0fbe8ba280ed84a9b177eaaa3c6e0ba50c31ea82 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:20:04 +0200 Subject: [PATCH 42/92] fix(gnovm): inconsistency in the single-linked list implementation (cont.) (#4960) Fix: #4955 (cherry picked from commit 4598c267daf8318d6ca05532ed772dbf9a886208) --- gnovm/pkg/gnolang/values.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/gnovm/pkg/gnolang/values.go b/gnovm/pkg/gnolang/values.go index cc24cdb2e76..36e8cf939f3 100644 --- a/gnovm/pkg/gnolang/values.go +++ b/gnovm/pkg/gnolang/values.go @@ -659,15 +659,16 @@ func (ml MapList) MarshalAmino() (MapListImage, error) { func (ml *MapList) UnmarshalAmino(mlimg MapListImage) error { for i, item := range mlimg.List { if i == 0 { + item.Prev = nil ml.Head = item ml.Tail = item - item.Prev = nil + ml.Size = 1 } else { item.Prev = ml.Tail ml.Tail.Next = item ml.Tail = item + ml.Size++ } - ml.Size++ } return nil } @@ -684,15 +685,19 @@ func (ml *MapList) Append(alloc *Allocator, key TypedValue) *MapListItem { if ml.Head == nil { ml.Head = item ml.Tail = item + ml.Size = 1 } else { ml.Tail.Next = item ml.Tail = item + ml.Size++ } - ml.Size++ return item } func (ml *MapList) Remove(mli *MapListItem) { + if ml.Size == 0 { + return + } prev, next := mli.Prev, mli.Next if prev == nil { ml.Head = next From 56dbe311d3401313684506fd04250b8fc42d53d7 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:03:58 +0200 Subject: [PATCH 43/92] fix(tm2/rpc): prevent index out of bounds panic (#5136) https://hackmd.io/@snwSRExvRiK7iENYqPrasw/B1NnYPcvZg (cherry picked from commit e72b47960135d55e7951a66904dd11156289bf7f) --- tm2/pkg/bft/rpc/core/tx.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tm2/pkg/bft/rpc/core/tx.go b/tm2/pkg/bft/rpc/core/tx.go index 1023e5aef1f..3c6d54dea0a 100644 --- a/tm2/pkg/bft/rpc/core/tx.go +++ b/tm2/pkg/bft/rpc/core/tx.go @@ -34,7 +34,7 @@ func Tx(ctx *rpctypes.Context, hash []byte) (*ctypes.ResultTx, error) { } numTxs := len(block.Txs) - if int(resultIndex.TxIndex) > numTxs || numTxs == 0 { + if int(resultIndex.TxIndex) >= numTxs || numTxs == 0 { return nil, fmt.Errorf( "unable to get block transaction for block %d, index %d", resultIndex.BlockNum, @@ -51,7 +51,7 @@ func Tx(ctx *rpctypes.Context, hash []byte) (*ctypes.ResultTx, error) { } // Grab the block deliver response - if len(blockResults.DeliverTxs) < int(resultIndex.TxIndex) { + if len(blockResults.DeliverTxs) <= int(resultIndex.TxIndex) { return nil, fmt.Errorf( "unable to get deliver result for block %d, index %d", resultIndex.BlockNum, From 842cc257a268ee1cf6d467067b67e6702ad6d7ec Mon Sep 17 00:00:00 2001 From: 6h057 <15034695+omarsy@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:44:19 +0200 Subject: [PATCH 44/92] fix(gnovm): track block item allocations in PrepareNewValues (#5436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fix GC allocation/recount mismatch that causes `"should not happen, allocation limit exceeded while gc."` panic during infinite recursion - Root cause: `PrepareNewValues` appends block items without calling `AllocateBlockItems`, so GC recount exceeds `alloc.bytes` - Add `AllocateBlockItems` call before appending to `block.Values` in `PrepareNewValues` - Fix pre-existing nil pointer panic in `GetLocalIndex` debug logging ## Test plan - [x] New filetest `alloc_12.gno` verifies infinite recursion triggers correct "allocation limit exceeded" error - [x] Updated gas test values for `const.gno`, `nested_alloc.gno`, `slice_alloc.gno` - [x] Full `go test ./gnovm/pkg/gnolang/` passes - [x] ADR included at `gnovm/adr/prxxxx_fix_gc_alloc_mismatch.md` Note: AI assisted PR — see ADR for detailed analysis. (cherry picked from commit e4533a45c9205f73c8e58a80461ffb08b48aad19) --- .../integration/testdata/gnokey_gasfee.txtar | 6 +- .../integration/testdata/restart_gas.txtar | 6 +- gno.land/pkg/sdk/vm/gas_test.go | 2 +- gnovm/adr/pr5436_fix_gc_alloc_mismatch.md | 57 +++++++++++++++++++ gnovm/pkg/gnolang/nodes.go | 1 + gnovm/tests/files/alloc_12.gno | 18 ++++++ gnovm/tests/files/gas/const.gno | 2 +- gnovm/tests/files/gas/nested_alloc.gno | 2 +- gnovm/tests/files/gas/slice_alloc.gno | 4 +- 9 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 gnovm/adr/pr5436_fix_gc_alloc_mismatch.md create mode 100644 gnovm/tests/files/alloc_12.gno diff --git a/gno.land/pkg/integration/testdata/gnokey_gasfee.txtar b/gno.land/pkg/integration/testdata/gnokey_gasfee.txtar index 3bc4edb9df4..f11707ee2fe 100644 --- a/gno.land/pkg/integration/testdata/gnokey_gasfee.txtar +++ b/gno.land/pkg/integration/testdata/gnokey_gasfee.txtar @@ -11,8 +11,8 @@ stdout '"coins": "10000000000000ugnot"' # Tx add package -simulate only, estimate gas used and gas fee gnokey maketx addpkg -pkgdir $WORK/hello -pkgpath gno.land/r/hello -gas-wanted 2000000 -gas-fee 1000000ugnot -broadcast -chainid tendermint_test -simulate only test1 -stdout 'GAS USED: 269942' -stdout 'INFO: estimated gas usage: 269942, gas fee: 283ugnot, current gas price: 1ugnot/1000gas' +stdout 'GAS USED: 270022' +stdout 'INFO: estimated gas usage: 270022, gas fee: 284ugnot, current gas price: 1ugnot/1000gas' ## No fee was charged, and the sequence number did not change. gnokey query auth/accounts/$test1_user_addr @@ -20,7 +20,7 @@ stdout '"sequence": "0"' stdout '"coins": "10000000000000ugnot"' # Using the simulated gas and estimated gas fee should ensure the transaction executes successfully. -gnokey maketx addpkg -pkgdir $WORK/hello -pkgpath gno.land/r/hello -gas-wanted 269914 -gas-fee 282ugnot -broadcast -chainid tendermint_test test1 +gnokey maketx addpkg -pkgdir $WORK/hello -pkgpath gno.land/r/hello -gas-wanted 269994 -gas-fee 282ugnot -broadcast -chainid tendermint_test test1 stdout 'OK' stdout 'EVENTS: \[.*"fee_delta":\{"denom":"ugnot","amount":207700\}.*\]' diff --git a/gno.land/pkg/integration/testdata/restart_gas.txtar b/gno.land/pkg/integration/testdata/restart_gas.txtar index 0fc7b763439..d3b71d87d8e 100644 --- a/gno.land/pkg/integration/testdata/restart_gas.txtar +++ b/gno.land/pkg/integration/testdata/restart_gas.txtar @@ -1,15 +1,15 @@ gnoland start gnokey maketx addpkg -pkgdir $WORK/bar -pkgpath gno.land/r/$test1_user_addr/bar -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test test1 -stdout 'GAS USED: +593639' +stdout 'GAS USED: +593679' gnokey maketx addpkg -pkgdir $WORK/bar -pkgpath gno.land/r/$test1_user_addr/foo -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test test1 -stdout 'GAS USED: +593666' +stdout 'GAS USED: +593706' gnoland restart gnokey maketx addpkg -pkgdir $WORK/baz -pkgpath gno.land/r/$test1_user_addr/baz -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test test1 -stdout 'GAS USED: +593666' +stdout 'GAS USED: +593706' -- bar/gnomod.toml -- module = "bar" diff --git a/gno.land/pkg/sdk/vm/gas_test.go b/gno.land/pkg/sdk/vm/gas_test.go index 3094b3671f7..c67b5da1d05 100644 --- a/gno.land/pkg/sdk/vm/gas_test.go +++ b/gno.land/pkg/sdk/vm/gas_test.go @@ -72,7 +72,7 @@ func TestAddPkgDeliverTx(t *testing.T) { assert.True(t, res.IsOK()) // NOTE: let's try to keep this bellow 250_000 :) - assert.Equal(t, int64(226738), gasDeliver) + assert.Equal(t, int64(226778), gasDeliver) } // Enough gas for a failed transaction. diff --git a/gnovm/adr/pr5436_fix_gc_alloc_mismatch.md b/gnovm/adr/pr5436_fix_gc_alloc_mismatch.md new file mode 100644 index 00000000000..daa373b2c3b --- /dev/null +++ b/gnovm/adr/pr5436_fix_gc_alloc_mismatch.md @@ -0,0 +1,57 @@ +# Fix GC allocation/recount mismatch + +## Context + +When Gno code triggers infinite recursion (e.g., `func f() int { return f() }`), +the VM panics with `"should not happen, allocation limit exceeded while gc."` +instead of the expected `"allocation limit exceeded"`. + +The allocator tracks memory via `alloc.bytes`. When the limit is reached, GC runs: +it resets `alloc.bytes` to 0 and walks all live objects, recounting their sizes via +`GetShallowSize()`. If GC recounts more bytes than were originally tracked, it +concludes the limit was exceeded during GC itself -- a path marked as "should not happen". + +The root cause is in `PrepareNewValues` (`nodes.go`): when new package-level +declarations (functions, variables) are added after initial package creation, +their block items are appended to the package block's `Values` slice via +`block.Values = append(block.Values, nvs...)` **without** calling +`AllocateBlockItems`. This means: + +- **During allocation**: `alloc.bytes` does not account for the new block items +- **During GC recount**: `Block.GetShallowSize()` uses `len(b.Values)` which + includes the appended items + +The mismatch is small (e.g., 80 bytes for 2 functions), but in infinite recursion +the allocator fills to near-max with block allocations, and the untracked bytes +tip the GC recount past `maxBytes`. + +## Decision + +Add `alloc.AllocateBlockItems(int64(len(nvs)))` in `PrepareNewValues` before +appending to `block.Values`. This ensures the allocator tracks the same bytes +that GC will recount. + +## Alternatives considered + +1. **Fix GC to not stop early when recount exceeds maxBytes** -- this masks the + mismatch rather than fixing it. GC would always succeed, but the allocator + state would be inconsistent. + +2. **Track allocations in preprocessing** (adding `AllocateFunc` calls in + `preprocess.go` for FuncValues created during `tryPredefine`) -- this was + explored but turned out to be unnecessary. The FuncValues are properly + allocated via `fv.Copy(alloc)` in `PrepareNewValues`. The actual mismatch + was only the block items. + +3. **Change the panic message** from "should not happen" to "allocation limit + exceeded" -- this was considered as a minimal fix but doesn't address the + underlying inconsistency. + +## Consequences + +- Infinite recursion now correctly panics with `"allocation limit exceeded"` +- GC recount is consistent with allocator tracking for package block items +- Gas values for some tests change slightly because `AllocateBlockItems` charges + gas during package setup +- The "should not happen" panic path remains as a safety net for any future + mismatches -- it should now truly never trigger diff --git a/gnovm/pkg/gnolang/nodes.go b/gnovm/pkg/gnolang/nodes.go index 4beaa95b1e5..aeb0641e537 100644 --- a/gnovm/pkg/gnolang/nodes.go +++ b/gnovm/pkg/gnolang/nodes.go @@ -1434,6 +1434,7 @@ func (pn *PackageNode) PrepareNewValues(alloc *Allocator, pv *PackageValue) []Ty } } } + alloc.AllocateBlockItems(int64(len(nvs))) block.Values = append(block.Values, nvs...) return block.Values[pvl:] } else if pvl > pnl { diff --git a/gnovm/tests/files/alloc_12.gno b/gnovm/tests/files/alloc_12.gno new file mode 100644 index 00000000000..6165b0a20f6 --- /dev/null +++ b/gnovm/tests/files/alloc_12.gno @@ -0,0 +1,18 @@ +// MAXALLOC: 500000000 + +package main + +// Test that infinite recursion triggers "allocation limit exceeded" +// and NOT "should not happen, allocation limit exceeded while gc." +// This verifies that GC recount is consistent with allocator tracking. + +func f() int { + return f() +} + +func main() { + f() +} + +// Error: +// allocation limit exceeded diff --git a/gnovm/tests/files/gas/const.gno b/gnovm/tests/files/gas/const.gno index ce7b5b85843..8c0db7e5abe 100644 --- a/gnovm/tests/files/gas/const.gno +++ b/gnovm/tests/files/gas/const.gno @@ -8,4 +8,4 @@ func main() { } // Gas: -// 2966 +// 3046 diff --git a/gnovm/tests/files/gas/nested_alloc.gno b/gnovm/tests/files/gas/nested_alloc.gno index ec379eda4c8..d57ab37aff7 100644 --- a/gnovm/tests/files/gas/nested_alloc.gno +++ b/gnovm/tests/files/gas/nested_alloc.gno @@ -9,4 +9,4 @@ func main() { } // Gas: -// 13273861 +// 13273901 diff --git a/gnovm/tests/files/gas/slice_alloc.gno b/gnovm/tests/files/gas/slice_alloc.gno index c5f263b4629..e26eb829af3 100644 --- a/gnovm/tests/files/gas/slice_alloc.gno +++ b/gnovm/tests/files/gas/slice_alloc.gno @@ -2,7 +2,7 @@ package main func main() { - alloc(12499894) // 12499894 is the threshold to reach the allocation limit + alloc(12499880) // 12499880 is the threshold to reach the allocation limit } func alloc(n int) { @@ -11,4 +11,4 @@ func alloc(n int) { } // Gas: -// 500003087 +// 500002511 From 4ddfddeaf7d7bc02215796e8264915ae25d8b6c9 Mon Sep 17 00:00:00 2001 From: Morgan Date: Wed, 8 Apr 2026 21:32:40 +0200 Subject: [PATCH 45/92] fix(gnovm): deep-copy array elements in ArrayValue.Copy (#5445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/gnolang/gno/issues/5332 ArrayValue.Copy used Go's builtin copy() to duplicate its []TypedValue slice, which copies TypedValue structs by value. For composite element types (structs, nested arrays), this meant both the original and the copy shared the same *StructValue/*ArrayValue pointers, silently corrupting persistent realm state. The fix mirrors StructValue.Copy, which already calls field.Copy(alloc) per element for exactly this reason. TypedValue.Copy dispatches correctly on the dynamic type: *StructValue and *ArrayValue are deep-copied, while reference-like values (slices, maps, pointers, functions) fall through to the shallow default — so interface-held reference types are handled correctly. The gas expectation for gnovm/tests/files/gas/nested_alloc.gno is updated: building a 10000-deep [1]interface{}{x} chain now incurs O(n²) copy cost because each assignment deep-copies the entire chain, which is the correct cost under Gno's value semantics for interface-held arrays. --------- Co-authored-by: 6h057 <15034695+omarsy@users.noreply.github.com> (cherry picked from commit c64feef1daf41b39d77dcd10dbb8a86d994a2426) --- gnovm/pkg/gnolang/values.go | 4 ++- gnovm/tests/files/array6.gno | 42 ++++++++++++++++++++++++++ gnovm/tests/files/array7.gno | 35 +++++++++++++++++++++ gnovm/tests/files/gas/nested_alloc.gno | 2 +- 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 gnovm/tests/files/array6.gno create mode 100644 gnovm/tests/files/array7.gno diff --git a/gnovm/pkg/gnolang/values.go b/gnovm/pkg/gnolang/values.go index 36e8cf939f3..25622bdfe5d 100644 --- a/gnovm/pkg/gnolang/values.go +++ b/gnovm/pkg/gnolang/values.go @@ -331,7 +331,9 @@ func (av *ArrayValue) Copy(alloc *Allocator) *ArrayValue { */ if av.Data == nil { av2 := alloc.NewListArray(len(av.List)) - copy(av2.List, av.List) + for i, tv := range av.List { + av2.List[i] = tv.Copy(alloc) + } return av2 } av2 := alloc.NewDataArray(len(av.Data)) diff --git a/gnovm/tests/files/array6.gno b/gnovm/tests/files/array6.gno new file mode 100644 index 00000000000..212bc3c90a3 --- /dev/null +++ b/gnovm/tests/files/array6.gno @@ -0,0 +1,42 @@ +package main + +type Record struct { + Value int + Label string +} + +func main() { + var a [2]Record + a[0] = Record{Value: 100, Label: "original-0"} + a[1] = Record{Value: 200, Label: "original-1"} + + // Array assignment must produce a deep copy (Go value semantics). + // Mutating b must never affect a. + b := a + b[0].Value = 999 + b[0].Label = "modified-0" + b[1].Value = 888 + b[1].Label = "modified-1" + + println(a[0].Label, a[0].Value) + println(a[1].Label, a[1].Value) + println(b[0].Label, b[0].Value) + println(b[1].Label, b[1].Value) + + // Also test pass-by-value: modifying a function's array parameter + // must not affect the caller's copy. + modify(a) + println(a[0].Label, a[0].Value) +} + +func modify(arr [2]Record) { + arr[0].Value = 777 + arr[0].Label = "param-modified" +} + +// Output: +// original-0 100 +// original-1 200 +// modified-0 999 +// modified-1 888 +// original-0 100 diff --git a/gnovm/tests/files/array7.gno b/gnovm/tests/files/array7.gno new file mode 100644 index 00000000000..127d821de48 --- /dev/null +++ b/gnovm/tests/files/array7.gno @@ -0,0 +1,35 @@ +package main + +// Nested array assignment must deep-copy (not shallow alias). + +func main() { + // Direct assignment + a := [1][1]int{{1}} + b := a + b[0][0] = 2 + println(a[0][0], b[0][0]) + + // Function argument passing + c := [1][1]int{{1}} + f(c) + println(c[0][0]) + + // Return by value + d := [1][1]int{{1}} + e := g(d) + e[0][0] = 2 + println(d[0][0], e[0][0]) +} + +func f(x [1][1]int) { + x[0][0] = 2 +} + +func g(a [1][1]int) [1][1]int { + return a +} + +// Output: +// 1 2 +// 1 +// 1 2 diff --git a/gnovm/tests/files/gas/nested_alloc.gno b/gnovm/tests/files/gas/nested_alloc.gno index d57ab37aff7..71563a2c74f 100644 --- a/gnovm/tests/files/gas/nested_alloc.gno +++ b/gnovm/tests/files/gas/nested_alloc.gno @@ -9,4 +9,4 @@ func main() { } // Gas: -// 13273901 +// 24810947885 From cbd151c8f5c80ee36216e72662e9c51a0994d7db Mon Sep 17 00:00:00 2001 From: 6h057 <15034695+omarsy@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:42:33 +0200 Subject: [PATCH 46/92] fix(gnoland): prevent duplicate validator removals in EndBlocker (#5356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the test11 chain halt at block 702499, where the node crashed with `"failed to find validator to remove"` after proposals 13 and 14 were executed in consecutive blocks. **Root cause**: `fireEvents()` in `execution.go` fires `EventTx` AFTER block commit. The validator event collector picks these up, so block N's events trigger block N+1's EndBlocker. `GetChanges(from)` used an unbounded inclusive range on the AVL tree, causing the same removals to be returned twice across consecutive EndBlocker calls. **Fix**: Change `GetChanges(from)` to `GetChanges(from, to)` with a bounded `[from, to]` range. The EndBlocker calls `GetChanges(h, h)` where `h = LastBlockHeight()`, fetching exactly one block's changes per call. ## Test plan - [x] `TestGetChanges_BoundedRange` — Gno realm test verifying bounded range semantics on the real AVL tree - [x] `TestEndBlocker/consecutive_proposals_cause_duplicate_removal` — Unit test reproducing the exact test11 scenario - [x] `TestReproduceTest11ChainHalt` — Integration test with real node: crashes without fix (`signal: terminated`), survives with fix ## Breaking change `GetChanges` signature changed from `GetChanges(from int64)` to `GetChanges(from, to int64)`. The only caller is the EndBlocker in `app.go`. 🤖 Generated with [Claude Code](https://claude.ai/claude-code) (cherry picked from commit b9778503678046f152c37b9b52226981e3efdbf3) --- .../gno.land/r/sys/validators/v2/gnosdk.gno | 23 ++++-- .../r/sys/validators/v2/validators_test.gno | 75 ++++++++++++++++++- gno.land/pkg/gnoland/app.go | 5 +- 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/examples/gno.land/r/sys/validators/v2/gnosdk.gno b/examples/gno.land/r/sys/validators/v2/gnosdk.gno index 60b8f84966c..2e705a3bfba 100644 --- a/examples/gno.land/r/sys/validators/v2/gnosdk.gno +++ b/examples/gno.land/r/sys/validators/v2/gnosdk.gno @@ -1,16 +1,29 @@ package validators import ( + "math" + "gno.land/p/sys/validators" ) -// GetChanges returns the validator changes stored on the realm, since the given block number. -// This function is intended to be called by gno.land through the GnoSDK -func GetChanges(from int64) []validators.Validator { +// GetChanges returns the validator changes stored on the realm, +// for blocks in the [from, to] range (inclusive on both ends). +// If to >= math.MaxInt64, it is clamped to math.MaxInt64-1 to avoid overflow. +// Panics if from > to (after clamping). +// This function is intended to be called by gno.land through the GnoSDK. +func GetChanges(from, to int64) []validators.Validator { + if to > math.MaxInt64-1 { + to = math.MaxInt64 - 1 + } + if to < from { + panic("invalid range: from must be <= to") + } + valsetChanges := make([]validators.Validator, 0) - // Gather the changes from the specified block - changes.Iterate(getBlockID(from), "", func(_ string, value any) bool { + // Gather the changes in the [from, to] block range. + // AVL Iterate uses an exclusive end, so we pass to+1. + changes.Iterate(getBlockID(from), getBlockID(to+1), func(_ string, value any) bool { chs := value.([]change) for _, ch := range chs { diff --git a/examples/gno.land/r/sys/validators/v2/validators_test.gno b/examples/gno.land/r/sys/validators/v2/validators_test.gno index 763b17bdd65..497e09ede24 100644 --- a/examples/gno.land/r/sys/validators/v2/validators_test.gno +++ b/examples/gno.land/r/sys/validators/v2/validators_test.gno @@ -2,9 +2,11 @@ package validators import ( "chain/runtime" + "math" "testing" "gno.land/p/nt/avl/v0" + "gno.land/p/nt/poa/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/ufmt/v0" @@ -49,7 +51,7 @@ func TestValidators_AddRemove(t *testing.T) { for i := initialHeight; i < initialHeight+int64(len(vals)); i++ { // Make sure the changes are saved - chs := GetChanges(i) + chs := GetChanges(i, initialHeight+int64(len(vals))) // We use the funky index calculation to make sure // changes are properly handled for each block span @@ -83,7 +85,7 @@ func TestValidators_AddRemove(t *testing.T) { for i := initialRemoveHeight; i < initialRemoveHeight+int64(len(vals)); i++ { // Make sure the changes are saved - chs := GetChanges(i) + chs := GetChanges(i, initialRemoveHeight+int64(len(vals))) // We use the funky index calculation to make sure // changes are properly handled for each block span @@ -99,3 +101,72 @@ func TestValidators_AddRemove(t *testing.T) { } } } + +// TestGetChanges_BoundedRange verifies that GetChanges(from, to) correctly +// returns only changes within the [from, to] block range. +func TestGetChanges_BoundedRange(t *testing.T) { + changes = avl.NewTree() + vp = poa.NewPoA() + + vals := generateTestValidators(3) + + // Store additions at block h1 + h1 := runtime.ChainHeight() + for _, val := range vals { + addValidator(val) + } + testing.SkipHeights(1) + + // Store removals at block h2 + h2 := runtime.ChainHeight() + for _, val := range vals { + removeValidator(val.Address) + } + testing.SkipHeights(1) + + // Query spanning both blocks returns all changes + all := GetChanges(h1, h2) + uassert.Equal(t, 6, len(all)) + + // Query for h1 only returns additions + atH1 := GetChanges(h1, h1) + uassert.Equal(t, 3, len(atH1)) + for i, ch := range atH1 { + uassert.Equal(t, vals[i].Address, ch.Address) + uassert.True(t, ch.VotingPower > 0) + } + + // Query for h2 only returns removals + atH2 := GetChanges(h2, h2) + uassert.Equal(t, 3, len(atH2)) + for i, ch := range atH2 { + uassert.Equal(t, vals[i].Address, ch.Address) + uassert.Equal(t, uint64(0), ch.VotingPower) + } + + // Query beyond stored range returns empty + uassert.Equal(t, 0, len(GetChanges(h2+1, h2+1))) +} + +func TestGetChanges_PanicsOnInvalidRange(t *testing.T) { + uassert.PanicsWithMessage(t, "invalid range: from must be <= to", func() { + GetChanges(10, 5) + }) +} + +func TestGetChanges_ClampsMaxInt64(t *testing.T) { + changes = avl.NewTree() + + vals := generateTestValidators(1) + + // Simulate a validator change at block math.MaxInt64-1 (the boundary value). + changes.Set(getBlockID(math.MaxInt64-1), []change{ + {blockNum: math.MaxInt64 - 1, validator: vals[0]}, + }) + + // Passing math.MaxInt64 as "to" means "get all updates from here onwards". + // The clamp (to = MaxInt64-1) must still include the boundary block. + result := GetChanges(math.MaxInt64-1, math.MaxInt64) + uassert.Equal(t, 1, len(result)) + uassert.Equal(t, vals[0].Address, result[0].Address) +} diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index b0936fd7764..213bc17582e 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -615,11 +615,12 @@ func EndBlocker( return abci.ResponseEndBlock{} } - // Run the VM to get the updates from the chain + // Run the VM to get the validator changes for the last committed block. + lastHeight := app.LastBlockHeight() response, err := vmk.QueryEval( ctx, valRealm, - fmt.Sprintf("%s(%d)", valChangesFn, app.LastBlockHeight()), + fmt.Sprintf("%s(%d,%d)", valChangesFn, lastHeight, lastHeight), ) if err != nil { app.Logger().Error("unable to call VM during EndBlocker", "err", err) From 040fa3f452b7879427c90350bc3518cc9148b32a Mon Sep 17 00:00:00 2001 From: Morgan Date: Mon, 13 Apr 2026 12:03:38 +0200 Subject: [PATCH 47/92] fix(tm2): reject block parts with mismatched proofs in AddPart (#5479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PartSet.AddPart was missing two checks before calling Proof.Verify: - part.Index == part.Proof.Index - part.Proof.Total == ps.total SimpleProof.Verify uses Proof.Index internally when computing the root hash, so a Byzantine peer could gossip a Part with a swapped Index while keeping the original Proof intact. The merkle check would pass, the part would be stored at the wrong position, and IsComplete() would return true with scrambled data — making the assembled block undecodable and causing every consensus round to time out. Add the two guard checks before the merkle verification and add regression tests for both the index-swap and wrong-total cases. (cherry picked from commit b56b78f1e5657e312a6826b822c8bd9f9173480b) --- tm2/pkg/bft/types/part_set.go | 11 +++++++ tm2/pkg/bft/types/part_set_test.go | 47 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/tm2/pkg/bft/types/part_set.go b/tm2/pkg/bft/types/part_set.go index 5311ade6fa0..e7f0523863c 100644 --- a/tm2/pkg/bft/types/part_set.go +++ b/tm2/pkg/bft/types/part_set.go @@ -209,6 +209,17 @@ func (ps *PartSet) AddPart(part *Part) (bool, error) { return false, nil } + // Verify proof metadata matches what we expect. + // Proof.Verify uses Proof.Index internally, so a Byzantine peer could send + // a Part with part.Index != part.Proof.Index and pass the merkle check + // while storing bytes at the wrong position. + if part.Proof.Index != part.Index { + return false, ErrPartSetInvalidProof + } + if part.Proof.Total != ps.total { + return false, ErrPartSetInvalidProof + } + // Check hash proof if part.Proof.Verify(ps.Hash(), part.Bytes) != nil { return false, ErrPartSetInvalidProof diff --git a/tm2/pkg/bft/types/part_set_test.go b/tm2/pkg/bft/types/part_set_test.go index fc346643ea7..546ee9df02c 100644 --- a/tm2/pkg/bft/types/part_set_test.go +++ b/tm2/pkg/bft/types/part_set_test.go @@ -89,6 +89,53 @@ func TestWrongProof(t *testing.T) { } } +// TestAddPartSwappedIndex is a regression test for a Byzantine attack where a +// peer sends a Part with part.Index != part.Proof.Index. Because +// SimpleProof.Verify uses Proof.Index internally, the merkle check passes even +// though the bytes would be stored at the wrong slot, making the assembled +// block undecodable. AddPart must reject such parts before the merkle check. +func TestAddPartSwappedIndex(t *testing.T) { + t.Parallel() + + data := random.RandBytes(testPartSize * 3) + legitSet := NewPartSetFromData(data, testPartSize) + require.Equal(t, 3, legitSet.Total()) + + part0 := legitSet.GetPart(0) + part1 := legitSet.GetPart(1) + + victimSet := NewPartSetFromHeader(legitSet.Header()) + + // Byzantine: send part1's proof+bytes but claim Index=0. + added, err := victimSet.AddPart(&Part{Index: 0, Bytes: part1.Bytes, Proof: part1.Proof}) + assert.False(t, added) + assert.ErrorIs(t, err, ErrPartSetInvalidProof) + + // Byzantine: send part0's proof+bytes but claim Index=1. + added, err = victimSet.AddPart(&Part{Index: 1, Bytes: part0.Bytes, Proof: part0.Proof}) + assert.False(t, added) + assert.ErrorIs(t, err, ErrPartSetInvalidProof) +} + +// TestAddPartWrongTotal checks that a Part whose proof carries a different +// total than the PartSet is rejected. +func TestAddPartWrongTotal(t *testing.T) { + t.Parallel() + + data := random.RandBytes(testPartSize * 3) + legitSet := NewPartSetFromData(data, testPartSize) + require.Equal(t, 3, legitSet.Total()) + + part := legitSet.GetPart(0) + victimSet := NewPartSetFromHeader(legitSet.Header()) + + // Tamper the proof total. + part.Proof.Total = legitSet.Total() + 1 + added, err := victimSet.AddPart(part) + assert.False(t, added) + assert.ErrorIs(t, err, ErrPartSetInvalidProof) +} + func TestPartSetHeaderValidateBasic(t *testing.T) { t.Parallel() From 1624cb18f9c568503a7435f1601905d92f8e125c Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:11:06 +0200 Subject: [PATCH 48/92] feat(gnovm): implement iterative exception recovery to prevent stack overflow (#5439) fix: https://dashboard.hackenproof.com/manager/companies/newtendermint/gno-dot-land/reports/NEWTENDG-182 --------- Co-authored-by: Morgan Bazalgette (cherry picked from commit 3be0408f0e224501dba06b578c95151731c08f61) --- .../pr5439_iterative_exception_recovery.md | 78 +++++++++++++++++++ gnovm/pkg/gnolang/machine.go | 34 +++++--- gnovm/tests/files/defer11.gno | 29 +++++++ 3 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 gnovm/adr/pr5439_iterative_exception_recovery.md create mode 100644 gnovm/tests/files/defer11.gno diff --git a/gnovm/adr/pr5439_iterative_exception_recovery.md b/gnovm/adr/pr5439_iterative_exception_recovery.md new file mode 100644 index 00000000000..e5cc92f608c --- /dev/null +++ b/gnovm/adr/pr5439_iterative_exception_recovery.md @@ -0,0 +1,78 @@ +# PR5439: Iterative Exception Recovery in Machine.Run() + +## Context + +`Machine.Run()` contained a `defer/recover` handler that caught Go-level `*Exception` +panics and recursively called `m.Run(st)`. This design meant each panicking deferred +function added a new Go stack frame. An attacker could register enough deferred closures +that each trigger a nil pointer dereference (a Go-level `panic(&Exception{})`), causing +unbounded Go stack growth that exceeds the Go runtime's 1GB goroutine stack limit +(~500K defers are sufficient to crash the process). + +The resulting `runtime.throw("stack overflow")` is a fatal error that bypasses all +`recover()` handlers in the call chain — including the VM keeper's `doRecover` and +`BaseApp.runTx()` — killing the node process. + +The GnoVM has two panic mechanisms: +1. **Cooperative path** (`pushPanic`): pushes `OpReturnCallDefers` + `OpPanic2` onto the + op stack and returns. The main `for` loop processes defers iteratively — no Go stack growth. +2. **Go-level path** (`panic(&Exception{...})`): used at ~19 call sites in `values.go`, + `alloc.go`, and `realm.go`. These trigger real Go panics that unwind past the `for` loop + and are caught by `Run()`'s defer/recover. + +The old code converted Go-level exceptions back into the cooperative path via `pushPanic`, +but then re-entered the op loop by recursively calling `m.Run(st)` — accumulating one Go +stack frame per exception. + +## Decision + +Split `Machine.Run()` into two methods: + +- **`Run(st Stage)`** — outer method, contains the benchmark defers and an iterative loop. + When `runOnce()` returns a caught `*Exception`, it calls `pushPanic` and loops back. + No recursion, O(1) Go stack frames regardless of the number of panicking defers. + +- **`runOnce() *Exception`** — inner method with its own `defer/recover`. Runs the op loop + until `OpHalt` (returns nil) or a Go-level `*Exception` panic is caught (returns the + exception). Non-Exception panics are re-raised. + +This preserves the existing semantics: Go-level `*Exception` panics are still converted +to the cooperative `pushPanic` path, and the op loop still processes `OpReturnCallDefers` +iteratively. The only change is that re-entering the op loop after catching an exception +no longer adds a Go stack frame. + +### Alternatives considered + +1. **Depth counter on recursive `Run()`**: Would limit recursion depth, but choosing the + right limit is fragile and the recursive design is fundamentally unnecessary. + +2. **Convert all 19 Go-level `panic(&Exception{})` sites to use `pushPanic`**: Would + eliminate the problem at the source, but is a much larger change that touches many + files and risks subtle behavioral differences. The iterative approach is a minimal, + surgical fix. + +## Key files + +| File | Role | +|------|------| +| `gnovm/pkg/gnolang/machine.go:1268` | `Run()` — outer iterative loop | +| `gnovm/pkg/gnolang/machine.go:1300` | `runOnce()` — inner op loop with defer/recover | +| `gnovm/tests/files/defer_panic_many.gno` | Regression filetest — 500K panicking defers | + +## Testing + +A dedicated filetest (`gnovm/tests/files/defer_panic_many.gno`) registers 500K deferred +closures that each trigger a nil pointer dereference — a Go-level `panic(&Exception{})`. +Before the fix, this would exhaust the Go goroutine stack via recursive `m.Run(st)` calls, +crashing the process with `runtime.throw("stack overflow")`. With the iterative recovery +loop, all 500K panicking defers complete in ~1s and the final panic is recovered normally. + +The fix is also validated by the existing 96 panic/defer/recover file tests in +`gnovm/tests/files/`, which exercise the `Run()`/`runOnce()` iterative recovery path +on every run. + +## Consequences + +- Node processes can no longer be crashed by transactions with many panicking defers. +- The Gno-level panic semantics are preserved — all 96 panic/defer/recover file tests pass. +- `runOnce` is unexported, keeping the public API unchanged. diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 5c751b3071b..18e70803320 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -1279,18 +1279,30 @@ func (m *Machine) Run(st Stage) { bm.FinishNative() }() } - defer func() { - r := recover() - if r != nil { - switch r := r.(type) { - case *Exception: - if r.Stacktrace.IsZero() { - r.Stacktrace = m.Stacktrace() - } - m.pushPanic(r.Value) - m.Run(st) - default: + // Iterative exception recovery: catch Go-level *Exception panics and + // convert them to the cooperative pushPanic path without recursion. + for { + caught := m.runOnce() + if caught == nil { + return + } + if caught.Stacktrace.IsZero() { + caught.Stacktrace = m.Stacktrace() + } + m.pushPanic(caught.Value) + } +} + +// runOnce executes the op loop until it completes (OpHalt) or a Go-level +// *Exception panic is caught. Returns the caught exception, or nil if the +// loop completed normally. Non-Exception panics are re-raised. +func (m *Machine) runOnce() (caught *Exception) { + defer func() { + if r := recover(); r != nil { + if ex, ok := r.(*Exception); ok { + caught = ex + } else { panic(r) } } diff --git a/gnovm/tests/files/defer11.gno b/gnovm/tests/files/defer11.gno new file mode 100644 index 00000000000..34ccbdb644a --- /dev/null +++ b/gnovm/tests/files/defer11.gno @@ -0,0 +1,29 @@ +package main + +// Regression test for iterative exception recovery in Machine.Run(). +// Before the fix, each panicking defer added a Go stack frame via recursive +// m.Run(st), and ~500K defers would exceed the 1GB goroutine stack limit, +// crashing the process with runtime.throw("stack overflow"). +// The iterative recovery loop handles this in O(1) Go stack frames. + +type S struct { + X int +} + +func main() { + defer func() { + // Recover the final panic after all 500K panicking defers complete. + println(recover()) + }() + var p *S + for i := 0; i < 500000; i++ { + defer func() { + // Nil pointer dereference triggers a Go-level panic(&Exception{}), + // not the cooperative pushPanic path. + _ = p.X + }() + } +} + +// Output: +// nil pointer dereference From a3468c0055c1f3d0585025e305f7c35a7696c23c Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 14 Apr 2026 14:36:35 +0900 Subject: [PATCH 49/92] fix(gnovm): skip closure frames in `AssertOriginCall` origin check (#5407) ## Description fixes #3918 Fixes an "invalid non-origin call" panic occurs when `runtime.AssertOriginCall()` is invoked through an anonymous(closure) function. The previous `isOriginCall` determined whether a call was an origin call by comparing the number of frames against a hardcoded constant. Closures add a frame upon invocation, but they should not be semantically treated as a separate method call. Although `FuncValue.IsClosure` clearly distinguish between func lits and func decls, `isOriginCall` did not utilize this and instead compared only the total number of frames. https://github.com/gnolang/gno/blob/c59fc2eb0057ef3016078d475bfffb11242be680/gnovm/pkg/gnolang/values.go#L486 I believe this was the root cause of the issue, so I updated all instance of `isOriginCall` to determine origin call status based on the frame count excluding closure frames. ### Changes - Added `Machine.NumNonClosureFrames()` to count only non-closure frame calls - Added file tests reproducing this issue (`std13.gno`, `std14.gno`) --------- Co-authored-by: ltzMaxwell (cherry picked from commit d27fdaff526549459d2aeb6d0d3c19c238122e81) --- gnovm/pkg/gnolang/machine.go | 16 +++++ gnovm/stdlibs/chain/runtime/native.go | 7 ++- gnovm/tests/files/std13.gno | 18 ++++++ gnovm/tests/files/std14.gno | 47 +++++++++++++++ gnovm/tests/files/std15.gno | 33 +++++++++++ gnovm/tests/files/std16.gno | 59 +++++++++++++++++++ .../stdlibs/chain/runtime/testing_runtime.go | 15 +++-- 7 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 gnovm/tests/files/std13.gno create mode 100644 gnovm/tests/files/std14.gno create mode 100644 gnovm/tests/files/std15.gno create mode 100644 gnovm/tests/files/std16.gno diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 18e70803320..d0b866f00d0 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -2148,6 +2148,22 @@ func (m *Machine) NumFrames() int { return len(m.Frames) } +// NumCallFrames returns the number of actual function call frames, +// excluding closure frames (func literals) and control-flow basic +// frames (for/range/switch where Func is nil). Only named, non-closure +// function calls count as separate call boundaries for origin-call +// purposes. +func (m *Machine) NumCallFrames() int { + count := 0 + for i := range m.Frames { + fr := &m.Frames[i] + if fr.Func != nil && !fr.Func.IsClosure { + count++ + } + } + return count +} + // Returns the current frame. func (m *Machine) LastFrame() *Frame { return &m.Frames[len(m.Frames)-1] diff --git a/gnovm/stdlibs/chain/runtime/native.go b/gnovm/stdlibs/chain/runtime/native.go index fe45c5208b0..ac9b6ed1288 100644 --- a/gnovm/stdlibs/chain/runtime/native.go +++ b/gnovm/stdlibs/chain/runtime/native.go @@ -18,7 +18,12 @@ func isOriginCall(m *gno.Machine) bool { } firstPkg := m.Frames[0].LastPackage isMsgCall := firstPkg != nil && firstPkg.PkgPath == "" - return n <= 2 && isMsgCall + if !isMsgCall { + return false + } + // Count only actual function call frames (excludes closures + // and control-flow basic frames like for/range/switch). + return m.NumCallFrames() <= 2 } func ChainID(m *gno.Machine) string { diff --git a/gnovm/tests/files/std13.gno b/gnovm/tests/files/std13.gno new file mode 100644 index 00000000000..3ff922eecda --- /dev/null +++ b/gnovm/tests/files/std13.gno @@ -0,0 +1,18 @@ +package main + +import "chain/runtime" + +func Register() { + runtime.AssertOriginCall() +} + +func main() { + fn := func() { + Register() + } + fn() + println("ok") +} + +// Output: +// ok diff --git a/gnovm/tests/files/std14.gno b/gnovm/tests/files/std14.gno new file mode 100644 index 00000000000..794a27b871c --- /dev/null +++ b/gnovm/tests/files/std14.gno @@ -0,0 +1,47 @@ +package main + +import "chain/runtime" + +func Register() { + runtime.AssertOriginCall() +} + +func helper() { + Register() +} + +func main() { + // Case 1: closure wrapping Register — should NOT panic. + // Closures are transparent: main → Register → AssertOriginCall (3 call frames). + func() { + Register() + }() + println("closure call ok") + + // Case 2: nested closures wrapping Register — should NOT panic. + // All closures are transparent: main → Register → AssertOriginCall (3 call frames). + func() { + func() { + Register() + }() + }() + println("nested closure call ok") + + // Case 3: named function calling Register — SHOULD panic. + // main → helper → Register → AssertOriginCall (4 call frames > 3). + panicked := false + func() { + defer func() { + if r := recover(); r != nil { + panicked = true + } + }() + helper() + }() + println("named func call panicked:", panicked) +} + +// Output: +// closure call ok +// nested closure call ok +// named func call panicked: true diff --git a/gnovm/tests/files/std15.gno b/gnovm/tests/files/std15.gno new file mode 100644 index 00000000000..65edfd1ada9 --- /dev/null +++ b/gnovm/tests/files/std15.gno @@ -0,0 +1,33 @@ +package main + +import "chain/runtime" + +func Register() { + runtime.AssertOriginCall() +} + +func main() { + // for-loop basic frame should be transparent. + for i := 0; i < 1; i++ { + Register() + } + println("for ok") + + // range basic frame should be transparent. + for range []int{1} { + Register() + } + println("range ok") + + // switch basic frame should be transparent. + switch 1 { + case 1: + Register() + } + println("switch ok") +} + +// Output: +// for ok +// range ok +// switch ok diff --git a/gnovm/tests/files/std16.gno b/gnovm/tests/files/std16.gno new file mode 100644 index 00000000000..64a4b32385e --- /dev/null +++ b/gnovm/tests/files/std16.gno @@ -0,0 +1,59 @@ +package main + +import "chain/runtime" + +func Register() { + runtime.AssertOriginCall() +} + +func callFunc(f func()) { + f() +} + +func helper() { + Register() +} + +func main() { + // Security case 1: closure passed as callback to a named function. + // main → callFunc → Register → AssertOriginCall (4 call frames > 3). + // The closure is transparent, but callFunc is a real call boundary — SHOULD panic. + panicked := false + func() { + defer func() { + if r := recover(); r != nil { + panicked = true + } + }() + callFunc(func() { + Register() + }) + }() + println("callback to named func panicked:", panicked) + + // Security case 2: named function wrapped in closure. + // main → helper → Register → AssertOriginCall (4 call frames > 3). + // The outer closure is transparent, but helper is a real call — SHOULD panic. + panicked = false + func() { + defer func() { + if r := recover(); r != nil { + panicked = true + } + }() + helper() + }() + println("named func in closure panicked:", panicked) + + // Sanity: direct closure call — should NOT panic. + // main → Register → AssertOriginCall (3 call frames). + func() { + Register() + }() + println("direct closure ok") +} + +// Output: +// callback to named func panicked: true +// named func in closure panicked: true +// direct closure ok diff --git a/gnovm/tests/stdlibs/chain/runtime/testing_runtime.go b/gnovm/tests/stdlibs/chain/runtime/testing_runtime.go index 4951c43ddd2..ec93776857d 100644 --- a/gnovm/tests/stdlibs/chain/runtime/testing_runtime.go +++ b/gnovm/tests/stdlibs/chain/runtime/testing_runtime.go @@ -40,25 +40,30 @@ func typedString(s gno.StringValue) gno.TypedValue { func isOriginCall(m *gno.Machine) bool { tname := m.Frames[0].Func.Name + // Count only actual function call frames (excludes closures and + // control-flow basic frames like for/range/switch). + callFrames := m.NumCallFrames() switch tname { case "main": // test is a _filetest + // Non-closure frames expected: // 0. main // 1. $RealmFuncName - // 2. td.IsOriginCall - return len(m.Frames) == 3 + // 2. runtime.AssertOriginCall + return callFrames == 3 case "RunTest": // test is a _test + // Non-closure frames expected: // 0. testing.RunTest // 1. tRunner // 2. $TestFuncName // 3. $RealmFuncName - // 4. std.IsOriginCall - return len(m.Frames) == 5 + // 4. runtime.AssertOriginCall + return callFrames == 5 } // support init() in _filetest // XXX do we need to distinguish from 'runtest'/_test? // XXX pretty hacky even if not. if strings.HasPrefix(string(tname), "init.") { - return len(m.Frames) == 3 + return callFrames == 3 } panic("unable to determine if test is a _test or a _filetest") } From 1445587d5309535c35417a8d7e5fd4031f8ec27f Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:48:27 +0200 Subject: [PATCH 50/92] feat(gnoweb): Add Source and Action button for realm explorer (#5032) close: #5028 image --------- Co-authored-by: Guilhem Fanton <8671905+gfanton@users.noreply.github.com> (cherry picked from commit e6637e7d0c383cc487682758891e7b736975e278) --- gno.land/pkg/gnoweb/components/template.go | 2 + .../pkg/gnoweb/components/view_directory.go | 19 ++++---- gno.land/pkg/gnoweb/components/view_test.go | 48 +------------------ .../gnoweb/components/views/directory.html | 28 +++++++---- .../pkg/gnoweb/frontend/css/06-blocks.css | 11 ++++- gno.land/pkg/gnoweb/handler_http_test.go | 24 ++++++++++ gno.land/pkg/gnoweb/public/main.css | 2 +- 7 files changed, 69 insertions(+), 65 deletions(-) diff --git a/gno.land/pkg/gnoweb/components/template.go b/gno.land/pkg/gnoweb/components/template.go index 20e31a77dea..4ebe808e3c7 100644 --- a/gno.land/pkg/gnoweb/components/template.go +++ b/gno.land/pkg/gnoweb/components/template.go @@ -6,6 +6,7 @@ import ( "fmt" "html/template" "net/url" + "strings" ) //go:embed ui/*.html views/*.html layouts/*.html @@ -34,6 +35,7 @@ func registerCommonFuncs(funcs template.FuncMap) { return vals.Has(key) } funcs["FormatRelativeTime"] = FormatRelativeTimeSince + funcs["hasPrefix"] = strings.HasPrefix // dict creates a map from key-value pairs for passing multiple values to templates funcs["dict"] = func(kv ...any) (map[string]any, error) { if len(kv)%2 != 0 { diff --git a/gno.land/pkg/gnoweb/components/view_directory.go b/gno.land/pkg/gnoweb/components/view_directory.go index 3c75d83d047..1e41bf39ed9 100644 --- a/gno.land/pkg/gnoweb/components/view_directory.go +++ b/gno.land/pkg/gnoweb/components/view_directory.go @@ -4,7 +4,6 @@ const DirectoryViewType ViewType = "dir-view" type DirData struct { PkgPath string - Files []string FileCounter int FilesLinks FilesLinks Mode ViewMode @@ -18,7 +17,7 @@ const ( DirLinkTypeFile ) -// Get the prefixed link depending on link type - Package Source Code or Package File +// LinkPrefix returns the prefixed link depending on link type func (d DirLinkType) LinkPrefix(pkgPath string) string { switch d { case DirLinkTypeSource: @@ -29,28 +28,32 @@ func (d DirLinkType) LinkPrefix(pkgPath string) string { return "" } -// Files has to be an array with Link (prefixed) and Name (filename) +// FullFileLink represents a package entry in the directory listing. type FullFileLink struct { Link string Name string } -// FilesLinks has to be an array of FileLink +// FilesLinks is a slice of FullFileLink type FilesLinks []FullFileLink -func GetFullLinks(files []string, linkType DirLinkType, pkgPath string) FilesLinks { +// buildFilesLinks creates FilesLinks from files +func buildFilesLinks(files []string, linkType DirLinkType, pkgPath string) FilesLinks { result := make(FilesLinks, len(files)) for i, file := range files { - result[i] = FullFileLink{Link: linkType.LinkPrefix(pkgPath) + file, Name: file} + result[i] = FullFileLink{ + Link: linkType.LinkPrefix(pkgPath) + file, + Name: file, + } } return result } +// DirectoryView creates a directory view func DirectoryView(pkgPath string, files []string, fileCounter int, linkType DirLinkType, mode ViewMode, readme ...Component) *View { viewData := DirData{ PkgPath: pkgPath, - Files: files, - FilesLinks: GetFullLinks(files, linkType, pkgPath), + FilesLinks: buildFilesLinks(files, linkType, pkgPath), FileCounter: fileCounter, Mode: mode, } diff --git a/gno.land/pkg/gnoweb/components/view_test.go b/gno.land/pkg/gnoweb/components/view_test.go index fcc03968e00..37a3181fce5 100644 --- a/gno.land/pkg/gnoweb/components/view_test.go +++ b/gno.land/pkg/gnoweb/components/view_test.go @@ -304,7 +304,7 @@ func TestDirectoryView(t *testing.T) { assert.True(t, ok, "expected DirData type in component data") assert.Equal(t, pkgPath, dirData.PkgPath, "expected PkgPath %s, got %s", pkgPath, dirData.PkgPath) - assert.Equal(t, len(files), len(dirData.Files), "expected %d files, got %d", len(files), len(dirData.Files)) + assert.Equal(t, len(files), len(dirData.FilesLinks), "expected %d files, got %d", len(files), len(dirData.FilesLinks)) assert.Equal(t, fileCounter, dirData.FileCounter, "expected FileCounter %d, got %d", fileCounter, dirData.FileCounter) assert.Equal(t, mode, dirData.Mode, "expected Mode %v, got %v", mode, dirData.Mode) @@ -347,52 +347,6 @@ func TestDirLinkType_LinkPrefix(t *testing.T) { } } -func TestGetFullLinks(t *testing.T) { - cases := []struct { - name string - files []string - linkType DirLinkType - pkgPath string - expected FilesLinks - }{ - { - name: "Source link type with multiple files", - files: []string{"file1.gno", "file2.gno"}, - linkType: DirLinkTypeSource, - pkgPath: "/r/test/pkg", - expected: FilesLinks{ - {Link: "/r/test/pkg$source&file=file1.gno", Name: "file1.gno"}, - {Link: "/r/test/pkg$source&file=file2.gno", Name: "file2.gno"}, - }, - }, - { - name: "File link type with multiple files", - files: []string{"file1.gno", "file2.gno"}, - linkType: DirLinkTypeFile, - pkgPath: "/r/test/pkg", - expected: FilesLinks{ - {Link: "file1.gno", Name: "file1.gno"}, - {Link: "file2.gno", Name: "file2.gno"}, - }, - }, - { - name: "Empty files list", - files: []string{}, - linkType: DirLinkTypeSource, - pkgPath: "/r/test/pkg", - expected: FilesLinks{}, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - result := GetFullLinks(tc.files, tc.linkType, tc.pkgPath) - assert.Equal(t, tc.expected, result) - }) - } -} - func TestUserView(t *testing.T) { data := UserData{ Username: "testuser", diff --git a/gno.land/pkg/gnoweb/components/views/directory.html b/gno.land/pkg/gnoweb/components/views/directory.html index 27d75050973..e68ee3d1c9c 100644 --- a/gno.land/pkg/gnoweb/components/views/directory.html +++ b/gno.land/pkg/gnoweb/components/views/directory.html @@ -11,15 +11,27 @@

{{ $pkgpath }}

diff --git a/gno.land/pkg/gnoweb/frontend/css/06-blocks.css b/gno.land/pkg/gnoweb/frontend/css/06-blocks.css index b18936cba52..fb79e7744f5 100644 --- a/gno.land/pkg/gnoweb/frontend/css/06-blocks.css +++ b/gno.land/pkg/gnoweb/frontend/css/06-blocks.css @@ -895,7 +895,7 @@ a.b-banner:hover { border-top: var(--s-border); } - & > a { + & > :where(a, div) { display: flex; justify-content: space-between; align-items: center; @@ -908,6 +908,15 @@ a.b-banner:hover { .c-icon { margin-inline-start: 0; } + + & > a { + flex: 1; + min-width: 0; + + &:hover { + background-color: transparent; + } + } } } diff --git a/gno.land/pkg/gnoweb/handler_http_test.go b/gno.land/pkg/gnoweb/handler_http_test.go index d48f0b7b217..d6110a27ae9 100644 --- a/gno.land/pkg/gnoweb/handler_http_test.go +++ b/gno.land/pkg/gnoweb/handler_http_test.go @@ -365,6 +365,30 @@ func TestHTTPHandler_DirectoryViewErrorTotal(t *testing.T) { assert.Contains(t, rr.Body.String(), "internal error") } +// TestHTTPHandler_RealmExplorerWithRender tests realms with Render() show realm icon and Source button. +func TestHTTPHandler_RealmExplorerWithRender(t *testing.T) { + t.Parallel() + + realmWithRender := &gnoweb.MockPackage{ + Domain: "gno.land", + Path: "/r/demo/withrender", + Files: map[string]string{"render.gno": `package withrender`}, + Functions: []*doc.JSONFunc{{ + Name: "Render", + Params: []*doc.JSONField{{Name: "path", Type: "string"}}, + Results: []*doc.JSONField{{Type: "string"}}, + }}, + } + + handler, _ := gnoweb.NewHTTPHandler(slog.New(slog.NewTextHandler(&testingLogger{t}, nil)), newTestHandlerConfig(t, gnoweb.NewMockClient(realmWithRender))) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/r/demo/withrender", nil)) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Source") + assert.Contains(t, rr.Body.String(), "Action") +} + // TestNewWebHandlerInvalidConfig ensures that NewWebHandler fails on invalid config. func TestHTTPHandler_NewInvalidConfig(t *testing.T) { t.Parallel() diff --git a/gno.land/pkg/gnoweb/public/main.css b/gno.land/pkg/gnoweb/public/main.css index ece537a6e51..c592ed16f73 100644 --- a/gno.land/pkg/gnoweb/public/main.css +++ b/gno.land/pkg/gnoweb/public/main.css @@ -1,5 +1,5 @@ :root{--g-px-base:16;--g-space-mult:4;--g-space-base:calc(1rem/var(--g-space-mult));--g-breakpoint-max:calc(1580/var(--g-px-base)*1rem);--g-z-min:-1;--g-z-1:1;--g-z-max:9999;--g-duration-75:75ms;--g-duration-150:150ms;--g-opacity-50:0.5;--g-grid-1:repeat(1,minmax(0,1fr));--g-grid-10:repeat(10,minmax(0,1fr));--g-space-px:1px;--g-space-0-5:calc(var(--g-space-base)*0.5);--g-space-1:var(--g-space-base);--g-space-1-5:calc(var(--g-space-base)*1.5);--g-space-2:calc(var(--g-space-base)*2);--g-space-2-5:calc(var(--g-space-base)*2.5);--g-space-3:calc(var(--g-space-base)*3);--g-space-4:calc(var(--g-space-base)*4);--g-space-4-5:calc(var(--g-space-base)*4.5);--g-space-5:calc(var(--g-space-base)*5);--g-space-6:calc(var(--g-space-base)*6);--g-space-7:calc(var(--g-space-base)*7);--g-space-8:calc(var(--g-space-base)*8);--g-space-10:calc(var(--g-space-base)*10);--g-space-12:calc(var(--g-space-base)*12);--g-space-14:calc(var(--g-space-base)*14);--g-space-20:calc(var(--g-space-base)*20);--g-space-24:calc(var(--g-space-base)*24);--g-space-28:calc(var(--g-space-base)*28);--g-space-32:calc(var(--g-space-base)*32);--g-space-36:calc(var(--g-space-base)*36);--g-space-44:calc(var(--g-space-base)*44);--g-space-48:calc(var(--g-space-base)*48);--g-space-52:calc(var(--g-space-base)*52);--g-space-72:calc(var(--g-space-base)*72);--g-space-96:calc(var(--g-space-base)*96);--g-font-size-50:calc(12/var(--g-px-base)*1rem);--g-font-size-100:calc(14/var(--g-px-base)*1rem);--g-font-size-200:calc(16/var(--g-px-base)*1rem);--g-font-size-300:calc(18/var(--g-px-base)*1rem);--g-font-size-400:calc(20/var(--g-px-base)*1rem);--g-font-size-500:calc(22/var(--g-px-base)*1rem);--g-font-size-600:calc(24/var(--g-px-base)*1rem);--g-font-size-700:calc(32/var(--g-px-base)*1rem);--g-font-size-800:calc(38/var(--g-px-base)*1rem);--g-font-family-mono:"Roboto",'Menlo, Consolas, "Ubuntu Mono", "Roboto Mono", "DejaVu Sans Mono", monospace';--g-font-family-inter-var:"Inter",'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif';--g-font-normal:400;--g-font-medium:500;--g-font-semibold:600;--g-font-bold:700;--g-italic:oblique 14deg;--g-line-height-tight:1.25;--g-line-height-snug:1.375;--g-line-height-normal:1.5;--g-border-radius-sm:calc(4/var(--g-px-base)*1rem);--g-border-radius:calc(6/var(--g-px-base)*1rem);--g-border-radius-full:9999px;--g-color-light:#fff;--g-color-transparent:transparent;--g-color-gray-50:#f0f0f0;--g-color-gray-100:#e2e2e2;--g-color-gray-200:#bdbdbd;--g-color-gray-300:#999;--g-color-gray-400:#7c7c7c;--g-color-gray-500:#696969;--g-color-gray-600:#585858;--g-color-gray-700:#292929;--g-color-gray-750:#1f1f1f;--g-color-gray-800:#141414;--g-color-gray-850:#0e0e0e;--g-color-gray-900:#090909;--g-color-green-50:#e7efed;--g-color-green-400:#60ab96;--g-color-green-500:#277b63;--g-color-green-600:#226c57;--g-color-green-900:#144134;--g-color-green-950:#002c20;--g-color-blue-400:#49afeb;--g-color-blue-600:#3e96c9;--g-color-blue-900:#21506b;--g-color-yellow-50:#fff7eb;--g-color-yellow-400:#facc32;--g-color-yellow-600:#fbbf24;--g-color-yellow-900:#7b4807;--g-color-yellow-950:#362600;--g-color-red-400:#eb6c49;--g-color-red-600:#c95c3e;--g-color-red-900:#6b2521;--g-color-purple-400:#7f49eb;--g-color-purple-600:#6c3ec9;--g-color-purple-900:#39216b}@supports (color:color(display-p3 0 0 0%)){:root{--g-color-green-950:#002c20;--g-color-yellow-50:#fff7eb;--g-color-yellow-950:#362600}@media (color-gamut:p3){:root{--g-color-green-950:color(display-p3 0.04602 0.17026 0.1277);--g-color-yellow-50:color(display-p3 0.99709 0.97106 0.92232);--g-color-yellow-950:color(display-p3 0.2031 0.15112 0.01811)}}}:root{--s-color-bg-base:var(--g-color-light,#fff);--s-color-bg-base-dev:var(--g-color-gray-50,#f0f0f0);--s-color-bg-surface-primary:var(--g-color-gray-50,#f0f0f0);--s-color-bg-surface-primary-hover:var(--g-color-gray-100,#f0f0f0);--s-color-bg-surface-secondary:var(--g-color-gray-100,#e2e2e2);--s-color-bg-surface-quaternary:var(--g-color-gray-400,#7c7c7c);--s-color-bg-brand-default:var(--g-color-green-600,#226c57);--s-color-bg-brand-weak:var(--g-color-green-50,#f0f9ff);--s-color-bg-success-default:var(--g-color-green-600,#144134);--s-color-bg-info-default:var(--g-color-blue-600,#21506b);--s-color-bg-warning-default:var(--g-color-yellow-600,#665100);--s-color-bg-warning-weak:var(--g-color-yellow-50,#f9d985);--s-color-bg-warning-action:var(--g-color-yellow-400,#f9d985);--s-color-bg-caution-default:var(--g-color-red-600,#610);--s-color-bg-tip-default:var(--g-color-purple-600,#49216b);--s-color-bg-note-default:var(--g-color-gray-600,#21506b);--s-color-bg-input:var(--g-color-light,#fff);--s-color-text-base:var(--g-color-light,#fff);--s-color-text-primary:var(--g-color-gray-900,#080809);--s-color-text-secondary:var(--g-color-gray-600,#454a4e);--s-color-text-tertiary:var(--g-color-gray-400,#f0f0f0);--s-color-text-tertiary-hover:var(--g-color-gray-600,#e2e2e2);--s-color-text-quaternary:var(--g-color-gray-100,#f0f0f0);--s-color-text-brand-default:var(--g-color-light,#fff);--s-color-text-link:var(--g-color-green-600,#226c57);--s-color-text-link-hover:var(--g-color-green-600,#226c57);--s-color-text-success:var(--g-color-green-900,#144134);--s-color-text-info:var(--g-color-blue-900,#21506b);--s-color-text-warning:var(--g-color-yellow-900,#665100);--s-color-text-caution:var(--g-color-red-900,#610);--s-color-text-tip:var(--g-color-purple-900,#49216b);--s-color-border-primary:var(--g-color-gray-200,#bdbdbd);--s-color-border-secondary:var(--g-color-gray-100,#e2e2e2);--s-color-border-tertiary:var(--g-color-gray-300,#999);--s-color-border-quaternary:var(--g-color-gray-400,#7c7c7c);--s-color-border-transparent:var(--g-color-transparent,transparent);--s-color-border-input:var(--g-color-gray-300,#999);--s-color-border-brand-default:var(--g-color-green-600,#226c57);--s-color-border-success:var(--g-color-green-600,#144134);--s-color-border-info:var(--g-color-blue-600,#21506b);--s-color-border-warning:var(--g-color-yellow-600,#665100);--s-color-border-error:var(--g-color-red-600,#610);--s-color-border-tip:var(--g-color-purple-600,#49216b);--s-color-border-note:var(--g-color-gray-600,#21506b);--s-rounded-sm:var(--g-border-radius-sm,4px);--s-rounded:var(--g-border-radius,6px);--s-rounded-full:var(--g-border-radius-full,9999px);--s-border:var(--g-space-px,1px) solid var(--s-color-border-primary);--s-border-secondary:var(--g-space-px,1px) solid var(--s-color-border-secondary);--s-logo-hat:var(--g-color-green-600,#226c57);--s-logo-beard:var(--g-color-gray-300,#999)}[data-theme=dark]{--s-color-bg-base:var(--g-color-gray-850);--s-color-bg-base-dev:var(--g-color-gray-800);--s-color-bg-surface-primary:var(--g-color-gray-800);--s-color-bg-surface-primary-hover:var(--g-color-gray-750);--s-color-bg-surface-secondary:var(--g-color-gray-750);--s-color-bg-surface-quaternary:var(--g-color-gray-600);--s-color-bg-brand-weak:var(--g-color-green-950);--s-color-bg-warning-weak:var(--g-color-yellow-950);--s-color-bg-input:var(--g-color-gray-800);--s-color-text-primary:var(--g-color-gray-100);--s-color-text-secondary:var(--g-color-gray-200);--s-color-text-tertiary:var(--g-color-gray-400);--s-color-text-tertiary-hover:var(--g-color-gray-300);--s-color-text-quaternary:var(--g-color-gray-500);--s-color-text-brand-default:var(--g-color-light);--s-color-text-link:var(--g-color-green-500);--s-color-text-link-hover:var(--g-color-green-400);--s-color-text-success:var(--g-color-green-400);--s-color-text-info:var(--g-color-blue-400);--s-color-text-warning:var(--g-color-yellow-400);--s-color-text-caution:var(--g-color-red-400);--s-color-text-tip:var(--g-color-purple-400);--s-color-border-primary:var(--g-color-gray-700);--s-color-border-secondary:var(--g-color-gray-750);--s-color-border-tertiary:var(--g-color-gray-600);--s-color-border-quaternary:var(--g-color-gray-500);--s-color-border-input:var(--g-color-gray-700);--s-color-border-brand-default:var(--g-color-green-600);--s-color-border-success:var(--g-color-green-400);--s-color-border-info:var(--g-color-blue-400);--s-color-border-warning:var(--g-color-yellow-400);--s-color-border-error:var(--g-color-red-400);--s-color-border-tip:var(--g-color-purple-400);--s-color-border-note:var(--g-color-gray-600);--s-logo-hat:#fff;--s-logo-beard:grey}*,::backdrop,::file-selector-button,:after,:before{border:0 solid;box-sizing:border-box;margin:0;padding:0}html{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}h1,h2,h3{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub{bottom:-.25em;font-size:75%;line-height:0;position:relative;vertical-align:baseline}table{border-collapse:collapse;border-color:inherit;text-indent:0}summary{display:list-item}menu,ol,ul{list-style:none}embed,img,object,svg{display:block;vertical-align:middle}img{height:auto;max-width:100%}::file-selector-button,button,input,select,textarea{background-color:transparent;border-radius:0;color:inherit;font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;opacity:1}::file-selector-button{margin-right:4px}::-moz-placeholder{opacity:1}::placeholder{opacity:1}@supports (not (-webkit-appearance:-apple-pay-button)) or (contain-intrinsic-size:1px){::-moz-placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}::-webkit-calendar-picker-indicator{line-height:1}::file-selector-button,button,input:where([type=button],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}@font-face{font-display:swap;font-family:Roboto;font-style:normal;font-weight:900;src:url(fonts/roboto/roboto-mono-normal.woff2) format("woff2"),url(fonts/roboto/roboto-mono-normal.woff) format("woff")}@font-face{font-display:block;font-family:Inter;font-style:oblique 0deg 10deg;font-variant:normal;font-weight:100 900;src:url(fonts/intervar/Intervar.woff2) format("woff2")}html{background-color:var(--s-color-bg-base);color:var(--s-color-text-secondary);font-family:var(--g-font-family-inter-var);font-feature-settings:"kern" on,"liga" on,"calt" off,"zero" on,contextual common-ligatures,"kern";-webkit-font-feature-settings:"kern" on,"liga" on,"calt" off,"zero" on;font-size:calc(var(--g-px-base)*1px);line-height:var(--g-line-height-normal);-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-kerning:normal;font-variant-ligatures:contextual common-ligatures;text-rendering:optimizeLegibility}body{display:flex;flex-direction:column;min-height:100vh}main{background-color:var(--s-color-bg-base);flex-grow:2;width:100%}main.dev-mode{background-color:var(--s-color-bg-base-dev)}main>section{display:grid;grid-auto-flow:dense;grid-template-columns:var(--g-grid-1);grid-column-gap:var(--g-space-20);-moz-column-gap:var(--g-space-20);column-gap:var(--g-space-20);min-height:100%;padding-left:var(--g-space-4);padding-right:var(--g-space-4)}@media (min-width:calc(640 / 16 * 1rem)){main>section{padding-left:var(--g-space-10);padding-right:var(--g-space-10)}}@media (min-width:calc(820 / 16 * 1rem)){main>section{grid-template-columns:var(--g-grid-10)}}@media (min-width:calc(1366 / 16 * 1rem)){main>section{-moz-column-gap:var(--g-space-32);column-gap:var(--g-space-32)}}svg{max-height:100%;max-width:100%}form{margin-bottom:0;margin-top:0}code{font-family:var(--g-font-mono)}summary{cursor:pointer}md-renderer{margin-top:var(--g-space-4);padding-bottom:var(--g-space-24)}@media (min-width:calc(820 / 16 * 1rem)){md-renderer{grid-column:span 7;margin-top:0}}::-moz-selection{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}::selection{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}summary::-webkit-details-marker{display:none}summary::marker{display:none}.c-stack{display:flex;flex-direction:column;justify-content:flex-start}.c-stack>*+*{margin-top:var(--g-space-4)}.c-inline{align-items:center;display:inline-flex;gap:var(--g-space-3)}.c-between{align-items:center;display:flex;justify-content:space-between}.c-center{box-sizing:border-box;margin-left:auto;margin-right:auto;max-width:var(--g-breakpoint-max);padding-left:var(--g-space-4);padding-right:var(--g-space-4)}@media (min-width:calc(640 / 16 * 1rem)){.c-center{padding-left:var(--g-space-10);padding-right:var(--g-space-10)}}.c-full-screen{align-items:center;display:flex;flex-direction:column;grid-column:1/-1;height:100%;justify-content:center;margin-top:var(--g-space-10);padding-bottom:var(--g-space-24);width:100%}.c-reel{display:flex;overflow:scroll}.c-icon{flex-shrink:0;height:1.15em;width:1.15em}.c-with-icon{align-items:flex-start;display:inline-flex}.c-with-icon .c-icon,.c-with-icon--inline .c-icon{margin-left:.3em;margin-right:.3em;margin-top:.15em}.c-with-icon--inline{display:inline-block}.c-with-icon--inline>*{vertical-align:middle}.c-with-icon--inline .c-icon{margin-top:0}.c-view-grid{display:flex;flex-direction:column}@media (min-width:calc(640 / 16 * 1rem)){.c-view-grid{-moz-column-gap:var(--g-space-8);column-gap:var(--g-space-8);flex-direction:row}}@media (min-width:calc(820 / 16 * 1rem)){.c-view-grid{display:grid;grid-template-columns:var(--g-grid-10);grid-column-gap:var(--g-space-20);-moz-column-gap:var(--g-space-20);column-gap:var(--g-space-20)}}@media (min-width:calc(1366 / 16 * 1rem)){.c-view-grid{-moz-column-gap:var(--g-space-32);column-gap:var(--g-space-32)}}.c-toggle-btn>input{display:none}.c-toggle-btn label{visibility:hidden}.c-toggle-btn input:checked+label{visibility:visible}.c-readme-view,.c-realm-view{--cr-px-base:var(--g-px-base);--cr-space-mult:1;--cr-space-base:calc(1em/var(--g-space-mult)*var(--cr-space-mult));--cr-space-0:0;--cr-space-0-5:calc(var(--cr-space-base)*0.5);--cr-space-1:var(--cr-space-base);--cr-space-2:calc(var(--cr-space-base)*2);--cr-space-3:calc(var(--cr-space-base)*3);--cr-space-4:calc(var(--cr-space-base)*4);--cr-space-5:calc(var(--cr-space-base)*5);--cr-space-7:calc(var(--cr-space-base)*7);--cr-space-8:calc(var(--cr-space-base)*8);--cr-space-24:calc(var(--cr-space-base)*24);--cr-color-brand-default:var(--s-color-text-link);display:block;font-size:calc(var(--cr-px-base)*1px);padding-top:var(--g-space-4);word-break:break-word}.c-readme-view:empty,.c-realm-view:empty{display:none}.c-realm-view:has(.b-btn:only-child){display:none}.c-readme-view:has(.b-btn:only-child){display:none}@media (min-width:calc(820 / 16 * 1rem)){.c-readme-view,.c-realm-view{grid-row-start:1;padding-top:var(--g-space-6)}}.c-readme-view a,.c-realm-view a{color:var(--cr-color-brand-default);display:inline-block;font-weight:inherit;position:relative;text-wrap:balance;vertical-align:top}.c-readme-view a:hover,.c-realm-view a:hover{-webkit-text-decoration:underline;text-decoration:underline}.c-realm-view a:has(>img){vertical-align:middle}.c-readme-view a:has(>img){vertical-align:middle}.c-readme-view a>span,.c-realm-view a>span{margin-bottom:.1em}.c-readme-view a>.tooltip+.tooltip,.c-realm-view a>.tooltip+.tooltip{margin-left:.2em}.c-readme-view a>.tooltip:last-of-type,.c-realm-view a>.tooltip:last-of-type{margin-right:.2em}.c-realm-view a:has(>img:first-child):has(.tooltip:last-child):not(:has(>:nth-child(3)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip:last-child):not(:has(>:nth-child(3)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-realm-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-realm-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip:first-of-type{bottom:var(--g-space-2);left:var(--g-space-7);position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip:first-of-type{bottom:var(--g-space-2);left:var(--g-space-7);position:absolute}.c-readme-view h1+h2,.c-readme-view h2+h3,.c-readme-view h3+h4,.c-realm-view h1+h2,.c-realm-view h2+h3,.c-realm-view h3+h4{margin-top:var(--cr-space-4)}.c-readme-view h1,.c-readme-view h2,.c-readme-view h3,.c-readme-view h4,.c-realm-view h1,.c-realm-view h2,.c-realm-view h3,.c-realm-view h4{color:var(--s-color-text-primary);line-height:var(--g-line-height-tight);margin-top:var(--cr-space-4)}.c-readme-view h1,.c-realm-view h1{font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);margin-bottom:var(--cr-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h1,.c-realm-view h1{font-size:var(--g-font-size-800)}}.c-readme-view h2,.c-realm-view h2{font-size:var(--g-font-size-500);font-weight:var(--g-font-bold)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h2,.c-realm-view h2{font-size:var(--g-font-size-600)}}.c-readme-view h2 *,.c-realm-view h2 *{font-weight:var(--g-font-bold)}.c-readme-view h3,.c-readme-view h4,.c-realm-view h3,.c-realm-view h4{color:var(--s-color-text-secondary);font-weight:var(--g-font-semibold)}.c-readme-view h3,.c-realm-view h3{font-size:var(--g-font-size-400);margin-top:var(--cr-space-4)}.c-readme-view h4,.c-realm-view h4{font-size:var(--g-font-size-300);margin-top:var(--cr-space-3)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h4,.c-realm-view h4{font-size:var(--g-font-size-300)}}.c-readme-view h3 *,.c-readme-view h4 *,.c-realm-view h3 *,.c-realm-view h4 *{font-weight:var(--g-font-semibold)}.c-readme-view h5,.c-readme-view h6,.c-realm-view h5,.c-realm-view h6{font-size:var(--g-font-size-300);font-weight:var(--g-font-bold);margin-bottom:var(--cr-space-0);margin-top:var(--cr-space-0)}.c-readme-view h5+p,.c-readme-view h6+p,.c-realm-view h5+p,.c-realm-view h6+p{margin-top:var(--cr-space-0)}.c-readme-view img,.c-realm-view img{border:1px solid var(--s-color-bg-surface-primary);border-radius:var(--g-border-radius-sm);margin-bottom:var(--cr-space-2);margin-top:var(--cr-space-2);max-width:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none}.c-readme-view figure,.c-realm-view figure{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3);text-align:center}.c-readme-view figcaption,.c-realm-view figcaption{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100)}.c-readme-view video,.c-realm-view video{margin-bottom:var(--g-space-4);margin-top:var(--g-space-4);max-width:100%}.c-readme-view p,.c-realm-view p{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3)}.c-realm-view p:has(>a:only-child>img){margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4)}.c-readme-view p:has(>a:only-child>img){margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4)}.c-realm-view p:has(>a:only-child>img) img{margin-bottom:0;margin-top:0}.c-readme-view p:has(>a:only-child>img) img{margin-bottom:0;margin-top:0}.c-readme-view strong,.c-readme-view strong *,.c-realm-view strong,.c-realm-view strong *{font-weight:var(--g-font-bold)}.c-readme-view em,.c-realm-view em{font-style:var(--g-italic)}.c-readme-view blockquote,.c-realm-view blockquote{border-left:solid var(--g-space-0-5) var(--s-color-border-tertiary);color:var(--s-color-text-secondary);margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4);padding-left:var(--g-space-3)}.c-readme-view blockquote>blockquote,.c-realm-view blockquote>blockquote{margin-bottom:var(--cr-space-7);margin-top:var(--cr-space-7)}.c-readme-view caption,.c-realm-view caption{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);margin-top:var(--cr-space-2);text-align:left}.c-readme-view q,.c-realm-view q{quotes:"“" "”"}.c-readme-view q:before,.c-realm-view q:before{content:open-quote}.c-readme-view q:after,.c-realm-view q:after{content:close-quote}.c-readme-view details,.c-realm-view details{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3)}.c-readme-view summary,.c-realm-view summary{cursor:pointer;font-weight:var(--g-font-bold)}.c-readme-view math,.c-realm-view math{font-family:var(--g-font-family-mono)}.c-readme-view small,.c-realm-view small{font-size:var(--g-font-size-100)}.c-readme-view del,.c-realm-view del{-webkit-text-decoration:line-through;text-decoration:line-through}.c-readme-view sub,.c-realm-view sub{font-size:var(--g-font-size-50);vertical-align:sub}.c-readme-view sup,.c-realm-view sup{font-size:var(--g-font-size-50);padding-left:var(--space-px);vertical-align:middle}.c-readme-view sup>a,.c-realm-view sup>a{vertical-align:middle}.c-readme-view ol,.c-readme-view ul,.c-realm-view ol,.c-realm-view ul{margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4);padding-left:var(--g-space-4)}.c-readme-view ul,.c-realm-view ul{list-style:disc}.c-readme-view ol,.c-realm-view ol{list-style:decimal}.c-readme-view ol ol,.c-readme-view ol ul,.c-readme-view ul ol,.c-readme-view ul ul,.c-realm-view ol ol,.c-realm-view ol ul,.c-realm-view ul ol,.c-realm-view ul ul{margin-bottom:var(--cr-space-2);margin-top:var(--cr-space-2);padding-left:var(--g-space-4)}.c-readme-view li,.c-realm-view li{margin-bottom:var(--cr-space-1);margin-top:var(--cr-space-1)}.c-readme-view code,.c-readme-view pre,.c-realm-view code,.c-realm-view pre{font-family:var(--g-font-family-mono)}.c-readme-view pre,.c-readme-view pre.chroma-chroma,.c-realm-view pre,.c-realm-view pre.chroma-chroma{background-color:var(--s-color-bg-surface-primary);border-radius:var(--g-border-radius-sm);margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3);overflow-x:auto;padding:var(--cr-space-4)}.c-readme-view :not(pre)>code,.c-realm-view :not(pre)>code{background-color:var(--s-color-bg-surface-secondary);border-radius:var(--g-border-radius-sm);font-size:.96em;padding:var(--cr-space-0-5) var(--cr-space-1)}.c-readme-view a code,.c-realm-view a code{color:inherit}.c-readme-view hr,.c-realm-view hr{border-top:var(--s-border-secondary);margin-bottom:var(--cr-space-8);margin-top:var(--cr-space-8)}.c-readme-view table,.c-realm-view table{border-collapse:collapse;display:block;margin-bottom:var(--cr-space-5);margin-top:var(--cr-space-5);max-width:100%;width:100%}.c-readme-view td,.c-readme-view th,.c-realm-view td,.c-realm-view th{border:var(--s-border);padding:var(--cr-space-2) var(--cr-space-4);white-space:normal;word-break:break-word}.c-readme-view th,.c-realm-view th{background-color:var(--s-color-bg-surface-secondary);font-weight:var(--g-font-bold)}.c-readme-view button,.c-readme-view input,.c-readme-view select,.c-readme-view textarea,.c-realm-view button,.c-realm-view input,.c-realm-view select,.c-realm-view textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--s-color-bg-input);border:var(--s-border);padding:var(--cr-space-2) var(--cr-space-4)}.c-readme-view>.realm-view__btns:first-child+*,.c-readme-view>:first-child:not(.realm-view__btns),.c-realm-view>.realm-view__btns:first-child+*,.c-realm-view>:first-child:not(.realm-view__btns){margin-top:0!important}.c-readme-view .footnote-backref,.c-readme-view h1:not(.does-not-exist),.c-readme-view h2:not(.does-not-exist),.c-readme-view h3:not(.does-not-exist),.c-readme-view h4:not(.does-not-exist),.c-readme-view sup:not(.does-not-exist),.c-realm-view .footnote-backref,.c-realm-view h1:not(.does-not-exist),.c-realm-view h2:not(.does-not-exist),.c-realm-view h3:not(.does-not-exist),.c-realm-view h4:not(.does-not-exist),.c-realm-view sup:not(.does-not-exist){scroll-margin-top:var(--cr-space-24)}.c-readme-view .b-btn,.c-realm-view .b-btn{color:var(--s-color-text-secondary);display:inline-flex}.c-readme-view .b-btn:hover,.c-realm-view .b-btn:hover{-webkit-text-decoration:none;text-decoration:none}.c-readme-view .b-btn:first-child,.c-realm-view .b-btn:first-child{float:right;margin-top:var(--g-space-4)}.c-readme-view>.b-btn:first-child+*,.c-readme-view>:first-child:not(.b-btn),.c-realm-view>.b-btn:first-child+*,.c-realm-view>:first-child:not(.b-btn){margin-top:0}.c-readme-view{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius);margin-bottom:var(--g-space-6);padding:var(--g-space-6) var(--g-space-4) var(--g-space-4);width:100%}@media (min-width:calc(820 / 16 * 1rem)){.c-readme-view{grid-row-start:auto}}.b-gnome .hat,.b-logo .hat{fill:var(--s-logo-hat)}.b-gnome .beard,.b-logo .beard{fill:var(--s-logo-beard)}.b-banner{align-items:center;background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base);display:flex;font-size:var(--g-font-size-50);font-weight:var(--g-font-semibold);justify-content:center;padding:var(--g-space-1-5) var(--g-space-4);text-align:center;-webkit-text-decoration:none;text-decoration:none;width:100%}@media (min-width:calc(640 / 16 * 1rem)){.b-banner{font-size:var(--g-font-size-100)}}a.b-banner:hover{opacity:.9;-webkit-text-decoration:underline;text-decoration:underline}.b-header{background-color:var(--s-color-bg-base);border-bottom:var(--s-border);font-size:var(--g-font-size-100);position:sticky;top:0;z-index:var(--g-z-max)}.b-header nav{align-items:stretch;height:auto}.b-header .main-nav{align-items:stretch;display:flex;flex:1 1 auto;gap:var(--g-space-1);height:100%;min-width:0;padding-bottom:var(--g-space-2);padding-top:var(--g-space-2);width:100%}@media (min-width:calc(820 / 16 * 1rem)){.b-header .main-nav{grid-column:span 7}}.b-header .main-nav--explorer{grid-column:span 10}.b-header .user-picture{border:var(--s-border-secondary);border-radius:var(--s-rounded);cursor:pointer;flex-shrink:0;height:var(--g-space-10);width:var(--g-space-10)}.b-header .user-picture>svg{height:100%;width:100%}.b-main-navigation{color:var(--s-color-text-quaternary);height:auto;position:relative;width:100%}.b-main-navigation>.inner{align-items:center;background-color:var(--s-color-bg-surface-secondary);border:var(--s-border-secondary);border-radius:var(--s-rounded);height:100%;padding-left:var(--g-space-1-5);padding-right:var(--g-space-1-5);position:relative}@media (min-width:calc(640 / 16 * 1rem)){.b-main-navigation>.inner{padding-right:var(--g-space-8)}}.b-main-navigation>.inner:has([data-role=header-input-search]:focus-within){border-color:var(--s-color-border-tertiary)}.b-main-navigation .searchbar{bottom:0;color:var(--s-color-text-secondary);font-size:var(--g-font-size-200);font-weight:var(--g-font-medium);left:0;padding:var(--g-space-1-5);padding-right:var(--g-space-8);position:absolute;right:0;top:0}.b-main-navigation .searchbar>input{background-color:transparent;height:100%;outline:none;width:100%}.b-main-navigation .searchbar:focus-within+.b-breadcrumb{display:none}.b-main-navigation .network-toggle{align-items:center;background-color:var(--g-color-transparent);border-radius:var(--g-border-radius);cursor:pointer;display:none;height:calc(100% - 2px);justify-content:center;padding:var(--g-space-1-5);position:absolute;right:1px;top:1px;z-index:var(--g-z-max)}@media (min-width:calc(640 / 16 * 1rem)){.b-main-navigation .network-toggle{display:flex}}.b-main-navigation .network-toggle>svg{color:var(--s-color-text-tertiary);height:var(--g-space-5);width:var(--g-space-5)}.b-main-navigation .network-toggle:hover>svg{color:var(--s-color-text-tertiary-hover)}.b-main-navigation .b-popup-dialog>.inner{color:var(--s-color-text-tertiary);width:var(--g-space-72)}.b-main-navigation .b-popup-dialog header>span{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold)}.b-main-navigation .b-popup-dialog .item{display:flex;gap:var(--g-space-1)}.b-main-navigation .b-popup-dialog .item>svg{height:var(--g-space-4);width:var(--g-space-4)}.b-main-navigation .b-popup-dialog .item-content{display:flex;flex-direction:column}.b-main-navigation .b-popup-dialog .item-label{font-size:var(--g-font-size-50)}.b-main-navigation .b-popup-dialog .item-value{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold)}.b-main-menu{display:flex;flex:0 0 auto;grid-column:span 3;height:var(--g-space-12)}@media (min-width:calc(640 / 16 * 1rem)){.b-main-menu{height:auto}}.b-main-menu .menu-toggle{align-items:center;cursor:pointer;display:flex;margin-left:auto;order:3}.b-main-menu .menu-toggle>svg{height:var(--g-space-5);margin-left:var(--g-space-4);width:var(--g-space-5)}@media (min-width:calc(820 / 16 * 1rem)){.b-main-menu .menu-toggle>svg{margin-left:var(--g-space-2)}}.b-main-menu .menu-toggle-input~.menu-dev{display:none}.b-main-menu .menu-toggle-input:checked~.menu-dev{display:flex}.b-main-menu .menu-toggle-input:checked~.menu-general{display:none}.b-main-menu .menu-dev,.b-main-menu .menu-general{display:flex;height:100%;justify-content:flex-end}.b-menu-link:last-child,.b-menu-link:last-child .link{margin-right:0}.b-menu-link .link{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold);gap:var(--g-space-1);height:100%;margin-right:var(--g-space-3);position:relative}.b-menu-link .link:hover{color:var(--s-color-text-tertiary-hover)}.b-menu-link .link:after{background-color:var(--s-color-bg-brand-default);border-radius:var(--s-rounded) var(--s-rounded) 0 0;bottom:0;content:"";height:var(--g-space-1);left:0;position:absolute;transition:width var(--g-transition-fast);width:0}.b-menu-link .link>svg{flex-shrink:0;height:var(--g-space-5);min-width:var(--g-space-2);width:var(--g-space-5)}@media (min-width:calc(1020 / 16 * 1rem)){.b-menu-link .link>svg{display:none}}@media (min-width:calc(1366 / 16 * 1rem)){.b-menu-link .link>svg{display:inline-block;height:var(--g-space-4-5);width:var(--g-space-4-5)}}@media (min-width:calc(640 / 16 * 1rem)){.b-menu-link .link{font-weight:var(--g-font-bold)}}@media (min-width:calc(1366 / 16 * 1rem)){.b-menu-link .link{margin-right:var(--g-space-6);padding-right:var(--g-space-1)}}@media (min-width:calc(640 / 16 * 1rem)){.b-menu-link .link-label{display:none}}@media (min-width:calc(1020 / 16 * 1rem)){.b-menu-link .link-label{display:inline}}.b-menu-link .link--icon{font-weight:var(--g-font-regular);margin-right:var(--g-space-4)}@media (min-width:calc(480 / 16 * 1rem)){.b-menu-link .link--icon{margin-right:var(--g-space-6)}}.b-menu-link .link--is-active{color:var(--s-color-text-secondary)}.b-menu-link .link--is-active:after{width:100%}.b-menu-link .link--is-active>svg{color:var(--s-color-bg-brand-default)}.menu-general .link{color:var(--s-color-text-secondary)}.menu-general .link:hover{color:var(--s-color-text-link-hover)}.b-breadcrumb{display:flex}.b-breadcrumb,.b-breadcrumb:after{background-color:var(--s-color-bg-surface-secondary)}.b-breadcrumb:after{bottom:0;content:"";display:block;left:0;pointer-events:none;position:absolute;right:0;top:0}.b-breadcrumb>ol{color:var(--s-color-text-primary);display:flex;font-weight:var(--g-font-semibold);line-height:var(--g-line-height-snug)}.b-breadcrumb .argument,.b-breadcrumb .element,.b-breadcrumb .query{align-items:center;display:flex;white-space:nowrap;z-index:var(--g-z-1)}.b-breadcrumb .argument:not(:first-child):before,.b-breadcrumb .element:not(:first-child):before,.b-breadcrumb .query:not(:first-child):before{color:var(--s-color-text-tertiary);content:"/";line-height:var(--g-line-height-normal);padding-left:.18rem;padding-right:.18rem;padding-top:var(--g-space-px)}.b-breadcrumb .argument a,.b-breadcrumb .element a,.b-breadcrumb .query a{background-color:var(--s-color-bg-base);border:1px solid var(--s-color-border-transparent);border-radius:var(--s-rounded-sm);display:inline-block;min-width:var(--g-space-4);padding:var(--g-space-0-5);text-align:center}.b-breadcrumb .argument a:hover,.b-breadcrumb .element a:hover,.b-breadcrumb .query a:hover{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}.b-breadcrumb .argument:not(:first-child):before{content:":"}.b-breadcrumb .argument a{background-color:var(--s-color-bg-surface-quaternary);color:var(--s-color-text-base)}.b-breadcrumb .query:not(:first-child):before{content:"&"}.b-breadcrumb .query:nth-child(1 of .query):before{content:"?"}.b-breadcrumb .query label{background-color:var(--s-color-bg-surface-primary);border:var(--s-border);border-radius:var(--s-rounded-sm);color:var(--s-color-text-secondary);cursor:text;display:inline-flex;height:100%;min-width:var(--g-space-4);padding:var(--g-space-0-5) var(--g-space-1);position:relative;text-align:center;width:100%}.b-breadcrumb .query label:focus-within{border-color:var(--s-color-border-quaternary)}.b-breadcrumb .query label:hover{border-color:var(--s-color-border-quaternary)}.b-breadcrumb .query input{background-color:var(--s-color-bg-surface-primary);max-width:10ch;order:3;outline:none;field-sizing:content}@supports not (field-sizing:content){.b-breadcrumb .query input{width:5rem!important}}.b-breadcrumb .query input::-moz-placeholder{opacity:0}.b-breadcrumb .query input::placeholder{opacity:0}.b-breadcrumb .query input:-moz-placeholder{width:var(--g-space-px)}.b-breadcrumb .query input:placeholder-shown{width:var(--g-space-px)}.b-breadcrumb .query input:placeholder-shown::-moz-placeholder{color:var(--g-color-transparent)}.b-breadcrumb .query input:-moz-placeholder::placeholder{color:var(--g-color-transparent)}.b-breadcrumb .query input:placeholder-shown::placeholder{color:var(--g-color-transparent)}.b-footer{border-top:var(--s-border);font-size:var(--g-font-size-100);padding-bottom:var(--g-space-4);padding-top:var(--g-space-4);width:100%}.b-footer>nav{flex-direction:column;row-gap:var(--g-space-8)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer>nav{flex-wrap:wrap}}.b-footer .logo{color:var(--s-color-text-primary);grid-column:1/-1;width:var(--g-space-44)}.b-footer .logo:hover{color:var(--s-color-text-primary);-webkit-text-decoration:none;text-decoration:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .logo{align-self:center;grid-column:1/3;grid-row:1/1;width:60%}}.b-footer .nav-primary{display:flex;gap:var(--g-space-10);grid-column:1/-1;grid-row:2/3}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-primary{align-items:center;flex:1 0 0%;flex-direction:row;gap:var(--g-space-6);justify-content:space-between}}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .nav-primary{grid-column:2/8;grid-row:1/1}}.b-footer .nav-primary>ul{display:flex;flex:1;flex-direction:column;flex-wrap:wrap;gap:var(--g-space-1) var(--g-space-3)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-primary>ul{flex:initial;flex-direction:row}.b-footer .nav-social{margin-left:auto}}@media (min-width:calc(820 / 16 * 1rem)){.b-footer .nav-social{grid-column:span 3;justify-self:end;margin-left:0}}.b-footer .nav-theme{align-items:center;display:flex;gap:var(--g-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-theme{flex-basis:100%}}@media (min-width:calc(820 / 16 * 1rem)){.b-footer .nav-theme{grid-column:span 3}}.b-footer .nav-theme .nav-theme-label{color:var(--s-color-text-secondary)}.b-footer .nav-theme:has([data-theme-target=sun]:not(.u-hidden)) .nav-theme-label:before{content:"Light"}.b-footer .nav-theme:has([data-theme-target=moon]:not(.u-hidden)) .nav-theme-label:before{content:"Dark"}.b-footer .nav-theme:has([data-theme-target=system]:not(.u-hidden)) - .nav-theme-label:before{content:"System"}.b-footer .legal{color:var(--s-color-text-tertiary);font-size:var(--g-font-size-50);margin-top:var(--g-space-3);padding-top:var(--g-space-3)}.b-footer .legal>nav{color:var(--s-color-text-secondary);display:flex;flex-direction:column;flex-wrap:wrap;gap:var(--g-space-1) var(--g-space-3);margin-top:var(--g-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .legal>nav{flex-direction:row}.b-footer .legal>nav>a+a:before{color:var(--s-color-text-quaternary);content:"|";margin-right:var(--g-space-3)}}.b-footer .legal>nav:nth-child(3){grid-column:span 2/span 2}.b-footer .legal>:last-child:not(ul),.b-footer .legal>nav li{margin-bottom:var(--g-space-2);margin-top:var(--g-space-2)}.b-footer .legal>:last-child:not(ul){flex-basis:100%}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .legal>:last-child:not(ul){flex-basis:auto;grid-column:span 1/span 1}}.b-footer a:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}.b-content-header{display:flex;flex-direction:column;gap:var(--g-space-3);grid-row:span 1/span 1;margin-bottom:var(--g-space-6);margin-top:var(--g-space-10)}@media (min-width:calc(820 / 16 * 1rem)){.b-content-header{grid-column:span 7/span 7;grid-row-start:1;justify-content:space-between;margin-top:var(--g-space-10)}}@media (min-width:calc(1020 / 16 * 1rem)){.b-content-header{align-items:center;flex-direction:row}}.b-content-header .title{align-items:center;display:flex;gap:var(--g-space-3)}.b-content-header .header-info{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);gap:var(--g-space-12);justify-content:space-between}.b-content-header .b-inline-btn>span{display:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-content-header .b-inline-btn>span{display:inline}}.b-content-h1{font-size:var(--g-font-size-600);text-align:center}.b-content-h1,.b-content-h2{color:var(--s-color-text-primary);font-weight:var(--g-font-bold)}.b-content-h2{font-size:var(--g-font-size-400);margin-bottom:var(--g-space-4)}.b-btns{align-items:center;display:flex;gap:var(--g-space-1)}@media (min-width:calc(1020 / 16 * 1rem)){.b-btns{gap:var(--g-space-2)}}.b-btn{border:var(--s-border);border-radius:var(--s-rounded-sm);cursor:pointer;display:inline-flex;gap:var(--g-space-1-5);min-width:-moz-max-content;min-width:max-content;padding:var(--g-space-1) var(--g-space-2)}.b-btn:hover{background-color:var(--s-color-bg-surface-primary-hover)}.b-btn .c-icon{margin-left:0;margin-right:0}.b-btn--secondary:hover{background-color:var(--s-color-bg-surface-primary)}.b-inline-btn{color:var(--s-color-text-tertiary);cursor:pointer}.b-inline-btn:hover{color:var(--s-color-text-tertiary-hover)}.b-switch input,.b-switch label:last-child{display:none}.b-switch input+label,.b-switch input:checked~label:last-child{display:block}.b-switch input:checked+label{display:none}.b-block-form,.b-inline-form{color:var(--s-color-text-tertiary);display:flex;flex-direction:column;gap:var(--g-space-2) var(--g-space-3)}@media (min-width:calc(820 / 16 * 1rem)){.b-block-form,.b-inline-form{flex-direction:row}}.b-block-form{align-items:stretch}@media (min-width:calc(820 / 16 * 1rem)){.b-block-form{flex-direction:column}}.b-input{border:var(--s-border);border-radius:var(--s-rounded-sm);color:var(--s-color-text-secondary);display:flex;font-size:var(--g-font-size-100);min-width:var(--g-space-48);overflow:hidden;position:relative}.b-input>svg{height:var(--g-space-4);pointer-events:none;position:absolute;top:50%;transform:translateY(-50%);width:var(--g-space-4)}.b-input>svg:first-child{left:var(--g-space-2)}.b-input>svg:last-child{right:var(--g-space-2)}.b-input:hover,.b-input>input:focus,.b-input>input:hover{border-color:var(--s-color-border-tertiary)}.b-input:has(input:focus),.b-input:hover,.b-input>input:focus,.b-input>input:hover{border-color:var(--s-color-border-tertiary)}.b-input:hover>label{background-color:var(--s-color-bg-surface-primary)}.b-input:has(input:focus)>label,.b-input:hover>label{background-color:var(--s-color-bg-surface-primary)}.b-input>label{align-items:center;background-color:var(--s-color-bg-surface-secondary);gap:var(--g-space-3);white-space:nowrap}.b-input>input,.b-input>label,.b-input>select{display:flex;padding:var(--g-space-1-5) var(--g-space-3)}.b-input>input,.b-input>select{color:inherit;outline:none;width:100%}@media (min-width:calc(820 / 16 * 1rem)){.b-input>input,.b-input>select{padding:var(--g-space-1-5) var(--g-space-2)}}.b-input>select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--s-color-bg-surface-secondary);cursor:pointer}.b-input>select:hover{background-color:var(--s-color-bg-surface-primary)}.b-input>input{background-color:var(--s-color-bg-base);border-left:none}.b-input>label+input{border-left:var(--s-border)}.b-list{margin-bottom:var(--g-space-10)}.b-list>li{border-bottom:var(--s-border);color:var(--s-color-text-tertiary)}.b-list>li:first-child{border-top:var(--s-border)}.b-list>li>a{align-items:center;display:flex;justify-content:space-between;padding:var(--g-space-2)}.b-list>li>a:hover{background-color:var(--s-color-bg-surface-primary-hover)}.b-list>li>a .c-icon{margin-left:0}.b-list .name{display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;color:var(--s-color-text-secondary);margin-left:var(--g-space-1);max-width:100%;overflow:hidden;text-overflow:ellipsis}.b-user-sidebar{margin-top:var(--g-space-4)}.b-user-sidebar>*+*{margin-top:var(--g-space-8)}.b-user-sidebar .user-avatar{border:var(--s-border);border-radius:var(--s-rounded);height:var(--g-space-24);width:var(--g-space-24)}@media (min-width:calc(640 / 16 * 1rem)){.b-user-sidebar .user-avatar{height:var(--g-space-36);width:var(--g-space-36)}}.b-user-sidebar .user-avatar img,.b-user-sidebar .user-avatar svg{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.b-user-sidebar .user-info{align-items:flex-start;display:flex;gap:var(--g-space-6)}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar .user-info{flex-direction:column}}.b-user-sidebar .user-info>div:last-child{align-self:flex-end}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar .user-info>div:last-child{align-self:flex-start}}.b-user-sidebar .title{color:var(--s-color-text-primary);display:bock;font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);line-height:var(--g-line-height-tight);text-transform:capitalize;word-break:break-all}@media (min-width:calc(640 / 16 * 1rem)){.b-user-sidebar .title{font-size:var(--g-font-size-800)}}.b-user-sidebar .subtitle{color:var(--s-color-text-secondary);display:block;font-size:var(--g-font-size-100);line-height:var(--g-line-height-tight);margin-top:var(--g-space-2)}.b-user-sidebar>a{align-items:center;display:flex;justify-content:center}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar>a{display:inline-flex}}.b-sidebar{border-bottom:var(--s-border);grid-column:span 1/span 1;padding-bottom:var(--g-space-10);position:relative}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar{border-bottom:none;grid-column:span 3/span 3;grid-row:span 2/span 2;grid-row-start:1;height:100%;margin-bottom:0;order:2;padding-bottom:0}.b-sidebar+md-renderer:empty+*{grid-row-start:1;padding-top:var(--g-space-6)}.b-sidebar+md-renderer:empty+*,.b-sidebar+md-renderer:has(.b-btn:only-child)+*{grid-row-start:1;padding-top:var(--g-space-6)}}.b-sidebar:first-child{margin-top:var(--g-space-8)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar:first-child{margin-top:0}}.b-sidebar>div{padding-top:var(--g-space-2);position:sticky;top:var(--g-space-14)}.b-sidebar>div:has(.inner):not(:has(nav li)){display:none}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar>div{padding-bottom:var(--g-space-2)}}.b-sidebar .inner{background-color:var(--s-color-bg-surface-primary);border-radius:var(--s-rounded-sm);max-height:100vh;overflow:scroll;scrollbar-width:none}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .inner{background-color:var(--g-color-transparent)}}.b-sidebar .inner>nav{display:none;font-size:var(--g-font-size-100);margin-top:var(--g-space-2);padding:var(--g-space-2) var(--g-space-4) var(--g-space-6)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .inner>nav{display:block;margin-top:0;padding-bottom:var(--g-space-28);padding-left:0;padding-right:0}.b-sidebar .inner>nav>*{padding-left:0}}.b-sidebar .b-expend-btn{align-items:center;background-color:var(--s-color-bg-base);border:var(--s-border);border-radius:var(--s-rounded-sm);cursor:pointer;display:flex;font-size:var(--g-font-size-100);justify-content:space-between;padding:var(--g-space-2) var(--g-space-4)}.b-sidebar .b-expend-btn:hover{background-color:var(--s-color-bg-surface-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .b-expend-btn{border:none;cursor:default;font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);margin-top:var(--g-space-10);padding:0}.b-sidebar .b-expend-btn,.b-sidebar .b-expend-btn:hover{background-color:var(--g-color-transparent)}}.b-sidebar .b-expend-btn:has(#toc-expend:checked)+nav{display:block}.b-sidebar .b-expend-btn>input{display:none}.b-sidebar .b-expend-btn>input:checked+.wrapper-icon:before{content:"close"}.b-sidebar .b-expend-btn>input:checked+.wrapper-icon>svg{transform:rotate(180deg)}.b-sidebar .wrapper-icon{align-items:center;display:flex;gap:var(--g-space-1-5)}.b-sidebar .wrapper-icon:before{content:"open"}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .wrapper-icon{display:none}}.dev-mode .b-sidebar .b-expend-btn{background-color:var(--s-color-bg-surface-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.dev-mode .b-sidebar .b-expend-btn{background-color:var(--g-color-transparent)}}.dev-mode .b-sidebar .b-expend-btn:hover{background-color:var(--s-color-bg-surface-primary)}.b-source-code{font-family:var(--g-font-mono)}.b-source-code>pre{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded);font-size:var(--g-font-size-100);overflow:scroll;padding:var(--g-space-4) var(--g-space-1)}@media (min-width:calc(640 / 16 * 1rem)){.b-source-code>pre{font-size:var(--g-font-size-200);padding:var(--g-space-8) var(--g-space-3)}}.b-source-code>pre a:hover{-webkit-text-decoration:none;text-decoration:none}[data-theme=dark] .b-source-code>pre{background-color:var(--s-color-bg-base)}.b-toc{list-style:none;margin-top:var(--g-space-2)}.b-toc>*+*{margin-bottom:var(--g-space-1-5);margin-top:var(--g-space-1-5)}.b-toc .b-toc{border-left:1px solid var(--s-color-border-secondary);margin-bottom:var(--g-space-4);padding-left:var(--g-space-4)}.b-toc a>span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-overflow:ellipsis}.b-toc a:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}main.dev-mode .b-toc a{word-break:break-all}.b-source-toc>.b-toc{margin-bottom:var(--g-space-4)}.b-source-toc>*+*{margin-top:var(--g-space-1-5)}.b-source-toc .accordion summary>svg{transform:rotate(-90deg)}.b-source-toc .accordion summary:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}.b-source-toc .accordion[open] summary>svg{transform:rotate(0deg)}.b-source-toc .accordion>.b-toc{padding-left:var(--g-space-5)}.b-source-toc .accordion h3{font-size:var(--g-font-size-100);font-weight:var(--g-font-medium);margin-top:0}.b-action-overview{margin-bottom:var(--g-space-12)}.b-action-overview>p{font-size:var(--g-font-size-200)}.b-action-function{background-color:var(--s-color-bg-surface-secondary);border-radius:var(--s-rounded);margin-bottom:var(--g-space-3);padding:var(--g-space-4)}.b-action-function .title{align-items:baseline;display:flex;flex-wrap:wrap;font-size:var(--g-font-size-50);gap:var(--g-space-1) var(--g-space-4);margin-bottom:var(--g-space-1)}.b-action-function>header{align-items:flex-start;display:flex;font-size:var(--g-font-size-100);justify-content:space-between;margin-bottom:var(--g-space-4)}.b-action-function>header .signature>code{color:var(--s--text-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.b-action-function>header .signature{font-size:var(--g-font-size-50)}}.b-action-function>header h2{color:var(--s-color-text-primary);font-size:var(--g-font-size-300);font-weight:var(--g-font-semibold);line-height:var(--g-line-height-tight)}.b-action-function .description{color:var(--s-color-text-secondary);font-size:var(--g-font-size-200)}.b-action-function .params{align-items:stretch;color:var(--s-color-text-tertiary);display:flex;flex-direction:column;font-size:var(--g-font-size-100);gap:var(--g-space-1);margin-bottom:var(--g-space-1);margin-top:var(--g-space-6);width:100%}.b-action-function .params label{background-color:var(--s-color-bg-surface-primary)}.b-action-function .params .b-input:has(input:focus) label{background-color:var(--s-color-bg-surface-secondary)}.b-action-function .params .b-input:has(input:hover) label{background-color:var(--s-color-bg-surface-secondary)}.b-action-function .b-alert{background-color:var(--s-color-bg-warning-weak);border-left:var(--g-space-1) solid var(--s-color-border-tertiary);border-left-color:var(--s-color-border-warning);border-radius:var(--s-rounded);color:var(--s-color-text-secondary);color:var(--s-color-text-warning);margin-bottom:var(--g-space-10);margin-top:var(--g-space-5);padding:var(--g-space-3) var(--g-space-4)}.b-action-function .b-alert>h1:first-child,.b-action-function .b-alert>h2:first-child,.b-action-function .b-alert>h3:first-child{font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);margin-bottom:var(--g-space-2)}.b-action-function .b-alert .b-btn,.b-action-function .b-alert label{background-color:var(--s-color-bg-warning-action);border:none;color:var(--s-color-bg-warning-weak);cursor:pointer}.b-action-function .b-alert .b-btn{margin-top:var(--g-space-4)}.b-code{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded);font-size:var(--g-font-size-100);position:relative}.b-code pre{color:var(--s-color-text-secondary);padding:var(--g-space-4);padding-right:var(--g-space-10);white-space:pre-wrap}.b-code .btn-copy{background-color:var(--g-color-transparent);color:var(--s-color-text-tertiary);cursor:pointer;padding:0;position:absolute;right:var(--g-space-2);top:var(--g-space-2)}.b-code .btn-copy:hover{color:var(--s-color-text-primary)}.b-packages{min-height:var(--g-space-96);padding-bottom:var(--g-space-24);scroll-margin-block-start:var(--g-space-24)}@media (min-width:calc(820 / 16 * 1rem)){.b-packages{grid-column:span 7/span 7}}.b-packages .title{color:var(--s-color-text-primary);display:block;font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);margin-bottom:var(--g-space-6)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .title{font-size:var(--g-font-size-800)}}.b-packages nav{display:grid;grid-template-columns:repeat(4,1fr);grid-gap:var(--g-space-3);gap:var(--g-space-3);margin-bottom:var(--g-space-6)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages nav{border-bottom:var(--s-border);padding-bottom:var(--g-space-2)}}.b-packages .packages-tabs{border-bottom:var(--s-border);color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);gap:var(--g-space-4);grid-column:span 4/span 4;padding-bottom:var(--g-space-2);width:auto}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-tabs{border-bottom:none;font-size:var(--g-font-size-100);grid-column:span 2/span 2;padding-bottom:0;width:100%}}@media (min-width:calc(1020 / 16 * 1rem)){.b-packages .packages-tabs{gap:var(--g-space-6);margin-left:0;width:100%}}.b-packages .packages-tabs label{align-items:center;cursor:pointer;display:flex;gap:var(--g-space-1);position:relative}.b-packages .packages-tabs label:hover{color:var(--s-color-text-tertiary-hover)}.b-packages .packages-tabs label .b-tag--secondary{display:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-packages .packages-tabs label .b-tag--secondary{display:inline}}.b-packages .packages-filters{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);gap:var(--g-space-2);grid-column:span 2/span 2}@media (min-width:calc(480 / 16 * 1rem)){.b-packages .packages-filters{grid-column:span 1/span 1}}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-filters{justify-content:flex-end}}.b-packages .packages-filters>div{display:grid}.b-packages .packages-filters label{align-items:center;cursor:pointer;display:flex;gap:var(--g-space-0-5);grid-column:1/1;grid-row:1/1;justify-content:space-between}.b-packages .packages-filters label:hover>*{color:var(--s-color-text-tertiary-hover)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-filters label span{display:none}}@media (min-width:calc(1366 / 16 * 1rem)){.b-packages .packages-filters label span{display:inline}}.b-packages .packages-search{display:flex;font-size:var(--g-font-size-100);grid-column:span 2/span 2;position:relative}@media (min-width:calc(480 / 16 * 1rem)){.b-packages .packages-search{grid-column:span 3/span 3}}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-search{grid-column:span 1/span 1}}.b-packages .range{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-gap:var(--g-space-2);color:var(--s-color-text-tertiary);font-size:var(--g-font-size-100);gap:var(--g-space-2)}.b-packages .range:before{color:var(--s-color-text-tertiary);display:none;font-size:var(--g-font-size-200);font-weight:var(--g-font-weight-bold);grid-column:1/-1;padding-bottom:var(--g-space-2);padding-top:var(--g-space-2);text-align:center;width:100%}.b-packages .range:after{content:"Add a package to your namespace to get started";display:none;font-size:var(--g-font-size-100);grid-column:1/-1;text-align:center}.b-packages .range:empty:before{content:"No packages found";display:block}.b-packages .range:empty:after{content:"Add a package to your namespace to get started";display:block}.b-packages article{background-color:var(--s-color-bg-surface-primary);border-radius:var(--s-rounded);display:flex;flex-direction:column;gap:var(--g-space-6);padding:var(--g-space-1)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages article{gap:var(--g-space-2)}}.b-packages article .article-content{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded-sm);display:flex;flex-direction:column;height:100%;padding:var(--g-space-2);width:100%}.b-packages article .article-content .title{align-items:center;display:flex;gap:var(--g-space-2);margin-bottom:var(--g-space-1);overflow:hidden;width:100%}.b-packages article .article-content h3{font-size:var(--g-font-size-200);font-weight:var(--g-font-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.b-packages article .article-content h3>a{color:var(--s-color-text-link-hover)}.b-packages article .article-content h3>a:hover{-webkit-text-decoration:underline;text-decoration:underline}.b-packages article .article-content>p{overflow:hidden;text-overflow:ellipsis;width:100%}.b-packages article .article-content>p>a:hover{-webkit-text-decoration:underline;text-decoration:underline}.b-packages article footer{display:flex;font-size:var(--g-font-size-50);gap:var(--g-space-1);justify-content:space-between;padding-bottom:var(--g-space-1);padding-left:var(--g-space-2);padding-right:var(--g-space-2)}.b-packages article footer time{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.b-packages article footer .size{text-align:right}.b-packages article,.b-packages li{display:none}.b-packages:has(input[value=packages]:checked) li{display:flex}.b-packages:has(input[value=packages]:checked) article{display:flex}.b-packages:has(input[value=realms]:checked) + .nav-theme-label:before{content:"System"}.b-footer .legal{color:var(--s-color-text-tertiary);font-size:var(--g-font-size-50);margin-top:var(--g-space-3);padding-top:var(--g-space-3)}.b-footer .legal>nav{color:var(--s-color-text-secondary);display:flex;flex-direction:column;flex-wrap:wrap;gap:var(--g-space-1) var(--g-space-3);margin-top:var(--g-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .legal>nav{flex-direction:row}.b-footer .legal>nav>a+a:before{color:var(--s-color-text-quaternary);content:"|";margin-right:var(--g-space-3)}}.b-footer .legal>nav:nth-child(3){grid-column:span 2/span 2}.b-footer .legal>:last-child:not(ul),.b-footer .legal>nav li{margin-bottom:var(--g-space-2);margin-top:var(--g-space-2)}.b-footer .legal>:last-child:not(ul){flex-basis:100%}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .legal>:last-child:not(ul){flex-basis:auto;grid-column:span 1/span 1}}.b-footer a:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}.b-content-header{display:flex;flex-direction:column;gap:var(--g-space-3);grid-row:span 1/span 1;margin-bottom:var(--g-space-6);margin-top:var(--g-space-10)}@media (min-width:calc(820 / 16 * 1rem)){.b-content-header{grid-column:span 7/span 7;grid-row-start:1;justify-content:space-between;margin-top:var(--g-space-10)}}@media (min-width:calc(1020 / 16 * 1rem)){.b-content-header{align-items:center;flex-direction:row}}.b-content-header .title{align-items:center;display:flex;gap:var(--g-space-3)}.b-content-header .header-info{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);gap:var(--g-space-12);justify-content:space-between}.b-content-header .b-inline-btn>span{display:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-content-header .b-inline-btn>span{display:inline}}.b-content-h1{font-size:var(--g-font-size-600);text-align:center}.b-content-h1,.b-content-h2{color:var(--s-color-text-primary);font-weight:var(--g-font-bold)}.b-content-h2{font-size:var(--g-font-size-400);margin-bottom:var(--g-space-4)}.b-btns{align-items:center;display:flex;gap:var(--g-space-1)}@media (min-width:calc(1020 / 16 * 1rem)){.b-btns{gap:var(--g-space-2)}}.b-btn{border:var(--s-border);border-radius:var(--s-rounded-sm);cursor:pointer;display:inline-flex;gap:var(--g-space-1-5);min-width:-moz-max-content;min-width:max-content;padding:var(--g-space-1) var(--g-space-2)}.b-btn:hover{background-color:var(--s-color-bg-surface-primary-hover)}.b-btn .c-icon{margin-left:0;margin-right:0}.b-btn--secondary:hover{background-color:var(--s-color-bg-surface-primary)}.b-inline-btn{color:var(--s-color-text-tertiary);cursor:pointer}.b-inline-btn:hover{color:var(--s-color-text-tertiary-hover)}.b-switch input,.b-switch label:last-child{display:none}.b-switch input+label,.b-switch input:checked~label:last-child{display:block}.b-switch input:checked+label{display:none}.b-block-form,.b-inline-form{color:var(--s-color-text-tertiary);display:flex;flex-direction:column;gap:var(--g-space-2) var(--g-space-3)}@media (min-width:calc(820 / 16 * 1rem)){.b-block-form,.b-inline-form{flex-direction:row}}.b-block-form{align-items:stretch}@media (min-width:calc(820 / 16 * 1rem)){.b-block-form{flex-direction:column}}.b-input{border:var(--s-border);border-radius:var(--s-rounded-sm);color:var(--s-color-text-secondary);display:flex;font-size:var(--g-font-size-100);min-width:var(--g-space-48);overflow:hidden;position:relative}.b-input>svg{height:var(--g-space-4);pointer-events:none;position:absolute;top:50%;transform:translateY(-50%);width:var(--g-space-4)}.b-input>svg:first-child{left:var(--g-space-2)}.b-input>svg:last-child{right:var(--g-space-2)}.b-input:hover,.b-input>input:focus,.b-input>input:hover{border-color:var(--s-color-border-tertiary)}.b-input:has(input:focus),.b-input:hover,.b-input>input:focus,.b-input>input:hover{border-color:var(--s-color-border-tertiary)}.b-input:hover>label{background-color:var(--s-color-bg-surface-primary)}.b-input:has(input:focus)>label,.b-input:hover>label{background-color:var(--s-color-bg-surface-primary)}.b-input>label{align-items:center;background-color:var(--s-color-bg-surface-secondary);gap:var(--g-space-3);white-space:nowrap}.b-input>input,.b-input>label,.b-input>select{display:flex;padding:var(--g-space-1-5) var(--g-space-3)}.b-input>input,.b-input>select{color:inherit;outline:none;width:100%}@media (min-width:calc(820 / 16 * 1rem)){.b-input>input,.b-input>select{padding:var(--g-space-1-5) var(--g-space-2)}}.b-input>select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--s-color-bg-surface-secondary);cursor:pointer}.b-input>select:hover{background-color:var(--s-color-bg-surface-primary)}.b-input>input{background-color:var(--s-color-bg-base);border-left:none}.b-input>label+input{border-left:var(--s-border)}.b-list{margin-bottom:var(--g-space-10)}.b-list>li{border-bottom:var(--s-border);color:var(--s-color-text-tertiary)}.b-list>li:first-child{border-top:var(--s-border)}.b-list>li>:where(a,div){align-items:center;display:flex;justify-content:space-between;padding:var(--g-space-2)}.b-list>li>:where(a,div):hover{background-color:var(--s-color-bg-surface-primary-hover)}.b-list>li>:where(a,div) .c-icon{margin-left:0}.b-list>li>:where(a,div)>a{flex:1;min-width:0}.b-list>li>:where(a,div)>a:hover{background-color:transparent}.b-list .name{display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;color:var(--s-color-text-secondary);margin-left:var(--g-space-1);max-width:100%;overflow:hidden;text-overflow:ellipsis}.b-user-sidebar{margin-top:var(--g-space-4)}.b-user-sidebar>*+*{margin-top:var(--g-space-8)}.b-user-sidebar .user-avatar{border:var(--s-border);border-radius:var(--s-rounded);height:var(--g-space-24);width:var(--g-space-24)}@media (min-width:calc(640 / 16 * 1rem)){.b-user-sidebar .user-avatar{height:var(--g-space-36);width:var(--g-space-36)}}.b-user-sidebar .user-avatar img,.b-user-sidebar .user-avatar svg{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.b-user-sidebar .user-info{align-items:flex-start;display:flex;gap:var(--g-space-6)}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar .user-info{flex-direction:column}}.b-user-sidebar .user-info>div:last-child{align-self:flex-end}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar .user-info>div:last-child{align-self:flex-start}}.b-user-sidebar .title{color:var(--s-color-text-primary);display:bock;font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);line-height:var(--g-line-height-tight);text-transform:capitalize;word-break:break-all}@media (min-width:calc(640 / 16 * 1rem)){.b-user-sidebar .title{font-size:var(--g-font-size-800)}}.b-user-sidebar .subtitle{color:var(--s-color-text-secondary);display:block;font-size:var(--g-font-size-100);line-height:var(--g-line-height-tight);margin-top:var(--g-space-2)}.b-user-sidebar>a{align-items:center;display:flex;justify-content:center}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar>a{display:inline-flex}}.b-sidebar{border-bottom:var(--s-border);grid-column:span 1/span 1;padding-bottom:var(--g-space-10);position:relative}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar{border-bottom:none;grid-column:span 3/span 3;grid-row:span 2/span 2;grid-row-start:1;height:100%;margin-bottom:0;order:2;padding-bottom:0}.b-sidebar+md-renderer:empty+*{grid-row-start:1;padding-top:var(--g-space-6)}.b-sidebar+md-renderer:empty+*,.b-sidebar+md-renderer:has(.b-btn:only-child)+*{grid-row-start:1;padding-top:var(--g-space-6)}}.b-sidebar:first-child{margin-top:var(--g-space-8)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar:first-child{margin-top:0}}.b-sidebar>div{padding-top:var(--g-space-2);position:sticky;top:var(--g-space-14)}.b-sidebar>div:has(.inner):not(:has(nav li)){display:none}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar>div{padding-bottom:var(--g-space-2)}}.b-sidebar .inner{background-color:var(--s-color-bg-surface-primary);border-radius:var(--s-rounded-sm);max-height:100vh;overflow:scroll;scrollbar-width:none}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .inner{background-color:var(--g-color-transparent)}}.b-sidebar .inner>nav{display:none;font-size:var(--g-font-size-100);margin-top:var(--g-space-2);padding:var(--g-space-2) var(--g-space-4) var(--g-space-6)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .inner>nav{display:block;margin-top:0;padding-bottom:var(--g-space-28);padding-left:0;padding-right:0}.b-sidebar .inner>nav>*{padding-left:0}}.b-sidebar .b-expend-btn{align-items:center;background-color:var(--s-color-bg-base);border:var(--s-border);border-radius:var(--s-rounded-sm);cursor:pointer;display:flex;font-size:var(--g-font-size-100);justify-content:space-between;padding:var(--g-space-2) var(--g-space-4)}.b-sidebar .b-expend-btn:hover{background-color:var(--s-color-bg-surface-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .b-expend-btn{border:none;cursor:default;font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);margin-top:var(--g-space-10);padding:0}.b-sidebar .b-expend-btn,.b-sidebar .b-expend-btn:hover{background-color:var(--g-color-transparent)}}.b-sidebar .b-expend-btn:has(#toc-expend:checked)+nav{display:block}.b-sidebar .b-expend-btn>input{display:none}.b-sidebar .b-expend-btn>input:checked+.wrapper-icon:before{content:"close"}.b-sidebar .b-expend-btn>input:checked+.wrapper-icon>svg{transform:rotate(180deg)}.b-sidebar .wrapper-icon{align-items:center;display:flex;gap:var(--g-space-1-5)}.b-sidebar .wrapper-icon:before{content:"open"}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .wrapper-icon{display:none}}.dev-mode .b-sidebar .b-expend-btn{background-color:var(--s-color-bg-surface-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.dev-mode .b-sidebar .b-expend-btn{background-color:var(--g-color-transparent)}}.dev-mode .b-sidebar .b-expend-btn:hover{background-color:var(--s-color-bg-surface-primary)}.b-source-code{font-family:var(--g-font-mono)}.b-source-code>pre{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded);font-size:var(--g-font-size-100);overflow:scroll;padding:var(--g-space-4) var(--g-space-1)}@media (min-width:calc(640 / 16 * 1rem)){.b-source-code>pre{font-size:var(--g-font-size-200);padding:var(--g-space-8) var(--g-space-3)}}.b-source-code>pre a:hover{-webkit-text-decoration:none;text-decoration:none}[data-theme=dark] .b-source-code>pre{background-color:var(--s-color-bg-base)}.b-toc{list-style:none;margin-top:var(--g-space-2)}.b-toc>*+*{margin-bottom:var(--g-space-1-5);margin-top:var(--g-space-1-5)}.b-toc .b-toc{border-left:1px solid var(--s-color-border-secondary);margin-bottom:var(--g-space-4);padding-left:var(--g-space-4)}.b-toc a>span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-overflow:ellipsis}.b-toc a:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}main.dev-mode .b-toc a{word-break:break-all}.b-source-toc>.b-toc{margin-bottom:var(--g-space-4)}.b-source-toc>*+*{margin-top:var(--g-space-1-5)}.b-source-toc .accordion summary>svg{transform:rotate(-90deg)}.b-source-toc .accordion summary:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}.b-source-toc .accordion[open] summary>svg{transform:rotate(0deg)}.b-source-toc .accordion>.b-toc{padding-left:var(--g-space-5)}.b-source-toc .accordion h3{font-size:var(--g-font-size-100);font-weight:var(--g-font-medium);margin-top:0}.b-action-overview{margin-bottom:var(--g-space-12)}.b-action-overview>p{font-size:var(--g-font-size-200)}.b-action-function{background-color:var(--s-color-bg-surface-secondary);border-radius:var(--s-rounded);margin-bottom:var(--g-space-3);padding:var(--g-space-4)}.b-action-function .title{align-items:baseline;display:flex;flex-wrap:wrap;font-size:var(--g-font-size-50);gap:var(--g-space-1) var(--g-space-4);margin-bottom:var(--g-space-1)}.b-action-function>header{align-items:flex-start;display:flex;font-size:var(--g-font-size-100);justify-content:space-between;margin-bottom:var(--g-space-4)}.b-action-function>header .signature>code{color:var(--s--text-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.b-action-function>header .signature{font-size:var(--g-font-size-50)}}.b-action-function>header h2{color:var(--s-color-text-primary);font-size:var(--g-font-size-300);font-weight:var(--g-font-semibold);line-height:var(--g-line-height-tight)}.b-action-function .description{color:var(--s-color-text-secondary);font-size:var(--g-font-size-200)}.b-action-function .params{align-items:stretch;color:var(--s-color-text-tertiary);display:flex;flex-direction:column;font-size:var(--g-font-size-100);gap:var(--g-space-1);margin-bottom:var(--g-space-1);margin-top:var(--g-space-6);width:100%}.b-action-function .params label{background-color:var(--s-color-bg-surface-primary)}.b-action-function .params .b-input:has(input:focus) label{background-color:var(--s-color-bg-surface-secondary)}.b-action-function .params .b-input:has(input:hover) label{background-color:var(--s-color-bg-surface-secondary)}.b-action-function .b-alert{background-color:var(--s-color-bg-warning-weak);border-left:var(--g-space-1) solid var(--s-color-border-tertiary);border-left-color:var(--s-color-border-warning);border-radius:var(--s-rounded);color:var(--s-color-text-secondary);color:var(--s-color-text-warning);margin-bottom:var(--g-space-10);margin-top:var(--g-space-5);padding:var(--g-space-3) var(--g-space-4)}.b-action-function .b-alert>h1:first-child,.b-action-function .b-alert>h2:first-child,.b-action-function .b-alert>h3:first-child{font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);margin-bottom:var(--g-space-2)}.b-action-function .b-alert .b-btn,.b-action-function .b-alert label{background-color:var(--s-color-bg-warning-action);border:none;color:var(--s-color-bg-warning-weak);cursor:pointer}.b-action-function .b-alert .b-btn{margin-top:var(--g-space-4)}.b-code{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded);font-size:var(--g-font-size-100);position:relative}.b-code pre{color:var(--s-color-text-secondary);padding:var(--g-space-4);padding-right:var(--g-space-10);white-space:pre-wrap}.b-code .btn-copy{background-color:var(--g-color-transparent);color:var(--s-color-text-tertiary);cursor:pointer;padding:0;position:absolute;right:var(--g-space-2);top:var(--g-space-2)}.b-code .btn-copy:hover{color:var(--s-color-text-primary)}.b-packages{min-height:var(--g-space-96);padding-bottom:var(--g-space-24);scroll-margin-block-start:var(--g-space-24)}@media (min-width:calc(820 / 16 * 1rem)){.b-packages{grid-column:span 7/span 7}}.b-packages .title{color:var(--s-color-text-primary);display:block;font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);margin-bottom:var(--g-space-6)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .title{font-size:var(--g-font-size-800)}}.b-packages nav{display:grid;grid-template-columns:repeat(4,1fr);grid-gap:var(--g-space-3);gap:var(--g-space-3);margin-bottom:var(--g-space-6)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages nav{border-bottom:var(--s-border);padding-bottom:var(--g-space-2)}}.b-packages .packages-tabs{border-bottom:var(--s-border);color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);gap:var(--g-space-4);grid-column:span 4/span 4;padding-bottom:var(--g-space-2);width:auto}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-tabs{border-bottom:none;font-size:var(--g-font-size-100);grid-column:span 2/span 2;padding-bottom:0;width:100%}}@media (min-width:calc(1020 / 16 * 1rem)){.b-packages .packages-tabs{gap:var(--g-space-6);margin-left:0;width:100%}}.b-packages .packages-tabs label{align-items:center;cursor:pointer;display:flex;gap:var(--g-space-1);position:relative}.b-packages .packages-tabs label:hover{color:var(--s-color-text-tertiary-hover)}.b-packages .packages-tabs label .b-tag--secondary{display:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-packages .packages-tabs label .b-tag--secondary{display:inline}}.b-packages .packages-filters{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);gap:var(--g-space-2);grid-column:span 2/span 2}@media (min-width:calc(480 / 16 * 1rem)){.b-packages .packages-filters{grid-column:span 1/span 1}}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-filters{justify-content:flex-end}}.b-packages .packages-filters>div{display:grid}.b-packages .packages-filters label{align-items:center;cursor:pointer;display:flex;gap:var(--g-space-0-5);grid-column:1/1;grid-row:1/1;justify-content:space-between}.b-packages .packages-filters label:hover>*{color:var(--s-color-text-tertiary-hover)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-filters label span{display:none}}@media (min-width:calc(1366 / 16 * 1rem)){.b-packages .packages-filters label span{display:inline}}.b-packages .packages-search{display:flex;font-size:var(--g-font-size-100);grid-column:span 2/span 2;position:relative}@media (min-width:calc(480 / 16 * 1rem)){.b-packages .packages-search{grid-column:span 3/span 3}}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-search{grid-column:span 1/span 1}}.b-packages .range{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-gap:var(--g-space-2);color:var(--s-color-text-tertiary);font-size:var(--g-font-size-100);gap:var(--g-space-2)}.b-packages .range:before{color:var(--s-color-text-tertiary);display:none;font-size:var(--g-font-size-200);font-weight:var(--g-font-weight-bold);grid-column:1/-1;padding-bottom:var(--g-space-2);padding-top:var(--g-space-2);text-align:center;width:100%}.b-packages .range:after{content:"Add a package to your namespace to get started";display:none;font-size:var(--g-font-size-100);grid-column:1/-1;text-align:center}.b-packages .range:empty:before{content:"No packages found";display:block}.b-packages .range:empty:after{content:"Add a package to your namespace to get started";display:block}.b-packages article{background-color:var(--s-color-bg-surface-primary);border-radius:var(--s-rounded);display:flex;flex-direction:column;gap:var(--g-space-6);padding:var(--g-space-1)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages article{gap:var(--g-space-2)}}.b-packages article .article-content{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded-sm);display:flex;flex-direction:column;height:100%;padding:var(--g-space-2);width:100%}.b-packages article .article-content .title{align-items:center;display:flex;gap:var(--g-space-2);margin-bottom:var(--g-space-1);overflow:hidden;width:100%}.b-packages article .article-content h3{font-size:var(--g-font-size-200);font-weight:var(--g-font-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.b-packages article .article-content h3>a{color:var(--s-color-text-link-hover)}.b-packages article .article-content h3>a:hover{-webkit-text-decoration:underline;text-decoration:underline}.b-packages article .article-content>p{overflow:hidden;text-overflow:ellipsis;width:100%}.b-packages article .article-content>p>a:hover{-webkit-text-decoration:underline;text-decoration:underline}.b-packages article footer{display:flex;font-size:var(--g-font-size-50);gap:var(--g-space-1);justify-content:space-between;padding-bottom:var(--g-space-1);padding-left:var(--g-space-2);padding-right:var(--g-space-2)}.b-packages article footer time{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.b-packages article footer .size{text-align:right}.b-packages article,.b-packages li{display:none}.b-packages:has(input[value=packages]:checked) li{display:flex}.b-packages:has(input[value=packages]:checked) article{display:flex}.b-packages:has(input[value=realms]:checked) li[data-list-type-value=realm]{display:flex}.b-packages:has(input[value=realms]:checked) article[data-list-type-value=realm]{display:flex}.b-packages:has(input[value=pures]:checked) li[data-list-type-value=pure]{display:flex}.b-packages:has(input[value=pures]:checked) From 77ffb7ebd25a264db25d84204c0e4a7627433f34 Mon Sep 17 00:00:00 2001 From: Guilhem Fanton <8671905+gfanton@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:48:39 +0200 Subject: [PATCH 51/92] fix: add generic markdown customized banner (#5378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `GNOWEB_BANNER_TEXT` + `GNOWEB_BANNER_URL` with a single `GNOWEB_BANNER_TEXT` that accepts inline markdown - Banner content is parsed via goldmark, links get `target="_blank"` via AST rewriting - Input is single-line, capped at 400 chars Example: `GNOWEB_BANNER_TEXT='This is **beta** — [Learn more](https://gno.land)'` Screenshot 2026-03-27 at 11 52 13 --------- Co-authored-by: Alexis Colin (cherry picked from commit df30676ac55c7824b9371ccb15248ce624099ce9) --- gno.land/cmd/gnoweb/main.go | 12 +- .../pkg/gnoweb/components/layout_index.go | 114 ++++++++++- gno.land/pkg/gnoweb/components/layout_test.go | 183 +++++++++++++----- .../pkg/gnoweb/components/layouts/index.html | 4 +- .../pkg/gnoweb/frontend/css/06-blocks.css | 21 +- gno.land/pkg/gnoweb/public/main.css | 2 +- 6 files changed, 270 insertions(+), 66 deletions(-) diff --git a/gno.land/cmd/gnoweb/main.go b/gno.land/cmd/gnoweb/main.go index 93167de9794..10ea4ebbe15 100644 --- a/gno.land/cmd/gnoweb/main.go +++ b/gno.land/cmd/gnoweb/main.go @@ -12,6 +12,7 @@ import ( "time" "github.com/gnolang/gno/gno.land/pkg/gnoweb" + "github.com/gnolang/gno/gno.land/pkg/gnoweb/components" "github.com/gnolang/gno/gno.land/pkg/log" "github.com/gnolang/gno/tm2/pkg/commands" "go.uber.org/zap" @@ -80,7 +81,7 @@ func main() { LongHelp: `gnoweb web interface Environment variables: - GNOWEB_BANNER_TEXT Banner text displayed at the top of the page. + GNOWEB_BANNER_TEXT Banner content (supports inline markdown). Max 400 chars. GNOWEB_BANNER_URL Optional link for the banner (requires GNOWEB_BANNER_TEXT).`, }, &cfg, @@ -241,8 +242,13 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) { // Parse banner from env if text := os.Getenv("GNOWEB_BANNER_TEXT"); text != "" { - appcfg.Banner.Text = text - appcfg.Banner.URL = os.Getenv("GNOWEB_BANNER_URL") + bannerURL := os.Getenv("GNOWEB_BANNER_URL") + banner, err := components.NewBannerData(text, bannerURL) + if err != nil { + logger.Warn("invalid banner markdown, banner disabled", "error", err) + } else { + appcfg.Banner = banner + } } else if os.Getenv("GNOWEB_BANNER_URL") != "" { logger.Warn("GNOWEB_BANNER_URL is set but GNOWEB_BANNER_TEXT is empty; banner will not be shown") } diff --git a/gno.land/pkg/gnoweb/components/layout_index.go b/gno.land/pkg/gnoweb/components/layout_index.go index 245143c52b4..2a3172d4ecc 100644 --- a/gno.land/pkg/gnoweb/components/layout_index.go +++ b/gno.land/pkg/gnoweb/components/layout_index.go @@ -1,6 +1,16 @@ package components -import "strings" +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/text" +) // ViewMode represents the current view mode of the application // It affects the layout, navigation, and display of content @@ -45,17 +55,107 @@ type HeadData struct { BuildTime string } -// BannerData holds configuration for the site-wide banner displayed above the header. +// MaxBannerLength is the maximum character length for banner markdown source. +const MaxBannerLength = 400 + +// BannerData implements Component. +var _ Component = BannerData{} + +// BannerData holds pre-rendered inline HTML from markdown. type BannerData struct { - Text string - URL string + content string + url string } -func (b BannerData) HasURL() bool { - return strings.HasPrefix(b.URL, "https://") || strings.HasPrefix(b.URL, "http://") +func (b BannerData) Enabled() bool { return b.content != "" } +func (b BannerData) HasURL() bool { return b.url != "" } +func (b BannerData) URL() string { return b.url } + +func (b BannerData) Render(w io.Writer) (err error) { + _, err = io.WriteString(w, b.content) + return err } -func (b BannerData) Enabled() bool { return b.Text != "" } +// NewBannerData parses inline markdown into a BannerData with pre-rendered HTML. +// Content after the first newline is discarded. Content is truncated to MaxBannerLength runes. +// If globalURL is non-empty (http/https only), the banner acts as a single clickable link +// and any inline markdown links are unwrapped to plain text. +func NewBannerData(markdown, globalURL string) (BannerData, error) { + // Keep only the first line + if i := strings.IndexAny(markdown, "\n\r"); i >= 0 { + markdown = markdown[:i] + } + markdown = strings.TrimSpace(markdown) + + if markdown == "" { + return BannerData{}, nil + } + + // Truncate to max length (rune-safe) + if runes := []rune(markdown); len(runes) > MaxBannerLength { + markdown = string(runes[:MaxBannerLength]) + } + + // Validate global URL: only http/https allowed. + globalURL = strings.TrimSpace(globalURL) + hasGlobalURL := strings.HasPrefix(globalURL, "https://") || strings.HasPrefix(globalURL, "http://") + + md := goldmark.New(goldmark.WithExtensions(extension.Strikethrough)) + src := []byte(markdown) + doc := md.Parser().Parse(text.NewReader(src)) + + // Keep only Paragraph nodes (the inline-content wrapper). All other + // block-level nodes (headings, code blocks, lists, HTML blocks, etc.) + // are removed so the banner contains only inline markup. + for c := doc.FirstChild(); c != nil; { + next := c.NextSibling() + if c.Kind() != ast.KindParagraph { + doc.RemoveChild(doc, c) + } + c = next + } + + ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering || n.Kind() != ast.KindLink { + return ast.WalkContinue, nil + } + + if hasGlobalURL { + // Replace link node with its children (keep text, drop the ). + parent := n.Parent() + for c := n.FirstChild(); c != nil; { + next := c.NextSibling() + parent.InsertBefore(parent, n, c) + c = next + } + parent.RemoveChild(parent, n) + return ast.WalkSkipChildren, nil + } + + n.SetAttributeString("target", "_blank") + n.SetAttributeString("rel", "noopener noreferrer") + return ast.WalkContinue, nil + }) + + var buf bytes.Buffer + if err := md.Renderer().Render(&buf, src, doc); err != nil { + return BannerData{}, fmt.Errorf("banner markdown rendering: %w", err) + } + + // Strip the

wrapper that goldmark adds for single-paragraph content. + result := strings.TrimSpace(buf.String()) + if after, ok := strings.CutPrefix(result, "

"); ok { + if inner, ok := strings.CutSuffix(after, "

"); ok { + result = inner + } + } + + bd := BannerData{content: result} + if hasGlobalURL { + bd.url = globalURL + } + return bd, nil +} type IndexData struct { HeadData diff --git a/gno.land/pkg/gnoweb/components/layout_test.go b/gno.land/pkg/gnoweb/components/layout_test.go index b387a44cd6e..a43093184f8 100644 --- a/gno.land/pkg/gnoweb/components/layout_test.go +++ b/gno.land/pkg/gnoweb/components/layout_test.go @@ -370,64 +370,136 @@ func TestIndexLayout_ThemePropagation(t *testing.T) { } } -func TestBannerData(t *testing.T) { +func TestNewBannerData(t *testing.T) { t.Parallel() cases := []struct { - name string - banner BannerData - wantEnabled bool - wantHasURL bool + name string + input string + globalURL string + wantEnabled bool + wantHasURL bool + wantContains string + wantNotContains string }{ { - name: "empty banner is disabled", - banner: BannerData{}, + name: "empty is disabled", + input: "", wantEnabled: false, - wantHasURL: false, }, { - name: "text only", - banner: BannerData{Text: "Beta"}, - wantEnabled: true, - wantHasURL: false, + name: "plain text", + input: "Beta", + wantEnabled: true, + wantContains: "Beta", }, { - name: "text with https URL", - banner: BannerData{Text: "Beta", URL: "https://example.com"}, - wantEnabled: true, - wantHasURL: true, + name: "markdown link gets target blank", + input: "[Beta](https://example.com)", + wantEnabled: true, + wantContains: `
Beta`, }, { - name: "text with http URL", - banner: BannerData{Text: "Beta", URL: "http://example.com"}, - wantEnabled: true, - wantHasURL: true, + name: "bold and italic", + input: "This is **bold** and *italic*", + wantEnabled: true, + wantContains: "bold", + }, + { + name: "content after newline discarded", + input: "line one\nline two", + wantEnabled: true, + wantContains: "line one", }, { - name: "rejects javascript scheme", - banner: BannerData{Text: "Click me", URL: "javascript:alert(1)"}, + name: "truncated over max length", + input: strings.Repeat("a", MaxBannerLength+50), wantEnabled: true, - wantHasURL: false, }, { - name: "rejects data scheme", - banner: BannerData{Text: "Click me", URL: "data:text/html,

hi

"}, + name: "HTML block stripped", + input: ``, + wantEnabled: false, + }, + { + name: "javascript URL sanitized", + input: `[click](javascript:alert(1))`, + wantEnabled: true, + wantContains: `href=""`, + }, + { + name: "global URL strips inline links", + input: "[click](https://other.com)", + globalURL: "https://gno.land", + wantEnabled: true, + wantHasURL: true, + wantContains: "click", + wantNotContains: `href="https://other.com"`, + }, + { + name: "global javascript URL rejected", + input: "Hello", + globalURL: "javascript:alert(1)", wantEnabled: true, wantHasURL: false, }, { - name: "rejects schemeless URL", - banner: BannerData{Text: "Click me", URL: "example.com"}, + name: "global ftp URL rejected", + input: "Hello", + globalURL: "ftp://bad.com", wantEnabled: true, wantHasURL: false, }, + { + name: "heading block stripped", + input: "# Big Heading", + wantEnabled: false, + }, + { + name: "blockquote stripped", + input: "> quoted text", + wantEnabled: false, + }, + { + name: "thematic break stripped", + input: "---", + wantEnabled: false, + }, + { + name: "list item stripped", + input: "- list entry", + wantEnabled: false, + }, + { + name: "leading whitespace trimmed before parsing", + input: " code line", + wantEnabled: true, + wantContains: "code line", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tc.wantEnabled, tc.banner.Enabled()) - assert.Equal(t, tc.wantHasURL, tc.banner.HasURL()) + + banner, err := NewBannerData(tc.input, tc.globalURL) + require.NoError(t, err) + assert.Equal(t, tc.wantEnabled, banner.Enabled()) + assert.Equal(t, tc.wantHasURL, banner.HasURL()) + + var buf strings.Builder + require.NoError(t, banner.Render(&buf)) + rendered := buf.String() + + if tc.wantContains != "" { + assert.Contains(t, rendered, tc.wantContains) + } + if tc.wantNotContains != "" { + assert.NotContains(t, rendered, tc.wantNotContains) + } + if banner.Enabled() { + assert.NotContains(t, rendered, "

") + } }) } } @@ -436,30 +508,44 @@ func TestIndexLayout_Banner(t *testing.T) { t.Parallel() cases := []struct { - name string - banner BannerData - wantBanner bool - wantLink bool - wantContains string + name string + markdown string + url string + wantBanner bool + wantContains string + wantNotContains string }{ { name: "no banner when empty", - banner: BannerData{}, + markdown: "", wantBanner: false, }, { - name: "text-only renders div", - banner: BannerData{Text: "Maintenance"}, + name: "plain text renders in div", + markdown: "Maintenance", wantBanner: true, - wantLink: false, wantContains: "Maintenance", }, { - name: "text with URL renders link", - banner: BannerData{Text: "Beta", URL: "https://example.com"}, + name: "markdown link renders inline", + markdown: "[Beta](https://example.com)", wantBanner: true, - wantLink: true, - wantContains: "https://example.com", + wantContains: `href="https://example.com"`, + }, + { + name: "global URL wraps banner in anchor", + markdown: "Beta release", + url: "https://gno.land", + wantBanner: true, + wantContains: ` - {{ .IndexData.Banner.Text }} + {{ render .IndexData.Banner }} {{ else -}}

- {{ .IndexData.Banner.Text }} + {{ render .IndexData.Banner }}
{{ end -}} {{ end -}} diff --git a/gno.land/pkg/gnoweb/frontend/css/06-blocks.css b/gno.land/pkg/gnoweb/frontend/css/06-blocks.css index fb79e7744f5..aaa5c8e76a9 100644 --- a/gno.land/pkg/gnoweb/frontend/css/06-blocks.css +++ b/gno.land/pkg/gnoweb/frontend/css/06-blocks.css @@ -26,16 +26,27 @@ font-size: var(--g-font-size-50); font-weight: var(--g-font-semibold); text-align: center; - text-decoration: none; @media (--md) { font-size: var(--g-font-size-100); } -} -a.b-banner:hover { - opacity: 0.9; - text-decoration: underline; + & a { + color: inherit; + text-decoration: underline; + } + + & a:hover, + &:is(a):hover { + opacity: 0.8; + } + + & code { + padding: var(--g-space-0-5) var(--g-space-1); + border-radius: var(--g-border-radius-sm); + background-color: var(--s-color-bg-brand-action); + font-size: 0.96em; + } } /* ===== HEADER COMPONENT ===== */ diff --git a/gno.land/pkg/gnoweb/public/main.css b/gno.land/pkg/gnoweb/public/main.css index c592ed16f73..86900eca7c6 100644 --- a/gno.land/pkg/gnoweb/public/main.css +++ b/gno.land/pkg/gnoweb/public/main.css @@ -1,4 +1,4 @@ -:root{--g-px-base:16;--g-space-mult:4;--g-space-base:calc(1rem/var(--g-space-mult));--g-breakpoint-max:calc(1580/var(--g-px-base)*1rem);--g-z-min:-1;--g-z-1:1;--g-z-max:9999;--g-duration-75:75ms;--g-duration-150:150ms;--g-opacity-50:0.5;--g-grid-1:repeat(1,minmax(0,1fr));--g-grid-10:repeat(10,minmax(0,1fr));--g-space-px:1px;--g-space-0-5:calc(var(--g-space-base)*0.5);--g-space-1:var(--g-space-base);--g-space-1-5:calc(var(--g-space-base)*1.5);--g-space-2:calc(var(--g-space-base)*2);--g-space-2-5:calc(var(--g-space-base)*2.5);--g-space-3:calc(var(--g-space-base)*3);--g-space-4:calc(var(--g-space-base)*4);--g-space-4-5:calc(var(--g-space-base)*4.5);--g-space-5:calc(var(--g-space-base)*5);--g-space-6:calc(var(--g-space-base)*6);--g-space-7:calc(var(--g-space-base)*7);--g-space-8:calc(var(--g-space-base)*8);--g-space-10:calc(var(--g-space-base)*10);--g-space-12:calc(var(--g-space-base)*12);--g-space-14:calc(var(--g-space-base)*14);--g-space-20:calc(var(--g-space-base)*20);--g-space-24:calc(var(--g-space-base)*24);--g-space-28:calc(var(--g-space-base)*28);--g-space-32:calc(var(--g-space-base)*32);--g-space-36:calc(var(--g-space-base)*36);--g-space-44:calc(var(--g-space-base)*44);--g-space-48:calc(var(--g-space-base)*48);--g-space-52:calc(var(--g-space-base)*52);--g-space-72:calc(var(--g-space-base)*72);--g-space-96:calc(var(--g-space-base)*96);--g-font-size-50:calc(12/var(--g-px-base)*1rem);--g-font-size-100:calc(14/var(--g-px-base)*1rem);--g-font-size-200:calc(16/var(--g-px-base)*1rem);--g-font-size-300:calc(18/var(--g-px-base)*1rem);--g-font-size-400:calc(20/var(--g-px-base)*1rem);--g-font-size-500:calc(22/var(--g-px-base)*1rem);--g-font-size-600:calc(24/var(--g-px-base)*1rem);--g-font-size-700:calc(32/var(--g-px-base)*1rem);--g-font-size-800:calc(38/var(--g-px-base)*1rem);--g-font-family-mono:"Roboto",'Menlo, Consolas, "Ubuntu Mono", "Roboto Mono", "DejaVu Sans Mono", monospace';--g-font-family-inter-var:"Inter",'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif';--g-font-normal:400;--g-font-medium:500;--g-font-semibold:600;--g-font-bold:700;--g-italic:oblique 14deg;--g-line-height-tight:1.25;--g-line-height-snug:1.375;--g-line-height-normal:1.5;--g-border-radius-sm:calc(4/var(--g-px-base)*1rem);--g-border-radius:calc(6/var(--g-px-base)*1rem);--g-border-radius-full:9999px;--g-color-light:#fff;--g-color-transparent:transparent;--g-color-gray-50:#f0f0f0;--g-color-gray-100:#e2e2e2;--g-color-gray-200:#bdbdbd;--g-color-gray-300:#999;--g-color-gray-400:#7c7c7c;--g-color-gray-500:#696969;--g-color-gray-600:#585858;--g-color-gray-700:#292929;--g-color-gray-750:#1f1f1f;--g-color-gray-800:#141414;--g-color-gray-850:#0e0e0e;--g-color-gray-900:#090909;--g-color-green-50:#e7efed;--g-color-green-400:#60ab96;--g-color-green-500:#277b63;--g-color-green-600:#226c57;--g-color-green-900:#144134;--g-color-green-950:#002c20;--g-color-blue-400:#49afeb;--g-color-blue-600:#3e96c9;--g-color-blue-900:#21506b;--g-color-yellow-50:#fff7eb;--g-color-yellow-400:#facc32;--g-color-yellow-600:#fbbf24;--g-color-yellow-900:#7b4807;--g-color-yellow-950:#362600;--g-color-red-400:#eb6c49;--g-color-red-600:#c95c3e;--g-color-red-900:#6b2521;--g-color-purple-400:#7f49eb;--g-color-purple-600:#6c3ec9;--g-color-purple-900:#39216b}@supports (color:color(display-p3 0 0 0%)){:root{--g-color-green-950:#002c20;--g-color-yellow-50:#fff7eb;--g-color-yellow-950:#362600}@media (color-gamut:p3){:root{--g-color-green-950:color(display-p3 0.04602 0.17026 0.1277);--g-color-yellow-50:color(display-p3 0.99709 0.97106 0.92232);--g-color-yellow-950:color(display-p3 0.2031 0.15112 0.01811)}}}:root{--s-color-bg-base:var(--g-color-light,#fff);--s-color-bg-base-dev:var(--g-color-gray-50,#f0f0f0);--s-color-bg-surface-primary:var(--g-color-gray-50,#f0f0f0);--s-color-bg-surface-primary-hover:var(--g-color-gray-100,#f0f0f0);--s-color-bg-surface-secondary:var(--g-color-gray-100,#e2e2e2);--s-color-bg-surface-quaternary:var(--g-color-gray-400,#7c7c7c);--s-color-bg-brand-default:var(--g-color-green-600,#226c57);--s-color-bg-brand-weak:var(--g-color-green-50,#f0f9ff);--s-color-bg-success-default:var(--g-color-green-600,#144134);--s-color-bg-info-default:var(--g-color-blue-600,#21506b);--s-color-bg-warning-default:var(--g-color-yellow-600,#665100);--s-color-bg-warning-weak:var(--g-color-yellow-50,#f9d985);--s-color-bg-warning-action:var(--g-color-yellow-400,#f9d985);--s-color-bg-caution-default:var(--g-color-red-600,#610);--s-color-bg-tip-default:var(--g-color-purple-600,#49216b);--s-color-bg-note-default:var(--g-color-gray-600,#21506b);--s-color-bg-input:var(--g-color-light,#fff);--s-color-text-base:var(--g-color-light,#fff);--s-color-text-primary:var(--g-color-gray-900,#080809);--s-color-text-secondary:var(--g-color-gray-600,#454a4e);--s-color-text-tertiary:var(--g-color-gray-400,#f0f0f0);--s-color-text-tertiary-hover:var(--g-color-gray-600,#e2e2e2);--s-color-text-quaternary:var(--g-color-gray-100,#f0f0f0);--s-color-text-brand-default:var(--g-color-light,#fff);--s-color-text-link:var(--g-color-green-600,#226c57);--s-color-text-link-hover:var(--g-color-green-600,#226c57);--s-color-text-success:var(--g-color-green-900,#144134);--s-color-text-info:var(--g-color-blue-900,#21506b);--s-color-text-warning:var(--g-color-yellow-900,#665100);--s-color-text-caution:var(--g-color-red-900,#610);--s-color-text-tip:var(--g-color-purple-900,#49216b);--s-color-border-primary:var(--g-color-gray-200,#bdbdbd);--s-color-border-secondary:var(--g-color-gray-100,#e2e2e2);--s-color-border-tertiary:var(--g-color-gray-300,#999);--s-color-border-quaternary:var(--g-color-gray-400,#7c7c7c);--s-color-border-transparent:var(--g-color-transparent,transparent);--s-color-border-input:var(--g-color-gray-300,#999);--s-color-border-brand-default:var(--g-color-green-600,#226c57);--s-color-border-success:var(--g-color-green-600,#144134);--s-color-border-info:var(--g-color-blue-600,#21506b);--s-color-border-warning:var(--g-color-yellow-600,#665100);--s-color-border-error:var(--g-color-red-600,#610);--s-color-border-tip:var(--g-color-purple-600,#49216b);--s-color-border-note:var(--g-color-gray-600,#21506b);--s-rounded-sm:var(--g-border-radius-sm,4px);--s-rounded:var(--g-border-radius,6px);--s-rounded-full:var(--g-border-radius-full,9999px);--s-border:var(--g-space-px,1px) solid var(--s-color-border-primary);--s-border-secondary:var(--g-space-px,1px) solid var(--s-color-border-secondary);--s-logo-hat:var(--g-color-green-600,#226c57);--s-logo-beard:var(--g-color-gray-300,#999)}[data-theme=dark]{--s-color-bg-base:var(--g-color-gray-850);--s-color-bg-base-dev:var(--g-color-gray-800);--s-color-bg-surface-primary:var(--g-color-gray-800);--s-color-bg-surface-primary-hover:var(--g-color-gray-750);--s-color-bg-surface-secondary:var(--g-color-gray-750);--s-color-bg-surface-quaternary:var(--g-color-gray-600);--s-color-bg-brand-weak:var(--g-color-green-950);--s-color-bg-warning-weak:var(--g-color-yellow-950);--s-color-bg-input:var(--g-color-gray-800);--s-color-text-primary:var(--g-color-gray-100);--s-color-text-secondary:var(--g-color-gray-200);--s-color-text-tertiary:var(--g-color-gray-400);--s-color-text-tertiary-hover:var(--g-color-gray-300);--s-color-text-quaternary:var(--g-color-gray-500);--s-color-text-brand-default:var(--g-color-light);--s-color-text-link:var(--g-color-green-500);--s-color-text-link-hover:var(--g-color-green-400);--s-color-text-success:var(--g-color-green-400);--s-color-text-info:var(--g-color-blue-400);--s-color-text-warning:var(--g-color-yellow-400);--s-color-text-caution:var(--g-color-red-400);--s-color-text-tip:var(--g-color-purple-400);--s-color-border-primary:var(--g-color-gray-700);--s-color-border-secondary:var(--g-color-gray-750);--s-color-border-tertiary:var(--g-color-gray-600);--s-color-border-quaternary:var(--g-color-gray-500);--s-color-border-input:var(--g-color-gray-700);--s-color-border-brand-default:var(--g-color-green-600);--s-color-border-success:var(--g-color-green-400);--s-color-border-info:var(--g-color-blue-400);--s-color-border-warning:var(--g-color-yellow-400);--s-color-border-error:var(--g-color-red-400);--s-color-border-tip:var(--g-color-purple-400);--s-color-border-note:var(--g-color-gray-600);--s-logo-hat:#fff;--s-logo-beard:grey}*,::backdrop,::file-selector-button,:after,:before{border:0 solid;box-sizing:border-box;margin:0;padding:0}html{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}h1,h2,h3{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub{bottom:-.25em;font-size:75%;line-height:0;position:relative;vertical-align:baseline}table{border-collapse:collapse;border-color:inherit;text-indent:0}summary{display:list-item}menu,ol,ul{list-style:none}embed,img,object,svg{display:block;vertical-align:middle}img{height:auto;max-width:100%}::file-selector-button,button,input,select,textarea{background-color:transparent;border-radius:0;color:inherit;font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;opacity:1}::file-selector-button{margin-right:4px}::-moz-placeholder{opacity:1}::placeholder{opacity:1}@supports (not (-webkit-appearance:-apple-pay-button)) or (contain-intrinsic-size:1px){::-moz-placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}::-webkit-calendar-picker-indicator{line-height:1}::file-selector-button,button,input:where([type=button],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}@font-face{font-display:swap;font-family:Roboto;font-style:normal;font-weight:900;src:url(fonts/roboto/roboto-mono-normal.woff2) format("woff2"),url(fonts/roboto/roboto-mono-normal.woff) format("woff")}@font-face{font-display:block;font-family:Inter;font-style:oblique 0deg 10deg;font-variant:normal;font-weight:100 900;src:url(fonts/intervar/Intervar.woff2) format("woff2")}html{background-color:var(--s-color-bg-base);color:var(--s-color-text-secondary);font-family:var(--g-font-family-inter-var);font-feature-settings:"kern" on,"liga" on,"calt" off,"zero" on,contextual common-ligatures,"kern";-webkit-font-feature-settings:"kern" on,"liga" on,"calt" off,"zero" on;font-size:calc(var(--g-px-base)*1px);line-height:var(--g-line-height-normal);-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-kerning:normal;font-variant-ligatures:contextual common-ligatures;text-rendering:optimizeLegibility}body{display:flex;flex-direction:column;min-height:100vh}main{background-color:var(--s-color-bg-base);flex-grow:2;width:100%}main.dev-mode{background-color:var(--s-color-bg-base-dev)}main>section{display:grid;grid-auto-flow:dense;grid-template-columns:var(--g-grid-1);grid-column-gap:var(--g-space-20);-moz-column-gap:var(--g-space-20);column-gap:var(--g-space-20);min-height:100%;padding-left:var(--g-space-4);padding-right:var(--g-space-4)}@media (min-width:calc(640 / 16 * 1rem)){main>section{padding-left:var(--g-space-10);padding-right:var(--g-space-10)}}@media (min-width:calc(820 / 16 * 1rem)){main>section{grid-template-columns:var(--g-grid-10)}}@media (min-width:calc(1366 / 16 * 1rem)){main>section{-moz-column-gap:var(--g-space-32);column-gap:var(--g-space-32)}}svg{max-height:100%;max-width:100%}form{margin-bottom:0;margin-top:0}code{font-family:var(--g-font-mono)}summary{cursor:pointer}md-renderer{margin-top:var(--g-space-4);padding-bottom:var(--g-space-24)}@media (min-width:calc(820 / 16 * 1rem)){md-renderer{grid-column:span 7;margin-top:0}}::-moz-selection{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}::selection{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}summary::-webkit-details-marker{display:none}summary::marker{display:none}.c-stack{display:flex;flex-direction:column;justify-content:flex-start}.c-stack>*+*{margin-top:var(--g-space-4)}.c-inline{align-items:center;display:inline-flex;gap:var(--g-space-3)}.c-between{align-items:center;display:flex;justify-content:space-between}.c-center{box-sizing:border-box;margin-left:auto;margin-right:auto;max-width:var(--g-breakpoint-max);padding-left:var(--g-space-4);padding-right:var(--g-space-4)}@media (min-width:calc(640 / 16 * 1rem)){.c-center{padding-left:var(--g-space-10);padding-right:var(--g-space-10)}}.c-full-screen{align-items:center;display:flex;flex-direction:column;grid-column:1/-1;height:100%;justify-content:center;margin-top:var(--g-space-10);padding-bottom:var(--g-space-24);width:100%}.c-reel{display:flex;overflow:scroll}.c-icon{flex-shrink:0;height:1.15em;width:1.15em}.c-with-icon{align-items:flex-start;display:inline-flex}.c-with-icon .c-icon,.c-with-icon--inline .c-icon{margin-left:.3em;margin-right:.3em;margin-top:.15em}.c-with-icon--inline{display:inline-block}.c-with-icon--inline>*{vertical-align:middle}.c-with-icon--inline .c-icon{margin-top:0}.c-view-grid{display:flex;flex-direction:column}@media (min-width:calc(640 / 16 * 1rem)){.c-view-grid{-moz-column-gap:var(--g-space-8);column-gap:var(--g-space-8);flex-direction:row}}@media (min-width:calc(820 / 16 * 1rem)){.c-view-grid{display:grid;grid-template-columns:var(--g-grid-10);grid-column-gap:var(--g-space-20);-moz-column-gap:var(--g-space-20);column-gap:var(--g-space-20)}}@media (min-width:calc(1366 / 16 * 1rem)){.c-view-grid{-moz-column-gap:var(--g-space-32);column-gap:var(--g-space-32)}}.c-toggle-btn>input{display:none}.c-toggle-btn label{visibility:hidden}.c-toggle-btn input:checked+label{visibility:visible}.c-readme-view,.c-realm-view{--cr-px-base:var(--g-px-base);--cr-space-mult:1;--cr-space-base:calc(1em/var(--g-space-mult)*var(--cr-space-mult));--cr-space-0:0;--cr-space-0-5:calc(var(--cr-space-base)*0.5);--cr-space-1:var(--cr-space-base);--cr-space-2:calc(var(--cr-space-base)*2);--cr-space-3:calc(var(--cr-space-base)*3);--cr-space-4:calc(var(--cr-space-base)*4);--cr-space-5:calc(var(--cr-space-base)*5);--cr-space-7:calc(var(--cr-space-base)*7);--cr-space-8:calc(var(--cr-space-base)*8);--cr-space-24:calc(var(--cr-space-base)*24);--cr-color-brand-default:var(--s-color-text-link);display:block;font-size:calc(var(--cr-px-base)*1px);padding-top:var(--g-space-4);word-break:break-word}.c-readme-view:empty,.c-realm-view:empty{display:none}.c-realm-view:has(.b-btn:only-child){display:none}.c-readme-view:has(.b-btn:only-child){display:none}@media (min-width:calc(820 / 16 * 1rem)){.c-readme-view,.c-realm-view{grid-row-start:1;padding-top:var(--g-space-6)}}.c-readme-view a,.c-realm-view a{color:var(--cr-color-brand-default);display:inline-block;font-weight:inherit;position:relative;text-wrap:balance;vertical-align:top}.c-readme-view a:hover,.c-realm-view a:hover{-webkit-text-decoration:underline;text-decoration:underline}.c-realm-view a:has(>img){vertical-align:middle}.c-readme-view a:has(>img){vertical-align:middle}.c-readme-view a>span,.c-realm-view a>span{margin-bottom:.1em}.c-readme-view a>.tooltip+.tooltip,.c-realm-view a>.tooltip+.tooltip{margin-left:.2em}.c-readme-view a>.tooltip:last-of-type,.c-realm-view a>.tooltip:last-of-type{margin-right:.2em}.c-realm-view a:has(>img:first-child):has(.tooltip:last-child):not(:has(>:nth-child(3)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip:last-child):not(:has(>:nth-child(3)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-realm-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-realm-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip:first-of-type{bottom:var(--g-space-2);left:var(--g-space-7);position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip:first-of-type{bottom:var(--g-space-2);left:var(--g-space-7);position:absolute}.c-readme-view h1+h2,.c-readme-view h2+h3,.c-readme-view h3+h4,.c-realm-view h1+h2,.c-realm-view h2+h3,.c-realm-view h3+h4{margin-top:var(--cr-space-4)}.c-readme-view h1,.c-readme-view h2,.c-readme-view h3,.c-readme-view h4,.c-realm-view h1,.c-realm-view h2,.c-realm-view h3,.c-realm-view h4{color:var(--s-color-text-primary);line-height:var(--g-line-height-tight);margin-top:var(--cr-space-4)}.c-readme-view h1,.c-realm-view h1{font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);margin-bottom:var(--cr-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h1,.c-realm-view h1{font-size:var(--g-font-size-800)}}.c-readme-view h2,.c-realm-view h2{font-size:var(--g-font-size-500);font-weight:var(--g-font-bold)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h2,.c-realm-view h2{font-size:var(--g-font-size-600)}}.c-readme-view h2 *,.c-realm-view h2 *{font-weight:var(--g-font-bold)}.c-readme-view h3,.c-readme-view h4,.c-realm-view h3,.c-realm-view h4{color:var(--s-color-text-secondary);font-weight:var(--g-font-semibold)}.c-readme-view h3,.c-realm-view h3{font-size:var(--g-font-size-400);margin-top:var(--cr-space-4)}.c-readme-view h4,.c-realm-view h4{font-size:var(--g-font-size-300);margin-top:var(--cr-space-3)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h4,.c-realm-view h4{font-size:var(--g-font-size-300)}}.c-readme-view h3 *,.c-readme-view h4 *,.c-realm-view h3 *,.c-realm-view h4 *{font-weight:var(--g-font-semibold)}.c-readme-view h5,.c-readme-view h6,.c-realm-view h5,.c-realm-view h6{font-size:var(--g-font-size-300);font-weight:var(--g-font-bold);margin-bottom:var(--cr-space-0);margin-top:var(--cr-space-0)}.c-readme-view h5+p,.c-readme-view h6+p,.c-realm-view h5+p,.c-realm-view h6+p{margin-top:var(--cr-space-0)}.c-readme-view img,.c-realm-view img{border:1px solid var(--s-color-bg-surface-primary);border-radius:var(--g-border-radius-sm);margin-bottom:var(--cr-space-2);margin-top:var(--cr-space-2);max-width:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none}.c-readme-view figure,.c-realm-view figure{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3);text-align:center}.c-readme-view figcaption,.c-realm-view figcaption{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100)}.c-readme-view video,.c-realm-view video{margin-bottom:var(--g-space-4);margin-top:var(--g-space-4);max-width:100%}.c-readme-view p,.c-realm-view p{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3)}.c-realm-view p:has(>a:only-child>img){margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4)}.c-readme-view p:has(>a:only-child>img){margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4)}.c-realm-view p:has(>a:only-child>img) img{margin-bottom:0;margin-top:0}.c-readme-view p:has(>a:only-child>img) img{margin-bottom:0;margin-top:0}.c-readme-view strong,.c-readme-view strong *,.c-realm-view strong,.c-realm-view strong *{font-weight:var(--g-font-bold)}.c-readme-view em,.c-realm-view em{font-style:var(--g-italic)}.c-readme-view blockquote,.c-realm-view blockquote{border-left:solid var(--g-space-0-5) var(--s-color-border-tertiary);color:var(--s-color-text-secondary);margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4);padding-left:var(--g-space-3)}.c-readme-view blockquote>blockquote,.c-realm-view blockquote>blockquote{margin-bottom:var(--cr-space-7);margin-top:var(--cr-space-7)}.c-readme-view caption,.c-realm-view caption{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);margin-top:var(--cr-space-2);text-align:left}.c-readme-view q,.c-realm-view q{quotes:"“" "”"}.c-readme-view q:before,.c-realm-view q:before{content:open-quote}.c-readme-view q:after,.c-realm-view q:after{content:close-quote}.c-readme-view details,.c-realm-view details{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3)}.c-readme-view summary,.c-realm-view summary{cursor:pointer;font-weight:var(--g-font-bold)}.c-readme-view math,.c-realm-view math{font-family:var(--g-font-family-mono)}.c-readme-view small,.c-realm-view small{font-size:var(--g-font-size-100)}.c-readme-view del,.c-realm-view del{-webkit-text-decoration:line-through;text-decoration:line-through}.c-readme-view sub,.c-realm-view sub{font-size:var(--g-font-size-50);vertical-align:sub}.c-readme-view sup,.c-realm-view sup{font-size:var(--g-font-size-50);padding-left:var(--space-px);vertical-align:middle}.c-readme-view sup>a,.c-realm-view sup>a{vertical-align:middle}.c-readme-view ol,.c-readme-view ul,.c-realm-view ol,.c-realm-view ul{margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4);padding-left:var(--g-space-4)}.c-readme-view ul,.c-realm-view ul{list-style:disc}.c-readme-view ol,.c-realm-view ol{list-style:decimal}.c-readme-view ol ol,.c-readme-view ol ul,.c-readme-view ul ol,.c-readme-view ul ul,.c-realm-view ol ol,.c-realm-view ol ul,.c-realm-view ul ol,.c-realm-view ul ul{margin-bottom:var(--cr-space-2);margin-top:var(--cr-space-2);padding-left:var(--g-space-4)}.c-readme-view li,.c-realm-view li{margin-bottom:var(--cr-space-1);margin-top:var(--cr-space-1)}.c-readme-view code,.c-readme-view pre,.c-realm-view code,.c-realm-view pre{font-family:var(--g-font-family-mono)}.c-readme-view pre,.c-readme-view pre.chroma-chroma,.c-realm-view pre,.c-realm-view pre.chroma-chroma{background-color:var(--s-color-bg-surface-primary);border-radius:var(--g-border-radius-sm);margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3);overflow-x:auto;padding:var(--cr-space-4)}.c-readme-view :not(pre)>code,.c-realm-view :not(pre)>code{background-color:var(--s-color-bg-surface-secondary);border-radius:var(--g-border-radius-sm);font-size:.96em;padding:var(--cr-space-0-5) var(--cr-space-1)}.c-readme-view a code,.c-realm-view a code{color:inherit}.c-readme-view hr,.c-realm-view hr{border-top:var(--s-border-secondary);margin-bottom:var(--cr-space-8);margin-top:var(--cr-space-8)}.c-readme-view table,.c-realm-view table{border-collapse:collapse;display:block;margin-bottom:var(--cr-space-5);margin-top:var(--cr-space-5);max-width:100%;width:100%}.c-readme-view td,.c-readme-view th,.c-realm-view td,.c-realm-view th{border:var(--s-border);padding:var(--cr-space-2) var(--cr-space-4);white-space:normal;word-break:break-word}.c-readme-view th,.c-realm-view th{background-color:var(--s-color-bg-surface-secondary);font-weight:var(--g-font-bold)}.c-readme-view button,.c-readme-view input,.c-readme-view select,.c-readme-view textarea,.c-realm-view button,.c-realm-view input,.c-realm-view select,.c-realm-view textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--s-color-bg-input);border:var(--s-border);padding:var(--cr-space-2) var(--cr-space-4)}.c-readme-view>.realm-view__btns:first-child+*,.c-readme-view>:first-child:not(.realm-view__btns),.c-realm-view>.realm-view__btns:first-child+*,.c-realm-view>:first-child:not(.realm-view__btns){margin-top:0!important}.c-readme-view .footnote-backref,.c-readme-view h1:not(.does-not-exist),.c-readme-view h2:not(.does-not-exist),.c-readme-view h3:not(.does-not-exist),.c-readme-view h4:not(.does-not-exist),.c-readme-view sup:not(.does-not-exist),.c-realm-view .footnote-backref,.c-realm-view h1:not(.does-not-exist),.c-realm-view h2:not(.does-not-exist),.c-realm-view h3:not(.does-not-exist),.c-realm-view h4:not(.does-not-exist),.c-realm-view sup:not(.does-not-exist){scroll-margin-top:var(--cr-space-24)}.c-readme-view .b-btn,.c-realm-view .b-btn{color:var(--s-color-text-secondary);display:inline-flex}.c-readme-view .b-btn:hover,.c-realm-view .b-btn:hover{-webkit-text-decoration:none;text-decoration:none}.c-readme-view .b-btn:first-child,.c-realm-view .b-btn:first-child{float:right;margin-top:var(--g-space-4)}.c-readme-view>.b-btn:first-child+*,.c-readme-view>:first-child:not(.b-btn),.c-realm-view>.b-btn:first-child+*,.c-realm-view>:first-child:not(.b-btn){margin-top:0}.c-readme-view{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius);margin-bottom:var(--g-space-6);padding:var(--g-space-6) var(--g-space-4) var(--g-space-4);width:100%}@media (min-width:calc(820 / 16 * 1rem)){.c-readme-view{grid-row-start:auto}}.b-gnome .hat,.b-logo .hat{fill:var(--s-logo-hat)}.b-gnome .beard,.b-logo .beard{fill:var(--s-logo-beard)}.b-banner{align-items:center;background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base);display:flex;font-size:var(--g-font-size-50);font-weight:var(--g-font-semibold);justify-content:center;padding:var(--g-space-1-5) var(--g-space-4);text-align:center;-webkit-text-decoration:none;text-decoration:none;width:100%}@media (min-width:calc(640 / 16 * 1rem)){.b-banner{font-size:var(--g-font-size-100)}}a.b-banner:hover{opacity:.9;-webkit-text-decoration:underline;text-decoration:underline}.b-header{background-color:var(--s-color-bg-base);border-bottom:var(--s-border);font-size:var(--g-font-size-100);position:sticky;top:0;z-index:var(--g-z-max)}.b-header nav{align-items:stretch;height:auto}.b-header .main-nav{align-items:stretch;display:flex;flex:1 1 auto;gap:var(--g-space-1);height:100%;min-width:0;padding-bottom:var(--g-space-2);padding-top:var(--g-space-2);width:100%}@media (min-width:calc(820 / 16 * 1rem)){.b-header .main-nav{grid-column:span 7}}.b-header .main-nav--explorer{grid-column:span 10}.b-header .user-picture{border:var(--s-border-secondary);border-radius:var(--s-rounded);cursor:pointer;flex-shrink:0;height:var(--g-space-10);width:var(--g-space-10)}.b-header .user-picture>svg{height:100%;width:100%}.b-main-navigation{color:var(--s-color-text-quaternary);height:auto;position:relative;width:100%}.b-main-navigation>.inner{align-items:center;background-color:var(--s-color-bg-surface-secondary);border:var(--s-border-secondary);border-radius:var(--s-rounded);height:100%;padding-left:var(--g-space-1-5);padding-right:var(--g-space-1-5);position:relative}@media (min-width:calc(640 / 16 * 1rem)){.b-main-navigation>.inner{padding-right:var(--g-space-8)}}.b-main-navigation>.inner:has([data-role=header-input-search]:focus-within){border-color:var(--s-color-border-tertiary)}.b-main-navigation .searchbar{bottom:0;color:var(--s-color-text-secondary);font-size:var(--g-font-size-200);font-weight:var(--g-font-medium);left:0;padding:var(--g-space-1-5);padding-right:var(--g-space-8);position:absolute;right:0;top:0}.b-main-navigation .searchbar>input{background-color:transparent;height:100%;outline:none;width:100%}.b-main-navigation .searchbar:focus-within+.b-breadcrumb{display:none}.b-main-navigation .network-toggle{align-items:center;background-color:var(--g-color-transparent);border-radius:var(--g-border-radius);cursor:pointer;display:none;height:calc(100% - 2px);justify-content:center;padding:var(--g-space-1-5);position:absolute;right:1px;top:1px;z-index:var(--g-z-max)}@media (min-width:calc(640 / 16 * 1rem)){.b-main-navigation .network-toggle{display:flex}}.b-main-navigation .network-toggle>svg{color:var(--s-color-text-tertiary);height:var(--g-space-5);width:var(--g-space-5)}.b-main-navigation .network-toggle:hover>svg{color:var(--s-color-text-tertiary-hover)}.b-main-navigation .b-popup-dialog>.inner{color:var(--s-color-text-tertiary);width:var(--g-space-72)}.b-main-navigation .b-popup-dialog header>span{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold)}.b-main-navigation .b-popup-dialog .item{display:flex;gap:var(--g-space-1)}.b-main-navigation .b-popup-dialog .item>svg{height:var(--g-space-4);width:var(--g-space-4)}.b-main-navigation .b-popup-dialog .item-content{display:flex;flex-direction:column}.b-main-navigation .b-popup-dialog .item-label{font-size:var(--g-font-size-50)}.b-main-navigation .b-popup-dialog .item-value{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold)}.b-main-menu{display:flex;flex:0 0 auto;grid-column:span 3;height:var(--g-space-12)}@media (min-width:calc(640 / 16 * 1rem)){.b-main-menu{height:auto}}.b-main-menu .menu-toggle{align-items:center;cursor:pointer;display:flex;margin-left:auto;order:3}.b-main-menu .menu-toggle>svg{height:var(--g-space-5);margin-left:var(--g-space-4);width:var(--g-space-5)}@media (min-width:calc(820 / 16 * 1rem)){.b-main-menu .menu-toggle>svg{margin-left:var(--g-space-2)}}.b-main-menu .menu-toggle-input~.menu-dev{display:none}.b-main-menu .menu-toggle-input:checked~.menu-dev{display:flex}.b-main-menu .menu-toggle-input:checked~.menu-general{display:none}.b-main-menu .menu-dev,.b-main-menu .menu-general{display:flex;height:100%;justify-content:flex-end}.b-menu-link:last-child,.b-menu-link:last-child .link{margin-right:0}.b-menu-link .link{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold);gap:var(--g-space-1);height:100%;margin-right:var(--g-space-3);position:relative}.b-menu-link .link:hover{color:var(--s-color-text-tertiary-hover)}.b-menu-link .link:after{background-color:var(--s-color-bg-brand-default);border-radius:var(--s-rounded) var(--s-rounded) 0 0;bottom:0;content:"";height:var(--g-space-1);left:0;position:absolute;transition:width var(--g-transition-fast);width:0}.b-menu-link .link>svg{flex-shrink:0;height:var(--g-space-5);min-width:var(--g-space-2);width:var(--g-space-5)}@media (min-width:calc(1020 / 16 * 1rem)){.b-menu-link .link>svg{display:none}}@media (min-width:calc(1366 / 16 * 1rem)){.b-menu-link .link>svg{display:inline-block;height:var(--g-space-4-5);width:var(--g-space-4-5)}}@media (min-width:calc(640 / 16 * 1rem)){.b-menu-link .link{font-weight:var(--g-font-bold)}}@media (min-width:calc(1366 / 16 * 1rem)){.b-menu-link .link{margin-right:var(--g-space-6);padding-right:var(--g-space-1)}}@media (min-width:calc(640 / 16 * 1rem)){.b-menu-link .link-label{display:none}}@media (min-width:calc(1020 / 16 * 1rem)){.b-menu-link .link-label{display:inline}}.b-menu-link .link--icon{font-weight:var(--g-font-regular);margin-right:var(--g-space-4)}@media (min-width:calc(480 / 16 * 1rem)){.b-menu-link .link--icon{margin-right:var(--g-space-6)}}.b-menu-link .link--is-active{color:var(--s-color-text-secondary)}.b-menu-link .link--is-active:after{width:100%}.b-menu-link .link--is-active>svg{color:var(--s-color-bg-brand-default)}.menu-general .link{color:var(--s-color-text-secondary)}.menu-general .link:hover{color:var(--s-color-text-link-hover)}.b-breadcrumb{display:flex}.b-breadcrumb,.b-breadcrumb:after{background-color:var(--s-color-bg-surface-secondary)}.b-breadcrumb:after{bottom:0;content:"";display:block;left:0;pointer-events:none;position:absolute;right:0;top:0}.b-breadcrumb>ol{color:var(--s-color-text-primary);display:flex;font-weight:var(--g-font-semibold);line-height:var(--g-line-height-snug)}.b-breadcrumb .argument,.b-breadcrumb .element,.b-breadcrumb .query{align-items:center;display:flex;white-space:nowrap;z-index:var(--g-z-1)}.b-breadcrumb .argument:not(:first-child):before,.b-breadcrumb .element:not(:first-child):before,.b-breadcrumb .query:not(:first-child):before{color:var(--s-color-text-tertiary);content:"/";line-height:var(--g-line-height-normal);padding-left:.18rem;padding-right:.18rem;padding-top:var(--g-space-px)}.b-breadcrumb .argument a,.b-breadcrumb .element a,.b-breadcrumb .query a{background-color:var(--s-color-bg-base);border:1px solid var(--s-color-border-transparent);border-radius:var(--s-rounded-sm);display:inline-block;min-width:var(--g-space-4);padding:var(--g-space-0-5);text-align:center}.b-breadcrumb .argument a:hover,.b-breadcrumb .element a:hover,.b-breadcrumb .query a:hover{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}.b-breadcrumb .argument:not(:first-child):before{content:":"}.b-breadcrumb .argument a{background-color:var(--s-color-bg-surface-quaternary);color:var(--s-color-text-base)}.b-breadcrumb .query:not(:first-child):before{content:"&"}.b-breadcrumb .query:nth-child(1 of .query):before{content:"?"}.b-breadcrumb .query label{background-color:var(--s-color-bg-surface-primary);border:var(--s-border);border-radius:var(--s-rounded-sm);color:var(--s-color-text-secondary);cursor:text;display:inline-flex;height:100%;min-width:var(--g-space-4);padding:var(--g-space-0-5) var(--g-space-1);position:relative;text-align:center;width:100%}.b-breadcrumb .query label:focus-within{border-color:var(--s-color-border-quaternary)}.b-breadcrumb .query label:hover{border-color:var(--s-color-border-quaternary)}.b-breadcrumb .query input{background-color:var(--s-color-bg-surface-primary);max-width:10ch;order:3;outline:none;field-sizing:content}@supports not (field-sizing:content){.b-breadcrumb .query input{width:5rem!important}}.b-breadcrumb .query input::-moz-placeholder{opacity:0}.b-breadcrumb .query input::placeholder{opacity:0}.b-breadcrumb .query input:-moz-placeholder{width:var(--g-space-px)}.b-breadcrumb .query input:placeholder-shown{width:var(--g-space-px)}.b-breadcrumb .query input:placeholder-shown::-moz-placeholder{color:var(--g-color-transparent)}.b-breadcrumb .query input:-moz-placeholder::placeholder{color:var(--g-color-transparent)}.b-breadcrumb .query input:placeholder-shown::placeholder{color:var(--g-color-transparent)}.b-footer{border-top:var(--s-border);font-size:var(--g-font-size-100);padding-bottom:var(--g-space-4);padding-top:var(--g-space-4);width:100%}.b-footer>nav{flex-direction:column;row-gap:var(--g-space-8)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer>nav{flex-wrap:wrap}}.b-footer .logo{color:var(--s-color-text-primary);grid-column:1/-1;width:var(--g-space-44)}.b-footer .logo:hover{color:var(--s-color-text-primary);-webkit-text-decoration:none;text-decoration:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .logo{align-self:center;grid-column:1/3;grid-row:1/1;width:60%}}.b-footer .nav-primary{display:flex;gap:var(--g-space-10);grid-column:1/-1;grid-row:2/3}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-primary{align-items:center;flex:1 0 0%;flex-direction:row;gap:var(--g-space-6);justify-content:space-between}}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .nav-primary{grid-column:2/8;grid-row:1/1}}.b-footer .nav-primary>ul{display:flex;flex:1;flex-direction:column;flex-wrap:wrap;gap:var(--g-space-1) var(--g-space-3)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-primary>ul{flex:initial;flex-direction:row}.b-footer .nav-social{margin-left:auto}}@media (min-width:calc(820 / 16 * 1rem)){.b-footer .nav-social{grid-column:span 3;justify-self:end;margin-left:0}}.b-footer .nav-theme{align-items:center;display:flex;gap:var(--g-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-theme{flex-basis:100%}}@media (min-width:calc(820 / 16 * 1rem)){.b-footer .nav-theme{grid-column:span 3}}.b-footer .nav-theme .nav-theme-label{color:var(--s-color-text-secondary)}.b-footer .nav-theme:has([data-theme-target=sun]:not(.u-hidden)) .nav-theme-label:before{content:"Light"}.b-footer .nav-theme:has([data-theme-target=moon]:not(.u-hidden)) .nav-theme-label:before{content:"Dark"}.b-footer .nav-theme:has([data-theme-target=system]:not(.u-hidden)) +:root{--g-px-base:16;--g-space-mult:4;--g-space-base:calc(1rem/var(--g-space-mult));--g-breakpoint-max:calc(1580/var(--g-px-base)*1rem);--g-z-min:-1;--g-z-1:1;--g-z-max:9999;--g-duration-75:75ms;--g-duration-150:150ms;--g-opacity-50:0.5;--g-grid-1:repeat(1,minmax(0,1fr));--g-grid-10:repeat(10,minmax(0,1fr));--g-space-px:1px;--g-space-0-5:calc(var(--g-space-base)*0.5);--g-space-1:var(--g-space-base);--g-space-1-5:calc(var(--g-space-base)*1.5);--g-space-2:calc(var(--g-space-base)*2);--g-space-2-5:calc(var(--g-space-base)*2.5);--g-space-3:calc(var(--g-space-base)*3);--g-space-4:calc(var(--g-space-base)*4);--g-space-4-5:calc(var(--g-space-base)*4.5);--g-space-5:calc(var(--g-space-base)*5);--g-space-6:calc(var(--g-space-base)*6);--g-space-7:calc(var(--g-space-base)*7);--g-space-8:calc(var(--g-space-base)*8);--g-space-10:calc(var(--g-space-base)*10);--g-space-12:calc(var(--g-space-base)*12);--g-space-14:calc(var(--g-space-base)*14);--g-space-20:calc(var(--g-space-base)*20);--g-space-24:calc(var(--g-space-base)*24);--g-space-28:calc(var(--g-space-base)*28);--g-space-32:calc(var(--g-space-base)*32);--g-space-36:calc(var(--g-space-base)*36);--g-space-44:calc(var(--g-space-base)*44);--g-space-48:calc(var(--g-space-base)*48);--g-space-52:calc(var(--g-space-base)*52);--g-space-72:calc(var(--g-space-base)*72);--g-space-96:calc(var(--g-space-base)*96);--g-font-size-50:calc(12/var(--g-px-base)*1rem);--g-font-size-100:calc(14/var(--g-px-base)*1rem);--g-font-size-200:calc(16/var(--g-px-base)*1rem);--g-font-size-300:calc(18/var(--g-px-base)*1rem);--g-font-size-400:calc(20/var(--g-px-base)*1rem);--g-font-size-500:calc(22/var(--g-px-base)*1rem);--g-font-size-600:calc(24/var(--g-px-base)*1rem);--g-font-size-700:calc(32/var(--g-px-base)*1rem);--g-font-size-800:calc(38/var(--g-px-base)*1rem);--g-font-family-mono:"Roboto",'Menlo, Consolas, "Ubuntu Mono", "Roboto Mono", "DejaVu Sans Mono", monospace';--g-font-family-inter-var:"Inter",'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif';--g-font-normal:400;--g-font-medium:500;--g-font-semibold:600;--g-font-bold:700;--g-italic:oblique 14deg;--g-line-height-tight:1.25;--g-line-height-snug:1.375;--g-line-height-normal:1.5;--g-border-radius-sm:calc(4/var(--g-px-base)*1rem);--g-border-radius:calc(6/var(--g-px-base)*1rem);--g-border-radius-full:9999px;--g-color-light:#fff;--g-color-transparent:transparent;--g-color-gray-50:#f0f0f0;--g-color-gray-100:#e2e2e2;--g-color-gray-200:#bdbdbd;--g-color-gray-300:#999;--g-color-gray-400:#7c7c7c;--g-color-gray-500:#696969;--g-color-gray-600:#585858;--g-color-gray-700:#292929;--g-color-gray-750:#1f1f1f;--g-color-gray-800:#141414;--g-color-gray-850:#0e0e0e;--g-color-gray-900:#090909;--g-color-green-50:#e7efed;--g-color-green-400:#60ab96;--g-color-green-500:#277b63;--g-color-green-600:#226c57;--g-color-green-900:#144134;--g-color-green-950:#002c20;--g-color-blue-400:#49afeb;--g-color-blue-600:#3e96c9;--g-color-blue-900:#21506b;--g-color-yellow-50:#fff7eb;--g-color-yellow-400:#facc32;--g-color-yellow-600:#fbbf24;--g-color-yellow-900:#7b4807;--g-color-yellow-950:#362600;--g-color-red-400:#eb6c49;--g-color-red-600:#c95c3e;--g-color-red-900:#6b2521;--g-color-purple-400:#7f49eb;--g-color-purple-600:#6c3ec9;--g-color-purple-900:#39216b}@supports (color:color(display-p3 0 0 0%)){:root{--g-color-green-950:#002c20;--g-color-yellow-50:#fff7eb;--g-color-yellow-950:#362600}@media (color-gamut:p3){:root{--g-color-green-950:color(display-p3 0.04602 0.17026 0.1277);--g-color-yellow-50:color(display-p3 0.99709 0.97106 0.92232);--g-color-yellow-950:color(display-p3 0.2031 0.15112 0.01811)}}}:root{--s-color-bg-base:var(--g-color-light,#fff);--s-color-bg-base-dev:var(--g-color-gray-50,#f0f0f0);--s-color-bg-surface-primary:var(--g-color-gray-50,#f0f0f0);--s-color-bg-surface-primary-hover:var(--g-color-gray-100,#f0f0f0);--s-color-bg-surface-secondary:var(--g-color-gray-100,#e2e2e2);--s-color-bg-surface-quaternary:var(--g-color-gray-400,#7c7c7c);--s-color-bg-brand-default:var(--g-color-green-600,#226c57);--s-color-bg-brand-weak:var(--g-color-green-50,#f0f9ff);--s-color-bg-brand-action:var(--g-color-green-400,#60ab96);--s-color-bg-success-default:var(--g-color-green-600,#144134);--s-color-bg-info-default:var(--g-color-blue-600,#21506b);--s-color-bg-warning-default:var(--g-color-yellow-600,#665100);--s-color-bg-warning-weak:var(--g-color-yellow-50,#f9d985);--s-color-bg-warning-action:var(--g-color-yellow-400,#f9d985);--s-color-bg-caution-default:var(--g-color-red-600,#610);--s-color-bg-tip-default:var(--g-color-purple-600,#49216b);--s-color-bg-note-default:var(--g-color-gray-600,#21506b);--s-color-bg-input:var(--g-color-light,#fff);--s-color-text-base:var(--g-color-light,#fff);--s-color-text-primary:var(--g-color-gray-900,#080809);--s-color-text-secondary:var(--g-color-gray-600,#454a4e);--s-color-text-tertiary:var(--g-color-gray-400,#f0f0f0);--s-color-text-tertiary-hover:var(--g-color-gray-600,#e2e2e2);--s-color-text-quaternary:var(--g-color-gray-100,#f0f0f0);--s-color-text-brand-default:var(--g-color-light,#fff);--s-color-text-link:var(--g-color-green-600,#226c57);--s-color-text-link-hover:var(--g-color-green-600,#226c57);--s-color-text-success:var(--g-color-green-900,#144134);--s-color-text-info:var(--g-color-blue-900,#21506b);--s-color-text-warning:var(--g-color-yellow-900,#665100);--s-color-text-caution:var(--g-color-red-900,#610);--s-color-text-tip:var(--g-color-purple-900,#49216b);--s-color-border-primary:var(--g-color-gray-200,#bdbdbd);--s-color-border-secondary:var(--g-color-gray-100,#e2e2e2);--s-color-border-tertiary:var(--g-color-gray-300,#999);--s-color-border-quaternary:var(--g-color-gray-400,#7c7c7c);--s-color-border-transparent:var(--g-color-transparent,transparent);--s-color-border-input:var(--g-color-gray-300,#999);--s-color-border-brand-default:var(--g-color-green-600,#226c57);--s-color-border-success:var(--g-color-green-600,#144134);--s-color-border-info:var(--g-color-blue-600,#21506b);--s-color-border-warning:var(--g-color-yellow-600,#665100);--s-color-border-error:var(--g-color-red-600,#610);--s-color-border-tip:var(--g-color-purple-600,#49216b);--s-color-border-note:var(--g-color-gray-600,#21506b);--s-rounded-sm:var(--g-border-radius-sm,4px);--s-rounded:var(--g-border-radius,6px);--s-rounded-full:var(--g-border-radius-full,9999px);--s-border:var(--g-space-px,1px) solid var(--s-color-border-primary);--s-border-secondary:var(--g-space-px,1px) solid var(--s-color-border-secondary);--s-logo-hat:var(--g-color-green-600,#226c57);--s-logo-beard:var(--g-color-gray-300,#999)}[data-theme=dark]{--s-color-bg-base:var(--g-color-gray-850);--s-color-bg-base-dev:var(--g-color-gray-800);--s-color-bg-surface-primary:var(--g-color-gray-800);--s-color-bg-surface-primary-hover:var(--g-color-gray-750);--s-color-bg-surface-secondary:var(--g-color-gray-750);--s-color-bg-surface-quaternary:var(--g-color-gray-600);--s-color-bg-brand-weak:var(--g-color-green-950);--s-color-bg-warning-weak:var(--g-color-yellow-950);--s-color-bg-input:var(--g-color-gray-800);--s-color-text-primary:var(--g-color-gray-100);--s-color-text-secondary:var(--g-color-gray-200);--s-color-text-tertiary:var(--g-color-gray-400);--s-color-text-tertiary-hover:var(--g-color-gray-300);--s-color-text-quaternary:var(--g-color-gray-500);--s-color-text-brand-default:var(--g-color-light);--s-color-text-link:var(--g-color-green-500);--s-color-text-link-hover:var(--g-color-green-400);--s-color-text-success:var(--g-color-green-400);--s-color-text-info:var(--g-color-blue-400);--s-color-text-warning:var(--g-color-yellow-400);--s-color-text-caution:var(--g-color-red-400);--s-color-text-tip:var(--g-color-purple-400);--s-color-border-primary:var(--g-color-gray-700);--s-color-border-secondary:var(--g-color-gray-750);--s-color-border-tertiary:var(--g-color-gray-600);--s-color-border-quaternary:var(--g-color-gray-500);--s-color-border-input:var(--g-color-gray-700);--s-color-border-brand-default:var(--g-color-green-600);--s-color-border-success:var(--g-color-green-400);--s-color-border-info:var(--g-color-blue-400);--s-color-border-warning:var(--g-color-yellow-400);--s-color-border-error:var(--g-color-red-400);--s-color-border-tip:var(--g-color-purple-400);--s-color-border-note:var(--g-color-gray-600);--s-logo-hat:#fff;--s-logo-beard:grey}*,::backdrop,::file-selector-button,:after,:before{border:0 solid;box-sizing:border-box;margin:0;padding:0}html{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}h1,h2,h3{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub{bottom:-.25em;font-size:75%;line-height:0;position:relative;vertical-align:baseline}table{border-collapse:collapse;border-color:inherit;text-indent:0}summary{display:list-item}menu,ol,ul{list-style:none}embed,img,object,svg{display:block;vertical-align:middle}img{height:auto;max-width:100%}::file-selector-button,button,input,select,textarea{background-color:transparent;border-radius:0;color:inherit;font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;opacity:1}::file-selector-button{margin-right:4px}::-moz-placeholder{opacity:1}::placeholder{opacity:1}@supports (not (-webkit-appearance:-apple-pay-button)) or (contain-intrinsic-size:1px){::-moz-placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}::-webkit-calendar-picker-indicator{line-height:1}::file-selector-button,button,input:where([type=button],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}@font-face{font-display:swap;font-family:Roboto;font-style:normal;font-weight:900;src:url(fonts/roboto/roboto-mono-normal.woff2) format("woff2"),url(fonts/roboto/roboto-mono-normal.woff) format("woff")}@font-face{font-display:block;font-family:Inter;font-style:oblique 0deg 10deg;font-variant:normal;font-weight:100 900;src:url(fonts/intervar/Intervar.woff2) format("woff2")}html{background-color:var(--s-color-bg-base);color:var(--s-color-text-secondary);font-family:var(--g-font-family-inter-var);font-feature-settings:"kern" on,"liga" on,"calt" off,"zero" on,contextual common-ligatures,"kern";-webkit-font-feature-settings:"kern" on,"liga" on,"calt" off,"zero" on;font-size:calc(var(--g-px-base)*1px);line-height:var(--g-line-height-normal);-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-kerning:normal;font-variant-ligatures:contextual common-ligatures;text-rendering:optimizeLegibility}body{display:flex;flex-direction:column;min-height:100vh}main{background-color:var(--s-color-bg-base);flex-grow:2;width:100%}main.dev-mode{background-color:var(--s-color-bg-base-dev)}main>section{display:grid;grid-auto-flow:dense;grid-template-columns:var(--g-grid-1);grid-column-gap:var(--g-space-20);-moz-column-gap:var(--g-space-20);column-gap:var(--g-space-20);min-height:100%;padding-left:var(--g-space-4);padding-right:var(--g-space-4)}@media (min-width:calc(640 / 16 * 1rem)){main>section{padding-left:var(--g-space-10);padding-right:var(--g-space-10)}}@media (min-width:calc(820 / 16 * 1rem)){main>section{grid-template-columns:var(--g-grid-10)}}@media (min-width:calc(1366 / 16 * 1rem)){main>section{-moz-column-gap:var(--g-space-32);column-gap:var(--g-space-32)}}svg{max-height:100%;max-width:100%}form{margin-bottom:0;margin-top:0}code{font-family:var(--g-font-mono)}summary{cursor:pointer}md-renderer{margin-top:var(--g-space-4);padding-bottom:var(--g-space-24)}@media (min-width:calc(820 / 16 * 1rem)){md-renderer{grid-column:span 7;margin-top:0}}::-moz-selection{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}::selection{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}summary::-webkit-details-marker{display:none}summary::marker{display:none}.c-stack{display:flex;flex-direction:column;justify-content:flex-start}.c-stack>*+*{margin-top:var(--g-space-4)}.c-inline{align-items:center;display:inline-flex;gap:var(--g-space-3)}.c-between{align-items:center;display:flex;justify-content:space-between}.c-center{box-sizing:border-box;margin-left:auto;margin-right:auto;max-width:var(--g-breakpoint-max);padding-left:var(--g-space-4);padding-right:var(--g-space-4)}@media (min-width:calc(640 / 16 * 1rem)){.c-center{padding-left:var(--g-space-10);padding-right:var(--g-space-10)}}.c-full-screen{align-items:center;display:flex;flex-direction:column;grid-column:1/-1;height:100%;justify-content:center;margin-top:var(--g-space-10);padding-bottom:var(--g-space-24);width:100%}.c-reel{display:flex;overflow:scroll}.c-icon{flex-shrink:0;height:1.15em;width:1.15em}.c-with-icon{align-items:flex-start;display:inline-flex}.c-with-icon .c-icon,.c-with-icon--inline .c-icon{margin-left:.3em;margin-right:.3em;margin-top:.15em}.c-with-icon--inline{display:inline-block}.c-with-icon--inline>*{vertical-align:middle}.c-with-icon--inline .c-icon{margin-top:0}.c-view-grid{display:flex;flex-direction:column}@media (min-width:calc(640 / 16 * 1rem)){.c-view-grid{-moz-column-gap:var(--g-space-8);column-gap:var(--g-space-8);flex-direction:row}}@media (min-width:calc(820 / 16 * 1rem)){.c-view-grid{display:grid;grid-template-columns:var(--g-grid-10);grid-column-gap:var(--g-space-20);-moz-column-gap:var(--g-space-20);column-gap:var(--g-space-20)}}@media (min-width:calc(1366 / 16 * 1rem)){.c-view-grid{-moz-column-gap:var(--g-space-32);column-gap:var(--g-space-32)}}.c-toggle-btn>input{display:none}.c-toggle-btn label{visibility:hidden}.c-toggle-btn input:checked+label{visibility:visible}.c-readme-view,.c-realm-view{--cr-px-base:var(--g-px-base);--cr-space-mult:1;--cr-space-base:calc(1em/var(--g-space-mult)*var(--cr-space-mult));--cr-space-0:0;--cr-space-0-5:calc(var(--cr-space-base)*0.5);--cr-space-1:var(--cr-space-base);--cr-space-2:calc(var(--cr-space-base)*2);--cr-space-3:calc(var(--cr-space-base)*3);--cr-space-4:calc(var(--cr-space-base)*4);--cr-space-5:calc(var(--cr-space-base)*5);--cr-space-7:calc(var(--cr-space-base)*7);--cr-space-8:calc(var(--cr-space-base)*8);--cr-space-24:calc(var(--cr-space-base)*24);--cr-color-brand-default:var(--s-color-text-link);display:block;font-size:calc(var(--cr-px-base)*1px);padding-top:var(--g-space-4);word-break:break-word}.c-readme-view:empty,.c-realm-view:empty{display:none}.c-realm-view:has(.b-btn:only-child){display:none}.c-readme-view:has(.b-btn:only-child){display:none}@media (min-width:calc(820 / 16 * 1rem)){.c-readme-view,.c-realm-view{grid-row-start:1;padding-top:var(--g-space-6)}}.c-readme-view a,.c-realm-view a{color:var(--cr-color-brand-default);display:inline-block;font-weight:inherit;position:relative;text-wrap:balance;vertical-align:top}.c-readme-view a:hover,.c-realm-view a:hover{-webkit-text-decoration:underline;text-decoration:underline}.c-realm-view a:has(>img){vertical-align:middle}.c-readme-view a:has(>img){vertical-align:middle}.c-readme-view a>span,.c-realm-view a>span{margin-bottom:.1em}.c-readme-view a>.tooltip+.tooltip,.c-realm-view a>.tooltip+.tooltip{margin-left:.2em}.c-readme-view a>.tooltip:last-of-type,.c-realm-view a>.tooltip:last-of-type{margin-right:.2em}.c-realm-view a:has(>img:first-child):has(.tooltip:last-child):not(:has(>:nth-child(3)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip:last-child):not(:has(>:nth-child(3)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-realm-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius-full);bottom:var(--g-space-2);left:var(--g-space-2);margin-left:0;position:absolute}.c-realm-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip:first-of-type{bottom:var(--g-space-2);left:var(--g-space-7);position:absolute}.c-readme-view a:has(>img:first-child):has(.tooltip+.tooltip:last-child):not(:has(>:nth-child(4)))>.tooltip:first-of-type{bottom:var(--g-space-2);left:var(--g-space-7);position:absolute}.c-readme-view h1+h2,.c-readme-view h2+h3,.c-readme-view h3+h4,.c-realm-view h1+h2,.c-realm-view h2+h3,.c-realm-view h3+h4{margin-top:var(--cr-space-4)}.c-readme-view h1,.c-readme-view h2,.c-readme-view h3,.c-readme-view h4,.c-realm-view h1,.c-realm-view h2,.c-realm-view h3,.c-realm-view h4{color:var(--s-color-text-primary);line-height:var(--g-line-height-tight);margin-top:var(--cr-space-4)}.c-readme-view h1,.c-realm-view h1{font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);margin-bottom:var(--cr-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h1,.c-realm-view h1{font-size:var(--g-font-size-800)}}.c-readme-view h2,.c-realm-view h2{font-size:var(--g-font-size-500);font-weight:var(--g-font-bold)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h2,.c-realm-view h2{font-size:var(--g-font-size-600)}}.c-readme-view h2 *,.c-realm-view h2 *{font-weight:var(--g-font-bold)}.c-readme-view h3,.c-readme-view h4,.c-realm-view h3,.c-realm-view h4{color:var(--s-color-text-secondary);font-weight:var(--g-font-semibold)}.c-readme-view h3,.c-realm-view h3{font-size:var(--g-font-size-400);margin-top:var(--cr-space-4)}.c-readme-view h4,.c-realm-view h4{font-size:var(--g-font-size-300);margin-top:var(--cr-space-3)}@media (min-width:calc(640 / 16 * 1rem)){.c-readme-view h4,.c-realm-view h4{font-size:var(--g-font-size-300)}}.c-readme-view h3 *,.c-readme-view h4 *,.c-realm-view h3 *,.c-realm-view h4 *{font-weight:var(--g-font-semibold)}.c-readme-view h5,.c-readme-view h6,.c-realm-view h5,.c-realm-view h6{font-size:var(--g-font-size-300);font-weight:var(--g-font-bold);margin-bottom:var(--cr-space-0);margin-top:var(--cr-space-0)}.c-readme-view h5+p,.c-readme-view h6+p,.c-realm-view h5+p,.c-realm-view h6+p{margin-top:var(--cr-space-0)}.c-readme-view img,.c-realm-view img{border:1px solid var(--s-color-bg-surface-primary);border-radius:var(--g-border-radius-sm);margin-bottom:var(--cr-space-2);margin-top:var(--cr-space-2);max-width:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none}.c-readme-view figure,.c-realm-view figure{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3);text-align:center}.c-readme-view figcaption,.c-realm-view figcaption{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100)}.c-readme-view video,.c-realm-view video{margin-bottom:var(--g-space-4);margin-top:var(--g-space-4);max-width:100%}.c-readme-view p,.c-realm-view p{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3)}.c-realm-view p:has(>a:only-child>img){margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4)}.c-readme-view p:has(>a:only-child>img){margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4)}.c-realm-view p:has(>a:only-child>img) img{margin-bottom:0;margin-top:0}.c-readme-view p:has(>a:only-child>img) img{margin-bottom:0;margin-top:0}.c-readme-view strong,.c-readme-view strong *,.c-realm-view strong,.c-realm-view strong *{font-weight:var(--g-font-bold)}.c-readme-view em,.c-realm-view em{font-style:var(--g-italic)}.c-readme-view blockquote,.c-realm-view blockquote{border-left:solid var(--g-space-0-5) var(--s-color-border-tertiary);color:var(--s-color-text-secondary);margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4);padding-left:var(--g-space-3)}.c-readme-view blockquote>blockquote,.c-realm-view blockquote>blockquote{margin-bottom:var(--cr-space-7);margin-top:var(--cr-space-7)}.c-readme-view caption,.c-realm-view caption{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);margin-top:var(--cr-space-2);text-align:left}.c-readme-view q,.c-realm-view q{quotes:"“" "”"}.c-readme-view q:before,.c-realm-view q:before{content:open-quote}.c-readme-view q:after,.c-realm-view q:after{content:close-quote}.c-readme-view details,.c-realm-view details{margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3)}.c-readme-view summary,.c-realm-view summary{cursor:pointer;font-weight:var(--g-font-bold)}.c-readme-view math,.c-realm-view math{font-family:var(--g-font-family-mono)}.c-readme-view small,.c-realm-view small{font-size:var(--g-font-size-100)}.c-readme-view del,.c-realm-view del{-webkit-text-decoration:line-through;text-decoration:line-through}.c-readme-view sub,.c-realm-view sub{font-size:var(--g-font-size-50);vertical-align:sub}.c-readme-view sup,.c-realm-view sup{font-size:var(--g-font-size-50);padding-left:var(--space-px);vertical-align:middle}.c-readme-view sup>a,.c-realm-view sup>a{vertical-align:middle}.c-readme-view ol,.c-readme-view ul,.c-realm-view ol,.c-realm-view ul{margin-bottom:var(--cr-space-4);margin-top:var(--cr-space-4);padding-left:var(--g-space-4)}.c-readme-view ul,.c-realm-view ul{list-style:disc}.c-readme-view ol,.c-realm-view ol{list-style:decimal}.c-readme-view ol ol,.c-readme-view ol ul,.c-readme-view ul ol,.c-readme-view ul ul,.c-realm-view ol ol,.c-realm-view ol ul,.c-realm-view ul ol,.c-realm-view ul ul{margin-bottom:var(--cr-space-2);margin-top:var(--cr-space-2);padding-left:var(--g-space-4)}.c-readme-view li,.c-realm-view li{margin-bottom:var(--cr-space-1);margin-top:var(--cr-space-1)}.c-readme-view code,.c-readme-view pre,.c-realm-view code,.c-realm-view pre{font-family:var(--g-font-family-mono)}.c-readme-view pre,.c-readme-view pre.chroma-chroma,.c-realm-view pre,.c-realm-view pre.chroma-chroma{background-color:var(--s-color-bg-surface-primary);border-radius:var(--g-border-radius-sm);margin-bottom:var(--cr-space-3);margin-top:var(--cr-space-3);overflow-x:auto;padding:var(--cr-space-4)}.c-readme-view :not(pre)>code,.c-realm-view :not(pre)>code{background-color:var(--s-color-bg-surface-secondary);border-radius:var(--g-border-radius-sm);font-size:.96em;padding:var(--cr-space-0-5) var(--cr-space-1)}.c-readme-view a code,.c-realm-view a code{color:inherit}.c-readme-view hr,.c-realm-view hr{border-top:var(--s-border-secondary);margin-bottom:var(--cr-space-8);margin-top:var(--cr-space-8)}.c-readme-view table,.c-realm-view table{border-collapse:collapse;display:block;margin-bottom:var(--cr-space-5);margin-top:var(--cr-space-5);max-width:100%;width:100%}.c-readme-view td,.c-readme-view th,.c-realm-view td,.c-realm-view th{border:var(--s-border);padding:var(--cr-space-2) var(--cr-space-4);white-space:normal;word-break:break-word}.c-readme-view th,.c-realm-view th{background-color:var(--s-color-bg-surface-secondary);font-weight:var(--g-font-bold)}.c-readme-view button,.c-readme-view input,.c-readme-view select,.c-readme-view textarea,.c-realm-view button,.c-realm-view input,.c-realm-view select,.c-realm-view textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--s-color-bg-input);border:var(--s-border);padding:var(--cr-space-2) var(--cr-space-4)}.c-readme-view>.realm-view__btns:first-child+*,.c-readme-view>:first-child:not(.realm-view__btns),.c-realm-view>.realm-view__btns:first-child+*,.c-realm-view>:first-child:not(.realm-view__btns){margin-top:0!important}.c-readme-view .footnote-backref,.c-readme-view h1:not(.does-not-exist),.c-readme-view h2:not(.does-not-exist),.c-readme-view h3:not(.does-not-exist),.c-readme-view h4:not(.does-not-exist),.c-readme-view sup:not(.does-not-exist),.c-realm-view .footnote-backref,.c-realm-view h1:not(.does-not-exist),.c-realm-view h2:not(.does-not-exist),.c-realm-view h3:not(.does-not-exist),.c-realm-view h4:not(.does-not-exist),.c-realm-view sup:not(.does-not-exist){scroll-margin-top:var(--cr-space-24)}.c-readme-view .b-btn,.c-realm-view .b-btn{color:var(--s-color-text-secondary);display:inline-flex}.c-readme-view .b-btn:hover,.c-realm-view .b-btn:hover{-webkit-text-decoration:none;text-decoration:none}.c-readme-view .b-btn:first-child,.c-realm-view .b-btn:first-child{float:right;margin-top:var(--g-space-4)}.c-readme-view>.b-btn:first-child+*,.c-readme-view>:first-child:not(.b-btn),.c-realm-view>.b-btn:first-child+*,.c-realm-view>:first-child:not(.b-btn){margin-top:0}.c-readme-view{background-color:var(--s-color-bg-base);border-radius:var(--g-border-radius);margin-bottom:var(--g-space-6);padding:var(--g-space-6) var(--g-space-4) var(--g-space-4);width:100%}@media (min-width:calc(820 / 16 * 1rem)){.c-readme-view{grid-row-start:auto}}.b-gnome .hat,.b-logo .hat{fill:var(--s-logo-hat)}.b-gnome .beard,.b-logo .beard{fill:var(--s-logo-beard)}.b-banner{align-items:center;background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base);display:flex;font-size:var(--g-font-size-50);font-weight:var(--g-font-semibold);justify-content:center;padding:var(--g-space-1-5) var(--g-space-4);text-align:center;width:100%}@media (min-width:calc(640 / 16 * 1rem)){.b-banner{font-size:var(--g-font-size-100)}}.b-banner a{color:inherit;-webkit-text-decoration:underline;text-decoration:underline}.b-banner a:hover,a.b-banner:hover{opacity:.8}.b-banner code{background-color:var(--s-color-bg-brand-action);border-radius:var(--g-border-radius-sm);font-size:.96em;padding:var(--g-space-0-5) var(--g-space-1)}.b-header{background-color:var(--s-color-bg-base);border-bottom:var(--s-border);font-size:var(--g-font-size-100);position:sticky;top:0;z-index:var(--g-z-max)}.b-header nav{align-items:stretch;height:auto}.b-header .main-nav{align-items:stretch;display:flex;flex:1 1 auto;gap:var(--g-space-1);height:100%;min-width:0;padding-bottom:var(--g-space-2);padding-top:var(--g-space-2);width:100%}@media (min-width:calc(820 / 16 * 1rem)){.b-header .main-nav{grid-column:span 7}}.b-header .main-nav--explorer{grid-column:span 10}.b-header .user-picture{border:var(--s-border-secondary);border-radius:var(--s-rounded);cursor:pointer;flex-shrink:0;height:var(--g-space-10);width:var(--g-space-10)}.b-header .user-picture>svg{height:100%;width:100%}.b-main-navigation{color:var(--s-color-text-quaternary);height:auto;position:relative;width:100%}.b-main-navigation>.inner{align-items:center;background-color:var(--s-color-bg-surface-secondary);border:var(--s-border-secondary);border-radius:var(--s-rounded);height:100%;padding-left:var(--g-space-1-5);padding-right:var(--g-space-1-5);position:relative}@media (min-width:calc(640 / 16 * 1rem)){.b-main-navigation>.inner{padding-right:var(--g-space-8)}}.b-main-navigation>.inner:has([data-role=header-input-search]:focus-within){border-color:var(--s-color-border-tertiary)}.b-main-navigation .searchbar{bottom:0;color:var(--s-color-text-secondary);font-size:var(--g-font-size-200);font-weight:var(--g-font-medium);left:0;padding:var(--g-space-1-5);padding-right:var(--g-space-8);position:absolute;right:0;top:0}.b-main-navigation .searchbar>input{background-color:transparent;height:100%;outline:none;width:100%}.b-main-navigation .searchbar:focus-within+.b-breadcrumb{display:none}.b-main-navigation .network-toggle{align-items:center;background-color:var(--g-color-transparent);border-radius:var(--g-border-radius);cursor:pointer;display:none;height:calc(100% - 2px);justify-content:center;padding:var(--g-space-1-5);position:absolute;right:1px;top:1px;z-index:var(--g-z-max)}@media (min-width:calc(640 / 16 * 1rem)){.b-main-navigation .network-toggle{display:flex}}.b-main-navigation .network-toggle>svg{color:var(--s-color-text-tertiary);height:var(--g-space-5);width:var(--g-space-5)}.b-main-navigation .network-toggle:hover>svg{color:var(--s-color-text-tertiary-hover)}.b-main-navigation .b-popup-dialog>.inner{color:var(--s-color-text-tertiary);width:var(--g-space-72)}.b-main-navigation .b-popup-dialog header>span{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold)}.b-main-navigation .b-popup-dialog .item{display:flex;gap:var(--g-space-1)}.b-main-navigation .b-popup-dialog .item>svg{height:var(--g-space-4);width:var(--g-space-4)}.b-main-navigation .b-popup-dialog .item-content{display:flex;flex-direction:column}.b-main-navigation .b-popup-dialog .item-label{font-size:var(--g-font-size-50)}.b-main-navigation .b-popup-dialog .item-value{color:var(--s-color-text-secondary);font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold)}.b-main-menu{display:flex;flex:0 0 auto;grid-column:span 3;height:var(--g-space-12)}@media (min-width:calc(640 / 16 * 1rem)){.b-main-menu{height:auto}}.b-main-menu .menu-toggle{align-items:center;cursor:pointer;display:flex;margin-left:auto;order:3}.b-main-menu .menu-toggle>svg{height:var(--g-space-5);margin-left:var(--g-space-4);width:var(--g-space-5)}@media (min-width:calc(820 / 16 * 1rem)){.b-main-menu .menu-toggle>svg{margin-left:var(--g-space-2)}}.b-main-menu .menu-toggle-input~.menu-dev{display:none}.b-main-menu .menu-toggle-input:checked~.menu-dev{display:flex}.b-main-menu .menu-toggle-input:checked~.menu-general{display:none}.b-main-menu .menu-dev,.b-main-menu .menu-general{display:flex;height:100%;justify-content:flex-end}.b-menu-link:last-child,.b-menu-link:last-child .link{margin-right:0}.b-menu-link .link{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);font-weight:var(--g-font-semibold);gap:var(--g-space-1);height:100%;margin-right:var(--g-space-3);position:relative}.b-menu-link .link:hover{color:var(--s-color-text-tertiary-hover)}.b-menu-link .link:after{background-color:var(--s-color-bg-brand-default);border-radius:var(--s-rounded) var(--s-rounded) 0 0;bottom:0;content:"";height:var(--g-space-1);left:0;position:absolute;transition:width var(--g-transition-fast);width:0}.b-menu-link .link>svg{flex-shrink:0;height:var(--g-space-5);min-width:var(--g-space-2);width:var(--g-space-5)}@media (min-width:calc(1020 / 16 * 1rem)){.b-menu-link .link>svg{display:none}}@media (min-width:calc(1366 / 16 * 1rem)){.b-menu-link .link>svg{display:inline-block;height:var(--g-space-4-5);width:var(--g-space-4-5)}}@media (min-width:calc(640 / 16 * 1rem)){.b-menu-link .link{font-weight:var(--g-font-bold)}}@media (min-width:calc(1366 / 16 * 1rem)){.b-menu-link .link{margin-right:var(--g-space-6);padding-right:var(--g-space-1)}}@media (min-width:calc(640 / 16 * 1rem)){.b-menu-link .link-label{display:none}}@media (min-width:calc(1020 / 16 * 1rem)){.b-menu-link .link-label{display:inline}}.b-menu-link .link--icon{font-weight:var(--g-font-regular);margin-right:var(--g-space-4)}@media (min-width:calc(480 / 16 * 1rem)){.b-menu-link .link--icon{margin-right:var(--g-space-6)}}.b-menu-link .link--is-active{color:var(--s-color-text-secondary)}.b-menu-link .link--is-active:after{width:100%}.b-menu-link .link--is-active>svg{color:var(--s-color-bg-brand-default)}.menu-general .link{color:var(--s-color-text-secondary)}.menu-general .link:hover{color:var(--s-color-text-link-hover)}.b-breadcrumb{display:flex}.b-breadcrumb,.b-breadcrumb:after{background-color:var(--s-color-bg-surface-secondary)}.b-breadcrumb:after{bottom:0;content:"";display:block;left:0;pointer-events:none;position:absolute;right:0;top:0}.b-breadcrumb>ol{color:var(--s-color-text-primary);display:flex;font-weight:var(--g-font-semibold);line-height:var(--g-line-height-snug)}.b-breadcrumb .argument,.b-breadcrumb .element,.b-breadcrumb .query{align-items:center;display:flex;white-space:nowrap;z-index:var(--g-z-1)}.b-breadcrumb .argument:not(:first-child):before,.b-breadcrumb .element:not(:first-child):before,.b-breadcrumb .query:not(:first-child):before{color:var(--s-color-text-tertiary);content:"/";line-height:var(--g-line-height-normal);padding-left:.18rem;padding-right:.18rem;padding-top:var(--g-space-px)}.b-breadcrumb .argument a,.b-breadcrumb .element a,.b-breadcrumb .query a{background-color:var(--s-color-bg-base);border:1px solid var(--s-color-border-transparent);border-radius:var(--s-rounded-sm);display:inline-block;min-width:var(--g-space-4);padding:var(--g-space-0-5);text-align:center}.b-breadcrumb .argument a:hover,.b-breadcrumb .element a:hover,.b-breadcrumb .query a:hover{background-color:var(--s-color-bg-brand-default);color:var(--s-color-text-base)}.b-breadcrumb .argument:not(:first-child):before{content:":"}.b-breadcrumb .argument a{background-color:var(--s-color-bg-surface-quaternary);color:var(--s-color-text-base)}.b-breadcrumb .query:not(:first-child):before{content:"&"}.b-breadcrumb .query:nth-child(1 of .query):before{content:"?"}.b-breadcrumb .query label{background-color:var(--s-color-bg-surface-primary);border:var(--s-border);border-radius:var(--s-rounded-sm);color:var(--s-color-text-secondary);cursor:text;display:inline-flex;height:100%;min-width:var(--g-space-4);padding:var(--g-space-0-5) var(--g-space-1);position:relative;text-align:center;width:100%}.b-breadcrumb .query label:focus-within{border-color:var(--s-color-border-quaternary)}.b-breadcrumb .query label:hover{border-color:var(--s-color-border-quaternary)}.b-breadcrumb .query input{background-color:var(--s-color-bg-surface-primary);max-width:10ch;order:3;outline:none;field-sizing:content}@supports not (field-sizing:content){.b-breadcrumb .query input{width:5rem!important}}.b-breadcrumb .query input::-moz-placeholder{opacity:0}.b-breadcrumb .query input::placeholder{opacity:0}.b-breadcrumb .query input:-moz-placeholder{width:var(--g-space-px)}.b-breadcrumb .query input:placeholder-shown{width:var(--g-space-px)}.b-breadcrumb .query input:placeholder-shown::-moz-placeholder{color:var(--g-color-transparent)}.b-breadcrumb .query input:-moz-placeholder::placeholder{color:var(--g-color-transparent)}.b-breadcrumb .query input:placeholder-shown::placeholder{color:var(--g-color-transparent)}.b-footer{border-top:var(--s-border);font-size:var(--g-font-size-100);padding-bottom:var(--g-space-4);padding-top:var(--g-space-4);width:100%}.b-footer>nav{flex-direction:column;row-gap:var(--g-space-8)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer>nav{flex-wrap:wrap}}.b-footer .logo{color:var(--s-color-text-primary);grid-column:1/-1;width:var(--g-space-44)}.b-footer .logo:hover{color:var(--s-color-text-primary);-webkit-text-decoration:none;text-decoration:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .logo{align-self:center;grid-column:1/3;grid-row:1/1;width:60%}}.b-footer .nav-primary{display:flex;gap:var(--g-space-10);grid-column:1/-1;grid-row:2/3}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-primary{align-items:center;flex:1 0 0%;flex-direction:row;gap:var(--g-space-6);justify-content:space-between}}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .nav-primary{grid-column:2/8;grid-row:1/1}}.b-footer .nav-primary>ul{display:flex;flex:1;flex-direction:column;flex-wrap:wrap;gap:var(--g-space-1) var(--g-space-3)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-primary>ul{flex:initial;flex-direction:row}.b-footer .nav-social{margin-left:auto}}@media (min-width:calc(820 / 16 * 1rem)){.b-footer .nav-social{grid-column:span 3;justify-self:end;margin-left:0}}.b-footer .nav-theme{align-items:center;display:flex;gap:var(--g-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .nav-theme{flex-basis:100%}}@media (min-width:calc(820 / 16 * 1rem)){.b-footer .nav-theme{grid-column:span 3}}.b-footer .nav-theme .nav-theme-label{color:var(--s-color-text-secondary)}.b-footer .nav-theme:has([data-theme-target=sun]:not(.u-hidden)) .nav-theme-label:before{content:"Light"}.b-footer .nav-theme:has([data-theme-target=moon]:not(.u-hidden)) .nav-theme-label:before{content:"Dark"}.b-footer .nav-theme:has([data-theme-target=system]:not(.u-hidden)) .nav-theme-label:before{content:"System"}.b-footer .legal{color:var(--s-color-text-tertiary);font-size:var(--g-font-size-50);margin-top:var(--g-space-3);padding-top:var(--g-space-3)}.b-footer .legal>nav{color:var(--s-color-text-secondary);display:flex;flex-direction:column;flex-wrap:wrap;gap:var(--g-space-1) var(--g-space-3);margin-top:var(--g-space-2)}@media (min-width:calc(640 / 16 * 1rem)){.b-footer .legal>nav{flex-direction:row}.b-footer .legal>nav>a+a:before{color:var(--s-color-text-quaternary);content:"|";margin-right:var(--g-space-3)}}.b-footer .legal>nav:nth-child(3){grid-column:span 2/span 2}.b-footer .legal>:last-child:not(ul),.b-footer .legal>nav li{margin-bottom:var(--g-space-2);margin-top:var(--g-space-2)}.b-footer .legal>:last-child:not(ul){flex-basis:100%}@media (min-width:calc(1020 / 16 * 1rem)){.b-footer .legal>:last-child:not(ul){flex-basis:auto;grid-column:span 1/span 1}}.b-footer a:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}.b-content-header{display:flex;flex-direction:column;gap:var(--g-space-3);grid-row:span 1/span 1;margin-bottom:var(--g-space-6);margin-top:var(--g-space-10)}@media (min-width:calc(820 / 16 * 1rem)){.b-content-header{grid-column:span 7/span 7;grid-row-start:1;justify-content:space-between;margin-top:var(--g-space-10)}}@media (min-width:calc(1020 / 16 * 1rem)){.b-content-header{align-items:center;flex-direction:row}}.b-content-header .title{align-items:center;display:flex;gap:var(--g-space-3)}.b-content-header .header-info{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);gap:var(--g-space-12);justify-content:space-between}.b-content-header .b-inline-btn>span{display:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-content-header .b-inline-btn>span{display:inline}}.b-content-h1{font-size:var(--g-font-size-600);text-align:center}.b-content-h1,.b-content-h2{color:var(--s-color-text-primary);font-weight:var(--g-font-bold)}.b-content-h2{font-size:var(--g-font-size-400);margin-bottom:var(--g-space-4)}.b-btns{align-items:center;display:flex;gap:var(--g-space-1)}@media (min-width:calc(1020 / 16 * 1rem)){.b-btns{gap:var(--g-space-2)}}.b-btn{border:var(--s-border);border-radius:var(--s-rounded-sm);cursor:pointer;display:inline-flex;gap:var(--g-space-1-5);min-width:-moz-max-content;min-width:max-content;padding:var(--g-space-1) var(--g-space-2)}.b-btn:hover{background-color:var(--s-color-bg-surface-primary-hover)}.b-btn .c-icon{margin-left:0;margin-right:0}.b-btn--secondary:hover{background-color:var(--s-color-bg-surface-primary)}.b-inline-btn{color:var(--s-color-text-tertiary);cursor:pointer}.b-inline-btn:hover{color:var(--s-color-text-tertiary-hover)}.b-switch input,.b-switch label:last-child{display:none}.b-switch input+label,.b-switch input:checked~label:last-child{display:block}.b-switch input:checked+label{display:none}.b-block-form,.b-inline-form{color:var(--s-color-text-tertiary);display:flex;flex-direction:column;gap:var(--g-space-2) var(--g-space-3)}@media (min-width:calc(820 / 16 * 1rem)){.b-block-form,.b-inline-form{flex-direction:row}}.b-block-form{align-items:stretch}@media (min-width:calc(820 / 16 * 1rem)){.b-block-form{flex-direction:column}}.b-input{border:var(--s-border);border-radius:var(--s-rounded-sm);color:var(--s-color-text-secondary);display:flex;font-size:var(--g-font-size-100);min-width:var(--g-space-48);overflow:hidden;position:relative}.b-input>svg{height:var(--g-space-4);pointer-events:none;position:absolute;top:50%;transform:translateY(-50%);width:var(--g-space-4)}.b-input>svg:first-child{left:var(--g-space-2)}.b-input>svg:last-child{right:var(--g-space-2)}.b-input:hover,.b-input>input:focus,.b-input>input:hover{border-color:var(--s-color-border-tertiary)}.b-input:has(input:focus),.b-input:hover,.b-input>input:focus,.b-input>input:hover{border-color:var(--s-color-border-tertiary)}.b-input:hover>label{background-color:var(--s-color-bg-surface-primary)}.b-input:has(input:focus)>label,.b-input:hover>label{background-color:var(--s-color-bg-surface-primary)}.b-input>label{align-items:center;background-color:var(--s-color-bg-surface-secondary);gap:var(--g-space-3);white-space:nowrap}.b-input>input,.b-input>label,.b-input>select{display:flex;padding:var(--g-space-1-5) var(--g-space-3)}.b-input>input,.b-input>select{color:inherit;outline:none;width:100%}@media (min-width:calc(820 / 16 * 1rem)){.b-input>input,.b-input>select{padding:var(--g-space-1-5) var(--g-space-2)}}.b-input>select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--s-color-bg-surface-secondary);cursor:pointer}.b-input>select:hover{background-color:var(--s-color-bg-surface-primary)}.b-input>input{background-color:var(--s-color-bg-base);border-left:none}.b-input>label+input{border-left:var(--s-border)}.b-list{margin-bottom:var(--g-space-10)}.b-list>li{border-bottom:var(--s-border);color:var(--s-color-text-tertiary)}.b-list>li:first-child{border-top:var(--s-border)}.b-list>li>:where(a,div){align-items:center;display:flex;justify-content:space-between;padding:var(--g-space-2)}.b-list>li>:where(a,div):hover{background-color:var(--s-color-bg-surface-primary-hover)}.b-list>li>:where(a,div) .c-icon{margin-left:0}.b-list>li>:where(a,div)>a{flex:1;min-width:0}.b-list>li>:where(a,div)>a:hover{background-color:transparent}.b-list .name{display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;color:var(--s-color-text-secondary);margin-left:var(--g-space-1);max-width:100%;overflow:hidden;text-overflow:ellipsis}.b-user-sidebar{margin-top:var(--g-space-4)}.b-user-sidebar>*+*{margin-top:var(--g-space-8)}.b-user-sidebar .user-avatar{border:var(--s-border);border-radius:var(--s-rounded);height:var(--g-space-24);width:var(--g-space-24)}@media (min-width:calc(640 / 16 * 1rem)){.b-user-sidebar .user-avatar{height:var(--g-space-36);width:var(--g-space-36)}}.b-user-sidebar .user-avatar img,.b-user-sidebar .user-avatar svg{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.b-user-sidebar .user-info{align-items:flex-start;display:flex;gap:var(--g-space-6)}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar .user-info{flex-direction:column}}.b-user-sidebar .user-info>div:last-child{align-self:flex-end}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar .user-info>div:last-child{align-self:flex-start}}.b-user-sidebar .title{color:var(--s-color-text-primary);display:bock;font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);line-height:var(--g-line-height-tight);text-transform:capitalize;word-break:break-all}@media (min-width:calc(640 / 16 * 1rem)){.b-user-sidebar .title{font-size:var(--g-font-size-800)}}.b-user-sidebar .subtitle{color:var(--s-color-text-secondary);display:block;font-size:var(--g-font-size-100);line-height:var(--g-line-height-tight);margin-top:var(--g-space-2)}.b-user-sidebar>a{align-items:center;display:flex;justify-content:center}@media (min-width:calc(820 / 16 * 1rem)){.b-user-sidebar>a{display:inline-flex}}.b-sidebar{border-bottom:var(--s-border);grid-column:span 1/span 1;padding-bottom:var(--g-space-10);position:relative}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar{border-bottom:none;grid-column:span 3/span 3;grid-row:span 2/span 2;grid-row-start:1;height:100%;margin-bottom:0;order:2;padding-bottom:0}.b-sidebar+md-renderer:empty+*{grid-row-start:1;padding-top:var(--g-space-6)}.b-sidebar+md-renderer:empty+*,.b-sidebar+md-renderer:has(.b-btn:only-child)+*{grid-row-start:1;padding-top:var(--g-space-6)}}.b-sidebar:first-child{margin-top:var(--g-space-8)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar:first-child{margin-top:0}}.b-sidebar>div{padding-top:var(--g-space-2);position:sticky;top:var(--g-space-14)}.b-sidebar>div:has(.inner):not(:has(nav li)){display:none}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar>div{padding-bottom:var(--g-space-2)}}.b-sidebar .inner{background-color:var(--s-color-bg-surface-primary);border-radius:var(--s-rounded-sm);max-height:100vh;overflow:scroll;scrollbar-width:none}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .inner{background-color:var(--g-color-transparent)}}.b-sidebar .inner>nav{display:none;font-size:var(--g-font-size-100);margin-top:var(--g-space-2);padding:var(--g-space-2) var(--g-space-4) var(--g-space-6)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .inner>nav{display:block;margin-top:0;padding-bottom:var(--g-space-28);padding-left:0;padding-right:0}.b-sidebar .inner>nav>*{padding-left:0}}.b-sidebar .b-expend-btn{align-items:center;background-color:var(--s-color-bg-base);border:var(--s-border);border-radius:var(--s-rounded-sm);cursor:pointer;display:flex;font-size:var(--g-font-size-100);justify-content:space-between;padding:var(--g-space-2) var(--g-space-4)}.b-sidebar .b-expend-btn:hover{background-color:var(--s-color-bg-surface-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .b-expend-btn{border:none;cursor:default;font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);margin-top:var(--g-space-10);padding:0}.b-sidebar .b-expend-btn,.b-sidebar .b-expend-btn:hover{background-color:var(--g-color-transparent)}}.b-sidebar .b-expend-btn:has(#toc-expend:checked)+nav{display:block}.b-sidebar .b-expend-btn>input{display:none}.b-sidebar .b-expend-btn>input:checked+.wrapper-icon:before{content:"close"}.b-sidebar .b-expend-btn>input:checked+.wrapper-icon>svg{transform:rotate(180deg)}.b-sidebar .wrapper-icon{align-items:center;display:flex;gap:var(--g-space-1-5)}.b-sidebar .wrapper-icon:before{content:"open"}@media (min-width:calc(820 / 16 * 1rem)){.b-sidebar .wrapper-icon{display:none}}.dev-mode .b-sidebar .b-expend-btn{background-color:var(--s-color-bg-surface-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.dev-mode .b-sidebar .b-expend-btn{background-color:var(--g-color-transparent)}}.dev-mode .b-sidebar .b-expend-btn:hover{background-color:var(--s-color-bg-surface-primary)}.b-source-code{font-family:var(--g-font-mono)}.b-source-code>pre{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded);font-size:var(--g-font-size-100);overflow:scroll;padding:var(--g-space-4) var(--g-space-1)}@media (min-width:calc(640 / 16 * 1rem)){.b-source-code>pre{font-size:var(--g-font-size-200);padding:var(--g-space-8) var(--g-space-3)}}.b-source-code>pre a:hover{-webkit-text-decoration:none;text-decoration:none}[data-theme=dark] .b-source-code>pre{background-color:var(--s-color-bg-base)}.b-toc{list-style:none;margin-top:var(--g-space-2)}.b-toc>*+*{margin-bottom:var(--g-space-1-5);margin-top:var(--g-space-1-5)}.b-toc .b-toc{border-left:1px solid var(--s-color-border-secondary);margin-bottom:var(--g-space-4);padding-left:var(--g-space-4)}.b-toc a>span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-overflow:ellipsis}.b-toc a:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}main.dev-mode .b-toc a{word-break:break-all}.b-source-toc>.b-toc{margin-bottom:var(--g-space-4)}.b-source-toc>*+*{margin-top:var(--g-space-1-5)}.b-source-toc .accordion summary>svg{transform:rotate(-90deg)}.b-source-toc .accordion summary:hover{color:var(--s-color-text-link-hover);-webkit-text-decoration:underline;text-decoration:underline}.b-source-toc .accordion[open] summary>svg{transform:rotate(0deg)}.b-source-toc .accordion>.b-toc{padding-left:var(--g-space-5)}.b-source-toc .accordion h3{font-size:var(--g-font-size-100);font-weight:var(--g-font-medium);margin-top:0}.b-action-overview{margin-bottom:var(--g-space-12)}.b-action-overview>p{font-size:var(--g-font-size-200)}.b-action-function{background-color:var(--s-color-bg-surface-secondary);border-radius:var(--s-rounded);margin-bottom:var(--g-space-3);padding:var(--g-space-4)}.b-action-function .title{align-items:baseline;display:flex;flex-wrap:wrap;font-size:var(--g-font-size-50);gap:var(--g-space-1) var(--g-space-4);margin-bottom:var(--g-space-1)}.b-action-function>header{align-items:flex-start;display:flex;font-size:var(--g-font-size-100);justify-content:space-between;margin-bottom:var(--g-space-4)}.b-action-function>header .signature>code{color:var(--s--text-secondary)}@media (min-width:calc(820 / 16 * 1rem)){.b-action-function>header .signature{font-size:var(--g-font-size-50)}}.b-action-function>header h2{color:var(--s-color-text-primary);font-size:var(--g-font-size-300);font-weight:var(--g-font-semibold);line-height:var(--g-line-height-tight)}.b-action-function .description{color:var(--s-color-text-secondary);font-size:var(--g-font-size-200)}.b-action-function .params{align-items:stretch;color:var(--s-color-text-tertiary);display:flex;flex-direction:column;font-size:var(--g-font-size-100);gap:var(--g-space-1);margin-bottom:var(--g-space-1);margin-top:var(--g-space-6);width:100%}.b-action-function .params label{background-color:var(--s-color-bg-surface-primary)}.b-action-function .params .b-input:has(input:focus) label{background-color:var(--s-color-bg-surface-secondary)}.b-action-function .params .b-input:has(input:hover) label{background-color:var(--s-color-bg-surface-secondary)}.b-action-function .b-alert{background-color:var(--s-color-bg-warning-weak);border-left:var(--g-space-1) solid var(--s-color-border-tertiary);border-left-color:var(--s-color-border-warning);border-radius:var(--s-rounded);color:var(--s-color-text-secondary);color:var(--s-color-text-warning);margin-bottom:var(--g-space-10);margin-top:var(--g-space-5);padding:var(--g-space-3) var(--g-space-4)}.b-action-function .b-alert>h1:first-child,.b-action-function .b-alert>h2:first-child,.b-action-function .b-alert>h3:first-child{font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);margin-bottom:var(--g-space-2)}.b-action-function .b-alert .b-btn,.b-action-function .b-alert label{background-color:var(--s-color-bg-warning-action);border:none;color:var(--s-color-bg-warning-weak);cursor:pointer}.b-action-function .b-alert .b-btn{margin-top:var(--g-space-4)}.b-code{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded);font-size:var(--g-font-size-100);position:relative}.b-code pre{color:var(--s-color-text-secondary);padding:var(--g-space-4);padding-right:var(--g-space-10);white-space:pre-wrap}.b-code .btn-copy{background-color:var(--g-color-transparent);color:var(--s-color-text-tertiary);cursor:pointer;padding:0;position:absolute;right:var(--g-space-2);top:var(--g-space-2)}.b-code .btn-copy:hover{color:var(--s-color-text-primary)}.b-packages{min-height:var(--g-space-96);padding-bottom:var(--g-space-24);scroll-margin-block-start:var(--g-space-24)}@media (min-width:calc(820 / 16 * 1rem)){.b-packages{grid-column:span 7/span 7}}.b-packages .title{color:var(--s-color-text-primary);display:block;font-size:var(--g-font-size-700);font-weight:var(--g-font-bold);margin-bottom:var(--g-space-6)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .title{font-size:var(--g-font-size-800)}}.b-packages nav{display:grid;grid-template-columns:repeat(4,1fr);grid-gap:var(--g-space-3);gap:var(--g-space-3);margin-bottom:var(--g-space-6)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages nav{border-bottom:var(--s-border);padding-bottom:var(--g-space-2)}}.b-packages .packages-tabs{border-bottom:var(--s-border);color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-200);font-weight:var(--g-font-semibold);gap:var(--g-space-4);grid-column:span 4/span 4;padding-bottom:var(--g-space-2);width:auto}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-tabs{border-bottom:none;font-size:var(--g-font-size-100);grid-column:span 2/span 2;padding-bottom:0;width:100%}}@media (min-width:calc(1020 / 16 * 1rem)){.b-packages .packages-tabs{gap:var(--g-space-6);margin-left:0;width:100%}}.b-packages .packages-tabs label{align-items:center;cursor:pointer;display:flex;gap:var(--g-space-1);position:relative}.b-packages .packages-tabs label:hover{color:var(--s-color-text-tertiary-hover)}.b-packages .packages-tabs label .b-tag--secondary{display:none}@media (min-width:calc(1020 / 16 * 1rem)){.b-packages .packages-tabs label .b-tag--secondary{display:inline}}.b-packages .packages-filters{align-items:center;color:var(--s-color-text-tertiary);display:flex;font-size:var(--g-font-size-100);gap:var(--g-space-2);grid-column:span 2/span 2}@media (min-width:calc(480 / 16 * 1rem)){.b-packages .packages-filters{grid-column:span 1/span 1}}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-filters{justify-content:flex-end}}.b-packages .packages-filters>div{display:grid}.b-packages .packages-filters label{align-items:center;cursor:pointer;display:flex;gap:var(--g-space-0-5);grid-column:1/1;grid-row:1/1;justify-content:space-between}.b-packages .packages-filters label:hover>*{color:var(--s-color-text-tertiary-hover)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-filters label span{display:none}}@media (min-width:calc(1366 / 16 * 1rem)){.b-packages .packages-filters label span{display:inline}}.b-packages .packages-search{display:flex;font-size:var(--g-font-size-100);grid-column:span 2/span 2;position:relative}@media (min-width:calc(480 / 16 * 1rem)){.b-packages .packages-search{grid-column:span 3/span 3}}@media (min-width:calc(640 / 16 * 1rem)){.b-packages .packages-search{grid-column:span 1/span 1}}.b-packages .range{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-gap:var(--g-space-2);color:var(--s-color-text-tertiary);font-size:var(--g-font-size-100);gap:var(--g-space-2)}.b-packages .range:before{color:var(--s-color-text-tertiary);display:none;font-size:var(--g-font-size-200);font-weight:var(--g-font-weight-bold);grid-column:1/-1;padding-bottom:var(--g-space-2);padding-top:var(--g-space-2);text-align:center;width:100%}.b-packages .range:after{content:"Add a package to your namespace to get started";display:none;font-size:var(--g-font-size-100);grid-column:1/-1;text-align:center}.b-packages .range:empty:before{content:"No packages found";display:block}.b-packages .range:empty:after{content:"Add a package to your namespace to get started";display:block}.b-packages article{background-color:var(--s-color-bg-surface-primary);border-radius:var(--s-rounded);display:flex;flex-direction:column;gap:var(--g-space-6);padding:var(--g-space-1)}@media (min-width:calc(640 / 16 * 1rem)){.b-packages article{gap:var(--g-space-2)}}.b-packages article .article-content{background-color:var(--s-color-bg-base);border-radius:var(--s-rounded-sm);display:flex;flex-direction:column;height:100%;padding:var(--g-space-2);width:100%}.b-packages article .article-content .title{align-items:center;display:flex;gap:var(--g-space-2);margin-bottom:var(--g-space-1);overflow:hidden;width:100%}.b-packages article .article-content h3{font-size:var(--g-font-size-200);font-weight:var(--g-font-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.b-packages article .article-content h3>a{color:var(--s-color-text-link-hover)}.b-packages article .article-content h3>a:hover{-webkit-text-decoration:underline;text-decoration:underline}.b-packages article .article-content>p{overflow:hidden;text-overflow:ellipsis;width:100%}.b-packages article .article-content>p>a:hover{-webkit-text-decoration:underline;text-decoration:underline}.b-packages article footer{display:flex;font-size:var(--g-font-size-50);gap:var(--g-space-1);justify-content:space-between;padding-bottom:var(--g-space-1);padding-left:var(--g-space-2);padding-right:var(--g-space-2)}.b-packages article footer time{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.b-packages article footer .size{text-align:right}.b-packages article,.b-packages li{display:none}.b-packages:has(input[value=packages]:checked) li{display:flex}.b-packages:has(input[value=packages]:checked) article{display:flex}.b-packages:has(input[value=realms]:checked) li[data-list-type-value=realm]{display:flex}.b-packages:has(input[value=realms]:checked) article[data-list-type-value=realm]{display:flex}.b-packages:has(input[value=pures]:checked) From fca4b6a6e6cc26becca995b429f9b4c99deb73c4 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:45:25 +0900 Subject: [PATCH 52/92] fix(tm2/client): return error message when ID is missing (#5081) fix: #4927 Since the request was over the MaxBodyLimit of 5MB, the server was returning an empty ID and we would have an ID mismatch. This fix ensure the correct error message is displayed. (cherry picked from commit c035dd83370d0392d9934a27529c7024d3ab7c9a) --- tm2/pkg/bft/rpc/lib/client/http/client.go | 6 ++ .../bft/rpc/lib/client/http/client_test.go | 55 +++++++++++++++++++ tm2/pkg/bft/rpc/lib/client/ws/client.go | 6 ++ 3 files changed, 67 insertions(+) diff --git a/tm2/pkg/bft/rpc/lib/client/http/client.go b/tm2/pkg/bft/rpc/lib/client/http/client.go index 61b93d7b2c8..a93425977a8 100644 --- a/tm2/pkg/bft/rpc/lib/client/http/client.go +++ b/tm2/pkg/bft/rpc/lib/client/http/client.go @@ -61,6 +61,9 @@ func (c *Client) SendRequest(ctx context.Context, request types.RPCRequest) (*ty // Make sure the ID matches if request.ID != response.ID { + if (response.ID == nil || response.ID.String() == "") && response.Error != nil { + return nil, response.Error + } return nil, ErrRequestResponseIDMismatch } @@ -83,6 +86,9 @@ func (c *Client) SendBatch(ctx context.Context, requests types.RPCRequests) (typ // Make sure the IDs match for index, response := range responses { if requests[index].ID != response.ID { + if (response.ID == nil || response.ID.String() == "") && response.Error != nil { + return nil, response.Error + } return nil, ErrRequestResponseIDMismatch } } diff --git a/tm2/pkg/bft/rpc/lib/client/http/client_test.go b/tm2/pkg/bft/rpc/lib/client/http/client_test.go index 0d88ee32650..28548b65916 100644 --- a/tm2/pkg/bft/rpc/lib/client/http/client_test.go +++ b/tm2/pkg/bft/rpc/lib/client/http/client_test.go @@ -3,8 +3,10 @@ package http import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -203,6 +205,59 @@ func TestClient_SendRequest(t *testing.T) { assert.Nil(t, resp) assert.ErrorIs(t, err, ErrRequestResponseIDMismatch) }) + + t.Run("body too large returns server error", func(t *testing.T) { + t.Parallel() + + const maxBodyBytes = 1024 + + // Mimic the real RPC server: apply MaxBytesReader, then + // attempt to read the body like makeJSONRPCHandler does. + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + + _, err := io.ReadAll(r.Body) + if err != nil { + resp := types.RPCInvalidRequestError( + types.JSONRPCStringID(""), + err, + ) + + data, marshalErr := json.Marshal(resp) + require.NoError(t, marshalErr) + + _, writeErr := w.Write(data) + require.NoError(t, writeErr) + + return + } + }) + + server := createTestServer(t, handler) + + c, err := NewClient(server.URL) + require.NoError(t, err) + + ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5) + defer cancelFn() + + request := types.RPCRequest{ + JSONRPC: "2.0", + ID: types.JSONRPCStringID("id"), + Method: "test", + Params: json.RawMessage(`"` + strings.Repeat("x", maxBodyBytes+1) + `"`), + } + + resp, err := c.SendRequest(ctx, request) + assert.Nil(t, resp) + require.Error(t, err) + + assert.NotErrorIs(t, err, ErrRequestResponseIDMismatch) + + var rpcErr *types.RPCError + require.ErrorAs(t, err, &rpcErr) + assert.Equal(t, -32600, rpcErr.Code) + }) } func TestClient_SendBatchRequest(t *testing.T) { diff --git a/tm2/pkg/bft/rpc/lib/client/ws/client.go b/tm2/pkg/bft/rpc/lib/client/ws/client.go index 0b74cb7f5ce..72971347eb1 100644 --- a/tm2/pkg/bft/rpc/lib/client/ws/client.go +++ b/tm2/pkg/bft/rpc/lib/client/ws/client.go @@ -96,6 +96,9 @@ func (c *Client) SendRequest(ctx context.Context, request types.RPCRequest) (*ty case response := <-responseCh: // Make sure the ID matches if response[0].ID != request.ID { + if (response[0].ID == nil || response[0].ID.String() == "") && response[0].Error != nil { + return nil, response[0].Error + } return nil, ErrRequestResponseIDMismatch } @@ -145,6 +148,9 @@ func (c *Client) SendBatch(ctx context.Context, requests types.RPCRequests) (typ // Make sure the IDs match for index, response := range responses { if requests[index].ID != response.ID { + if (response.ID == nil || response.ID.String() == "") && response.Error != nil { + return nil, response.Error + } return nil, ErrRequestResponseIDMismatch } } From 251bb943d5c4b5464bf24658ade5449dcb34258e Mon Sep 17 00:00:00 2001 From: ltzmaxwell Date: Wed, 15 Apr 2026 03:25:57 +0800 Subject: [PATCH 53/92] fix(gnovm): correct len/cap/hint type check (#5391) (cherry picked from commit 4c858f5bf182b604866812c148ff8ccc725ad4b6) --- gnovm/pkg/gnolang/alloc.go | 3 +++ gnovm/pkg/gnolang/op_binary.go | 8 ++++++-- gnovm/pkg/gnolang/op_call.go | 4 +++- gnovm/pkg/gnolang/preprocess.go | 28 ++++++++++++++++++++++++++++ gnovm/pkg/gnolang/type_check.go | 4 +++- gnovm/pkg/gnolang/values.go | 6 ------ gnovm/tests/files/make10.gno | 12 ++++++++++++ gnovm/tests/files/make11.gno | 12 ++++++++++++ gnovm/tests/files/make12.gno | 12 ++++++++++++ gnovm/tests/files/make13.gno | 10 ++++++++++ gnovm/tests/files/make14.gno | 12 ++++++++++++ gnovm/tests/files/make15.gno | 9 +++++++++ gnovm/tests/files/make3.gno | 12 ++++++++++++ gnovm/tests/files/make4.gno | 12 ++++++++++++ gnovm/tests/files/make5.gno | 12 ++++++++++++ gnovm/tests/files/make6.gno | 12 ++++++++++++ gnovm/tests/files/make7.gno | 12 ++++++++++++ gnovm/tests/files/make8.gno | 10 ++++++++++ gnovm/tests/files/make9.gno | 13 +++++++++++++ gnovm/tests/files/recover16.gno | 17 ----------------- gnovm/tests/files/types/varg_13.gno | 2 +- gnovm/tests/files/types/varg_2.gno | 2 +- 22 files changed, 195 insertions(+), 29 deletions(-) create mode 100644 gnovm/tests/files/make10.gno create mode 100644 gnovm/tests/files/make11.gno create mode 100644 gnovm/tests/files/make12.gno create mode 100644 gnovm/tests/files/make13.gno create mode 100644 gnovm/tests/files/make14.gno create mode 100644 gnovm/tests/files/make15.gno create mode 100644 gnovm/tests/files/make3.gno create mode 100644 gnovm/tests/files/make4.gno create mode 100644 gnovm/tests/files/make5.gno create mode 100644 gnovm/tests/files/make6.gno create mode 100644 gnovm/tests/files/make7.gno create mode 100644 gnovm/tests/files/make8.gno create mode 100644 gnovm/tests/files/make9.gno delete mode 100644 gnovm/tests/files/recover16.gno diff --git a/gnovm/pkg/gnolang/alloc.go b/gnovm/pkg/gnolang/alloc.go index 38ff1b882f0..5f2460ea4f7 100644 --- a/gnovm/pkg/gnolang/alloc.go +++ b/gnovm/pkg/gnolang/alloc.go @@ -353,6 +353,9 @@ func (alloc *Allocator) NewStructWithFields(fields ...TypedValue) *StructValue { } func (alloc *Allocator) NewMap(size int) *MapValue { + if size < 0 { + size = 0 + } alloc.AllocateMap(int64(size)) mv := &MapValue{} mv.MakeMap(size) diff --git a/gnovm/pkg/gnolang/op_binary.go b/gnovm/pkg/gnolang/op_binary.go index 7a971ed37b0..c361763793a 100644 --- a/gnovm/pkg/gnolang/op_binary.go +++ b/gnovm/pkg/gnolang/op_binary.go @@ -1162,7 +1162,9 @@ func xorAssign(lv, rv *TypedValue) { // for doOpShl and doOpShlAssign. func shlAssign(m *Machine, lv, rv *TypedValue) { - rv.AssertNonNegative("runtime error: negative shift amount") + if rv.Sign() < 0 { + panic(fmt.Sprintf("runtime error: negative shift amount: %v", rv)) + } checkOverflow := func(v func() bool) { if m.Stage == StagePre && !v() { @@ -1286,7 +1288,9 @@ func shlAssign(m *Machine, lv, rv *TypedValue) { // for doOpShr and doOpShrAssign. func shrAssign(m *Machine, lv, rv *TypedValue) { - rv.AssertNonNegative("runtime error: negative shift amount") + if rv.Sign() < 0 { + panic(fmt.Sprintf("runtime error: negative shift amount: %v", rv)) + } checkOverflow := func(v func() bool) { if m.Stage == StagePre && !v() { diff --git a/gnovm/pkg/gnolang/op_call.go b/gnovm/pkg/gnolang/op_call.go index 161f4845925..259d67225ee 100644 --- a/gnovm/pkg/gnolang/op_call.go +++ b/gnovm/pkg/gnolang/op_call.go @@ -67,7 +67,9 @@ func (m *Machine) doOpPrecall() { // No need for frames. xv := m.PeekValue(1) if cx.GetAttribute(ATTR_SHIFT_RHS) == true { - xv.AssertNonNegative("runtime error: negative shift amount") + if xv.Sign() < 0 { + panic(fmt.Sprintf("runtime error: negative shift amount: %v", xv)) + } } m.PushOp(OpConvert) if debug { diff --git a/gnovm/pkg/gnolang/preprocess.go b/gnovm/pkg/gnolang/preprocess.go index d6381c1b09c..7e35e923ef2 100644 --- a/gnovm/pkg/gnolang/preprocess.go +++ b/gnovm/pkg/gnolang/preprocess.go @@ -1804,6 +1804,22 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { "invalid argument: cannot make %s; type must be slice, map", tt)) } + // Reject negative constant size arguments (len, cap, hint). + // Skip n.Args[0] which is the type argument. + for _, arg := range n.Args[1:] { + if cx, ok := arg.(*ConstExpr); ok { + tv := cx.TypedValue + if tv.T == nil || !isNumeric(tv.T) { + panic(fmt.Sprintf( + "cannot use %v as type int in argument to make", tv)) + } + if tv.Sign() < 0 { + panic(fmt.Sprintf( + "invalid argument: index %v must not be negative", tv)) + } + } + } + // Specify function param/result generics. argTVs := evalStaticTypedValues(store, last, n.Args...) isVarg := n.Varg @@ -1840,6 +1856,18 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { checkOrConvertType(store, last, n, &n.Args[i], expectedType) } + // For slices with 3 args, check len <= cap when both are constants. + if _, ok := baseOf(tt).(*SliceType); ok && len(n.Args) == 3 { + lcx, lOk := n.Args[1].(*ConstExpr) + ccx, cOk := n.Args[2].(*ConstExpr) + if lOk && cOk { + if lcx.TypedValue.GetInt() > ccx.TypedValue.GetInt() { + panic(fmt.Sprintf( + "invalid argument: len larger than cap in make(%s)", tt)) + } + } + } + return n, TRANS_CONTINUE case "_cross_gno0p0": if ctxpn.GetAttribute(ATTR_FIX_FROM) == GnoVerMissing { diff --git a/gnovm/pkg/gnolang/type_check.go b/gnovm/pkg/gnolang/type_check.go index 8168ab9a8ae..bfcbbb5fc44 100644 --- a/gnovm/pkg/gnolang/type_check.go +++ b/gnovm/pkg/gnolang/type_check.go @@ -1011,7 +1011,9 @@ func (x *AssignStmt) AssertCompatible(store Store, last BlockNode) { // check negative if ric { rv := evalConst(store, last, x.Rhs[0]) - rv.AssertNonNegative("invalid operation: negative shift count") + if rv.TypedValue.Sign() < 0 { + panic(fmt.Sprintf("invalid operation: negative shift count: %v", &rv.TypedValue)) + } } default: // do nothing diff --git a/gnovm/pkg/gnolang/values.go b/gnovm/pkg/gnolang/values.go index 25622bdfe5d..c92000ee69b 100644 --- a/gnovm/pkg/gnolang/values.go +++ b/gnovm/pkg/gnolang/values.go @@ -1562,12 +1562,6 @@ func (tv *TypedValue) Sign() int { } } -func (tv *TypedValue) AssertNonNegative(msg string) { - if tv.Sign() < 0 { - panic(fmt.Sprintf("%s: %v", msg, tv)) - } -} - // ComputeMapKey returns the value of tv, encoded as a string for usage inside // of a map. // diff --git a/gnovm/tests/files/make10.gno b/gnovm/tests/files/make10.gno new file mode 100644 index 00000000000..efdaa663244 --- /dev/null +++ b/gnovm/tests/files/make10.gno @@ -0,0 +1,12 @@ +package main + +func main() { + t := make([]int, -1, 5) + println(t) +} + +// Error: +// main/make10.gno:4:7-25: invalid argument: index (-1 bigint) must not be negative + +// TypeCheckError: +// main/make10.gno:4:19: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/make11.gno b/gnovm/tests/files/make11.gno new file mode 100644 index 00000000000..1ffd761abc4 --- /dev/null +++ b/gnovm/tests/files/make11.gno @@ -0,0 +1,12 @@ +package main + +func main() { + t := make([]int, 5, -1) + println(t) +} + +// Error: +// main/make11.gno:4:7-25: invalid argument: index (-1 bigint) must not be negative + +// TypeCheckError: +// main/make11.gno:4:22: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/make12.gno b/gnovm/tests/files/make12.gno new file mode 100644 index 00000000000..18439a88dc3 --- /dev/null +++ b/gnovm/tests/files/make12.gno @@ -0,0 +1,12 @@ +package main + +func main() { + t := make(-1) + println(t) +} + +// Error: +// main/make12.gno:4:7-15: (const (-1 bigint)) is not a type + +// TypeCheckError: +// main/make12.gno:4:12: -1 is not a type diff --git a/gnovm/tests/files/make13.gno b/gnovm/tests/files/make13.gno new file mode 100644 index 00000000000..e53a21ec9bb --- /dev/null +++ b/gnovm/tests/files/make13.gno @@ -0,0 +1,10 @@ +package main + +func main() { + i := -1 + t := make([]int, 5, i) + println(t) +} + +// Error: +// makeslice: cap out of range diff --git a/gnovm/tests/files/make14.gno b/gnovm/tests/files/make14.gno new file mode 100644 index 00000000000..733ff96802d --- /dev/null +++ b/gnovm/tests/files/make14.gno @@ -0,0 +1,12 @@ +package main + +func main() { + t := make([]int, 6, 3) + println(t) +} + +// Error: +// main/make14.gno:4:7-24: invalid argument: len larger than cap in make([]int) + +// TypeCheckError: +// main/make14.gno:4:19: invalid argument: length and capacity swapped diff --git a/gnovm/tests/files/make15.gno b/gnovm/tests/files/make15.gno new file mode 100644 index 00000000000..fbedf418c2c --- /dev/null +++ b/gnovm/tests/files/make15.gno @@ -0,0 +1,9 @@ +package main + +func main() { + l := 2 + _ = make([]int, l, 1) +} + +// Error: +// makeslice: cap out of range diff --git a/gnovm/tests/files/make3.gno b/gnovm/tests/files/make3.gno new file mode 100644 index 00000000000..455a3783b7b --- /dev/null +++ b/gnovm/tests/files/make3.gno @@ -0,0 +1,12 @@ +package main + +func main() { + t := make([]int, -1) + println(t) +} + +// Error: +// main/make3.gno:4:7-22: invalid argument: index (-1 bigint) must not be negative + +// TypeCheckError: +// main/make3.gno:4:19: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/make4.gno b/gnovm/tests/files/make4.gno new file mode 100644 index 00000000000..49afe555d33 --- /dev/null +++ b/gnovm/tests/files/make4.gno @@ -0,0 +1,12 @@ +package main + +func main() { + t := make([]int, 1, -1.0) + println(t) +} + +// Error: +// main/make4.gno:4:7-27: invalid argument: index (-1.0 bigdec) must not be negative + +// TypeCheckError: +// main/make4.gno:4:22: invalid argument: index -1.0 (constant -1 of type int) must not be negative diff --git a/gnovm/tests/files/make5.gno b/gnovm/tests/files/make5.gno new file mode 100644 index 00000000000..54a0df54fa0 --- /dev/null +++ b/gnovm/tests/files/make5.gno @@ -0,0 +1,12 @@ +package main + +func main() { + t := make([]int, 1, -1.2) + println(t) +} + +// Error: +// main/make5.gno:4:7-27: invalid argument: index (-1.2 bigdec) must not be negative + +// TypeCheckError: +// main/make5.gno:4:22: -1.2 (untyped float constant) truncated to int diff --git a/gnovm/tests/files/make6.gno b/gnovm/tests/files/make6.gno new file mode 100644 index 00000000000..21042422909 --- /dev/null +++ b/gnovm/tests/files/make6.gno @@ -0,0 +1,12 @@ +package main + +func main() { + t := make([]int, 1, 1.2) + println(t) +} + +// TypeCheckError: +// main/make6.gno:4:22: 1.2 (untyped float constant) truncated to int + +// Error: +// main/make6.gno:4:7-26: cannot convert untyped bigdec to integer -- 1.2 not an exact integer diff --git a/gnovm/tests/files/make7.gno b/gnovm/tests/files/make7.gno new file mode 100644 index 00000000000..20a6fb042f0 --- /dev/null +++ b/gnovm/tests/files/make7.gno @@ -0,0 +1,12 @@ +package main + +func main() { + t := make(map[int]string, -1) + println(t) +} + +// TypeCheckError: +// main/make7.gno:4:28: invalid argument: index -1 (constant of type int) must not be negative + +// Error: +// main/make7.gno:4:7-31: invalid argument: index (-1 bigint) must not be negative diff --git a/gnovm/tests/files/make8.gno b/gnovm/tests/files/make8.gno new file mode 100644 index 00000000000..f47bafaa3c3 --- /dev/null +++ b/gnovm/tests/files/make8.gno @@ -0,0 +1,10 @@ +package main + +func main() { + i := -1 + t := make(map[int]string, i) + println(t) +} + +// Output: +// map{} diff --git a/gnovm/tests/files/make9.gno b/gnovm/tests/files/make9.gno new file mode 100644 index 00000000000..ad85982daa8 --- /dev/null +++ b/gnovm/tests/files/make9.gno @@ -0,0 +1,13 @@ +package main + +func main() { + const i = -1 + t := make(map[int]string, i) + println(t) +} + +// Error: +// main/make9.gno:5:7-30: invalid argument: index (-1 bigint) must not be negative + +// TypeCheckError: +// main/make9.gno:5:28: invalid argument: index i (constant -1 of type int) must not be negative diff --git a/gnovm/tests/files/recover16.gno b/gnovm/tests/files/recover16.gno deleted file mode 100644 index 09dc22db9b7..00000000000 --- a/gnovm/tests/files/recover16.gno +++ /dev/null @@ -1,17 +0,0 @@ -package main - - -func main() { - defer func() { - r := recover() - println("recover:", r) - }() - - _ = make([]int, -1) // Panics because of negative length -} - -// Output: -// recover: len out of range - -// TypeCheckError: -// main/recover16.gno:10:21: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/types/varg_13.gno b/gnovm/tests/files/types/varg_13.gno index d6e79032c83..234d451e2dd 100644 --- a/gnovm/tests/files/types/varg_13.gno +++ b/gnovm/tests/files/types/varg_13.gno @@ -8,4 +8,4 @@ func main() { // main/varg_13.gno:4:18: cannot convert "hello" (untyped string constant) to type int // Error: -// main/varg_13.gno:4:6-26: cannot use untyped string as IntKind +// main/varg_13.gno:4:6-26: cannot use ("hello" string) as type int in argument to make diff --git a/gnovm/tests/files/types/varg_2.gno b/gnovm/tests/files/types/varg_2.gno index 23a9164be68..8a73a76456d 100644 --- a/gnovm/tests/files/types/varg_2.gno +++ b/gnovm/tests/files/types/varg_2.gno @@ -7,7 +7,7 @@ func main() { } // Error: -// main/varg_2.gno:4:7-24: cannot use untyped string as IntKind +// main/varg_2.gno:4:7-24: cannot use ("h" string) as type int in argument to make // TypeCheckError: // main/varg_2.gno:4:20: cannot convert "h" (untyped string constant) to type int From bc5997081e17fcd05ec1f05e548fa696aefad7ef Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 15 Apr 2026 06:03:56 +0900 Subject: [PATCH 54/92] fix(tm2/rpc): handle malformed elements in batch requests (#5447) A single malformed element in a JSON-RPC batch would cause the entire batch to fail unmarshalling, silently dropping all valid requests. Unmarshal into []json.RawMessage first, then parse each element individually. Malformed elements now get RPCInvalidRequestError responses while valid requests are still processed, matching JSON-RPC 2.0 spec behavior. Applied to both HTTP and WebSocket handlers. This is a minor bug. --------- Co-authored-by: Lee ByeongJun Co-authored-by: Morgan Bazalgette (cherry picked from commit bb3528ab63d427eab2fb1acbcf4c8a62748eabd3) --- tm2/pkg/bft/rpc/lib/server/handlers.go | 52 +++++++++---- tm2/pkg/bft/rpc/lib/server/handlers_test.go | 82 +++++++++++++++++++++ 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/tm2/pkg/bft/rpc/lib/server/handlers.go b/tm2/pkg/bft/rpc/lib/server/handlers.go index 37c50fbee48..ba2555b6a2d 100644 --- a/tm2/pkg/bft/rpc/lib/server/handlers.go +++ b/tm2/pkg/bft/rpc/lib/server/handlers.go @@ -144,11 +144,19 @@ func makeJSONRPCHandler(funcMap map[string]*RPCFunc, logger *slog.Logger) http.H return } - // --- Branch 1: Attempt to Unmarshal as a Batch (Slice) of Requests --- - var requests types.RPCRequests - if err := json.Unmarshal(b, &requests); err == nil { + // --- Branch 1: Attempt to Unmarshal as a Batch (Slice) of raw messages --- + var rawRequests []json.RawMessage + if err := json.Unmarshal(b, &rawRequests); err == nil { var responses types.RPCResponses - for _, req := range requests { + for _, raw := range rawRequests { + var req types.RPCRequest + if err := json.Unmarshal(raw, &req); err != nil { + responses = append(responses, types.RPCInvalidRequestError( + types.JSONRPCStringID(""), + errors.Wrap(err, "error unmarshalling request"), + )) + continue + } if resp := processRequest(r, req, funcMap, logger); resp != nil { responses = append(responses, *resp) } @@ -665,8 +673,22 @@ func (wsc *wsConnection) readRoutine() { responses types.RPCResponses ) - // Try to unmarshal the requests as a batch - if err := json.Unmarshal(in, &requests); err != nil { + // Try to unmarshal as a batch of raw messages first, so that one + // malformed element does not prevent valid requests from being processed. + var rawRequests []json.RawMessage + if err := json.Unmarshal(in, &rawRequests); err == nil { + for _, raw := range rawRequests { + var req types.RPCRequest + if err := json.Unmarshal(raw, &req); err != nil { + responses = append(responses, types.RPCInvalidRequestError( + types.JSONRPCStringID(""), + errors.Wrap(err, "error unmarshalling request"), + )) + continue + } + requests = append(requests, req) + } + } else { // Next, try to unmarshal as a single request var request types.RPCRequest if err := json.Unmarshal(in, &request); err != nil { @@ -728,17 +750,17 @@ func (wsc *wsConnection) readRoutine() { } responses = append(responses, types.NewRPCSuccessResponse(request.ID, result)) + } - if len(responses) > 0 { - wsc.WriteRPCResponses(responses) + if len(responses) > 0 { + wsc.WriteRPCResponses(responses) - // Log telemetry - if telemetryEnabled { - metrics.WSRequestTime.Record( - context.Background(), - time.Since(responseStart).Milliseconds(), - ) - } + // Log telemetry + if telemetryEnabled { + metrics.WSRequestTime.Record( + context.Background(), + time.Since(responseStart).Milliseconds(), + ) } } } diff --git a/tm2/pkg/bft/rpc/lib/server/handlers_test.go b/tm2/pkg/bft/rpc/lib/server/handlers_test.go index 4dead7d42e8..afd66883933 100644 --- a/tm2/pkg/bft/rpc/lib/server/handlers_test.go +++ b/tm2/pkg/bft/rpc/lib/server/handlers_test.go @@ -217,6 +217,88 @@ func TestRPCNotificationInBatch(t *testing.T) { } } +func TestRPCBatchPartialUnmarshal(t *testing.T) { + t.Parallel() + + mux := testMux() + tests := []struct { + name string + payload string + expectCount int + }{ + { + // One valid request + one with null id (fails UnmarshalJSON). + // Valid request should succeed; malformed one should get an error response. + name: "null_id_does_not_drop_batch", + payload: `[ + {"jsonrpc":"2.0","method":"c","id":"1","params":["a","10"]}, + {"jsonrpc":"2.0","method":"c","id":null,"params":["a","10"]} + ]`, + expectCount: 2, + }, + { + // One valid request + one with missing id field. + name: "missing_id_does_not_drop_batch", + payload: `[ + {"jsonrpc":"2.0","method":"c","id":"1","params":["a","10"]}, + {"jsonrpc":"2.0","method":"c","params":["a","10"]} + ]`, + expectCount: 2, + }, + { + // One valid request + one with object id (fails parseID). + name: "object_id_does_not_drop_batch", + payload: `[ + {"jsonrpc":"2.0","method":"c","id":"1","params":["a","10"]}, + {"jsonrpc":"2.0","method":"c","id":{},"params":["a","10"]} + ]`, + expectCount: 2, + }, + { + // Three valid requests + one malformed in the middle. + name: "malformed_element_preserves_others", + payload: `[ + {"jsonrpc":"2.0","method":"c","id":"1","params":["a","10"]}, + {"jsonrpc":"2.0","method":"c","id":null,"params":["a","10"]}, + {"jsonrpc":"2.0","method":"c","id":"2","params":["a","10"]}, + {"jsonrpc":"2.0","method":"c","id":"3","params":["a","10"]} + ]`, + expectCount: 4, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req, _ := http.NewRequest("POST", "http://localhost/", strings.NewReader(tt.payload)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + res := rec.Result() + + assert.True(t, statusOK(res.StatusCode), "should always return 2XX") + blob, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.NotEmpty(t, blob, "response body should not be empty — batch must not be silently dropped") + + var responses types.RPCResponses + require.NoError(t, json.Unmarshal(blob, &responses), "response should be a JSON array, got: %s", blob) + require.Len(t, responses, tt.expectCount, "unexpected number of responses\nblob: %s", blob) + + // Verify we get at least one success and at least one error + var hasSuccess, hasError bool + for _, resp := range responses { + if resp.Error != nil { + hasError = true + } else { + hasSuccess = true + } + } + assert.True(t, hasSuccess, "expected at least one successful response") + assert.True(t, hasError, "expected at least one error response for malformed request") + }) + } +} + func TestUnknownRPCPath(t *testing.T) { t.Parallel() From 8b1227c0d2e2922714659f4fd5ca0b0c4f66aa6e Mon Sep 17 00:00:00 2001 From: Jeff Thompson Date: Tue, 14 Apr 2026 23:06:57 +0200 Subject: [PATCH 55/92] chore(std): Make non-panic versions of AddressFromBytes and NewCoin (#5329) `std.NewCoin` and `crypto.AddressFromBytes` panic on error. We want non-panic versions which return an error. * Add `std.NewCoinSafe`. Make `NewCoin` call it. We leave `NewCoin` as panicking since most code is calling it with constant values which are known not to panic * However, the `crypto` package already has [`AddressFromString` and `MustAddressFromString`](https://github.com/gnolang/gno/blob/e6da9024ac5c1d5d63be91ff55f82065003b5510/tm2/pkg/crypto/crypto.go#L33-L38) where one returns an error and the other panics on error. We want to follow the same pattern in the package. Therefore, we globally rename `AddressFromBytes` to `MustAddressFromBytes` which panics on error, and we add `AddressFromBytes` which returns an error. BREAKING CHANGE: The panicking version of `AddressFromBytes` is renamed to `MustAddressFromBytes` . Any code outside of the main repo must change, or use the new `AddressFromBytes` which returns an error. --------- Signed-off-by: Jeff Thompson Co-authored-by: MikaelVallenet Co-authored-by: David <60177543+davd-gzl@users.noreply.github.com> (cherry picked from commit a1c388e3ab02cef255e6734e136a352fecb1a1f9) --- .../bft/consensus/types/round_state_test.go | 2 +- tm2/pkg/crypto/bech32.go | 2 +- tm2/pkg/crypto/bech32_test.go | 4 ++-- tm2/pkg/crypto/crypto.go | 19 ++++++++++++++++--- tm2/pkg/crypto/ed25519/ed25519.go | 2 +- tm2/pkg/crypto/hd/fundraiser_test.go | 2 +- tm2/pkg/crypto/secp256k1/secp256k1.go | 2 +- tm2/pkg/crypto/secp256k1/secp256k1_test.go | 2 +- tm2/pkg/sdk/auth/keeper_bench_test.go | 12 ++++++------ tm2/pkg/std/coin.go | 16 ++++++++++++++-- 10 files changed, 44 insertions(+), 19 deletions(-) diff --git a/tm2/pkg/bft/consensus/types/round_state_test.go b/tm2/pkg/bft/consensus/types/round_state_test.go index 1f9081c8fb5..08133446958 100644 --- a/tm2/pkg/bft/consensus/types/round_state_test.go +++ b/tm2/pkg/bft/consensus/types/round_state_test.go @@ -27,7 +27,7 @@ func BenchmarkRoundStateDeepCopy(b *testing.B) { sig := make([]byte, ed25519.SignatureSize) for i := range nval { precommits[i] = (&types.Vote{ - ValidatorAddress: crypto.AddressFromBytes(random.RandBytes(20)), + ValidatorAddress: crypto.MustAddressFromBytes(random.RandBytes(20)), Timestamp: tmtime.Now(), BlockID: blockID, Signature: sig, diff --git a/tm2/pkg/crypto/bech32.go b/tm2/pkg/crypto/bech32.go index 2f76d3e413f..b575b1f0c2b 100644 --- a/tm2/pkg/crypto/bech32.go +++ b/tm2/pkg/crypto/bech32.go @@ -21,7 +21,7 @@ func AddressFromBech32(bech32str string) (Address, error) { if err != nil { return Address{}, err } else { - return AddressFromBytes(bz), nil + return AddressFromBytes(bz) } } diff --git a/tm2/pkg/crypto/bech32_test.go b/tm2/pkg/crypto/bech32_test.go index 9f17a465a0a..bce606ef0d8 100644 --- a/tm2/pkg/crypto/bech32_test.go +++ b/tm2/pkg/crypto/bech32_test.go @@ -27,7 +27,7 @@ func TestEmptyAddresses(t *testing.T) { require.Equal(t, (crypto.Address{}).String(), "g1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqluuxe") - addr := crypto.AddressFromBytes(make([]byte, 20)) + addr := crypto.MustAddressFromBytes(make([]byte, 20)) require.True(t, addr.IsZero()) addr, err := crypto.AddressFromBech32("") @@ -59,7 +59,7 @@ func TestRandBech32AddrConsistency(t *testing.T) { for range 1000 { cc8.Read(pub[:]) - addr := crypto.AddressFromBytes(pub.Address().Bytes()) + addr := crypto.MustAddressFromBytes(pub.Address().Bytes()) testMarshal(t, addr, amino.Marshal, amino.Unmarshal) testMarshal(t, addr, amino.MarshalJSON, amino.UnmarshalJSON) testMarshal(t, addr, json.Marshal, json.Unmarshal) diff --git a/tm2/pkg/crypto/crypto.go b/tm2/pkg/crypto/crypto.go index 5b413f3b494..15be9a086cf 100644 --- a/tm2/pkg/crypto/crypto.go +++ b/tm2/pkg/crypto/crypto.go @@ -44,18 +44,31 @@ func MustAddressFromString(str string) (addr Address) { } func AddressFromPreimage(bz []byte) Address { - addr := AddressFromBytes(tmhash.SumTruncated(bz)) + addr := MustAddressFromBytes(tmhash.SumTruncated(bz)) return addr } -func AddressFromBytes(bz []byte) (ret Address) { +// AddressFromBytes returns an Address from the bytes in bz. +// It returns an error if bz has an unexpected address byte length. +func AddressFromBytes(bz []byte) (ret Address, err error) { if len(bz) != AddressSize { - panic(fmt.Errorf("unexpected address byte length. expected %v, got %v", AddressSize, len(bz))) + err = fmt.Errorf("unexpected address byte length. expected %v, got %v", AddressSize, len(bz)) + return } copy(ret[:], bz) return } +// MustAddressFromBytes returns an Address from the bytes in bz. +// It panics if bz has an unexpected address byte length. +func MustAddressFromBytes(bz []byte) (ret Address) { + ret, err := AddressFromBytes(bz) + if err != nil { + panic(err) + } + return +} + func (addr Address) MarshalJSON() ([]byte, error) { b := AddressToBech32(addr) return []byte(`"` + b + `"`), nil diff --git a/tm2/pkg/crypto/ed25519/ed25519.go b/tm2/pkg/crypto/ed25519/ed25519.go index f8b9529b788..4f4e27d4527 100644 --- a/tm2/pkg/crypto/ed25519/ed25519.go +++ b/tm2/pkg/crypto/ed25519/ed25519.go @@ -119,7 +119,7 @@ type PubKeyEd25519 [PubKeyEd25519Size]byte // Address is the SHA256-20 of the raw pubkey bytes. func (pubKey PubKeyEd25519) Address() crypto.Address { - return crypto.AddressFromBytes(tmhash.SumTruncated(pubKey[:])) + return crypto.MustAddressFromBytes(tmhash.SumTruncated(pubKey[:])) } // Bytes marshals the PubKey using amino encoding. diff --git a/tm2/pkg/crypto/hd/fundraiser_test.go b/tm2/pkg/crypto/hd/fundraiser_test.go index 884425c6c39..d5d35e37f37 100644 --- a/tm2/pkg/crypto/hd/fundraiser_test.go +++ b/tm2/pkg/crypto/hd/fundraiser_test.go @@ -79,6 +79,6 @@ func TestFundraiserCompatibility(t *testing.T) { addr := pub.Address() t.Logf("ADDR \t%X %X\n", addrB, addr) - require.Equal(t, addr, crypto.AddressFromBytes(addrB), fmt.Sprintf("Expected addresses to match %d", i)) + require.Equal(t, addr, crypto.MustAddressFromBytes(addrB), fmt.Sprintf("Expected addresses to match %d", i)) } } diff --git a/tm2/pkg/crypto/secp256k1/secp256k1.go b/tm2/pkg/crypto/secp256k1/secp256k1.go index 2c05395e016..4fe8761e520 100644 --- a/tm2/pkg/crypto/secp256k1/secp256k1.go +++ b/tm2/pkg/crypto/secp256k1/secp256k1.go @@ -126,7 +126,7 @@ func (pubKey PubKeySecp256k1) Address() crypto.Address { hasherRIPEMD160 := ripemd160.New() //nolint:gosec hasherRIPEMD160.Write(sha) // does not error - return crypto.AddressFromBytes(hasherRIPEMD160.Sum(nil)) + return crypto.MustAddressFromBytes(hasherRIPEMD160.Sum(nil)) } // Bytes returns the pubkey marshalled with amino encoding. diff --git a/tm2/pkg/crypto/secp256k1/secp256k1_test.go b/tm2/pkg/crypto/secp256k1/secp256k1_test.go index dcc71026b81..46ec1f3afd2 100644 --- a/tm2/pkg/crypto/secp256k1/secp256k1_test.go +++ b/tm2/pkg/crypto/secp256k1/secp256k1_test.go @@ -35,7 +35,7 @@ func TestPubKeySecp256k1Address(t *testing.T) { privB, _ := hex.DecodeString(d.priv) pubB, _ := hex.DecodeString(d.pub) addrBbz, _, _ := base58.CheckDecode(d.addr) - addrB := crypto.AddressFromBytes(addrBbz) + addrB := crypto.MustAddressFromBytes(addrBbz) var priv secp256k1.PrivKeySecp256k1 copy(priv[:], privB) diff --git a/tm2/pkg/sdk/auth/keeper_bench_test.go b/tm2/pkg/sdk/auth/keeper_bench_test.go index be56844c2f0..578d8c146eb 100644 --- a/tm2/pkg/sdk/auth/keeper_bench_test.go +++ b/tm2/pkg/sdk/auth/keeper_bench_test.go @@ -19,7 +19,7 @@ func BenchmarkAccountMapperGetAccountFound(b *testing.B) { addr := make([]byte, crypto.AddressSize) arr := []byte{byte((i & 0xFF0000) >> 16), byte((i & 0xFF00) >> 8), byte(i & 0xFF)} copy(addr[:len(arr)], arr[:]) - caddr := crypto.AddressFromBytes(addr) + caddr := crypto.MustAddressFromBytes(addr) acc := env.acck.NewAccountWithAddress(env.ctx, caddr) env.acck.SetAccount(env.ctx, acc) } @@ -29,7 +29,7 @@ func BenchmarkAccountMapperGetAccountFound(b *testing.B) { addr := make([]byte, crypto.AddressSize) arr := []byte{byte((i & 0xFF0000) >> 16), byte((i & 0xFF00) >> 8), byte(i & 0xFF)} copy(addr[:len(arr)], arr[:]) - caddr := crypto.AddressFromBytes(addr) + caddr := crypto.MustAddressFromBytes(addr) env.acck.GetAccount(env.ctx, caddr) } } @@ -50,7 +50,7 @@ func BenchmarkAccountMapperGetAccountFoundWithCoins(b *testing.B) { addr := make([]byte, crypto.AddressSize) arr := []byte{byte((i & 0xFF0000) >> 16), byte((i & 0xFF00) >> 8), byte(i & 0xFF)} copy(addr[:len(arr)], arr[:]) - caddr := crypto.AddressFromBytes(addr) + caddr := crypto.MustAddressFromBytes(addr) acc := env.acck.NewAccountWithAddress(env.ctx, caddr) acc.SetCoins(coins) env.acck.SetAccount(env.ctx, acc) @@ -61,7 +61,7 @@ func BenchmarkAccountMapperGetAccountFoundWithCoins(b *testing.B) { addr := make([]byte, crypto.AddressSize) arr := []byte{byte((i & 0xFF0000) >> 16), byte((i & 0xFF00) >> 8), byte(i & 0xFF)} copy(addr[:len(arr)], arr[:]) - caddr := crypto.AddressFromBytes(addr) + caddr := crypto.MustAddressFromBytes(addr) env.acck.GetAccount(env.ctx, caddr) } } @@ -76,7 +76,7 @@ func BenchmarkAccountMapperSetAccount(b *testing.B) { addr := make([]byte, crypto.AddressSize) arr := []byte{byte((i & 0xFF0000) >> 16), byte((i & 0xFF00) >> 8), byte(i & 0xFF)} copy(addr[:len(arr)], arr[:]) - caddr := crypto.AddressFromBytes(addr) + caddr := crypto.MustAddressFromBytes(addr) acc := env.acck.NewAccountWithAddress(env.ctx, caddr) env.acck.SetAccount(env.ctx, acc) } @@ -100,7 +100,7 @@ func BenchmarkAccountMapperSetAccountWithCoins(b *testing.B) { addr := make([]byte, crypto.AddressSize) arr := []byte{byte((i & 0xFF0000) >> 16), byte((i & 0xFF00) >> 8), byte(i & 0xFF)} copy(addr[:len(arr)], arr[:]) - caddr := crypto.AddressFromBytes(addr) + caddr := crypto.MustAddressFromBytes(addr) acc := env.acck.NewAccountWithAddress(env.ctx, caddr) acc.SetCoins(coins) env.acck.SetAccount(env.ctx, acc) diff --git a/tm2/pkg/std/coin.go b/tm2/pkg/std/coin.go index a167748aa06..d657e781695 100644 --- a/tm2/pkg/std/coin.go +++ b/tm2/pkg/std/coin.go @@ -26,14 +26,26 @@ type Coin struct { // It will panic if the amount is negative. // To construct a negative (invalid) amount, use an operation. func NewCoin(denom string, amount int64) Coin { - if err := validate(denom, amount); err != nil { + coin, err := NewCoinSafe(denom, amount) + if err != nil { panic(err) } + return coin +} + +// NewCoinSafe returns a new coin with a denomination and amount. +// It will return an error if the amount is negative. +// To construct a negative (invalid) amount, use an operation. +func NewCoinSafe(denom string, amount int64) (Coin, error) { + if err := validate(denom, amount); err != nil { + return Coin{}, err + } + return Coin{ Denom: denom, Amount: amount, - } + }, nil } func (coin Coin) MarshalAmino() (string, error) { From b54c82349190f5cae55f9b7bc4c3de0a9feaca94 Mon Sep 17 00:00:00 2001 From: ltzmaxwell Date: Wed, 15 Apr 2026 05:35:58 +0800 Subject: [PATCH 56/92] fix(gnovm): post loopvar fixes (2) (#5266) - remove the rename process for range key, value, which is unnecessary. - improves on #5242 --------- Co-authored-by: Morgan Bazalgette (cherry picked from commit 930377606804c9d4ed37652bffb3d618735715d5) --- gnovm/pkg/gnolang/preprocess.go | 43 ++++++------------- gnovm/tests/files/assign39.gno | 21 +++++++++ gnovm/tests/files/const55.gno | 2 +- gnovm/tests/files/const55a.gno | 2 +- gnovm/tests/files/const62.gno | 19 ++++++++ gnovm/tests/files/heap_alloc_forloop1.gno | 2 +- gnovm/tests/files/heap_alloc_forloop1a.gno | 2 +- gnovm/tests/files/heap_alloc_forloop1b.gno | 2 +- gnovm/tests/files/heap_alloc_forloop2.gno | 2 +- gnovm/tests/files/heap_alloc_forloop2a.gno | 2 +- gnovm/tests/files/heap_alloc_forloop9.gno | 2 +- gnovm/tests/files/heap_alloc_gotoloop9_10.gno | 2 +- gnovm/tests/files/heap_alloc_range1.gno | 2 +- gnovm/tests/files/heap_alloc_range2.gno | 2 +- gnovm/tests/files/heap_alloc_range3.gno | 2 +- gnovm/tests/files/heap_alloc_range4.gno | 2 +- gnovm/tests/files/heap_alloc_range4b.gno | 2 +- gnovm/tests/files/heap_alloc_range4b1.gno | 2 +- gnovm/tests/files/loopvar_err_1.gno | 16 +++++++ gnovm/tests/files/loopvar_err_2.gno | 16 +++++++ gnovm/tests/files/loopvar_goto_1.gno | 20 +++++++++ gnovm/tests/files/loopvar_map_key_1.gno | 16 +++++++ gnovm/tests/files/loopvar_modify_body_1.gno | 18 ++++++++ gnovm/tests/files/loopvar_multi_init_1.gno | 23 ++++++++++ gnovm/tests/files/loopvar_multi_init_2.gno | 21 +++++++++ gnovm/tests/files/loopvar_outer_shadow_1.gno | 18 ++++++++ .../files/loopvar_range_capture_mutate_1.gno | 20 +++++++++ .../tests/files/loopvar_range_kv_shadow_2.gno | 21 +++++++++ .../files/loopvar_range_outer_shadow_1.gno | 14 ++++++ gnovm/tests/files/loopvar_redefine_4.gno | 2 +- gnovm/tests/files/loopvar_struct_field_1.gno | 30 +++++++++++++ gnovm/tests/files/loopvar_struct_field_2.gno | 19 ++++++++ gnovm/tests/files/loopvar_switch_1.gno | 28 ++++++++++++ 33 files changed, 349 insertions(+), 46 deletions(-) create mode 100644 gnovm/tests/files/assign39.gno create mode 100644 gnovm/tests/files/const62.gno create mode 100644 gnovm/tests/files/loopvar_err_1.gno create mode 100644 gnovm/tests/files/loopvar_err_2.gno create mode 100644 gnovm/tests/files/loopvar_goto_1.gno create mode 100644 gnovm/tests/files/loopvar_map_key_1.gno create mode 100644 gnovm/tests/files/loopvar_modify_body_1.gno create mode 100644 gnovm/tests/files/loopvar_multi_init_1.gno create mode 100644 gnovm/tests/files/loopvar_multi_init_2.gno create mode 100644 gnovm/tests/files/loopvar_outer_shadow_1.gno create mode 100644 gnovm/tests/files/loopvar_range_capture_mutate_1.gno create mode 100644 gnovm/tests/files/loopvar_range_kv_shadow_2.gno create mode 100644 gnovm/tests/files/loopvar_range_outer_shadow_1.gno create mode 100644 gnovm/tests/files/loopvar_struct_field_1.gno create mode 100644 gnovm/tests/files/loopvar_struct_field_2.gno create mode 100644 gnovm/tests/files/loopvar_switch_1.gno diff --git a/gnovm/pkg/gnolang/preprocess.go b/gnovm/pkg/gnolang/preprocess.go index 7e35e923ef2..5f2193acc8e 100644 --- a/gnovm/pkg/gnolang/preprocess.go +++ b/gnovm/pkg/gnolang/preprocess.go @@ -220,6 +220,8 @@ func initStaticBlocks1(store Store, ctx BlockNode, nn Node) { switch n := n.(type) { case *NameExpr: switch ftype { + case TRANS_COMPOSITE_KEY: + return n, TRANS_CONTINUE case TRANS_ASSIGN_LHS: as := ns[len(ns)-1].(*AssignStmt) if as.Op == DEFINE { @@ -318,32 +320,6 @@ func initStaticBlocks1(store Store, ctx BlockNode, nn Node) { if n.Op != DEFINE { return n, TRANS_CONTINUE } - if n.Key != nil { - ln := n.Key.(*NameExpr).Name - if ln == blankIdentifier { - return n, TRANS_CONTINUE - } - if strings.HasSuffix(string(ln), ".loopvar") { - // for idempotency (already converted) - return n, TRANS_CONTINUE - } - // replace all n.Key w/ .loopvar - n.Key.(*NameExpr).Name += ".loopvar" - replaceAllLoopvar(last, n, ln) - } - if n.Value != nil { - ln := n.Value.(*NameExpr).Name - if ln == blankIdentifier { - return n, TRANS_CONTINUE - } - if strings.HasSuffix(string(ln), ".loopvar") { - // for idempotency (already converted) - return n, TRANS_CONTINUE - } - // replace all n.Value w/ .loopvar - n.Value.(*NameExpr).Name += ".loopvar" - replaceAllLoopvar(last, n, ln) - } } } return n, TRANS_CONTINUE @@ -381,11 +357,13 @@ func initStaticBlocks2(store Store, ctx BlockNode, nn Node) { if ln == blankIdentifier { continue } - if !isLocallyDefined2(last, ln) { + if !isLocallyReserved(last, ln) { // if loopvar, will promote to // NameExprTypeHeapDefine later. nx.Type = NameExprTypeDefine last.Reserve(false, nx, n, NSDefine, i) + } else { + nx.Type = NameExprTypeDefine } } } @@ -2937,6 +2915,12 @@ func defineOrDecl( if numVals > 1 && numNames != numVals { panic(fmt.Sprintf("assignment mismatch: %d variable(s) but %d value(s)", numNames, numVals)) } + if isConst && numVals == 0 && numNames > 0 && typeExpr == nil { + // This occurs when a const group line inherits values from the previous + // line but the number of names doesn't match (go2gno sets Values to nil + // in that case). Report a proper error instead of a confusing internal panic. + panic(fmt.Sprintf("assignment mismatch: %d variable(s) but 0 value(s)", numNames)) + } sts := make([]Type, numNames) // static types tvs := make([]TypedValue, numNames) @@ -5785,9 +5769,8 @@ func isLocallyDefined(bn BlockNode, n Name) bool { return t != nil } -// r := 0 -// r, ok := 1, true -func isLocallyDefined2(bn BlockNode, n Name) bool { +// if name is is reserved. +func isLocallyReserved(bn BlockNode, n Name) bool { _, isLocal := bn.GetLocalIndex(n) return isLocal } diff --git a/gnovm/tests/files/assign39.gno b/gnovm/tests/files/assign39.gno new file mode 100644 index 00000000000..23cf463eb5b --- /dev/null +++ b/gnovm/tests/files/assign39.gno @@ -0,0 +1,21 @@ +package main + +// Verifies that in an AssignStmt, all RHS values are evaluated before LHS +// is written (Go spec §Assignments). This exercises the new interleaved +// Rhs[i]→Lhs[i] transcription order in transcribe.go. +func main() { + x, y := 1, 2 + + // Swap: both RHS (y and x) are evaluated before either LHS is written. + x, y = y, x + println(x, y) // 2 1 + + // a=b (old), b=a+b (old a + old b) + a, b := 3, 5 + a, b = b, a+b + println(a, b) // 5 8 +} + +// Output: +// 2 1 +// 5 8 diff --git a/gnovm/tests/files/const55.gno b/gnovm/tests/files/const55.gno index d2c7a80f9bb..8989995bcb7 100644 --- a/gnovm/tests/files/const55.gno +++ b/gnovm/tests/files/const55.gno @@ -11,7 +11,7 @@ func main() { } // Error: -// main/const55.gno:10:10-11: name not defined: m +// main/const55.gno:5:2-3: assignment mismatch: 1 variable(s) but 0 value(s) // TypeCheckError: // main/const55.gno:5:2: extra init expr at main/const55.gno:4:2 diff --git a/gnovm/tests/files/const55a.gno b/gnovm/tests/files/const55a.gno index 275caf13163..a292fc375dd 100644 --- a/gnovm/tests/files/const55a.gno +++ b/gnovm/tests/files/const55a.gno @@ -11,7 +11,7 @@ func main() { } // Error: -// main/const55a.gno:10:10-11: name not defined: m +// main/const55a.gno:5:2-9: assignment mismatch: 3 variable(s) but 0 value(s) // TypeCheckError: // main/const55a.gno:5:8: missing init expr for l diff --git a/gnovm/tests/files/const62.gno b/gnovm/tests/files/const62.gno new file mode 100644 index 00000000000..fef3ee0b5dc --- /dev/null +++ b/gnovm/tests/files/const62.gno @@ -0,0 +1,19 @@ +package main + +// Const group where a subsequent line has fewer names than the previous iota +// expression. Should produce a clear assignment mismatch error, not a +// confusing internal panic. +const ( + d, e = 1, "hello" + m +) + +func main() { + println(d, e) +} + +// Error: +// main/const62.gno:8:2-3: assignment mismatch: 1 variable(s) but 0 value(s) + +// TypeCheckError: +// main/const62.gno:8:2: extra init expr at main/const62.gno:7:2 diff --git a/gnovm/tests/files/heap_alloc_forloop1.gno b/gnovm/tests/files/heap_alloc_forloop1.gno index 8455c427a43..e5809718b0a 100644 --- a/gnovm/tests/files/heap_alloc_forloop1.gno +++ b/gnovm/tests/files/heap_alloc_forloop1.gno @@ -21,7 +21,7 @@ func main() { } // Preprocessed: -// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i.loopvar, e.loopvar := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i.loopvar, *(e.loopvar)) } }(); for i.loopvar := (const (0 int)); i.loopvar<~VPBlock(1,0)> < (const (3 int)); i.loopvar<~VPBlock(1,0)>++ { s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(i.loopvar<~VPBlock(1,0)>)) } }; func main() { forLoopRef() } } +// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i, e := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i, *(e)) } }(); for i.loopvar := (const (0 int)); i.loopvar<~VPBlock(1,0)> < (const (3 int)); i.loopvar<~VPBlock(1,0)>++ { s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(i.loopvar<~VPBlock(1,0)>)) } }; func main() { forLoopRef() } } // Output: // s1[0] is: 0 diff --git a/gnovm/tests/files/heap_alloc_forloop1a.gno b/gnovm/tests/files/heap_alloc_forloop1a.gno index af468eb91f7..7ae79575446 100644 --- a/gnovm/tests/files/heap_alloc_forloop1a.gno +++ b/gnovm/tests/files/heap_alloc_forloop1a.gno @@ -27,7 +27,7 @@ func main() { } // Preprocessed: -// file{ package main; import fmt fmt; type Int (const-type main.Int); var s1 []*(typeval{main.Int}); func inc2(j *(typeval{main.Int})) { *(j) = *(j) + (const (2 main.Int)) }; func forLoopRef() { defer func func(){ for i.loopvar, e.loopvar := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i.loopvar, *(e.loopvar)) } }(); for i.loopvar := (const (0 main.Int)); i.loopvar<~VPBlock(1,0)> < (const (10 main.Int)); inc2(&(i.loopvar<~VPBlock(1,0)>)) { s1<~VPBlock(4,1)> = (const (append func([]*main.Int, ...*main.Int) []*main.Int))(s1<~VPBlock(4,1)>, &(i.loopvar<~VPBlock(1,0)>)) } }; func main() { forLoopRef() } } +// file{ package main; import fmt fmt; type Int (const-type main.Int); var s1 []*(typeval{main.Int}); func inc2(j *(typeval{main.Int})) { *(j) = *(j) + (const (2 main.Int)) }; func forLoopRef() { defer func func(){ for i, e := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i, *(e)) } }(); for i.loopvar := (const (0 main.Int)); i.loopvar<~VPBlock(1,0)> < (const (10 main.Int)); inc2(&(i.loopvar<~VPBlock(1,0)>)) { s1<~VPBlock(4,1)> = (const (append func([]*main.Int, ...*main.Int) []*main.Int))(s1<~VPBlock(4,1)>, &(i.loopvar<~VPBlock(1,0)>)) } }; func main() { forLoopRef() } } // Output: // s1[0] is: 0 diff --git a/gnovm/tests/files/heap_alloc_forloop1b.gno b/gnovm/tests/files/heap_alloc_forloop1b.gno index 2031427a1e3..63f41ca6860 100644 --- a/gnovm/tests/files/heap_alloc_forloop1b.gno +++ b/gnovm/tests/files/heap_alloc_forloop1b.gno @@ -26,7 +26,7 @@ func main() { // go 1.22 loop var is not supported for now. // Preprocessed: -// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i.loopvar, e.loopvar := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i.loopvar, *(e.loopvar)) } }(); for i.loopvar := (const (0 int)); i.loopvar<~VPBlock(1,0)> < (const (3 int)); i.loopvar<~VPBlock(1,0)>++ { r := i.loopvar<~VPBlock(1,0)>; r, ok := (const (0 int)), (const (true bool)); (const (println func(...interface {})))(ok, r); s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(i.loopvar<~VPBlock(1,0)>)) } }; func main() { forLoopRef() } } +// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i, e := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i, *(e)) } }(); for i.loopvar := (const (0 int)); i.loopvar<~VPBlock(1,0)> < (const (3 int)); i.loopvar<~VPBlock(1,0)>++ { r := i.loopvar<~VPBlock(1,0)>; r, ok := (const (0 int)), (const (true bool)); (const (println func(...interface {})))(ok, r); s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(i.loopvar<~VPBlock(1,0)>)) } }; func main() { forLoopRef() } } // Output: // true 0 diff --git a/gnovm/tests/files/heap_alloc_forloop2.gno b/gnovm/tests/files/heap_alloc_forloop2.gno index 09b52d3d4cf..9d557602528 100644 --- a/gnovm/tests/files/heap_alloc_forloop2.gno +++ b/gnovm/tests/files/heap_alloc_forloop2.gno @@ -25,7 +25,7 @@ func main() { // You can tell by the preprocess printout of z and z<~...>. // Preprocessed: -// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i.loopvar, e.loopvar := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i.loopvar, *(e.loopvar)) } }(); for i.loopvar := (const (0 int)); i.loopvar < (const (3 int)); i.loopvar++ { z := i.loopvar + (const (1 int)); s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(z<~VPBlock(1,1)>)) } }; func main() { forLoopRef() } } +// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i, e := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i, *(e)) } }(); for i.loopvar := (const (0 int)); i.loopvar < (const (3 int)); i.loopvar++ { z := i.loopvar + (const (1 int)); s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(z<~VPBlock(1,1)>)) } }; func main() { forLoopRef() } } // Output: // s1[0] is: 1 diff --git a/gnovm/tests/files/heap_alloc_forloop2a.gno b/gnovm/tests/files/heap_alloc_forloop2a.gno index 538ee199325..0fc9358ec46 100644 --- a/gnovm/tests/files/heap_alloc_forloop2a.gno +++ b/gnovm/tests/files/heap_alloc_forloop2a.gno @@ -26,7 +26,7 @@ func main() { // You can tell by the preprocess printout of z and z<~...>. // Preprocessed: -// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i.loopvar, e.loopvar := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i.loopvar, *(e.loopvar)) } }(); for i.loopvar := (const (0 int)); i.loopvar < (const (3 int)); i.loopvar++ { z := i.loopvar; s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(z<~VPBlock(1,1)>)); z<~VPBlock(1,1)>++ } }; func main() { forLoopRef() } } +// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i, e := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i, *(e)) } }(); for i.loopvar := (const (0 int)); i.loopvar < (const (3 int)); i.loopvar++ { z := i.loopvar; s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(z<~VPBlock(1,1)>)); z<~VPBlock(1,1)>++ } }; func main() { forLoopRef() } } // Output: // s1[0] is: 1 diff --git a/gnovm/tests/files/heap_alloc_forloop9.gno b/gnovm/tests/files/heap_alloc_forloop9.gno index 2d97e09f239..9fc67f446e1 100644 --- a/gnovm/tests/files/heap_alloc_forloop9.gno +++ b/gnovm/tests/files/heap_alloc_forloop9.gno @@ -26,7 +26,7 @@ func main() { } // Preprocessed: -// file{ package main; import fmt fmt; func main() { var fns []func(.arg_0 (const-type int)) .res.0 (const-type int); var recursiveFunc func(.arg_0 (const-type int)) .res.0 (const-type int); for i.loopvar := (const (0 int)); i.loopvar<~VPBlock(1,0)> < (const (3 int)); i.loopvar<~VPBlock(1,0)>++ { recursiveFunc<~VPBlock(2,1)> = func func(num (const-type int)) .res.0 (const-type int){ x := i.loopvar<~VPBlock(1,3)>; (const (println func(...interface {})))((const ("value of x: " string)), x); if num <= (const (0 int)) { return (const (1 int)) }; return num * recursiveFunc<~VPBlock(1,4)>(num - (const (1 int))) }, recursiveFunc<()~VPBlock(2,1)>>; fns = (const (append func([]func(int) int, ...func(int) int) []func(int) int))(fns, recursiveFunc<~VPBlock(2,1)>) }; for i.loopvar, r.loopvar := range fns { result := r.loopvar(i.loopvar); (const (ref(fmt) package{})).Printf((const ("Factorial of %d is: %d\n" string)), i.loopvar, result) } } } +// file{ package main; import fmt fmt; func main() { var fns []func(.arg_0 (const-type int)) .res.0 (const-type int); var recursiveFunc func(.arg_0 (const-type int)) .res.0 (const-type int); for i.loopvar := (const (0 int)); i.loopvar<~VPBlock(1,0)> < (const (3 int)); i.loopvar<~VPBlock(1,0)>++ { recursiveFunc<~VPBlock(2,1)> = func func(num (const-type int)) .res.0 (const-type int){ x := i.loopvar<~VPBlock(1,3)>; (const (println func(...interface {})))((const ("value of x: " string)), x); if num <= (const (0 int)) { return (const (1 int)) }; return num * recursiveFunc<~VPBlock(1,4)>(num - (const (1 int))) }, recursiveFunc<()~VPBlock(2,1)>>; fns = (const (append func([]func(int) int, ...func(int) int) []func(int) int))(fns, recursiveFunc<~VPBlock(2,1)>) }; for i, r := range fns { result := r(i); (const (ref(fmt) package{})).Printf((const ("Factorial of %d is: %d\n" string)), i, result) } } } // Output: // value of x: 0 diff --git a/gnovm/tests/files/heap_alloc_gotoloop9_10.gno b/gnovm/tests/files/heap_alloc_gotoloop9_10.gno index 9f1aa8cd2d7..7058252c703 100644 --- a/gnovm/tests/files/heap_alloc_gotoloop9_10.gno +++ b/gnovm/tests/files/heap_alloc_gotoloop9_10.gno @@ -41,7 +41,7 @@ LOOP_2: } // Preprocessed: -// file{ package main; import fmt fmt; var s1 []*((const-type int)); var s2 []*((const-type int)); func main() { defer func func(){ for i.loopvar, v.loopvar := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is %d\n" string)), i.loopvar, *(v.loopvar)) }; for i.loopvar, v.loopvar := range (const (ref(main) package{})).s2 { (const (ref(fmt) package{})).Printf((const ("s2[%d] is %d\n" string)), i.loopvar, *(v.loopvar)) } }(); var c1, c2 (const-type int); x := c1; s1<~VPBlock(3,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(3,0)>, &(x<~VPBlock(1,2)>)); (const (println func(...interface {})))((const ("loop_1" string)), c1); c1++; y := c2; s2<~VPBlock(3,1)> = (const (append func([]*int, ...*int) []*int))(s2<~VPBlock(3,1)>, &(y<~VPBlock(1,3)>)); (const (println func(...interface {})))((const ("loop_2" string)), c2); c2++; if c1 < (const (3 int)) { goto LOOP_1<1,0,2> }; if c2 < (const (6 int)) { goto LOOP_2<1,0,6> } } } +// file{ package main; import fmt fmt; var s1 []*((const-type int)); var s2 []*((const-type int)); func main() { defer func func(){ for i, v := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is %d\n" string)), i, *(v)) }; for i, v := range (const (ref(main) package{})).s2 { (const (ref(fmt) package{})).Printf((const ("s2[%d] is %d\n" string)), i, *(v)) } }(); var c1, c2 (const-type int); x := c1; s1<~VPBlock(3,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(3,0)>, &(x<~VPBlock(1,2)>)); (const (println func(...interface {})))((const ("loop_1" string)), c1); c1++; y := c2; s2<~VPBlock(3,1)> = (const (append func([]*int, ...*int) []*int))(s2<~VPBlock(3,1)>, &(y<~VPBlock(1,3)>)); (const (println func(...interface {})))((const ("loop_2" string)), c2); c2++; if c1 < (const (3 int)) { goto LOOP_1<1,0,2> }; if c2 < (const (6 int)) { goto LOOP_2<1,0,6> } } } // Output: // loop_1 0 diff --git a/gnovm/tests/files/heap_alloc_range1.gno b/gnovm/tests/files/heap_alloc_range1.gno index 45785ba2d29..c20de43f4ef 100644 --- a/gnovm/tests/files/heap_alloc_range1.gno +++ b/gnovm/tests/files/heap_alloc_range1.gno @@ -22,7 +22,7 @@ func main() { } // Preprocessed: -// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i.loopvar, e.loopvar := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i.loopvar, *(e.loopvar)) } }(); s := (const-type []int){(const (0 int)), (const (1 int)), (const (2 int))}; for i.loopvar, _ := range s { s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(i.loopvar<~VPBlock(1,0)>)) } }; func main() { forLoopRef() } } +// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i, e := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i, *(e)) } }(); s := (const-type []int){(const (0 int)), (const (1 int)), (const (2 int))}; for i, _ := range s { s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(i<~VPBlock(1,0)>)) } }; func main() { forLoopRef() } } // Output: // s1[0] is: 0 diff --git a/gnovm/tests/files/heap_alloc_range2.gno b/gnovm/tests/files/heap_alloc_range2.gno index 5d85988b26e..88d6ec9d928 100644 --- a/gnovm/tests/files/heap_alloc_range2.gno +++ b/gnovm/tests/files/heap_alloc_range2.gno @@ -22,7 +22,7 @@ func main() { } // Preprocessed: -// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i.loopvar, e.loopvar := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i.loopvar, *(e.loopvar)) } }(); s := (const-type []int){(const (0 int)), (const (1 int)), (const (2 int))}; for _, v := range s { s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(v<~VPBlock(1,0)>)) } }; func main() { forLoopRef() } } +// file{ package main; import fmt fmt; var s1 []*((const-type int)); func forLoopRef() { defer func func(){ for i, e := range (const (ref(main) package{})).s1 { (const (ref(fmt) package{})).Printf((const ("s1[%d] is: %d\n" string)), i, *(e)) } }(); s := (const-type []int){(const (0 int)), (const (1 int)), (const (2 int))}; for _, v := range s { s1<~VPBlock(4,0)> = (const (append func([]*int, ...*int) []*int))(s1<~VPBlock(4,0)>, &(v<~VPBlock(1,0)>)) } }; func main() { forLoopRef() } } // Output: // s1[0] is: 0 diff --git a/gnovm/tests/files/heap_alloc_range3.gno b/gnovm/tests/files/heap_alloc_range3.gno index 08454e01199..7174267ee58 100644 --- a/gnovm/tests/files/heap_alloc_range3.gno +++ b/gnovm/tests/files/heap_alloc_range3.gno @@ -14,7 +14,7 @@ func main() { } // Preprocessed: -// file{ package main; func main() { s := (const-type []int){(const (1 int)), (const (2 int))}; f := func func(){ for i.loopvar, v.loopvar := range s<~VPBlock(2,0)> { (const (println func(...interface {})))(i.loopvar); (const (println func(...interface {})))(v.loopvar) } }>; f() } } +// file{ package main; func main() { s := (const-type []int){(const (1 int)), (const (2 int))}; f := func func(){ for i, v := range s<~VPBlock(2,0)> { (const (println func(...interface {})))(i); (const (println func(...interface {})))(v) } }>; f() } } // Output: // 0 diff --git a/gnovm/tests/files/heap_alloc_range4.gno b/gnovm/tests/files/heap_alloc_range4.gno index 51dc6b82546..942d1763b4a 100644 --- a/gnovm/tests/files/heap_alloc_range4.gno +++ b/gnovm/tests/files/heap_alloc_range4.gno @@ -16,7 +16,7 @@ func main() { } // Preprocessed: -// file{ package main; func main() { var fns []func() .res.0 (const-type int); s := (const-type []int){(const (1 int)), (const (2 int)), (const (3 int))}; for i.loopvar, _ := range s { x := i.loopvar; f := func func() .res.0 (const-type int){ return x<~VPBlock(1,1)> }>; fns = (const (append func([]func() int, ...func() int) []func() int))(fns, f) }; for _, fn := range fns { (const (println func(...interface {})))(fn()) } } } +// file{ package main; func main() { var fns []func() .res.0 (const-type int); s := (const-type []int){(const (1 int)), (const (2 int)), (const (3 int))}; for i, _ := range s { x := i; f := func func() .res.0 (const-type int){ return x<~VPBlock(1,1)> }>; fns = (const (append func([]func() int, ...func() int) []func() int))(fns, f) }; for _, fn := range fns { (const (println func(...interface {})))(fn()) } } } // Output: // 0 diff --git a/gnovm/tests/files/heap_alloc_range4b.gno b/gnovm/tests/files/heap_alloc_range4b.gno index 488670fc74e..6e399b9a99f 100644 --- a/gnovm/tests/files/heap_alloc_range4b.gno +++ b/gnovm/tests/files/heap_alloc_range4b.gno @@ -16,7 +16,7 @@ func main() { } // Preprocessed: -// file{ package main; func main() { var fns []func() .res.0 (const-type int); s := (const ("hello" string)); for i.loopvar, _ := range s { x := i.loopvar; f := func func() .res.0 (const-type int){ return x<~VPBlock(1,1)> }>; fns = (const (append func([]func() int, ...func() int) []func() int))(fns, f) }; for _, fn := range fns { (const (println func(...interface {})))(fn()) } } } +// file{ package main; func main() { var fns []func() .res.0 (const-type int); s := (const ("hello" string)); for i, _ := range s { x := i; f := func func() .res.0 (const-type int){ return x<~VPBlock(1,1)> }>; fns = (const (append func([]func() int, ...func() int) []func() int))(fns, f) }; for _, fn := range fns { (const (println func(...interface {})))(fn()) } } } // Output: // 0 diff --git a/gnovm/tests/files/heap_alloc_range4b1.gno b/gnovm/tests/files/heap_alloc_range4b1.gno index f6eadfc5f4f..e8600abd983 100644 --- a/gnovm/tests/files/heap_alloc_range4b1.gno +++ b/gnovm/tests/files/heap_alloc_range4b1.gno @@ -15,7 +15,7 @@ func main() { } // Preprocessed: -// file{ package main; func main() { var fns []func() .res.0 (const-type int); s := (const ("hello" string)); for i.loopvar, _ := range s { f := func func() .res.0 (const-type int){ return i.loopvar<~VPBlock(1,1)> }>; fns = (const (append func([]func() int, ...func() int) []func() int))(fns, f) }; for _, fn := range fns { (const (println func(...interface {})))(fn()) } } } +// file{ package main; func main() { var fns []func() .res.0 (const-type int); s := (const ("hello" string)); for i, _ := range s { f := func func() .res.0 (const-type int){ return i<~VPBlock(1,1)> }>; fns = (const (append func([]func() int, ...func() int) []func() int))(fns, f) }; for _, fn := range fns { (const (println func(...interface {})))(fn()) } } } // Output: // 0 diff --git a/gnovm/tests/files/loopvar_err_1.gno b/gnovm/tests/files/loopvar_err_1.gno new file mode 100644 index 00000000000..b161a67ecb8 --- /dev/null +++ b/gnovm/tests/files/loopvar_err_1.gno @@ -0,0 +1,16 @@ +package main + +// Type error inside a for loop: check that the error message does NOT +// expose the internal ".loopvar" name suffix to users. +func main() { + for i := 0; i < 3; i++ { + var s string = i + _ = s + } +} + +// Error: +// main/loopvar_err_1.gno:7:7-19: cannot use int as string + +// TypeCheckError: +// main/loopvar_err_1.gno:7:18: cannot use i (variable of type int) as string value in variable declaration diff --git a/gnovm/tests/files/loopvar_err_2.gno b/gnovm/tests/files/loopvar_err_2.gno new file mode 100644 index 00000000000..da485f542b9 --- /dev/null +++ b/gnovm/tests/files/loopvar_err_2.gno @@ -0,0 +1,16 @@ +package main + +// Using a loop variable after the loop exits should report "name not defined: i" +// not "name not defined: i.loopvar". +func main() { + for i := 0; i < 3; i++ { + _ = i + } + println(i) // i is out of scope here +} + +// Error: +// main/loopvar_err_2.gno:9:10-11: name i not declared + +// TypeCheckError: +// main/loopvar_err_2.gno:9:10: undefined: i diff --git a/gnovm/tests/files/loopvar_goto_1.gno b/gnovm/tests/files/loopvar_goto_1.gno new file mode 100644 index 00000000000..ab77923ec71 --- /dev/null +++ b/gnovm/tests/files/loopvar_goto_1.gno @@ -0,0 +1,20 @@ +package main + +// goto label inside for loop body, with loop var references on both sides of the label. +func main() { + for i := 0; i < 3; i++ { + if i == 1 { + goto SKIP + } + println("body:", i) + SKIP: + println("end:", i) + } +} + +// Output: +// body: 0 +// end: 0 +// end: 1 +// body: 2 +// end: 2 diff --git a/gnovm/tests/files/loopvar_map_key_1.gno b/gnovm/tests/files/loopvar_map_key_1.gno new file mode 100644 index 00000000000..dbd8ff5a12b --- /dev/null +++ b/gnovm/tests/files/loopvar_map_key_1.gno @@ -0,0 +1,16 @@ +package main + +// Map composite literal: the loop var name is used as a map key expression. +// Unlike struct fields, map keys ARE variable references and MUST be renamed. +// This also exercises the TRANS_COMPOSITE_KEY fix for struct field names. +func main() { + for k := range []string{"a", "b", "c"} { + m := map[int]string{k: "val"} + println(k, m[k]) + } +} + +// Output: +// 0 val +// 1 val +// 2 val diff --git a/gnovm/tests/files/loopvar_modify_body_1.gno b/gnovm/tests/files/loopvar_modify_body_1.gno new file mode 100644 index 00000000000..a1724923e7f --- /dev/null +++ b/gnovm/tests/files/loopvar_modify_body_1.gno @@ -0,0 +1,18 @@ +package main + +// Modifying the loop init var in the body affects the condition check, +// causing early termination. The closure captures the value after the modification. +func main() { + var fns []func() int + for i := 0; i < 10; i++ { + i += 5 // skip ahead; within same iteration + fns = append(fns, func() int { return i }) + } + for _, fn := range fns { + println(fn()) + } +} + +// Output: +// 5 +// 11 diff --git a/gnovm/tests/files/loopvar_multi_init_1.gno b/gnovm/tests/files/loopvar_multi_init_1.gno new file mode 100644 index 00000000000..06f2549f802 --- /dev/null +++ b/gnovm/tests/files/loopvar_multi_init_1.gno @@ -0,0 +1,23 @@ +package main + +// Multiple for-init vars directly captured by closures (no shadowing). +// Exercises NumInit==2 heap-copy path. j is mutated in the body; +// the closure for each iteration sees j's within-iteration modified value. +// i is not mutated in the body; closure sees pre-post-stmt value (0, 1, 2). +func main() { + var fns []func() (int, int) + for i, j := 0, 10; i < 3; i++ { + f := func() (int, int) { return i, j } + fns = append(fns, f) + j += 5 // modify loop var j in body + } + for _, fn := range fns { + a, b := fn() + println(a, b) + } +} + +// Output: +// 0 15 +// 1 20 +// 2 25 diff --git a/gnovm/tests/files/loopvar_multi_init_2.gno b/gnovm/tests/files/loopvar_multi_init_2.gno new file mode 100644 index 00000000000..811ded62082 --- /dev/null +++ b/gnovm/tests/files/loopvar_multi_init_2.gno @@ -0,0 +1,21 @@ +package main + +// Multiple for-init vars: both i and j are loop vars (NumInit==2). +// Shadow them to get per-iteration snapshots without body mutation. +func main() { + var fns []func() int + for i, j := 0, 10; i < 3; i++ { + i := i + j := j + fns = append(fns, func() int { return i*100 + j }) + j += 5 // modifies body-scope j after snapshot; closure sees it (j=15) + } + for _, fn := range fns { + println(fn()) + } +} + +// Output: +// 15 +// 115 +// 215 diff --git a/gnovm/tests/files/loopvar_outer_shadow_1.gno b/gnovm/tests/files/loopvar_outer_shadow_1.gno new file mode 100644 index 00000000000..b94accfe286 --- /dev/null +++ b/gnovm/tests/files/loopvar_outer_shadow_1.gno @@ -0,0 +1,18 @@ +package main + +// Outer var with same name as loop var should not be affected by the rename. +// Inside the loop, i refers to i.loopvar (the loop var). +// After the loop, i refers to the outer var (99). +func main() { + i := 99 + for i := 0; i < 3; i++ { + println("loop:", i) + } + println("outer:", i) +} + +// Output: +// loop: 0 +// loop: 1 +// loop: 2 +// outer: 99 diff --git a/gnovm/tests/files/loopvar_range_capture_mutate_1.gno b/gnovm/tests/files/loopvar_range_capture_mutate_1.gno new file mode 100644 index 00000000000..efd7d876644 --- /dev/null +++ b/gnovm/tests/files/loopvar_range_capture_mutate_1.gno @@ -0,0 +1,20 @@ +package main + +// Range loop: closure captures the range value var, then it's mutated. +// Each iteration has its own per-iteration v; mutation stays local. +func main() { + var fns []func() int + for _, v := range []int{1, 2, 3} { + f := func() int { return v } // capture first + v += 100 // mutate after; same iteration's v + fns = append(fns, f) + } + for _, fn := range fns { + println(fn()) + } +} + +// Output: +// 101 +// 102 +// 103 diff --git a/gnovm/tests/files/loopvar_range_kv_shadow_2.gno b/gnovm/tests/files/loopvar_range_kv_shadow_2.gno new file mode 100644 index 00000000000..95508816c65 --- /dev/null +++ b/gnovm/tests/files/loopvar_range_kv_shadow_2.gno @@ -0,0 +1,21 @@ +package main + +func main() { + for i, v := range []int{10, 20, 30} { + i := i+1 + v := v+1 + println("inner i:", i) + println("inner v:", v) + } +} + +// Output: +// inner i: 1 +// inner v: 11 +// inner i: 2 +// inner v: 21 +// inner i: 3 +// inner v: 31 + +// Preprocessed: +// file{ package main; func main() { for i, v := range (const-type []int){(const (10 int)), (const (20 int)), (const (30 int))} { i := i + (const (1 int)); v := v + (const (1 int)); (const (println func(...interface {})))((const ("inner i:" string)), i); (const (println func(...interface {})))((const ("inner v:" string)), v) } } } diff --git a/gnovm/tests/files/loopvar_range_outer_shadow_1.gno b/gnovm/tests/files/loopvar_range_outer_shadow_1.gno new file mode 100644 index 00000000000..61bff352733 --- /dev/null +++ b/gnovm/tests/files/loopvar_range_outer_shadow_1.gno @@ -0,0 +1,14 @@ +package main + +// Range loop var with same name as an outer var: outer var should be unchanged. +func main() { + i := 99 + for i, v := range []int{10, 20, 30} { + _ = v + _ = i + } + println("outer i:", i) // 99 +} + +// Output: +// outer i: 99 diff --git a/gnovm/tests/files/loopvar_redefine_4.gno b/gnovm/tests/files/loopvar_redefine_4.gno index 2dccdf40ce7..aa25a6a9695 100644 --- a/gnovm/tests/files/loopvar_redefine_4.gno +++ b/gnovm/tests/files/loopvar_redefine_4.gno @@ -9,7 +9,7 @@ func main() { } // Preprocessed: -// file{ package main; func main() { for i.loopvar, v.loopvar := range (const-type []int){(const (1 int)), (const (2 int)), (const (3 int))} { i := i.loopvar; v := v.loopvar; (const (println func(...interface {})))(i, v) } } } +// file{ package main; func main() { for i, v := range (const-type []int){(const (1 int)), (const (2 int)), (const (3 int))} { i := i; v := v; (const (println func(...interface {})))(i, v) } } } // Output: // 0 1 diff --git a/gnovm/tests/files/loopvar_struct_field_1.gno b/gnovm/tests/files/loopvar_struct_field_1.gno new file mode 100644 index 00000000000..3051a550521 --- /dev/null +++ b/gnovm/tests/files/loopvar_struct_field_1.gno @@ -0,0 +1,30 @@ +package main + +// Struct composite literal: field names (i, v) match range loop var names. +// Field names must NOT be renamed to i.loopvar / v.loopvar. +// Value expressions (i, v) must be renamed correctly. +type S struct { + i int + v string +} + +func main() { + var fns []func() S + for i, v := range []string{"a", "b", "c"} { + i := i + v := v + fns = append(fns, func() S { return S{i: i, v: v} }) + } + for _, fn := range fns { + s := fn() + println(s.i, s.v) + } +} + +// Preprocessed: +// file{ package main; type S (const-type main.S); func main() { var fns []func() .res.0 typeval{main.S}; for i, v := range (const-type []string){(const ("a" string)), (const ("b" string)), (const ("c" string))} { i := i<~VPBlock(1,0)>; v := v<~VPBlock(1,1)>; fns = (const (append func([]func() main.S, ...func() main.S) []func() main.S))(fns, func func() .res.0 typeval{main.S}{ return (const-type main.S){i: i<~VPBlock(1,1)>, v: v<~VPBlock(1,2)>} }, v<()~VPBlock(1,1)>>) }; for _, fn := range fns { s := fn(); (const (println func(...interface {})))(s.i, s.v) } } } + +// Output: +// 0 a +// 1 b +// 2 c diff --git a/gnovm/tests/files/loopvar_struct_field_2.gno b/gnovm/tests/files/loopvar_struct_field_2.gno new file mode 100644 index 00000000000..e1b2df9e55a --- /dev/null +++ b/gnovm/tests/files/loopvar_struct_field_2.gno @@ -0,0 +1,19 @@ +package main + +// Struct composite literal: field names that exactly match for-loop init vars. +// Tests that TRANS_COMPOSITE_KEY NameExprs are not renamed to i.loopvar. +type S struct { + i int +} + +func main() { + for i := 0; i < 3; i++ { + s := S{i: i * 10} + println(s.i) + } +} + +// Output: +// 0 +// 10 +// 20 diff --git a/gnovm/tests/files/loopvar_switch_1.gno b/gnovm/tests/files/loopvar_switch_1.gno new file mode 100644 index 00000000000..d527b53c73c --- /dev/null +++ b/gnovm/tests/files/loopvar_switch_1.gno @@ -0,0 +1,28 @@ +package main + +// switch on loop var: the loop var expression is used in the switch tag. +// Closures capture a body-scope label derived from the switch result. +func main() { + var fns []func() string + for i := 0; i < 4; i++ { + var label string + switch i { + case 0: + label = "zero" + case 1: + label = "one" + default: + label = "other" + } + fns = append(fns, func() string { return label }) + } + for _, fn := range fns { + println(fn()) + } +} + +// Output: +// zero +// one +// other +// other From d2e792c6e1387b1dd6837a67ea91f3277dab3a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jer=C3=B3nimo=20Albi?= <894299+jeronimoalbi@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:58:25 +0200 Subject: [PATCH 57/92] chore(boards2): escape thread titles (#5434) Also disallow Gno-Flavored Markdown forms within thread replies. --------- Co-authored-by: Lee ByeongJun (cherry picked from commit 73b07b82137341c7d43d265969dbc414284c9b33) --- .../filetests/z_create_reply_16_filetest.gno | 28 +++++++++++++ .../v1/filetests/z_edit_reply_08_filetest.gno | 29 ++++++++++++++ .../v1/filetests/z_ui_thread_07_filetest.gno | 39 +++++++++++++++++++ .../gno.land/r/gnoland/boards2/v1/public.gno | 4 ++ .../r/gnoland/boards2/v1/render_post.gno | 2 +- 5 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_reply_16_filetest.gno create mode 100644 examples/gno.land/r/gnoland/boards2/v1/filetests/z_edit_reply_08_filetest.gno create mode 100644 examples/gno.land/r/gnoland/boards2/v1/filetests/z_ui_thread_07_filetest.gno diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_reply_16_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_reply_16_filetest.gno new file mode 100644 index 00000000000..59da0754a01 --- /dev/null +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_create_reply_16_filetest.gno @@ -0,0 +1,28 @@ +package main + +import ( + "testing" + + "gno.land/p/gnoland/boards" + + boards2 "gno.land/r/gnoland/boards2/v1" +) + +const owner address = "g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh" + +var bid, tid boards.ID + +func init() { + testing.SetRealm(testing.NewUserRealm(owner)) + bid = boards2.CreateBoard(cross, "test-board", false, false) + tid = boards2.CreateThread(cross, bid, "Foo", "bar") +} + +func main() { + testing.SetRealm(testing.NewUserRealm(owner)) + + boards2.CreateReply(cross, bid, tid, 0, "") +} + +// Error: +// Gno-Flavored Markdown forms are not allowed in replies diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_edit_reply_08_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_edit_reply_08_filetest.gno new file mode 100644 index 00000000000..eddac49dab5 --- /dev/null +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_edit_reply_08_filetest.gno @@ -0,0 +1,29 @@ +package main + +import ( + "testing" + + "gno.land/p/gnoland/boards" + + boards2 "gno.land/r/gnoland/boards2/v1" +) + +const owner address = "g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh" + +var bid, tid, rid boards.ID + +func init() { + testing.SetRealm(testing.NewUserRealm(owner)) + bid = boards2.CreateBoard(cross, "test-board", false, false) + tid = boards2.CreateThread(cross, bid, "Foo", "bar") + rid = boards2.CreateReply(cross, bid, tid, 0, "body") +} + +func main() { + testing.SetRealm(testing.NewUserRealm(owner)) + + boards2.EditReply(cross, bid, tid, rid, "") +} + +// Error: +// Gno-Flavored Markdown forms are not allowed in replies diff --git a/examples/gno.land/r/gnoland/boards2/v1/filetests/z_ui_thread_07_filetest.gno b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_ui_thread_07_filetest.gno new file mode 100644 index 00000000000..622cc1c77ed --- /dev/null +++ b/examples/gno.land/r/gnoland/boards2/v1/filetests/z_ui_thread_07_filetest.gno @@ -0,0 +1,39 @@ +// Render thread with a title that contains Markdown +package main + +import ( + "testing" + + "gno.land/p/gnoland/boards" + + boards2 "gno.land/r/gnoland/boards2/v1" +) + +const ( + owner address = "g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh" + boardName = "test-board" +) + +var threadID boards.ID + +func init() { + testing.SetRealm(testing.NewUserRealm(owner)) + + // Create a board and a thread + boardID := boards2.CreateBoard(cross, boardName, false, false) + threadID = boards2.CreateThread(cross, boardID, "[Foo](https://foo.com)", "Body") +} + +func main() { + path := boardName + "/" + threadID.String() + println(boards2.Render(path)) +} + +// Output: +// # [Boards](/r/gnoland/boards2/v1) › [test\-board](/r/gnoland/boards2/v1:test-board) +// ## \[Foo\]\(https://foo\.com\) +// +// **[g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh](/u/g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh)** `owner` · 2009-02-13 11:31pm UTC +// Body +// +// ↳ [Flag](/r/gnoland/boards2/v1:test-board/1/flag) • [Repost](/r/gnoland/boards2/v1:test-board/1/repost) • [Comment](/r/gnoland/boards2/v1:test-board/1/reply) • [Edit](/r/gnoland/boards2/v1:test-board/1/edit) • [Delete](/r/gnoland/boards2/v1$help&func=DeleteThread&boardID=1&threadID=1) diff --git a/examples/gno.land/r/gnoland/boards2/v1/public.gno b/examples/gno.land/r/gnoland/boards2/v1/public.gno index 0cf7321b301..e1da793f7cb 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/public.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/public.gno @@ -780,6 +780,10 @@ func assertReplyBodyIsValid(body string) { if reDeniedReplyLinePrefixes.MatchString(body) { panic("using Markdown headings, blockquotes or horizontal lines is not allowed in replies") } + + if strings.Contains(body, "gno-form") { + panic("Gno-Flavored Markdown forms are not allowed in replies") + } } func assertMembersUpdateIsEnabled(boardID boards.ID) { diff --git a/examples/gno.land/r/gnoland/boards2/v1/render_post.gno b/examples/gno.land/r/gnoland/boards2/v1/render_post.gno index 942a1e826fc..b4870ed17c2 100644 --- a/examples/gno.land/r/gnoland/boards2/v1/render_post.gno +++ b/examples/gno.land/r/gnoland/boards2/v1/render_post.gno @@ -28,7 +28,7 @@ func renderPost(post *boards.Post, path, indent string, levels int) string { } if title != "" { // Replies don't have a title - b.WriteString(md.H2(title)) + b.WriteString(md.H2(md.EscapeText(title))) } b.WriteString(indent + "\n") From a5169ffae35d0213c5a8f3fc6a59317fd51bbb83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20BARBERO?= Date: Wed, 15 Apr 2026 12:08:49 +0200 Subject: [PATCH 58/92] feat: remove p/nt/ownable dependency in r/sys/names (#5244) Removed gno.land/p/nt/ownable usage from examples/gno.land/r/sys/names. - Removed ownership-drop behavior from Enable. - Replace ownable by a local admin address variable in verifier.gno. (govdao t1 multisig from https://github.com/gnolang/gno/pull/5222) - Enable now authorizes via `runtime.PreviousRealm().Address() == admin` --------- Signed-off-by: D4ryl00 (cherry picked from commit 375fe89c6d20bc7fed138104c111903ca4f93eec) --- examples/gno.land/r/sys/names/verifier.gno | 10 +++++----- examples/gno.land/r/sys/names/verifier_test.gno | 13 ++++++------- .../pkg/integration/testdata/addpkg_namespace.txtar | 2 +- .../pkg/integration/testdata/user_journey.txtar | 2 +- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/examples/gno.land/r/sys/names/verifier.gno b/examples/gno.land/r/sys/names/verifier.gno index 0f075b7a0b5..4f4e5a3cf14 100644 --- a/examples/gno.land/r/sys/names/verifier.gno +++ b/examples/gno.land/r/sys/names/verifier.gno @@ -2,10 +2,10 @@ // Only address-prefix (PA) namespaces are allowed. package names -import "gno.land/p/nt/ownable/v0" +import "chain/runtime" var ( - Ownable = ownable.NewWithAddressByPrevious("g1edq4dugw0sgat4zxcw9xardvuydqf6cgleuc8p") // genesis deployer — dropped in genesis via Enable. + admin = address("g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh") // govdao t1 multisig enabled = false ) @@ -15,12 +15,12 @@ func IsAuthorizedAddressForNamespace(address_XXX address, namespace string) bool return verifier(enabled, address_XXX, namespace) } -// Enable enables the namespace check and drops centralized ownership of this realm. +// Enable enables the namespace check for this realm. // The namespace check is disabled initially to ease txtar and other testing contexts, // but this function is meant to be called in the genesis of a chain. func Enable(cur realm) { - if err := Ownable.DropOwnership(); err != nil { - panic(err) + if runtime.PreviousRealm().Address() != admin { + panic("caller is not admin") } enabled = true } diff --git a/examples/gno.land/r/sys/names/verifier_test.gno b/examples/gno.land/r/sys/names/verifier_test.gno index 8b55bb53e8c..644b26a8906 100644 --- a/examples/gno.land/r/sys/names/verifier_test.gno +++ b/examples/gno.land/r/sys/names/verifier_test.gno @@ -3,7 +3,6 @@ package names import ( "testing" - "gno.land/p/nt/ownable/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) @@ -30,15 +29,15 @@ func TestDefaultVerifier(t *testing.T) { } func TestEnable(t *testing.T) { - testing.SetRealm(testing.NewUserRealm("g1edq4dugw0sgat4zxcw9xardvuydqf6cgleuc8p")) - - uassert.NotPanics(t, func() { + testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("random"))) + uassert.AbortsWithMessage(t, "caller is not admin", func() { Enable(cross) }) + uassert.False(t, IsEnabled()) - // Confirm enable drops ownership - uassert.Equal(t, Ownable.Owner().String(), "") - uassert.AbortsWithMessage(t, ownable.ErrUnauthorized.Error(), func() { + testing.SetRealm(testing.NewUserRealm(admin)) + uassert.NotPanics(t, func() { Enable(cross) }) + uassert.True(t, IsEnabled()) } diff --git a/gno.land/pkg/integration/testdata/addpkg_namespace.txtar b/gno.land/pkg/integration/testdata/addpkg_namespace.txtar index d0dde24e05b..c848534e0f1 100644 --- a/gno.land/pkg/integration/testdata/addpkg_namespace.txtar +++ b/gno.land/pkg/integration/testdata/addpkg_namespace.txtar @@ -3,7 +3,7 @@ loadpkg gno.land/r/sys/names adduser admin adduser gui -patchpkg "g1edq4dugw0sgat4zxcw9xardvuydqf6cgleuc8p" $admin_user_addr # use our custom admin +patchpkg "g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh" $admin_user_addr # use our custom admin gnoland start diff --git a/gno.land/pkg/integration/testdata/user_journey.txtar b/gno.land/pkg/integration/testdata/user_journey.txtar index bcc1e9430f3..529f2ff1ab0 100644 --- a/gno.land/pkg/integration/testdata/user_journey.txtar +++ b/gno.land/pkg/integration/testdata/user_journey.txtar @@ -11,7 +11,7 @@ loadpkg gno.land/r/sys/names genesiscall gno.land/r/sys/users/init Bootstrap # Override admin address in r/sys/names with test1 address -patchpkg "g1edq4dugw0sgat4zxcw9xardvuydqf6cgleuc8p" $test1_user_addr +patchpkg "g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh" $test1_user_addr # Add 3 users with different balances adduser user1 1ugnot From affc526c1151f0bec39808f127d2bfe569572e04 Mon Sep 17 00:00:00 2001 From: ltzmaxwell Date: Thu, 16 Apr 2026 04:10:39 +0800 Subject: [PATCH 59/92] fix(gnovm): recoverable panic, runtime error prefix (#5501) (cherry picked from commit 326832e56066e7fbe6702eca515749aed6726370) --- gno.land/pkg/sdk/vm/handler_test.go | 2 +- gnovm/pkg/gnolang/machine.go | 2 +- gnovm/pkg/gnolang/op_binary.go | 8 +-- gnovm/pkg/gnolang/op_call.go | 2 +- gnovm/pkg/gnolang/op_exec.go | 2 +- gnovm/pkg/gnolang/op_expressions.go | 4 +- gnovm/pkg/gnolang/values.go | 78 +++++++++++++------------- gnovm/tests/files/defer11.gno | 2 +- gnovm/tests/files/panic3.gno | 4 +- gnovm/tests/files/panic3a.gno | 4 +- gnovm/tests/files/panic3b.gno | 4 +- gnovm/tests/files/panic5.gno | 4 +- gnovm/tests/files/panic5a.gno | 4 +- gnovm/tests/files/panic5b.gno | 4 +- gnovm/tests/files/panic5c.gno | 4 +- gnovm/tests/files/panic5d.gno | 4 +- gnovm/tests/files/panic6.gno | 4 +- gnovm/tests/files/panic7.gno | 4 +- gnovm/tests/files/panic8.gno | 4 +- gnovm/tests/files/ptr11.gno | 2 +- gnovm/tests/files/ptr11a.gno | 2 +- gnovm/tests/files/ptr11b.gno | 2 +- gnovm/tests/files/ptr_array4.gno | 2 +- gnovm/tests/files/ptr_array5.gno | 2 +- gnovm/tests/files/recover/recover3.gno | 4 +- gnovm/tests/files/recover12.gno | 2 +- gnovm/tests/files/recover13.gno | 2 +- gnovm/tests/files/recover14.gno | 4 +- gnovm/tests/files/recover17.gno | 2 +- gnovm/tests/files/recover18.gno | 2 +- gnovm/tests/files/recover19.gno | 2 +- gnovm/tests/files/recover20.gno | 4 +- gnovm/tests/files/recover21.gno | 17 ++++++ gnovm/tests/files/recover22.gno | 15 +++++ gnovm/tests/files/recover23.gno | 15 +++++ gnovm/tests/files/recover24.gno | 14 +++++ gnovm/tests/files/slice5.gno | 2 +- 37 files changed, 150 insertions(+), 89 deletions(-) create mode 100644 gnovm/tests/files/recover21.gno create mode 100644 gnovm/tests/files/recover22.gno create mode 100644 gnovm/tests/files/recover23.gno create mode 100644 gnovm/tests/files/recover24.gno diff --git a/gno.land/pkg/sdk/vm/handler_test.go b/gno.land/pkg/sdk/vm/handler_test.go index 07f7ac0f6fa..d0ecb1b50ec 100644 --- a/gno.land/pkg/sdk/vm/handler_test.go +++ b/gno.land/pkg/sdk/vm/handler_test.go @@ -99,7 +99,7 @@ func TestVmHandlerQuery_Eval(t *testing.T) { {input: []byte(`gno.land/r/hello.doesnotexist`), expectedErrorMatch: `^:0:0: name doesnotexist not declared:`}, // multiline error {input: []byte(`gno.land/r/doesnotexist.Foo`), expectedErrorMatch: `^invalid package path$`}, {input: []byte(`gno.land/r/hello.Panic()`), expectedErrorMatch: `^foo$`}, - {input: []byte(`gno.land/r/hello.sl[6]`), expectedErrorMatch: `^slice index out of bounds: 6 \(len=5\)$`}, + {input: []byte(`gno.land/r/hello.sl[6]`), expectedErrorMatch: `^runtime error: slice index out of bounds: 6 \(len=5\)$`}, {input: []byte(`gno.land/r/hello.func(){ for {} }()`), expectedErrorMatch: `out of gas in location: CPUCycles`}, } diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index d0b866f00d0..bf1e9e955e9 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -2352,7 +2352,7 @@ func (m *Machine) PopAsPointer2(lx Expr) (pv PointerValue, ro bool) { var ok bool if pv, ok = xv.V.(PointerValue); !ok { if xv.V == nil { - m.Panic(typedString("nil pointer dereference")) + m.Panic(typedString("runtime error: nil pointer dereference")) } panic("should not happen, not pointer nor nil") } diff --git a/gnovm/pkg/gnolang/op_binary.go b/gnovm/pkg/gnolang/op_binary.go index c361763793a..dd0acc34b06 100644 --- a/gnovm/pkg/gnolang/op_binary.go +++ b/gnovm/pkg/gnolang/op_binary.go @@ -826,7 +826,7 @@ func mulAssign(lv, rv *TypedValue) { // for doOpQuo and doOpQuoAssign. func quoAssign(lv, rv *TypedValue) *Exception { expt := &Exception{ - Value: typedString("division by zero"), + Value: typedString("runtime error: division by zero"), } // set the result in lv. @@ -925,7 +925,7 @@ func quoAssign(lv, rv *TypedValue) *Exception { // for doOpRem and doOpRemAssign. func remAssign(lv, rv *TypedValue) *Exception { expt := &Exception{ - Value: typedString("division by zero"), + Value: typedString("runtime error: division by zero"), } // set the result in lv. @@ -1163,7 +1163,7 @@ func xorAssign(lv, rv *TypedValue) { // for doOpShl and doOpShlAssign. func shlAssign(m *Machine, lv, rv *TypedValue) { if rv.Sign() < 0 { - panic(fmt.Sprintf("runtime error: negative shift amount: %v", rv)) + m.Panic(typedString(fmt.Sprintf("runtime error: negative shift amount: %v", rv))) } checkOverflow := func(v func() bool) { @@ -1289,7 +1289,7 @@ func shlAssign(m *Machine, lv, rv *TypedValue) { // for doOpShr and doOpShrAssign. func shrAssign(m *Machine, lv, rv *TypedValue) { if rv.Sign() < 0 { - panic(fmt.Sprintf("runtime error: negative shift amount: %v", rv)) + m.Panic(typedString(fmt.Sprintf("runtime error: negative shift amount: %v", rv))) } checkOverflow := func(v func() bool) { diff --git a/gnovm/pkg/gnolang/op_call.go b/gnovm/pkg/gnolang/op_call.go index 259d67225ee..a4c0c7b535e 100644 --- a/gnovm/pkg/gnolang/op_call.go +++ b/gnovm/pkg/gnolang/op_call.go @@ -68,7 +68,7 @@ func (m *Machine) doOpPrecall() { xv := m.PeekValue(1) if cx.GetAttribute(ATTR_SHIFT_RHS) == true { if xv.Sign() < 0 { - panic(fmt.Sprintf("runtime error: negative shift amount: %v", xv)) + m.Panic(typedString(fmt.Sprintf("runtime error: negative shift amount: %v", xv))) } } m.PushOp(OpConvert) diff --git a/gnovm/pkg/gnolang/op_exec.go b/gnovm/pkg/gnolang/op_exec.go index 26dc061d439..02dd3911cf4 100644 --- a/gnovm/pkg/gnolang/op_exec.go +++ b/gnovm/pkg/gnolang/op_exec.go @@ -161,7 +161,7 @@ func (m *Machine) doOpExec(op Op) { var dv *TypedValue if op == OpRangeIterArrayPtr { if xv.V == nil { - m.pushPanic(typedString("nil pointer dereference")) + m.pushPanic(typedString("runtime error: nil pointer dereference")) return } dv = xv.V.(PointerValue).TV diff --git a/gnovm/pkg/gnolang/op_expressions.go b/gnovm/pkg/gnolang/op_expressions.go index 5e5839bc7ed..a4c612dd83f 100644 --- a/gnovm/pkg/gnolang/op_expressions.go +++ b/gnovm/pkg/gnolang/op_expressions.go @@ -103,7 +103,7 @@ func (m *Machine) doOpSlice() { xv.T.Elem().Kind() == ArrayKind { // simply deref xv. if xv.V == nil { - m.pushPanic(typedString("nil pointer dereference")) + m.pushPanic(typedString("runtime error: nil pointer dereference")) return } *xv = xv.V.(PointerValue).Deref() @@ -146,7 +146,7 @@ func (m *Machine) doOpStar() { switch bt := baseOf(xv.T).(type) { case *PointerType: if xv.V == nil { - m.pushPanic(typedString("nil pointer dereference")) + m.pushPanic(typedString("runtime error: nil pointer dereference")) return } diff --git a/gnovm/pkg/gnolang/values.go b/gnovm/pkg/gnolang/values.go index c92000ee69b..79141ee4f22 100644 --- a/gnovm/pkg/gnolang/values.go +++ b/gnovm/pkg/gnolang/values.go @@ -380,13 +380,13 @@ func (sv *SliceValue) GetPointerAtIndexInt2(store Store, ii int, et Type) Pointe if ii < 0 { excpt := &Exception{ Value: typedString(fmt.Sprintf( - "slice index out of bounds: %d", ii)), + "runtime error: slice index out of bounds: %d", ii)), } panic(excpt) } else if sv.Length <= ii { excpt := &Exception{ Value: typedString(fmt.Sprintf( - "slice index out of bounds: %d (len=%d)", + "runtime error: slice index out of bounds: %d (len=%d)", ii, sv.Length)), } panic(excpt) @@ -1618,7 +1618,7 @@ func (tv *TypedValue) ComputeMapKey(store Store, omitType bool) (key MapKey, isN } } case FieldType: - panic("runtime error: field (pseudo)type cannot be used as map key") + panic(&Exception{Value: typedString("runtime error: field (pseudo)type cannot be used as map key")}) case *ArrayType: av := tv.V.(*ArrayValue) al := av.GetLength() @@ -1645,7 +1645,7 @@ func (tv *TypedValue) ComputeMapKey(store Store, omitType bool) (key MapKey, isN } bz = append(bz, ']') case *SliceType: - panic("runtime error: slice type cannot be used as map key") + panic(&Exception{Value: typedString("runtime error: slice type cannot be used as map key")}) case *StructType: sv := tv.V.(*StructValue) sl := len(sv.Fields) @@ -1786,7 +1786,7 @@ func (tv *TypedValue) GetPointerToFromTV(alloc *Allocator, store Store, path Val path.SetDepth(0) case 2: if tv.V == nil { - panic(&Exception{Value: typedString("nil pointer dereference")}) + panic(&Exception{Value: typedString("runtime error: nil pointer dereference")}) } dtv = tv.V.(PointerValue).TV isPtr = true @@ -1802,7 +1802,7 @@ func (tv *TypedValue) GetPointerToFromTV(alloc *Allocator, store Store, path Val } case VPDerefValMethod: if tv.V == nil { - panic(&Exception{Value: typedString("nil pointer dereference")}) + panic(&Exception{Value: typedString("runtime error: nil pointer dereference")}) } dtv2 := tv.V.(PointerValue).TV dtv = &TypedValue{ // In case method is called on converted type, like ((*othertype)x).Method(). @@ -1989,10 +1989,10 @@ func (tv *TypedValue) GetPointerAtIndex(rlm *Realm, alloc *Allocator, store Stor } if ii >= len(sv) { - panic(&Exception{Value: typedString(fmt.Sprintf("index out of range [%d] with length %d", ii, len(sv)))}) + panic(&Exception{Value: typedString(fmt.Sprintf("runtime error: index out of range [%d] with length %d", ii, len(sv)))}) } if ii < 0 { - panic(&Exception{Value: typedString(fmt.Sprintf("invalid slice index %d (index must be non-negative)", ii))}) + panic(&Exception{Value: typedString(fmt.Sprintf("runtime error: invalid slice index %d (index must be non-negative)", ii))}) } btv.SetUint8(sv[ii]) @@ -2010,14 +2010,14 @@ func (tv *TypedValue) GetPointerAtIndex(rlm *Realm, alloc *Allocator, store Stor return av.GetPointerAtIndexInt2(store, ii, bt.Elt) case *SliceType: if tv.V == nil { - panic("nil slice index (out of bounds)") + panic(&Exception{Value: typedString("runtime error: nil slice index (out of bounds)")}) } sv := tv.V.(*SliceValue) ii := int(iv.ConvertGetInt()) return sv.GetPointerAtIndexInt2(store, ii, bt.Elt) case *MapType: if tv.V == nil { - panic(&Exception{Value: typedString("uninitialized map index")}) + panic(&Exception{Value: typedString("runtime error: uninitialized map index")}) } mv := tv.V.(*MapValue) @@ -2165,24 +2165,24 @@ func (tv *TypedValue) GetCapacity() int { func (tv *TypedValue) GetSlice(alloc *Allocator, low, high int) TypedValue { if low < 0 { panic(&Exception{Value: typedString(fmt.Sprintf( - "invalid slice index %d (index must be non-negative)", + "runtime error: invalid slice index %d (index must be non-negative)", low))}) } if high < 0 { panic(&Exception{Value: typedString(fmt.Sprintf( - "invalid slice index %d (index must be non-negative)", + "runtime error: invalid slice index %d (index must be non-negative)", low))}) } if low > high { panic(&Exception{Value: typedString(fmt.Sprintf( - "invalid slice index %d > %d", + "runtime error: invalid slice index %d > %d", low, high))}) } switch t := baseOf(tv.T).(type) { case PrimitiveType: if tv.GetLength() < high { panic(&Exception{Value: typedString(fmt.Sprintf( - "slice bounds out of range [%d:%d] with string length %d", + "runtime error: slice bounds out of range [%d:%d] with string length %d", low, high, tv.GetLength()))}) } if t == StringType || t == UntypedStringType { @@ -2197,7 +2197,7 @@ func (tv *TypedValue) GetSlice(alloc *Allocator, low, high int) TypedValue { case *ArrayType: if tv.GetLength() < high { panic(&Exception{Value: typedString(fmt.Sprintf( - "slice bounds out of range [%d:%d] with array length %d", + "runtime error: slice bounds out of range [%d:%d] with array length %d", low, high, tv.GetLength()))}) } av := tv.V.(*ArrayValue) @@ -2218,12 +2218,12 @@ func (tv *TypedValue) GetSlice(alloc *Allocator, low, high int) TypedValue { // XXX consider restricting slice expansion if slice is readonly. if tv.GetCapacity() < high { panic(&Exception{Value: typedString(fmt.Sprintf( - "slice bounds out of range [%d:%d] with capacity %d", + "runtime error: slice bounds out of range [%d:%d] with capacity %d", low, high, tv.GetCapacity()))}) } if tv.V == nil { if low != 0 || high != 0 { - panic(&Exception{Value: typedString("nil slice index out of range")}) + panic(&Exception{Value: typedString("runtime error: nil slice index out of range")}) } return TypedValue{ T: tv.T, @@ -2248,39 +2248,39 @@ func (tv *TypedValue) GetSlice(alloc *Allocator, low, high int) TypedValue { func (tv *TypedValue) GetSlice2(alloc *Allocator, lowVal, highVal, maxVal int) TypedValue { if lowVal < 0 { - panic(fmt.Sprintf( - "invalid slice index %d (index must be non-negative)", - lowVal)) + panic(&Exception{Value: typedString(fmt.Sprintf( + "runtime error: invalid slice index %d (index must be non-negative)", + lowVal))}) } if highVal < 0 { - panic(fmt.Sprintf( - "invalid slice index %d (index must be non-negative)", - highVal)) + panic(&Exception{Value: typedString(fmt.Sprintf( + "runtime error: invalid slice index %d (index must be non-negative)", + highVal))}) } if maxVal < 0 { - panic(fmt.Sprintf( - "invalid slice index %d (index must be non-negative)", - maxVal)) + panic(&Exception{Value: typedString(fmt.Sprintf( + "runtime error: invalid slice index %d (index must be non-negative)", + maxVal))}) } if lowVal > highVal { - panic(fmt.Sprintf( - "invalid slice index %d > %d", - lowVal, highVal)) + panic(&Exception{Value: typedString(fmt.Sprintf( + "runtime error: invalid slice index %d > %d", + lowVal, highVal))}) } if highVal > maxVal { - panic(fmt.Sprintf( - "invalid slice index %d > %d", - highVal, maxVal)) + panic(&Exception{Value: typedString(fmt.Sprintf( + "runtime error: invalid slice index %d > %d", + highVal, maxVal))}) } if tv.GetCapacity() < highVal { - panic(fmt.Sprintf( - "slice bounds out of range [%d:%d:%d] with capacity %d", - lowVal, highVal, maxVal, tv.GetCapacity())) + panic(&Exception{Value: typedString(fmt.Sprintf( + "runtime error: slice bounds out of range [%d:%d:%d] with capacity %d", + lowVal, highVal, maxVal, tv.GetCapacity()))}) } if tv.GetCapacity() < maxVal { - panic(fmt.Sprintf( - "slice bounds out of range [%d:%d:%d] with capacity %d", - lowVal, highVal, maxVal, tv.GetCapacity())) + panic(&Exception{Value: typedString(fmt.Sprintf( + "runtime error: slice bounds out of range [%d:%d:%d] with capacity %d", + lowVal, highVal, maxVal, tv.GetCapacity()))}) } switch bt := baseOf(tv.T).(type) { case *ArrayType: @@ -2302,7 +2302,7 @@ func (tv *TypedValue) GetSlice2(alloc *Allocator, lowVal, highVal, maxVal int) T // XXX consider restricting slice expansion if slice is readonly. if tv.V == nil { if lowVal != 0 || highVal != 0 || maxVal != 0 { - panic("nil slice index out of range") + panic(&Exception{Value: typedString("runtime error: nil slice index out of range")}) } return TypedValue{ T: tv.T, diff --git a/gnovm/tests/files/defer11.gno b/gnovm/tests/files/defer11.gno index 34ccbdb644a..df261198574 100644 --- a/gnovm/tests/files/defer11.gno +++ b/gnovm/tests/files/defer11.gno @@ -26,4 +26,4 @@ func main() { } // Output: -// nil pointer dereference +// runtime error: nil pointer dereference diff --git a/gnovm/tests/files/panic3.gno b/gnovm/tests/files/panic3.gno index 2c7add03504..28ac3bc2364 100644 --- a/gnovm/tests/files/panic3.gno +++ b/gnovm/tests/files/panic3.gno @@ -6,9 +6,9 @@ func main() { } // Stacktrace: -// panic: nil pointer dereference +// panic: runtime error: nil pointer dereference // main() // main/panic3.gno:5 // Error: -// nil pointer dereference +// runtime error: nil pointer dereference diff --git a/gnovm/tests/files/panic3a.gno b/gnovm/tests/files/panic3a.gno index a8b926172e9..2bc4c4ce089 100644 --- a/gnovm/tests/files/panic3a.gno +++ b/gnovm/tests/files/panic3a.gno @@ -6,9 +6,9 @@ func main() { } // Stacktrace: -// panic: nil pointer dereference +// panic: runtime error: nil pointer dereference // main() // main/panic3a.gno:5 // Error: -// nil pointer dereference +// runtime error: nil pointer dereference diff --git a/gnovm/tests/files/panic3b.gno b/gnovm/tests/files/panic3b.gno index 85792011f1a..e5e1d8abeec 100644 --- a/gnovm/tests/files/panic3b.gno +++ b/gnovm/tests/files/panic3b.gno @@ -9,10 +9,10 @@ func main() { } // Error: -// division by zero +// runtime error: division by zero // Stacktrace: -// panic: division by zero +// panic: runtime error: division by zero // foo() // main/panic3b.gno:5 // main() diff --git a/gnovm/tests/files/panic5.gno b/gnovm/tests/files/panic5.gno index 2dec22a2e3e..1d00a706aa6 100644 --- a/gnovm/tests/files/panic5.gno +++ b/gnovm/tests/files/panic5.gno @@ -9,9 +9,9 @@ func main() { } // Stacktrace: -// panic: division by zero +// panic: runtime error: division by zero // main() // main/panic5.gno:6 // Error: -// division by zero +// runtime error: division by zero diff --git a/gnovm/tests/files/panic5a.gno b/gnovm/tests/files/panic5a.gno index 4e332de46ee..afe345a5a3d 100644 --- a/gnovm/tests/files/panic5a.gno +++ b/gnovm/tests/files/panic5a.gno @@ -8,9 +8,9 @@ func main() { } // Stacktrace: -// panic: division by zero +// panic: runtime error: division by zero // main() // main/panic5a.gno:5 // Error: -// division by zero +// runtime error: division by zero diff --git a/gnovm/tests/files/panic5b.gno b/gnovm/tests/files/panic5b.gno index 43de52a888c..1ba20419e02 100644 --- a/gnovm/tests/files/panic5b.gno +++ b/gnovm/tests/files/panic5b.gno @@ -9,9 +9,9 @@ func main() { } // Error: -// division by zero +// runtime error: division by zero // Stacktrace: -// panic: division by zero +// panic: runtime error: division by zero // main() // main/panic5b.gno:6 diff --git a/gnovm/tests/files/panic5c.gno b/gnovm/tests/files/panic5c.gno index 68667b276e6..adec22a43bb 100644 --- a/gnovm/tests/files/panic5c.gno +++ b/gnovm/tests/files/panic5c.gno @@ -6,12 +6,12 @@ func main() { } // Stacktrace: -// panic: division by zero +// panic: runtime error: division by zero // main() // main/panic5c.gno:5 // Error: -// division by zero +// runtime error: division by zero // TypeCheckError: // main/panic5c.gno:5:2: declared and not used: b diff --git a/gnovm/tests/files/panic5d.gno b/gnovm/tests/files/panic5d.gno index fd55834fd26..66bf05ac1c4 100644 --- a/gnovm/tests/files/panic5d.gno +++ b/gnovm/tests/files/panic5d.gno @@ -8,9 +8,9 @@ func main() { } // Error: -// division by zero +// runtime error: division by zero // Stacktrace: -// panic: division by zero +// panic: runtime error: division by zero // main() // main/panic5d.gno:5 diff --git a/gnovm/tests/files/panic6.gno b/gnovm/tests/files/panic6.gno index 937100f8708..2e276216bf5 100644 --- a/gnovm/tests/files/panic6.gno +++ b/gnovm/tests/files/panic6.gno @@ -20,9 +20,9 @@ func main() { } // Stacktrace: -// panic: nil pointer dereference +// panic: runtime error: nil pointer dereference // main() // main/panic6.gno:16 // Error: -// nil pointer dereference +// runtime error: nil pointer dereference diff --git a/gnovm/tests/files/panic7.gno b/gnovm/tests/files/panic7.gno index c82b4cdd1d9..fad910eadf5 100644 --- a/gnovm/tests/files/panic7.gno +++ b/gnovm/tests/files/panic7.gno @@ -6,9 +6,9 @@ func main() { } // Stacktrace: -// panic: nil pointer dereference +// panic: runtime error: nil pointer dereference // main() // main/panic7.gno:5 // Error: -// nil pointer dereference +// runtime error: nil pointer dereference diff --git a/gnovm/tests/files/panic8.gno b/gnovm/tests/files/panic8.gno index d767acaea3b..2497878b1d0 100644 --- a/gnovm/tests/files/panic8.gno +++ b/gnovm/tests/files/panic8.gno @@ -14,13 +14,13 @@ func main() { // world // Error: -// division by zero +// runtime error: division by zero // TypeCheckError: // main/panic8.gno:7:3: declared and not used: b // Stacktrace: -// panic: division by zero +// panic: runtime error: division by zero // defer func(){ ... }() // main/panic8.gno:7 // main() diff --git a/gnovm/tests/files/ptr11.gno b/gnovm/tests/files/ptr11.gno index 98421562842..0f0b2ab4632 100644 --- a/gnovm/tests/files/ptr11.gno +++ b/gnovm/tests/files/ptr11.gno @@ -10,4 +10,4 @@ func main() { } // Error: -// nil pointer dereference +// runtime error: nil pointer dereference diff --git a/gnovm/tests/files/ptr11a.gno b/gnovm/tests/files/ptr11a.gno index d027934474a..876d90e5de5 100644 --- a/gnovm/tests/files/ptr11a.gno +++ b/gnovm/tests/files/ptr11a.gno @@ -20,4 +20,4 @@ func main() { } // Output: -// recovered nil pointer dereference +// recovered runtime error: nil pointer dereference diff --git a/gnovm/tests/files/ptr11b.gno b/gnovm/tests/files/ptr11b.gno index e4d1a401325..67c26feb086 100644 --- a/gnovm/tests/files/ptr11b.gno +++ b/gnovm/tests/files/ptr11b.gno @@ -14,4 +14,4 @@ func main() { } // Error: -// nil pointer dereference +// runtime error: nil pointer dereference diff --git a/gnovm/tests/files/ptr_array4.gno b/gnovm/tests/files/ptr_array4.gno index ef29439f562..2a9142c9fbf 100644 --- a/gnovm/tests/files/ptr_array4.gno +++ b/gnovm/tests/files/ptr_array4.gno @@ -12,4 +12,4 @@ func main() { } // Output: -// recovered nil pointer dereference +// recovered runtime error: nil pointer dereference diff --git a/gnovm/tests/files/ptr_array5.gno b/gnovm/tests/files/ptr_array5.gno index 84974180dae..a883421380c 100644 --- a/gnovm/tests/files/ptr_array5.gno +++ b/gnovm/tests/files/ptr_array5.gno @@ -14,4 +14,4 @@ func main() { } // Output: -// recovered nil pointer dereference +// recovered runtime error: nil pointer dereference diff --git a/gnovm/tests/files/recover/recover3.gno b/gnovm/tests/files/recover/recover3.gno index f8c16e210f2..68a83249563 100644 --- a/gnovm/tests/files/recover/recover3.gno +++ b/gnovm/tests/files/recover/recover3.gno @@ -7,9 +7,9 @@ func main() { } // Error: -// division by zero +// runtime error: division by zero // Stacktrace: -// panic: division by zero +// panic: runtime error: division by zero // main() // main/recover3.gno:4 diff --git a/gnovm/tests/files/recover12.gno b/gnovm/tests/files/recover12.gno index 3c6429c6ba9..a343edc06f7 100644 --- a/gnovm/tests/files/recover12.gno +++ b/gnovm/tests/files/recover12.gno @@ -11,4 +11,4 @@ func main() { } // Output: -// recover: slice index out of bounds: 3 (len=3) +// recover: runtime error: slice index out of bounds: 3 (len=3) diff --git a/gnovm/tests/files/recover13.gno b/gnovm/tests/files/recover13.gno index 6b8bfaa754a..f1e3973f487 100644 --- a/gnovm/tests/files/recover13.gno +++ b/gnovm/tests/files/recover13.gno @@ -12,7 +12,7 @@ func main() { } // Output: -// recover: invalid slice index -1 (index must be non-negative) +// recover: runtime error: invalid slice index -1 (index must be non-negative) // TypeCheckError: // main/recover13.gno:11:13: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/recover14.gno b/gnovm/tests/files/recover14.gno index 46ade005cff..67ed68fd7b8 100644 --- a/gnovm/tests/files/recover14.gno +++ b/gnovm/tests/files/recover14.gno @@ -7,8 +7,8 @@ func main() { }() x, y := 10, 0 - _ = x / y // Panics because of division by zero + _ = x / y // Panics because of runtime error: division by zero } // Output: -// recover: division by zero +// recover: runtime error: division by zero diff --git a/gnovm/tests/files/recover17.gno b/gnovm/tests/files/recover17.gno index 5496622bab3..6f0c9a43fdc 100644 --- a/gnovm/tests/files/recover17.gno +++ b/gnovm/tests/files/recover17.gno @@ -11,4 +11,4 @@ func main() { } // Output: -// recover: index out of range [10] with length 5 +// recover: runtime error: index out of range [10] with length 5 diff --git a/gnovm/tests/files/recover18.gno b/gnovm/tests/files/recover18.gno index b27a30a5840..e47ab8c2c75 100644 --- a/gnovm/tests/files/recover18.gno +++ b/gnovm/tests/files/recover18.gno @@ -11,4 +11,4 @@ func main() { } // Output: -// recover: uninitialized map index +// recover: runtime error: uninitialized map index diff --git a/gnovm/tests/files/recover19.gno b/gnovm/tests/files/recover19.gno index da4bc454984..098c467a9a6 100644 --- a/gnovm/tests/files/recover19.gno +++ b/gnovm/tests/files/recover19.gno @@ -11,4 +11,4 @@ func main() { } // Output: -// recover: nil pointer dereference +// recover: runtime error: nil pointer dereference diff --git a/gnovm/tests/files/recover20.gno b/gnovm/tests/files/recover20.gno index ffa4436c16b..bf2b402ee69 100644 --- a/gnovm/tests/files/recover20.gno +++ b/gnovm/tests/files/recover20.gno @@ -8,9 +8,9 @@ func main() { } // Error: -// nil pointer dereference +// runtime error: nil pointer dereference // Stacktrace: -// panic: nil pointer dereference +// panic: runtime error: nil pointer dereference // main() // main/recover20.gno:5 diff --git a/gnovm/tests/files/recover21.gno b/gnovm/tests/files/recover21.gno new file mode 100644 index 00000000000..fe32bec2e2c --- /dev/null +++ b/gnovm/tests/files/recover21.gno @@ -0,0 +1,17 @@ +package main + +import "fmt" + +func main() { + defer func() { + if r := recover(); r != nil { + fmt.Println("recovered: ", r) + } + }() + + v := -1 + _ = 1 << v +} + +// Output: +// recovered: runtime error: negative shift amount: (-1 int) diff --git a/gnovm/tests/files/recover22.gno b/gnovm/tests/files/recover22.gno new file mode 100644 index 00000000000..6fdafd91048 --- /dev/null +++ b/gnovm/tests/files/recover22.gno @@ -0,0 +1,15 @@ +package main + +func main() { + defer func() { + r := recover() + println("recover:", r) + }() + + s := []int{1, 2, 3} + low := -1 + _ = s[low:2:3] +} + +// Output: +// recover: runtime error: invalid slice index -1 (index must be non-negative) diff --git a/gnovm/tests/files/recover23.gno b/gnovm/tests/files/recover23.gno new file mode 100644 index 00000000000..56dae3477f2 --- /dev/null +++ b/gnovm/tests/files/recover23.gno @@ -0,0 +1,15 @@ +package main + +func main() { + defer func() { + r := recover() + println("recover:", r) + }() + + s := []int{1, 2, 3} + high := 5 + _ = s[0:high:3] +} + +// Output: +// recover: runtime error: invalid slice index 5 > 3 diff --git a/gnovm/tests/files/recover24.gno b/gnovm/tests/files/recover24.gno new file mode 100644 index 00000000000..caea1fcab84 --- /dev/null +++ b/gnovm/tests/files/recover24.gno @@ -0,0 +1,14 @@ +package main + +func main() { + defer func() { + r := recover() + println("recover:", r) + }() + + s := []int{1, 2, 3} + _ = s[0:1:10] +} + +// Output: +// recover: runtime error: slice bounds out of range [0:1:10] with capacity 3 diff --git a/gnovm/tests/files/slice5.gno b/gnovm/tests/files/slice5.gno index 5801d3c0c3e..0f17ce0f6d7 100644 --- a/gnovm/tests/files/slice5.gno +++ b/gnovm/tests/files/slice5.gno @@ -6,4 +6,4 @@ func main() { } // Error: -// slice index out of bounds: 6 (len=5) +// runtime error: slice index out of bounds: 6 (len=5) From b78ad1dfbda94ac81bded4086fee3cefef314b4c Mon Sep 17 00:00:00 2001 From: MikaelVallenet Date: Wed, 15 Apr 2026 22:15:20 +0200 Subject: [PATCH 60/92] feat: improve rendering of r/sys/cla realm (#5331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR improves r/sys/cla rendering It's related to general improvement of CLA guidance with gnokey CLA error improvement PR here: #5325 I also fixed an issue i had in helplink pkg for relative path. the fix folllow the behavior of the pkg txlink, see the same method in txlink pkg from moul to compare. ⚠️ Text outdated from image, but the rendering look almost the same, check text in the github diff --- **BEFORE:** image image --- **AFTER:** image image (cherry picked from commit 571c798e38d8be57b8568bbfbe365db660b16b69) --- .../gno.land/p/moul/helplink/helplink.gno | 3 +- .../p/moul/helplink/helplink_test.gno | 3 +- examples/gno.land/r/sys/cla/render.gno | 42 +++++++++++++++---- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/examples/gno.land/p/moul/helplink/helplink.gno b/examples/gno.land/p/moul/helplink/helplink.gno index 087e4d2e1c8..60aad45dfd3 100644 --- a/examples/gno.land/p/moul/helplink/helplink.gno +++ b/examples/gno.land/p/moul/helplink/helplink.gno @@ -47,7 +47,8 @@ type Realm string func (r Realm) prefix() string { // relative if r == "" { - return "" + curPath := runtime.CurrentRealm().PkgPath() + return strings.TrimPrefix(curPath, chainDomain) } // local realm -> /realm diff --git a/examples/gno.land/p/moul/helplink/helplink_test.gno b/examples/gno.land/p/moul/helplink/helplink_test.gno index 27e3697bb00..de961a9b34d 100644 --- a/examples/gno.land/p/moul/helplink/helplink_test.gno +++ b/examples/gno.land/p/moul/helplink/helplink_test.gno @@ -85,12 +85,13 @@ func TestFuncURL(t *testing.T) { } func TestHome(t *testing.T) { + testing.SetRealm(testing.NewCodeRealm("gno.land/r/test/helplink")) cd := runtime.ChainDomain() tests := []struct { realm_XXX Realm want string }{ - {"", "$help"}, + {"", "/r/test/helplink$help"}, {Realm(cd + "/r/lorem/ipsum"), "/r/lorem/ipsum$help"}, {"gno.world/r/lorem/ipsum", "https://gno.world/r/lorem/ipsum$help"}, } diff --git a/examples/gno.land/r/sys/cla/render.gno b/examples/gno.land/r/sys/cla/render.gno index f59d867c69e..68477fcdcbe 100644 --- a/examples/gno.land/r/sys/cla/render.gno +++ b/examples/gno.land/r/sys/cla/render.gno @@ -1,18 +1,44 @@ package cla -import "gno.land/p/moul/helplink" +import ( + "gno.land/p/moul/helplink" + "gno.land/p/moul/md" + "gno.land/p/moul/mdtable" + "gno.land/p/nt/ufmt/v0" +) func Render(path string) string { + out := md.H1("Contributor License Agreement (CLA)") + + out += md.Paragraph("A Contributor License Agreement (CLA) must be signed before deploying packages.") + out += md.Paragraph( + "The Agreement governs Contributions uploaded, published, or made available " + + "for execution on the Gno.land blockchain network, and the " + + "related software and repositories used to publish such Contributions.", + ) + if requiredHash == "" { - return "# Gno CLA Registry\n\n**Status:** CLA enforcement is DISABLED\n" + out += md.HorizontalRule() + out += md.H2("Status") + out += md.Paragraph(md.Bold("CLA enforcement is currently DISABLED.")) + out += md.Paragraph("All package deployments are allowed.") + return out } - output := "# Gno CLA Registry\n\n**Status:** CLA enforcement is ENABLED\n\n" - output += "**Required Hash:** " + requiredHash + "\n" + out += md.HorizontalRule() + out += md.H2("Status") + out += md.Paragraph(md.Bold("CLA enforcement is ENABLED")) + if claURL != "" { - output += "**CLA Document:** " + claURL + "\n" + out += md.Paragraph("You can read the full agreement here: " + md.Link(claURL, claURL)) } - output += "\n### Actions\n\n" - output += helplink.Func("Sign CLA", "Sign", "hash", requiredHash) + "\n" - return output + + table := mdtable.Table{Headers: []string{"", ""}} + table.Append([]string{md.Bold("Required Hash"), md.InlineCode(requiredHash)}) + table.Append([]string{md.Bold("Signers"), ufmt.Sprintf("%d contributor(s)", signatures.Size())}) + out += table.String() + + out += md.H3("Actions") + out += md.Paragraph(helplink.Func("Sign CLA", "Sign", "hash", requiredHash)) + return out } From cd7900783b4ff8bbb2798a623ae73ca1f57eae68 Mon Sep 17 00:00:00 2001 From: Morgan Date: Wed, 15 Apr 2026 22:36:51 +0200 Subject: [PATCH 61/92] fix(gnoweb): apply IsDangerousURL to angle-bracket autolinks (#5515) Goldmark's default autolink renderer only applies URLEscape+EscapeHTML to autolinks, preserving javascript:, vbscript:, and data: schemes. The existing linkTransformer only walked *ast.Link nodes, so *ast.AutoLink nodes bypassed IsDangerousURL entirely. Extend linkTransformer.Transform to also handle *ast.AutoLink nodes: build a synthetic ast.Link (email autolinks get a mailto: prefix) with an ast.NewString label child, then wrap it in a GnoLink exactly like a regular link. renderGnoLink already calls IsDangerousURL before writing the href, so dangerous schemes now produce href="" for both link forms. Safe autolinks also gain rel="noopener nofollow ugc" and the external link icon, matching the treatment of regular [text](url) links. Adds a golden test covering https:, email, javascript:, and data: autolinks. (cherry picked from commit acdaf12c1d833efb92a23f16e2eac39583a35400) --- gno.land/pkg/gnoweb/markdown/ext_links.go | 41 +++++++++++++++---- .../golden/ext_link/autolink.md.txtar | 20 +++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 gno.land/pkg/gnoweb/markdown/golden/ext_link/autolink.md.txtar diff --git a/gno.land/pkg/gnoweb/markdown/ext_links.go b/gno.land/pkg/gnoweb/markdown/ext_links.go index 54fa2a3d2ca..a711b7a9379 100644 --- a/gno.land/pkg/gnoweb/markdown/ext_links.go +++ b/gno.land/pkg/gnoweb/markdown/ext_links.go @@ -87,34 +87,57 @@ func (*GnoLink) Kind() ast.NodeKind { // linkTransformer implements ASTTransformer type linkTransformer struct{} -// Transform replaces ast.Link nodes with GnoLink nodes in two passes. +// Transform replaces ast.Link and ast.AutoLink nodes with GnoLink nodes. func (t *linkTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { orig, ok := getUrlFromContext(pc) if !ok { return } - // Traverse through the document and transform link nodes to GnoLink nodes. ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } - link, ok := node.(*ast.Link) - if !ok { + var ( + gnoLink *GnoLink + rawDest []byte + ) + + switch n := node.(type) { + case *ast.Link: + // Wrap the existing link node directly. + gnoLink = &GnoLink{Link: n} + rawDest = n.Destination + + case *ast.AutoLink: + // Build a synthetic ast.Link so the existing renderGnoLink handles + // IsDangerousURL, rel attributes, and icons for autolinks too. + source := reader.Source() + rawURL := n.URL(source) + if n.AutoLinkType == ast.AutoLinkEmail { + rawDest = append([]byte("mailto:"), rawURL...) + } else { + rawDest = rawURL + } + link := ast.NewLink() + link.Destination = rawDest + labelNode := ast.NewString(n.Label(source)) + labelNode.SetRaw(true) + link.AppendChild(link, labelNode) + gnoLink = &GnoLink{Link: link} + + default: return ast.WalkContinue, nil } - // Create a new GnoLink node wrapping the original link. - gnoLink := &GnoLink{Link: link} - - // Replace the original link with the GnoLink wrapper. + // Replace the original node with the GnoLink wrapper. parent, next := node.Parent(), node.NextSibling() parent.RemoveChild(parent, node) parent.InsertBefore(parent, next, gnoLink) // Parse destination URL and check for validity. - dest, err := url.Parse(string(link.Destination)) + dest, err := url.Parse(string(rawDest)) if err != nil { gnoLink.LinkType = GnoLinkTypeInvalid return ast.WalkContinue, nil diff --git a/gno.land/pkg/gnoweb/markdown/golden/ext_link/autolink.md.txtar b/gno.land/pkg/gnoweb/markdown/golden/ext_link/autolink.md.txtar new file mode 100644 index 00000000000..a07780e9f32 --- /dev/null +++ b/gno.land/pkg/gnoweb/markdown/golden/ext_link/autolink.md.txtar @@ -0,0 +1,20 @@ +-- input.md -- + + + + + + + + + + + + +-- output.html -- +

https://example.com

+

user@example.com

+

javascript:alert(1)

+

vbscript:alert(1)

+

file:///etc/passwd

+

data:text/html,%3Cb%3Ehi%3C/b%3E

From c26636d7243c5afd9dfdfa2a0be5f715cd962a32 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 16 Apr 2026 10:50:59 +0200 Subject: [PATCH 62/92] feat(stdlibs): implement `realm.SentCoins()` (#5039) `realm.SentCoins()` returns the coins sent via the transaction's `send` field, but only when called from the realm that directly received them (i.e. the first realm in the call stack). ### Why not `banker.OriginSend()`? `banker.OriginSend()` returns the sent coins regardless of which realm calls it. This is unreliable for detecting whether the current realm actually received the funds, because another realm could have received them earlier in the call chain. Combining `banker.OriginSend()` with `runtime.PreviousRealm().IsUser()` doesn't solve the problem either, as it fails in scenarios like: ``` OriginCall -> realm R -> realm S -> realm R ``` In this case, realm `R` is called a second time by realm `S`, so `PreviousRealm().IsUser()` returns `false` even though `R` is the realm that received the funds. (cherry picked from commit 96b2b41638f132c2a9c87fd76fe56bdcea24eb2b) --- .../gno.land/r/tests/vm/subtests/subtests.gno | 13 +- examples/gno.land/r/tests/vm/tests.gno | 17 +++ examples/gno.land/r/tests/vm/z4_filetest.gno | 26 ++++ .../testdata/maketx_call_send.txtar | 108 +++++++++++++---- gnovm/pkg/gnolang/gotypecheck.go | 8 +- gnovm/pkg/gnolang/uverse.go | 113 ++++++++++++++++++ gnovm/stdlibs/builtin/shims.go | 5 + gnovm/stdlibs/internal/execctx/context.go | 7 ++ 8 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 examples/gno.land/r/tests/vm/z4_filetest.gno diff --git a/examples/gno.land/r/tests/vm/subtests/subtests.gno b/examples/gno.land/r/tests/vm/subtests/subtests.gno index 48fa5060478..55961aae1a5 100644 --- a/examples/gno.land/r/tests/vm/subtests/subtests.gno +++ b/examples/gno.land/r/tests/vm/subtests/subtests.gno @@ -1,6 +1,9 @@ package subtests -import "chain/runtime" +import ( + "chain/banker" + "chain/runtime" +) func GetCurrentRealm(cur realm) runtime.Realm { return runtime.CurrentRealm() @@ -21,3 +24,11 @@ func CallAssertOriginCall(cur realm) { func CallIsOriginCall(cur realm) bool { return runtime.PreviousRealm().IsUser() } + +func RealmSentCoins(cur realm) string { + return cur.SentCoins().String() +} + +func BankerOriginSend(cur realm) string { + return banker.OriginSend().String() +} diff --git a/examples/gno.land/r/tests/vm/tests.gno b/examples/gno.land/r/tests/vm/tests.gno index f249a26b8d1..796eb17a94f 100644 --- a/examples/gno.land/r/tests/vm/tests.gno +++ b/examples/gno.land/r/tests/vm/tests.gno @@ -1,6 +1,7 @@ package tests import ( + "chain/banker" "chain/runtime" "gno.land/p/demo/nestedpkg" @@ -120,3 +121,19 @@ func IsCallerParentPath(cur realm) bool { func HasCallerSameNamespace(cur realm) bool { return nestedpkg.IsSameNamespace() } + +func RealmSentCoins(cur realm) string { + return cur.SentCoins().String() +} + +func BankerOriginSend(cur realm) string { + return banker.OriginSend().String() +} + +func RTestsRealmSentCoins(cur realm) string { + return rsubtests.RealmSentCoins(cross) +} + +func RTestsOriginSend(cur realm) string { + return rsubtests.BankerOriginSend(cross) +} diff --git a/examples/gno.land/r/tests/vm/z4_filetest.gno b/examples/gno.land/r/tests/vm/z4_filetest.gno new file mode 100644 index 00000000000..fb3f9eb23a5 --- /dev/null +++ b/examples/gno.land/r/tests/vm/z4_filetest.gno @@ -0,0 +1,26 @@ +package main + +import ( + "chain" + "testing" + + tests "gno.land/r/tests/vm" +) + +func main() { + testing.SetOriginSend(chain.Coins{{Denom: "foo", Amount: 42}, {Denom: "ugnot", Amount: 100}}) + println("tests.RealmSentCoins: " + tests.RealmSentCoins(cross)) + println("tests.BankerOriginSend: " + tests.BankerOriginSend(cross)) + + // subtests is cross-called from tests, so cur.SentCoins() returns empty there. + println("tests.RTestsRealmSentCoins: " + tests.RTestsRealmSentCoins(cross)) + // banker.OriginSend() always returns the origin send regardless of call + // depth. + println("tests.RTestsOriginSend: " + tests.RTestsOriginSend(cross)) +} + +// Output: +// tests.RealmSentCoins: 42foo,100ugnot +// tests.BankerOriginSend: 42foo,100ugnot +// tests.RTestsRealmSentCoins: +// tests.RTestsOriginSend: 42foo,100ugnot diff --git a/gno.land/pkg/integration/testdata/maketx_call_send.txtar b/gno.land/pkg/integration/testdata/maketx_call_send.txtar index a9a50296c59..27444cb8945 100644 --- a/gno.land/pkg/integration/testdata/maketx_call_send.txtar +++ b/gno.land/pkg/integration/testdata/maketx_call_send.txtar @@ -1,36 +1,102 @@ -# load the package -loadpkg gno.land/r/foo/call_realm $WORK/realm +# This test verifies the behavior of the -send flag in maketx call transactions, +# and contrasts realm.SentCoins() with banker.OriginSend(). +# The call chain is: OriginCall -> firstrealm -> secondrealm -> firstrealm (re-entrant) +# +# The re-entrant call is achieved via a coinchecker.Caller interface defined in a +# package (not a realm), which breaks the circular import problem: secondrealm +# imports the package but not firstrealm; firstrealm passes itself as a Caller +# to secondrealm, which calls back into firstrealm through the interface. +# +# - When -send is not provided, both APIs return empty coins in all realms. +# +# - realm.SentCoins() returns the coins sent via the tx -send field, but ONLY +# for the realm that is the tx recipient (identified by address). It returns +# empty coins for any other realm, even re-entrantly called ones. firstrealm +# sees coins in both its initial and re-entrant invocations because it is the +# tx recipient either way; secondrealm never sees them. +# +# - banker.OriginSend() returns the tx -send coins regardless of which realm +# calls it. It is not a reliable way to know if the current realm received +# the coins. + +# load the packages +loadpkg gno.land/p/foo/coinchecker $WORK/coinchecker +loadpkg gno.land/r/foo/secondrealm $WORK/secondrealm +loadpkg gno.land/r/foo/firstrealm $WORK/firstrealm # start a new node gnoland start -## user balance before realm send -gnokey query auth/accounts/$test1_user_addr -stdout '"coins": "9999998810000ugnot"' +# call to firstrealm without -send: both APIs return empty in all realms +gnokey maketx call -pkgpath gno.land/r/foo/firstrealm -func GetSentCoins -gas-fee 1000000ugnot -gas-wanted 3000000 -broadcast -chainid=tendermint_test test1 +stdout '("firstrealm\[realm.SentCoins: , banker.OriginSend: \] secondrealm\[realm.SentCoins: , banker.OriginSend: \] firstrealm\(reentrant\)\[realm.SentCoins: , banker.OriginSend: \]")' -## realm balance before realm send -gnokey query auth/accounts/g1x4ykzcqksj2hc5qpvr8kd9zaffkd82rvmzqup7 +## firstrealm balance: no coins sent +gnokey query auth/accounts/g1d260hvgfya26huxscg80xckxh7ydsyy0tve4sp stdout '"coins": ""' -# call to realm with -send -gnokey maketx call -send 42ugnot -pkgpath gno.land/r/foo/call_realm -func GimmeMoney -gas-fee 1000000ugnot -gas-wanted 3000000 -broadcast -chainid=tendermint_test test1 -stdout '("send: 42ugnot")' +## secondrealm balance: no coins sent +gnokey query auth/accounts/g17xvzxkz7lqm2mtmw3tvq0ajgx79phg24h60a9x +stdout '"coins": ""' -## user balance after realm send -# reduced by -gas-fee AND -send -gnokey query auth/accounts/$test1_user_addr -stdout '"coins": "9999997809958ugnot"' +# call to firstrealm with -send +gnokey maketx call -send 42ugnot -pkgpath gno.land/r/foo/firstrealm -func GetSentCoins -gas-fee 1000000ugnot -gas-wanted 3000000 -broadcast -chainid=tendermint_test test1 +# realm.SentCoins() is tied to the realm address, not the call order: firstrealm +# sees the coins in both its initial and re-entrant invocations because it is +# the tx recipient either way. secondrealm never sees them. +# banker.OriginSend() returns the coins in all realms regardless of call depth. +stdout '("firstrealm\[realm.SentCoins: 42ugnot, banker.OriginSend: 42ugnot\] secondrealm\[realm.SentCoins: , banker.OriginSend: 42ugnot\] firstrealm\(reentrant\)\[realm.SentCoins: 42ugnot, banker.OriginSend: 42ugnot\]")' -## realm balance after realm send -gnokey query auth/accounts/g1x4ykzcqksj2hc5qpvr8kd9zaffkd82rvmzqup7 +## firstrealm balance after realm send +gnokey query auth/accounts/g1d260hvgfya26huxscg80xckxh7ydsyy0tve4sp stdout '"coins": "42ugnot"' +## secondrealm balance after realm send +gnokey query auth/accounts/g17xvzxkz7lqm2mtmw3tvq0ajgx79phg24h60a9x +stdout '"coins": ""' + + +-- coinchecker/coinchecker.gno -- +package coinchecker + +type Caller interface { + Report(cur realm) string +} +-- firstrealm/realm.gno -- +package firstrealm + +import ( + "chain/banker" + + "gno.land/p/foo/coinchecker" + "gno.land/r/foo/secondrealm" +) + +type self struct{} + +func (s self) Report(cur realm) string { + return "firstrealm(reentrant)[realm.SentCoins: " + cur.SentCoins().String() + + ", banker.OriginSend: " + banker.OriginSend().String() + "]" +} + +func GetSentCoins(cur realm) string { + return "firstrealm[realm.SentCoins: " + cur.SentCoins().String() + + ", banker.OriginSend: " + banker.OriginSend().String() + "] " + + secondrealm.GetSentCoins(cross, self{}) +} + +var _ coinchecker.Caller = self{} +-- secondrealm/realm.gno -- +package secondrealm --- realm/realm.gno -- -package call_realm +import ( + "chain/banker" -import "chain/banker" + "gno.land/p/foo/coinchecker" +) -func GimmeMoney(cur realm) string { - return "send: " + banker.OriginSend().String() +func GetSentCoins(cur realm, callback coinchecker.Caller) string { + return "secondrealm[realm.SentCoins: " + cur.SentCoins().String() + + ", banker.OriginSend: " + banker.OriginSend().String() + "] " + + callback.Report(cross) } diff --git a/gnovm/pkg/gnolang/gotypecheck.go b/gnovm/pkg/gnolang/gotypecheck.go index 167f2f2a41f..b2006961e2f 100644 --- a/gnovm/pkg/gnolang/gotypecheck.go +++ b/gnovm/pkg/gnolang/gotypecheck.go @@ -10,9 +10,10 @@ import ( "slices" "strings" - "github.com/gnolang/gno/tm2/pkg/std" "go.uber.org/multierr" "golang.org/x/tools/go/ast/astutil" + + "github.com/gnolang/gno/tm2/pkg/std" ) /* @@ -42,6 +43,7 @@ type realm interface { Address() address PkgPath() string Coins() gnocoins + SentCoins() gnocoins Send(coins gnocoins, to address) error Previous() realm Origin() realm @@ -55,12 +57,16 @@ func (a address) IsValid() bool { return false } // shim type Address = address type gnocoins []gnocoin +func (cz gnocoins) String() string { return "" } // shim + type Gnocoins = gnocoins type gnocoin struct { Denom string Amount int64 } +func (c gnocoin) String() string { return "" } // shim + type Gnocoin = gnocoin `) default: diff --git a/gnovm/pkg/gnolang/uverse.go b/gnovm/pkg/gnolang/uverse.go index be2634fc0ec..79c991426f9 100644 --- a/gnovm/pkg/gnolang/uverse.go +++ b/gnovm/pkg/gnolang/uverse.go @@ -4,10 +4,12 @@ import ( "bytes" "fmt" "io" + "strconv" bm "github.com/gnolang/gno/gnovm/pkg/benchops" "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/overflow" + "github.com/gnolang/gno/tm2/pkg/std" "github.com/gnolang/gno/tm2/pkg/store/types" ) @@ -96,6 +98,20 @@ var gCoinsType = &DeclaredType{ sealed: true, } +// OriginSendProvider is an interface for contexts that can provide origin send information. +// This interface is implemented by ExecContext to avoid import cycles. +type OriginSendProvider interface { + GetOriginSend() std.Coins +} + +// gnoCoinString formats a gnocoin StructValue as "amountdenom". +// Used by both gnocoin.String() and gnocoins.String() native methods. +func gnoCoinString(sv *StructValue) string { + denom := sv.Fields[0].GetString() + amount := sv.Fields[1].GetInt64() + return strconv.FormatInt(amount, 10) + denom +} + var gRealmType = &DeclaredType{ PkgPath: uversePkgPath, Name: "realm", @@ -126,6 +142,14 @@ var gRealmType = &DeclaredType{ Type: gCoinsType, }}, }, + }, { + Name: "SentCoins", + Type: &FuncType{ + Params: nil, + Results: []FieldType{{ + Type: gCoinsType, + }}, + }, }, { Name: "Send", Type: &FuncType{ @@ -1009,7 +1033,41 @@ func makeUverseNode() { }, ) def("gnocoin", asValue(gCoinType)) + defNativeMethod("gnocoin", "String", + nil, // params + Flds( // results + "", "string", + ), + func(m *Machine) { + arg0 := m.LastBlock().GetParams1(m.Store) + m.PushValue(typedString(gnoCoinString(arg0.TV.V.(*StructValue)))) + }, + ) def("gnocoins", asValue(gCoinsType)) + defNativeMethod("gnocoins", "String", + nil, // params + Flds( // results + "", "string", + ), + func(m *Machine) { + arg0 := m.LastBlock().GetParams1(m.Store) + sv, ok := arg0.TV.V.(*SliceValue) + if !ok || sv == nil || sv.GetLength() == 0 { + m.PushValue(typedString("")) + return + } + base := sv.GetBase(m.Store) + n := sv.GetLength() + var res string + for i := range n { + if i > 0 { + res += "," + } + res += gnoCoinString(base.List[sv.Offset+i].V.(*StructValue)) + } + m.PushValue(typedString(res)) + }, + ) def("realm", asValue(gRealmType)) def(".grealm", asValue(gConcreteRealmType)) defNativeMethod(".grealm", "Address", @@ -1039,6 +1097,61 @@ func makeUverseNode() { panic("not yet implemented") }, ) + defNativeMethod(".grealm", "SentCoins", + nil, // params + Flds( // results + "", "gnocoins", + ), + func(m *Machine) { + // Only return coins if the caller of SentCoins() is the first realm + // in the call stack, i.e. the realm that actually received the funds. + // + // Frame.LastPackage is the package that was active before the frame + // was pushed (the caller's package). So the innermost frame's + // LastPackage is the package that invoked SentCoins(). + var callerPkg string + if lp := m.Frames[m.NumFrames()-1].LastPackage; lp != nil { + callerPkg = lp.PkgPath + } + // Walk frames from oldest to newest; the first LastPackage that is + // a realm path is the first realm that appeared in the call chain. + var firstRealmPkg string + for i := 1; i < m.NumFrames(); i++ { + lp := m.Frames[i].LastPackage + if lp == nil { + continue + } + if pkg := lp.PkgPath; IsRealmPath(pkg) { + firstRealmPkg = pkg + break + } + } + var coins std.Coins + if callerPkg != "" && firstRealmPkg == callerPkg { + if osp, ok := m.Context.(OriginSendProvider); ok { + coins = osp.GetOriginSend() + } + } + // Manually construct a gnocoins. + n := len(coins) + baseArray := m.Alloc.NewListArray(n) + for i, coin := range coins { + fields := m.Alloc.NewStructFields(2) + fields[0] = TypedValue{T: StringType} + fields[0].V = m.Alloc.NewString(coin.Denom) + fields[1] = TypedValue{T: Int64Type} + fields[1].SetInt64(coin.Amount) + baseArray.List[i] = TypedValue{ + T: gCoinType, + V: m.Alloc.NewStruct(fields), + } + } + m.PushValue(TypedValue{ + T: gCoinsType, + V: m.Alloc.NewSlice(baseArray, 0, n, n), + }) + }, + ) defNativeMethod(".grealm", "Send", Flds( // params "coins", "gnocoins", diff --git a/gnovm/stdlibs/builtin/shims.go b/gnovm/stdlibs/builtin/shims.go index b2d6df68049..f94610fd258 100644 --- a/gnovm/stdlibs/builtin/shims.go +++ b/gnovm/stdlibs/builtin/shims.go @@ -4,6 +4,7 @@ type Realm interface { Address() Address PkgPath() string Coins() Gnocoins + SentCoins() Gnocoins Send(coins Gnocoins, to Address) error Previous() Realm Origin() Realm @@ -17,7 +18,11 @@ func (a Address) IsValid() bool { return false } type Gnocoins []Gnocoin +func (cz Gnocoins) String() string { return "" } + type Gnocoin struct { Denom string Amount int64 } + +func (c Gnocoin) String() string { return "" } diff --git a/gnovm/stdlibs/internal/execctx/context.go b/gnovm/stdlibs/internal/execctx/context.go index d19ac7d3be8..d4fd4f4354e 100644 --- a/gnovm/stdlibs/internal/execctx/context.go +++ b/gnovm/stdlibs/internal/execctx/context.go @@ -46,7 +46,14 @@ func (e ExecContext) GetExecContext() ExecContext { return e } +// GetOriginSend returns the OriginSend coins. +// This implements gno.OriginSendProvider to avoid import cycles. +func (e ExecContext) GetOriginSend() std.Coins { + return e.OriginSend +} + var _ ExecContexter = ExecContext{} +var _ gno.OriginSendProvider = ExecContext{} // ExecContexter is a type capable of returning the parent [ExecContext]. When // using these standard libraries, m.Context should always implement this From eda2f118151c4be6a184ce735c065a1e04a63d68 Mon Sep 17 00:00:00 2001 From: ltzmaxwell Date: Thu, 16 Apr 2026 22:56:52 +0800 Subject: [PATCH 63/92] fix(gnovm): prevent cross-realm state corruption via NameExpr assign+recover pattern (#5330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pre-mutation readonly checks for NameExpr in PopAsPointer2 to prevent cross-realm state inconsistency via assign+recover. - The problem: When a non-crossing function is called cross-realm, it directly assigns to the callee's package-level variables. Previously, `PopAsPointer2` returned ro = false for NameExpr, so the assignment landed in memory. `DidUpdate` in realm.go then panicked with a recoverable *Exception. If the caller had a defer/recover, the readonly panic was silently swallowed, and execution continued with in-memory state that diverged from what realm tracking would persist. - Example: A treasury realm has a SetOwner helper that was meant to be `unexported` but was accidentally capitalized. A cross-realm caller does SetOwner(myAddr) + recover(), silently hijacking ownership in memory. It then calls TransferToken(cross) — the realm reads the corrupted owner, passes the auth check, and sends funds. After the tx, ownership reverts but the funds are already gone. - Other Impact: Subsequent crossing calls read the corrupted in-memory value and act on it — transferring tokens, emitting events, or writing to other realms — all of which are persisted. But the original state change reverts after the tx, leaving cross-realm state inconsistent with no trace of what went wrong. (cherry picked from commit f87249327e375430d00640f46ab23946704ab218) --- .../r/tests/vm/crossrealm_d/crossrealm.gno | 38 +++++ .../r/tests/vm/crossrealm_d/gnomod.toml | 2 + .../r/tests/vm/crossrealm_e/crossrealm.gno | 41 +++++ .../r/tests/vm/crossrealm_e/gnomod.toml | 2 + .../testdata/crossrealm_assign_recover.txtar | 148 ++++++++++++++++++ .../testdata/interrealm_final.txtar | 38 ++--- .../testdata/interrealm_mix_call.txtar | 2 +- .../testdata/interrealm_mix_run.txtar | 2 +- gnovm/pkg/gnolang/debugger.go | 2 +- gnovm/pkg/gnolang/machine.go | 34 ++-- gnovm/pkg/gnolang/op_expressions.go | 4 +- gnovm/pkg/gnolang/realm.go | 16 +- gnovm/pkg/gnolang/values.go | 5 +- gnovm/tests/files/zrealm_crossrealm10.gno | 2 +- gnovm/tests/files/zrealm_crossrealm2.gno | 2 +- gnovm/tests/files/zrealm_crossrealm25.gno | 2 +- gnovm/tests/files/zrealm_crossrealm25b.gno | 2 +- gnovm/tests/files/zrealm_crossrealm25c.gno | 2 +- gnovm/tests/files/zrealm_crossrealm26.gno | 2 +- gnovm/tests/files/zrealm_crossrealm27.gno | 2 +- gnovm/tests/files/zrealm_crossrealm3.gno | 3 +- gnovm/tests/files/zrealm_crossrealm30.gno | 2 +- gnovm/tests/files/zrealm_crossrealm30b.gno | 2 +- gnovm/tests/files/zrealm_crossrealm30c.gno | 2 +- gnovm/tests/files/zrealm_crossrealm33.gno | 2 +- gnovm/tests/files/zrealm_crossrealm35.gno | 59 +++++++ gnovm/tests/files/zrealm_crossrealm36.gno | 25 +++ gnovm/tests/files/zrealm_crossrealm37.gno | 47 ++++++ gnovm/tests/files/zrealm_crossrealm4.gno | 3 +- gnovm/tests/files/zrealm_crossrealm5.gno | 2 +- gnovm/tests/files/zrealm_crossrealm7.gno | 2 +- gnovm/tests/files/zrealm_crossrealm8.gno | 2 +- gnovm/tests/files/zrealm_crossrealm9.gno | 2 +- gnovm/tests/files/zrealm_map2.gno | 2 +- gnovm/tests/files/zrealm_map3.gno | 2 +- 35 files changed, 451 insertions(+), 54 deletions(-) create mode 100644 examples/gno.land/r/tests/vm/crossrealm_d/crossrealm.gno create mode 100644 examples/gno.land/r/tests/vm/crossrealm_d/gnomod.toml create mode 100644 examples/gno.land/r/tests/vm/crossrealm_e/crossrealm.gno create mode 100644 examples/gno.land/r/tests/vm/crossrealm_e/gnomod.toml create mode 100644 gno.land/pkg/integration/testdata/crossrealm_assign_recover.txtar create mode 100644 gnovm/tests/files/zrealm_crossrealm35.gno create mode 100644 gnovm/tests/files/zrealm_crossrealm36.gno create mode 100644 gnovm/tests/files/zrealm_crossrealm37.gno diff --git a/examples/gno.land/r/tests/vm/crossrealm_d/crossrealm.gno b/examples/gno.land/r/tests/vm/crossrealm_d/crossrealm.gno new file mode 100644 index 00000000000..d57af4f4e45 --- /dev/null +++ b/examples/gno.land/r/tests/vm/crossrealm_d/crossrealm.gno @@ -0,0 +1,38 @@ +package crossrealm_d + +// Simple stateful realm for cross-realm consistency tests. +// Separated from crossrealm_b to avoid perturbing its object IDs. +// +// Contains both crossing and non-crossing setters so tests can +// demonstrate that non-crossing calls from another realm cannot +// silently mutate state via assign+recover. + +var counter int + +func init() { + counter = 100 +} + +// SetCounter: non-crossing. Calling this cross-realm triggers +// the readonly check because it directly assigns a package var. +func SetCounter(n int) { + counter = n +} + +// SetCounterCrossing: crossing version. This works correctly +// cross-realm because the caller enters this realm's context. +func SetCounterCrossing(cur realm, n int) { + counter = n +} + +func GetCounter(cur realm) int { + return counter +} + +// DoubleCounter reads counter and doubles it. Used to show that +// if counter were silently corrupted in memory, subsequent crossing +// calls would act on the wrong value. +func DoubleCounter(cur realm) int { + counter = counter * 2 + return counter +} diff --git a/examples/gno.land/r/tests/vm/crossrealm_d/gnomod.toml b/examples/gno.land/r/tests/vm/crossrealm_d/gnomod.toml new file mode 100644 index 00000000000..13232d96d28 --- /dev/null +++ b/examples/gno.land/r/tests/vm/crossrealm_d/gnomod.toml @@ -0,0 +1,2 @@ +module = "gno.land/r/tests/vm/crossrealm_d" +gno = "0.9" diff --git a/examples/gno.land/r/tests/vm/crossrealm_e/crossrealm.gno b/examples/gno.land/r/tests/vm/crossrealm_e/crossrealm.gno new file mode 100644 index 00000000000..9edf92ec72b --- /dev/null +++ b/examples/gno.land/r/tests/vm/crossrealm_e/crossrealm.gno @@ -0,0 +1,41 @@ +package crossrealm_e + +import "chain/runtime" + +var ( + balance int64 + owner address +) + +func init() { + balance = 1000 + SetOwner(address("g1dao_address_here")) +} + +// SetOwner is an internal helper that was exported by mistake +// (should be setOwner). Without the pre-mutation readonly check, +// a cross-realm caller could call SetOwner + recover to silently +// hijack ownership in memory, then call TransferToken to steal funds. +func SetOwner(o address) { + owner = o +} + +func GetOwner() address { + return owner +} + +func TransferOwnership(cur realm, o address) { + if runtime.PreviousRealm().Address() != owner { + panic("unauthorized") + } + owner = o +} + +func TransferToken(cur realm) { + caller := runtime.PreviousRealm().Address() + if caller != owner { + panic("unauthorized") + } + balance -= 500 + println("===send token to: ", caller) +} diff --git a/examples/gno.land/r/tests/vm/crossrealm_e/gnomod.toml b/examples/gno.land/r/tests/vm/crossrealm_e/gnomod.toml new file mode 100644 index 00000000000..4f9b03a854c --- /dev/null +++ b/examples/gno.land/r/tests/vm/crossrealm_e/gnomod.toml @@ -0,0 +1,2 @@ +module = "gno.land/r/tests/vm/crossrealm_e" +gno = "0.9" diff --git a/gno.land/pkg/integration/testdata/crossrealm_assign_recover.txtar b/gno.land/pkg/integration/testdata/crossrealm_assign_recover.txtar new file mode 100644 index 00000000000..90f207942d5 --- /dev/null +++ b/gno.land/pkg/integration/testdata/crossrealm_assign_recover.txtar @@ -0,0 +1,148 @@ +# Test: cross-realm assign+recover cannot corrupt state or steal funds. +# +# A vault realm has an exported SetOwner (non-crossing) that was meant +# to be unexported. An attacker deploys a realm that: +# 1. Calls SetOwner(myAddr) + recover — blocked by the pre-mutation +# readonly check so ownership never changes in memory. +# 2. Calls Withdraw(cross) — fails with "unauthorized" because +# the owner is still the original DAO address. +# +# Without the fix, step 1 would land in memory before DidUpdate panics. +# After recover(), the attacker owns the vault in memory and Withdraw +# succeeds — but the ownership change reverts after the tx, leaving +# cross-realm state inconsistent. + +adduser attacker + +## start a new node +gnoland start + +## deploy vault realm +gnokey maketx addpkg -pkgdir $WORK/vault -pkgpath gno.land/r/test/vault -gas-fee 1000000ugnot -gas-wanted 20000000 -broadcast -chainid=tendermint_test test1 + +## verify initial owner is the DAO address +gnokey query "vm/qrender" --data "gno.land/r/test/vault:" +stdout 'owner: g1dao0000000000000000000000000000000000' + +## deploy attack realm +gnokey maketx addpkg -pkgdir $WORK/attack -pkgpath gno.land/r/test/attack -gas-fee 1000000ugnot -gas-wanted 20000000 -broadcast -chainid=tendermint_test attacker + +## TEST 1: assign+recover then Withdraw — must fail with "unauthorized" +## The attacker does SetOwner(myAddr) + recover, then Withdraw(cross). +## With the fix, SetOwner is blocked before the write, so Withdraw sees +## the original owner and rejects the caller. +! gnokey maketx call -pkgpath gno.land/r/test/attack -func Attack -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test attacker +stderr 'unauthorized' + +## verify owner is unchanged after the attack attempt +gnokey query "vm/qrender" --data "gno.land/r/test/vault:" +stdout 'owner: g1dao0000000000000000000000000000000000' + +## TEST 2: assign without recover — the readonly panic aborts the tx +! gnokey maketx call -pkgpath gno.land/r/test/attack -func AttackNoRecover -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test attacker +stderr 'readonly tainted object' + +## verify owner is still unchanged +gnokey query "vm/qrender" --data "gno.land/r/test/vault:" +stdout 'owner: g1dao0000000000000000000000000000000000' + +## TEST 3: the crossing setter correctly rejects non-owners (positive control) +! gnokey maketx call -pkgpath gno.land/r/test/vault -func TransferOwnership -args ${attacker_user_addr} -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test attacker +stderr 'unauthorized' + +## owner unchanged +gnokey query "vm/qrender" --data "gno.land/r/test/vault:" +stdout 'owner: g1dao0000000000000000000000000000000000' + +-- vault/gnomod.toml -- +module = "gno.land/r/test/vault" +gno = "0.9" + +-- vault/vault.gno -- +package vault + +import ( + "chain" + "chain/banker" + "chain/runtime" +) + +var owner address + +func init() { + // Set initial owner to a DAO address via internal helper. + SetOwner(address("g1dao0000000000000000000000000000000000")) +} + +// SetOwner is an internal helper that was exported by mistake +// (should be setOwner). Without the pre-mutation readonly check, +// a cross-realm caller could call SetOwner + recover to silently +// hijack ownership in memory, then call Withdraw to steal funds. +func SetOwner(o address) { + owner = o +} + +func GetOwner(cur realm) address { + return owner +} + +// TransferOwnership is the legitimate crossing setter. +func TransferOwnership(cur realm, newOwner address) { + caller := runtime.PreviousRealm().Address() + if caller != owner { + panic("unauthorized") + } + owner = newOwner +} + +func Withdraw(cur realm, amount int64) { + caller := runtime.PreviousRealm().Address() + if caller != owner { + panic("unauthorized") + } + b := banker.NewBanker(banker.BankerTypeRealmSend) + pkgAddr := runtime.CurrentRealm().Address() + b.SendCoins(pkgAddr, caller, chain.Coins{{"ugnot", amount}}) +} + +func Render(_ string) string { + return "owner: " + string(owner) +} + +-- attack/gnomod.toml -- +module = "gno.land/r/test/attack" +gno = "0.9" + +-- attack/attack.gno -- +package attack + +import ( + "chain/runtime" + + "gno.land/r/test/vault" +) + +// Attack attempts to hijack ownership via assign+recover, then withdraw. +func Attack(cur realm) { + myAddr := runtime.CurrentRealm().Address() + + // Step 1: try to hijack ownership. The readonly check blocks the + // assignment before it lands in memory. recover() catches the panic. + func() { + defer func() { _ = recover() }() + vault.SetOwner(myAddr) + }() + + // Step 2: try to withdraw. Since owner is unchanged, this fails + // with "unauthorized". + vault.Withdraw(cross, 1000000) +} + +// AttackNoRecover is the negative control: without recover, the +// readonly panic aborts the entire transaction. +func AttackNoRecover(cur realm) { + myAddr := runtime.CurrentRealm().Address() + vault.SetOwner(myAddr) + // Never reached. + vault.Withdraw(cross, 1000000) +} diff --git a/gno.land/pkg/integration/testdata/interrealm_final.txtar b/gno.land/pkg/integration/testdata/interrealm_final.txtar index 200b16c499b..ab28c9d5738 100644 --- a/gno.land/pkg/integration/testdata/interrealm_final.txtar +++ b/gno.land/pkg/integration/testdata/interrealm_final.txtar @@ -23,31 +23,31 @@ gnokey maketx addpkg -pkgdir $WORK/callerrealm -pkgpath gno.land/r/test/callerre ## test CASE_rA1 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rA1 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_rA2 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rA2 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_rA3 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rA3 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_rB1 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rB1 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_rB2 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rB2 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_rB3 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rB3 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_rB4 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rB4 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_rC1 gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rC1 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner @@ -55,7 +55,7 @@ stdout 'OK' ## test CASE_rC2 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rC2 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_rC3 gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rC3 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner @@ -63,11 +63,11 @@ stdout 'OK' ## test CASE_rC4 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rC4 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_rC5 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_rC5 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_rD1 ! gnokey maketx addpkg -pkgdir $WORK/callerrealm_rd1 -pkgpath gno.land/r/test/callerrealm_rd1 -gas-fee 1000000ugnot -gas-wanted 20000000 -broadcast -chainid=tendermint_test test1 @@ -79,30 +79,30 @@ stderr 'cannot directly mutate gno.land/r/test/bob.AllowedList from gno.land/r/t ## test CASE_pA1 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pA1 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_pA2 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pA2 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_pA3 -- omitted (illegal crossing function) # ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pA3 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner ## test CASE_pB1 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pB1 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_pB2 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pB2 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_pB3 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pB3 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_pB4 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pB4 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_pC1 gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pC1 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner @@ -110,7 +110,7 @@ stdout 'OK' ## test CASE_pC2 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pC2 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_pC3 gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pC3 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner @@ -118,11 +118,11 @@ stdout 'OK' ## test CASE_pC4 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pC4 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## test CASE_pC5 ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func CASE_pC5 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test runner -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' ## entry diff --git a/gno.land/pkg/integration/testdata/interrealm_mix_call.txtar b/gno.land/pkg/integration/testdata/interrealm_mix_call.txtar index 2bd91a4cee9..f79f0dfff2e 100644 --- a/gno.land/pkg/integration/testdata/interrealm_mix_call.txtar +++ b/gno.land/pkg/integration/testdata/interrealm_mix_call.txtar @@ -18,7 +18,7 @@ stdout 'obj\.value = 0' ## execute NonCrossingMutation ! gnokey maketx call -pkgpath gno.land/r/test/callerrealm -func NonCrossingMutation -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test test2 -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' gnokey query "vm/qrender" --data "gno.land/r/test/borrowrealm:" stdout 'Object\.value = 0, value = 0' diff --git a/gno.land/pkg/integration/testdata/interrealm_mix_run.txtar b/gno.land/pkg/integration/testdata/interrealm_mix_run.txtar index 9c1fc680e3e..4f82e937174 100644 --- a/gno.land/pkg/integration/testdata/interrealm_mix_run.txtar +++ b/gno.land/pkg/integration/testdata/interrealm_mix_run.txtar @@ -16,7 +16,7 @@ stdout 'Object\.value = 0, value = 0' ## run non_crossing_mutation ! gnokey maketx run runner $WORK/run/non_crossing_mutation.gno -gas-fee 1000000ugnot -gas-wanted 20000000 -broadcast -chainid=tendermint_test -stderr 'cannot modify external-realm or non-realm object' +stderr 'readonly tainted object' gnokey query "vm/qrender" --data "gno.land/r/test/borrowrealm:" stdout 'Object\.value = 0, value = 0' diff --git a/gnovm/pkg/gnolang/debugger.go b/gnovm/pkg/gnolang/debugger.go index 8c624c9516e..5eefd58dfa6 100644 --- a/gnovm/pkg/gnolang/debugger.go +++ b/gnovm/pkg/gnolang/debugger.go @@ -728,7 +728,7 @@ func debugEvalExpr(m *Machine, node ast.Node) (tv TypedValue, err error) { if err != nil { return tv, err } - return x.GetPointerAtIndex(m.Realm, m.Alloc, m.Store, &index).Deref(), nil + return x.GetPointerAtIndex(nilRealm, m.Alloc, m.Store, &index).Deref(), nil default: err = fmt.Errorf("expression not supported: %v", n) } diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index bf1e9e955e9..dc7416f0b63 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -2275,19 +2275,18 @@ func (m *Machine) PopAsPointer(lx Expr) PointerValue { } func readonlyAccessPanic(x Expr) string { - return "cannot directly modify readonly tainted object (w/o method): " + x.String() + return "cannot directly modify readonly tainted object (use a method or crossing function): " + x.String() } -// Returns true iff: -// - m.Realm is nil (single user mode), or +// Returns false if m.Realm is nil (single user mode, nothing is readonly). +// Otherwise returns true iff: // - tv is a ref to (external) package path, or // - tv is N_Readonly, or // - tv is not an object ("first object" ID is zero), or // - tv is an unreal object (no object id), or // - tv is an object residing in external realm func (m *Machine) IsReadonly(tv *TypedValue) bool { - // Returns true iff: - // - m.Realm is nil (single user mode) + // m.Realm is nil → single user mode, nothing is readonly if m.Realm == nil { return false } @@ -2306,9 +2305,26 @@ func (m *Machine) IsReadonly(tv *TypedValue) bool { return tv.IsReadonlyBy(m.Realm.ID) } +// isExternalRealm returns true if base is a real Object belonging to +// a different realm than m.Realm. Used for NameExpr cross-realm checks +// where we have a Base (Block) rather than a TypedValue. +func (m *Machine) isExternalRealm(base Value) bool { + if m.Realm == nil { + return false + } + obj, ok := base.(Object) + if !ok { + return false + } + oid := obj.GetObjectID() + if oid.IsZero() { + return false // transient (local var, unreal block) + } + return oid.PkgID != m.Realm.ID +} + // Returns ro = true if the base is readonly, // or if the base's storage realm != m.Realm and both are non-nil, -// and the lx isn't a name (base is a block), // and the lx isn't a composite lit expr. func (m *Machine) PopAsPointer2(lx Expr) (pv PointerValue, ro bool) { switch lx := lx.(type) { @@ -2317,11 +2333,11 @@ func (m *Machine) PopAsPointer2(lx Expr) (pv PointerValue, ro bool) { case NameExprTypeNormal: lb := m.LastBlock() pv = lb.GetPointerTo(m.Store, lx.Path) - ro = false // always mutable + ro = m.isExternalRealm(pv.Base) case NameExprTypeHeapUse: lb := m.LastBlock() pv = lb.GetPointerTo(m.Store, lx.Path) - ro = false // always mutable + ro = m.isExternalRealm(pv.Base) case NameExprTypeHeapClosure: panic("should not happen") default: @@ -2365,7 +2381,7 @@ func (m *Machine) PopAsPointer2(lx Expr) (pv PointerValue, ro bool) { Base: hv, Index: 0, } - ro = false // always mutable + ro = false // always mutable; composite literals are freshly allocated (unreal) values not yet owned by any realm. default: panic("should not happen") } diff --git a/gnovm/pkg/gnolang/op_expressions.go b/gnovm/pkg/gnolang/op_expressions.go index a4c612dd83f..8cfa2620f2c 100644 --- a/gnovm/pkg/gnolang/op_expressions.go +++ b/gnovm/pkg/gnolang/op_expressions.go @@ -27,7 +27,7 @@ func (m *Machine) doOpIndex1() { } } default: - // NOTE: nilRealm is OK, not setting a map (w/ new key). + // Read-only: pass nilRealm so map key attach DidUpdate is a no-op. res := xv.GetPointerAtIndex(nilRealm, m.Alloc, m.Store, iv) *xv = res.Deref() // reuse as result } @@ -698,7 +698,7 @@ func (m *Machine) doOpConvert() { // These protect against inter-realm conversion exploits. // Case 1. - // Do not allow conversion of value stored in eternal realm. + // Do not allow conversion of value stored in external realm. // Otherwise anyone could convert an external object insecurely. if xv.T != nil && !xv.T.IsImmutable() && m.IsReadonly(&xv) { if xvdt, ok := xv.T.(*DeclaredType); ok && diff --git a/gnovm/pkg/gnolang/realm.go b/gnovm/pkg/gnolang/realm.go index c87761bef4c..36c9602e472 100644 --- a/gnovm/pkg/gnolang/realm.go +++ b/gnovm/pkg/gnolang/realm.go @@ -175,6 +175,17 @@ func (rlm *Realm) String() string { // if rlm or po is nil, do nothing. // xo or co is nil if the element value is undefined or has no // associated object. +// +// DidUpdate is called after mutation, so it cannot prevent the write — +// it can only detect a missing pre-check and panic. +// +// Direct callers (e.g. op_assign, machine.go) must perform a readonly +// check (IsReadonly/isExternalRealm) before the mutation. +// +// Indirect callers via GetPointerAtIndex (values.go, map key attach): +// - PopAsPointer2 (write path): checks readonly before calling. +// - doOpIndex (read path): passes nilRealm, so DidUpdate is a no-op. +// - debugger: passes nilRealm (read-only), so DidUpdate is a no-op. func (rlm *Realm) DidUpdate(po, xo, co Object) { if bm.OpsEnabled { bm.PauseOpCode() @@ -198,7 +209,10 @@ func (rlm *Realm) DidUpdate(po, xo, co Object) { return // do nothing. } if po.GetObjectID().PkgID != rlm.ID { - panic(&Exception{Value: typedString("cannot modify external-realm or non-realm object")}) + // Invariant violation: all mutation paths must have a pre-mutation + // readonly check (IsReadonly/isExternalRealm) that prevents reaching + // here. If this fires, a pre-check is missing. + panic("invariant violation: DidUpdate called on external-realm object without prior readonly check") } // XXX check if this boosts performance diff --git a/gnovm/pkg/gnolang/values.go b/gnovm/pkg/gnolang/values.go index 79141ee4f22..e79277d8c7b 100644 --- a/gnovm/pkg/gnolang/values.go +++ b/gnovm/pkg/gnolang/values.go @@ -2042,7 +2042,10 @@ func (tv *TypedValue) GetPointerAtIndex(rlm *Realm, alloc *Allocator, store Stor *(pv.TV) = defaultTypedValue(nil, vt) } } - // attach mapkey object, if changed + // Attach mapkey object to the map's ownership tree if changed. + // Only PopAsPointer2 (write path) reaches here with non-nil rlm, + // and it checks readonly before calling. + // Read paths (doOpIndex, debugger) pass nilRealm → DidUpdate is a no-op. newObject := ivk.GetFirstObject(store) if oldObject != newObject { rlm.DidUpdate(mv, oldObject, newObject) diff --git a/gnovm/tests/files/zrealm_crossrealm10.gno b/gnovm/tests/files/zrealm_crossrealm10.gno index f4975bfd68a..1ff61657712 100644 --- a/gnovm/tests/files/zrealm_crossrealm10.gno +++ b/gnovm/tests/files/zrealm_crossrealm10.gno @@ -11,4 +11,4 @@ func main(cur realm) { } // Error: -// cannot directly modify readonly tainted object (w/o method): SomeValue3<~VPBlock(3,5)>.Field +// cannot directly modify readonly tainted object (use a method or crossing function): SomeValue3<~VPBlock(3,5)>.Field diff --git a/gnovm/tests/files/zrealm_crossrealm2.gno b/gnovm/tests/files/zrealm_crossrealm2.gno index 13ca1f19142..fc7733df6ae 100644 --- a/gnovm/tests/files/zrealm_crossrealm2.gno +++ b/gnovm/tests/files/zrealm_crossrealm2.gno @@ -19,4 +19,4 @@ func main(cur realm) { } // Error: -// cannot directly modify readonly tainted object (w/o method): t.Field +// cannot directly modify readonly tainted object (use a method or crossing function): t.Field diff --git a/gnovm/tests/files/zrealm_crossrealm25.gno b/gnovm/tests/files/zrealm_crossrealm25.gno index 27bc63c5ad2..51ec572c63b 100644 --- a/gnovm/tests/files/zrealm_crossrealm25.gno +++ b/gnovm/tests/files/zrealm_crossrealm25.gno @@ -25,4 +25,4 @@ func main(cur realm) { // s.A = 123 // Error: -// cannot directly modify readonly tainted object (w/o method): s<~VPBlock(1,1)>.A +// cannot directly modify readonly tainted object (use a method or crossing function): s<~VPBlock(1,1)>.A diff --git a/gnovm/tests/files/zrealm_crossrealm25b.gno b/gnovm/tests/files/zrealm_crossrealm25b.gno index e9a637f338c..9fe534ee101 100644 --- a/gnovm/tests/files/zrealm_crossrealm25b.gno +++ b/gnovm/tests/files/zrealm_crossrealm25b.gno @@ -30,4 +30,4 @@ func main(cur realm) { // s.A = 123 // Error: -// cannot directly modify readonly tainted object (w/o method): s<~VPBlock(3,1)>.A +// cannot directly modify readonly tainted object (use a method or crossing function): s<~VPBlock(3,1)>.A diff --git a/gnovm/tests/files/zrealm_crossrealm25c.gno b/gnovm/tests/files/zrealm_crossrealm25c.gno index fb1ce73760b..259d6843132 100644 --- a/gnovm/tests/files/zrealm_crossrealm25c.gno +++ b/gnovm/tests/files/zrealm_crossrealm25c.gno @@ -34,4 +34,4 @@ func main(cur realm) { // s_g.A = 123 // Error: -// cannot directly modify readonly tainted object (w/o method): s_g<~VPBlock(3,1)>.A +// cannot directly modify readonly tainted object (use a method or crossing function): s_g<~VPBlock(3,1)>.A diff --git a/gnovm/tests/files/zrealm_crossrealm26.gno b/gnovm/tests/files/zrealm_crossrealm26.gno index e10c93ba418..c635f934a6e 100644 --- a/gnovm/tests/files/zrealm_crossrealm26.gno +++ b/gnovm/tests/files/zrealm_crossrealm26.gno @@ -17,4 +17,4 @@ func main(cur realm) { } // Error: -// cannot directly modify readonly tainted object (w/o method): s<~VPBlock(1,1)>.A +// cannot directly modify readonly tainted object (use a method or crossing function): s<~VPBlock(1,1)>.A diff --git a/gnovm/tests/files/zrealm_crossrealm27.gno b/gnovm/tests/files/zrealm_crossrealm27.gno index 8323f05814b..1b68cd11140 100644 --- a/gnovm/tests/files/zrealm_crossrealm27.gno +++ b/gnovm/tests/files/zrealm_crossrealm27.gno @@ -23,7 +23,7 @@ func main(cur realm) { } // Error: -// cannot directly modify readonly tainted object (w/o method): s<~VPBlock(3,1)>.A +// cannot directly modify readonly tainted object (use a method or crossing function): s<~VPBlock(3,1)>.A // Preprocessed: // file{ package crossrealm; import crossrealm_b gno.land/r/tests/vm/crossrealm_b; type Struct (const-type gno.land/r/crossrealm.Struct); var s *(typeval{gno.land/r/crossrealm.Struct}); func init.2() { s2 := &((const-type gno.land/r/crossrealm.Struct){A: (const (100 int))}); (const (ref(gno.land/r/tests/vm/crossrealm_b) package{})).SetObject((const (undefined)), func func(){ (const (println func(...interface {})))(&(s2<~VPBlock(1,0)>.A)) }>); s<~VPBlock(3,1)> = s2<~VPBlock(1,0)> }; func main(cur (const-type .uverse.realm)) { s<~VPBlock(3,1)>.A = (const (123 int)); (const (println func(...interface {})))(s<~VPBlock(3,1)>) } } diff --git a/gnovm/tests/files/zrealm_crossrealm3.gno b/gnovm/tests/files/zrealm_crossrealm3.gno index e91d4fee79f..63229c100d7 100644 --- a/gnovm/tests/files/zrealm_crossrealm3.gno +++ b/gnovm/tests/files/zrealm_crossrealm3.gno @@ -13,7 +13,8 @@ func init() { } func main(cur realm) { - // NOTE: it is also valid to modify it using an external realm function. + // NOTE: Modifying an external-realm object via a non-crossing method is + // allowed by soft crossing. somevalue.Modify() println(somevalue) } diff --git a/gnovm/tests/files/zrealm_crossrealm30.gno b/gnovm/tests/files/zrealm_crossrealm30.gno index 7be9656c453..4d4eaa9b3d8 100644 --- a/gnovm/tests/files/zrealm_crossrealm30.gno +++ b/gnovm/tests/files/zrealm_crossrealm30.gno @@ -39,4 +39,4 @@ func main() { } // Error: -// cannot directly modify readonly tainted object (w/o method): sa.A +// cannot directly modify readonly tainted object (use a method or crossing function): sa.A diff --git a/gnovm/tests/files/zrealm_crossrealm30b.gno b/gnovm/tests/files/zrealm_crossrealm30b.gno index 58b99ae6eb0..4206202e394 100644 --- a/gnovm/tests/files/zrealm_crossrealm30b.gno +++ b/gnovm/tests/files/zrealm_crossrealm30b.gno @@ -40,4 +40,4 @@ func main() { } // Error: -// cannot directly modify readonly tainted object (w/o method): sa.A +// cannot directly modify readonly tainted object (use a method or crossing function): sa.A diff --git a/gnovm/tests/files/zrealm_crossrealm30c.gno b/gnovm/tests/files/zrealm_crossrealm30c.gno index c67139fd70a..90830f0c063 100644 --- a/gnovm/tests/files/zrealm_crossrealm30c.gno +++ b/gnovm/tests/files/zrealm_crossrealm30c.gno @@ -45,4 +45,4 @@ func main() { } // Error: -// cannot directly modify readonly tainted object (w/o method): sa.A +// cannot directly modify readonly tainted object (use a method or crossing function): sa.A diff --git a/gnovm/tests/files/zrealm_crossrealm33.gno b/gnovm/tests/files/zrealm_crossrealm33.gno index 2783ce3ed35..71a2a8d420e 100644 --- a/gnovm/tests/files/zrealm_crossrealm33.gno +++ b/gnovm/tests/files/zrealm_crossrealm33.gno @@ -13,4 +13,4 @@ func main(cur realm) { // Output: // Error: -// cannot modify external-realm or non-realm object +// cannot directly modify readonly tainted object (use a method or crossing function): n<~VPBlock(3,10)> diff --git a/gnovm/tests/files/zrealm_crossrealm35.gno b/gnovm/tests/files/zrealm_crossrealm35.gno new file mode 100644 index 00000000000..ea0bf06ba79 --- /dev/null +++ b/gnovm/tests/files/zrealm_crossrealm35.gno @@ -0,0 +1,59 @@ +// PKGPATH: gno.land/r/crossrealm +package crossrealm + +// Verifies that cross-realm assign+recover does NOT corrupt state. +// +// crossrealm_d has a package-level counter (initially 100) with both +// a non-crossing setter (SetCounter) and a crossing setter +// (SetCounterCrossing). This test calls the non-crossing setter from +// another realm, recovers the panic, then checks that the state is +// unchanged. +// +// Impact without the fix: the non-crossing SetCounter(0) would land +// in memory before DidUpdate panics. After recover(), the counter is +// 0 in memory but 100 in persistence. A subsequent crossing call like +// DoubleCounter would read 0 and produce 0 instead of 200 — the +// realm acts on state that was never committed. +// +// The fix: PopAsPointer checks readonly BEFORE the assignment, so +// the counter never changes in memory. + +import ( + "gno.land/r/tests/vm/crossrealm_d" +) + +func main() { + // 1. Initial state. + println("counter:", crossrealm_d.GetCounter(cross)) // 100 + + // 2. Non-crossing set → panics, recover catches it. + func() { + defer func() { + r := recover() + if r != nil { + println("panic caught:", r.(string)) + } + }() + crossrealm_d.SetCounter(0) + }() + + // 3. Counter must still be 100. + println("counter after set+recover:", crossrealm_d.GetCounter(cross)) + + // 4. A crossing call that depends on counter. + // Without the fix: counter=0 in memory → DoubleCounter returns 0. + // With the fix: counter=100 → DoubleCounter returns 200. + result := crossrealm_d.DoubleCounter(cross) + println("double:", result) + + // 5. Verify the crossing setter works correctly for comparison. + crossrealm_d.SetCounterCrossing(cross, 50) + println("after crossing set:", crossrealm_d.GetCounter(cross)) // 50 +} + +// Output: +// counter: 100 +// panic caught: cannot directly modify readonly tainted object (use a method or crossing function): counter<~VPBlock(3,0)> +// counter after set+recover: 100 +// double: 200 +// after crossing set: 50 diff --git a/gnovm/tests/files/zrealm_crossrealm36.gno b/gnovm/tests/files/zrealm_crossrealm36.gno new file mode 100644 index 00000000000..492f103cecc --- /dev/null +++ b/gnovm/tests/files/zrealm_crossrealm36.gno @@ -0,0 +1,25 @@ +// PKGPATH: gno.land/r/crossrealm +package crossrealm + +// Negative control: without recover(), the cross-realm readonly +// check properly aborts the entire transaction. + +import ( + "gno.land/r/tests/vm/crossrealm_d" +) + +func main() { + println("counter:", crossrealm_d.GetCounter(cross)) + + // No recover — the cross-realm panic aborts everything. + crossrealm_d.SetCounter(0) + + // This line is never reached. + println("counter after:", crossrealm_d.GetCounter(cross)) +} + +// Output: +// counter: 100 + +// Error: +// cannot directly modify readonly tainted object (use a method or crossing function): counter<~VPBlock(3,0)> diff --git a/gnovm/tests/files/zrealm_crossrealm37.gno b/gnovm/tests/files/zrealm_crossrealm37.gno new file mode 100644 index 00000000000..10778f05592 --- /dev/null +++ b/gnovm/tests/files/zrealm_crossrealm37.gno @@ -0,0 +1,47 @@ +// PKGPATH: gno.land/r/crossrealm +package crossrealm + +import ( + "chain/runtime" + + "gno.land/r/tests/vm/crossrealm_e" +) + +func main() { + println("owner:", crossrealm_e.GetOwner()) + attacker := runtime.CurrentRealm().Address() + println("attacker: ", attacker) + + func() { + defer func() { + r := recover() + if r != nil { + println("exception caught: ", r.(string)) + } + }() + crossrealm_e.SetOwner(runtime.CurrentRealm().Address()) + }() + + owner := crossrealm_e.GetOwner() + println("owner == attacker: ", owner == attacker) + + crossrealm_e.TransferToken(cross) +} + +// before fix: +// owner: g1dao_address_here +// attacker: g1h2y7mn4d8w5ed08kqt8sdd7tp4j96eahyn6yan +// exception caught: cannot modify external-realm or non-realm object +// owner == attacker: true +// ===send token to: g1h2y7mn4d8w5ed08kqt8sdd7tp4j96eahyn6yan + +// after fix: + +// Output: +// owner: g1dao_address_here +// attacker: g1h2y7mn4d8w5ed08kqt8sdd7tp4j96eahyn6yan +// exception caught: cannot directly modify readonly tainted object (use a method or crossing function): owner<~VPBlock(3,1)> +// owner == attacker: false + +// Error: +// unauthorized diff --git a/gnovm/tests/files/zrealm_crossrealm4.gno b/gnovm/tests/files/zrealm_crossrealm4.gno index 40502eb71e0..5cdcbcf4419 100644 --- a/gnovm/tests/files/zrealm_crossrealm4.gno +++ b/gnovm/tests/files/zrealm_crossrealm4.gno @@ -13,7 +13,8 @@ func init() { } func main(cur realm) { - // NOTE: it is valid to modify it using the external realm function. + // NOTE: Modifying an external-realm object via a non-crossing method is + // allowed by soft crossing. somevalue.Modify() println(somevalue) } diff --git a/gnovm/tests/files/zrealm_crossrealm5.gno b/gnovm/tests/files/zrealm_crossrealm5.gno index 256e012d533..89e04a378db 100644 --- a/gnovm/tests/files/zrealm_crossrealm5.gno +++ b/gnovm/tests/files/zrealm_crossrealm5.gno @@ -19,4 +19,4 @@ func main(cur realm) { } // Error: -// cannot directly modify readonly tainted object (w/o method): somevalue<~VPBlock(3,0)>.Field +// cannot directly modify readonly tainted object (use a method or crossing function): somevalue<~VPBlock(3,0)>.Field diff --git a/gnovm/tests/files/zrealm_crossrealm7.gno b/gnovm/tests/files/zrealm_crossrealm7.gno index 183b99950d9..43c83207351 100644 --- a/gnovm/tests/files/zrealm_crossrealm7.gno +++ b/gnovm/tests/files/zrealm_crossrealm7.gno @@ -11,4 +11,4 @@ func main(cur realm) { } // Error: -// cannot directly modify readonly tainted object (w/o method): somevalue1<~VPBlock(3,3)>.Field +// cannot directly modify readonly tainted object (use a method or crossing function): somevalue1<~VPBlock(3,3)>.Field diff --git a/gnovm/tests/files/zrealm_crossrealm8.gno b/gnovm/tests/files/zrealm_crossrealm8.gno index d373c3a2991..d69e5edf6be 100644 --- a/gnovm/tests/files/zrealm_crossrealm8.gno +++ b/gnovm/tests/files/zrealm_crossrealm8.gno @@ -11,4 +11,4 @@ func main(cur realm) { } // Error: -// cannot directly modify readonly tainted object (w/o method): SomeValue2<~VPBlock(3,4)>.Field +// cannot directly modify readonly tainted object (use a method or crossing function): SomeValue2<~VPBlock(3,4)>.Field diff --git a/gnovm/tests/files/zrealm_crossrealm9.gno b/gnovm/tests/files/zrealm_crossrealm9.gno index 379dc4ec668..e1b60495503 100644 --- a/gnovm/tests/files/zrealm_crossrealm9.gno +++ b/gnovm/tests/files/zrealm_crossrealm9.gno @@ -11,4 +11,4 @@ func main(cur realm) { } // Error: -// cannot directly modify readonly tainted object (w/o method): (const (ref(gno.land/p/demo/tests) package{})).SomeValue2.Field +// cannot directly modify readonly tainted object (use a method or crossing function): (const (ref(gno.land/p/demo/tests) package{})).SomeValue2.Field diff --git a/gnovm/tests/files/zrealm_map2.gno b/gnovm/tests/files/zrealm_map2.gno index 456ae8cae6e..ab68a33962c 100644 --- a/gnovm/tests/files/zrealm_map2.gno +++ b/gnovm/tests/files/zrealm_map2.gno @@ -30,6 +30,6 @@ func main(cur realm) { } // Output: -// caught panic: cannot directly modify readonly tainted object (w/o method): rm<~VPBlock(1,0)>[(const ("attacker" string))] +// caught panic: cannot directly modify readonly tainted object (use a method or crossing function): rm<~VPBlock(1,0)>[(const ("attacker" string))] // len(m) = 1 // attacker key exists: false diff --git a/gnovm/tests/files/zrealm_map3.gno b/gnovm/tests/files/zrealm_map3.gno index bce84849748..cd9aa2b7110 100644 --- a/gnovm/tests/files/zrealm_map3.gno +++ b/gnovm/tests/files/zrealm_map3.gno @@ -44,7 +44,7 @@ func main(cur realm) { } // Output: -// assign panic: cannot directly modify readonly tainted object (w/o method): rm<~VPBlock(1,0)>[(const ("attacker" string))] +// assign panic: cannot directly modify readonly tainted object (use a method or crossing function): rm<~VPBlock(1,0)>[(const ("attacker" string))] // delete panic: cannot delete from readonly tainted map // admin exists: true // user1: 500 From 4ed6595549cd5c9058bb77853f3a070ebb49738f Mon Sep 17 00:00:00 2001 From: ltzmaxwell Date: Fri, 17 Apr 2026 11:17:36 +0800 Subject: [PATCH 64/92] fix(gnovm): add validation on alloc constructors and caller side (#5498) - add some missing validation on caller site - in alloc.go add/keep go panic as an invariant, in case validation missed on caller site --------- Co-authored-by: Morgan Bazalgette (cherry picked from commit 26dc377ab63423a4ed806ecd9f828c1ec3f42ec9) --- gnovm/pkg/gnolang/alloc.go | 8 ++++---- gnovm/pkg/gnolang/uverse.go | 11 ++++++++++- gnovm/tests/files/make13.gno | 2 +- gnovm/tests/files/make15.gno | 2 +- gnovm/tests/files/make16.gno | 9 +++++++++ gnovm/tests/files/make17.gno | 9 +++++++++ gnovm/tests/files/make18.gno | 9 +++++++++ gnovm/tests/files/recover16.gno | 15 +++++++++++++++ gnovm/tests/files/types/varg_4.gno | 2 +- 9 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 gnovm/tests/files/make16.gno create mode 100644 gnovm/tests/files/make17.gno create mode 100644 gnovm/tests/files/make18.gno create mode 100644 gnovm/tests/files/recover16.gno diff --git a/gnovm/pkg/gnolang/alloc.go b/gnovm/pkg/gnolang/alloc.go index 5f2460ea4f7..ede2799a739 100644 --- a/gnovm/pkg/gnolang/alloc.go +++ b/gnovm/pkg/gnolang/alloc.go @@ -242,7 +242,7 @@ func (alloc *Allocator) NewString(s string) StringValue { func (alloc *Allocator) NewListArray(n int) *ArrayValue { if n < 0 { - panic(&Exception{Value: typedString("len out of range")}) + panic("NewListArray: n must not be negative") } alloc.AllocateListArray(int64(n)) return &ArrayValue{ @@ -252,11 +252,11 @@ func (alloc *Allocator) NewListArray(n int) *ArrayValue { func (alloc *Allocator) NewListArray2(l, c int) *ArrayValue { if l < 0 || c < 0 { - panic(&Exception{Value: typedString("len or cap out of range")}) + panic("NewListArray2: l and c must not be negative") } if c < l { - panic(&Exception{Value: typedString("length and capacity swapped")}) + panic("NewListArray2: c must not be less than l") } alloc.AllocateListArray(int64(c)) @@ -267,7 +267,7 @@ func (alloc *Allocator) NewListArray2(l, c int) *ArrayValue { func (alloc *Allocator) NewDataArray(n int) *ArrayValue { if n < 0 { - panic(&Exception{Value: typedString("len out of range")}) + panic("NewDataArray: n must not be negative") } alloc.AllocateDataArray(int64(n)) diff --git a/gnovm/pkg/gnolang/uverse.go b/gnovm/pkg/gnolang/uverse.go index 79c991426f9..326d3047a5b 100644 --- a/gnovm/pkg/gnolang/uverse.go +++ b/gnovm/pkg/gnolang/uverse.go @@ -804,6 +804,9 @@ func makeUverseNode() { case 1: lv := vargs.TV.GetPointerAtIndexInt(m.Store, 0).Deref() li := int(lv.ConvertGetInt()) + if li < 0 { + m.Panic(typedString("runtime error: makeslice: len out of range")) + } if et.Kind() == Uint8Kind { arrayValue := m.Alloc.NewDataArray(li) m.PushValue(TypedValue{ @@ -833,8 +836,14 @@ func makeUverseNode() { cv := vargs.TV.GetPointerAtIndexInt(m.Store, 1).Deref() ci := int(cv.ConvertGetInt()) + if li < 0 { + m.Panic(typedString("runtime error: makeslice: len out of range")) + } + if ci < 0 { + m.Panic(typedString("runtime error: makeslice: cap out of range")) + } if ci < li { - m.Panic(typedString(`makeslice: cap out of range`)) + m.Panic(typedString("runtime error: makeslice: cap out of range")) } if et.Kind() == Uint8Kind { diff --git a/gnovm/tests/files/make13.gno b/gnovm/tests/files/make13.gno index e53a21ec9bb..306b91e4509 100644 --- a/gnovm/tests/files/make13.gno +++ b/gnovm/tests/files/make13.gno @@ -7,4 +7,4 @@ func main() { } // Error: -// makeslice: cap out of range +// runtime error: makeslice: cap out of range diff --git a/gnovm/tests/files/make15.gno b/gnovm/tests/files/make15.gno index fbedf418c2c..f9cf943a272 100644 --- a/gnovm/tests/files/make15.gno +++ b/gnovm/tests/files/make15.gno @@ -6,4 +6,4 @@ func main() { } // Error: -// makeslice: cap out of range +// runtime error: makeslice: cap out of range diff --git a/gnovm/tests/files/make16.gno b/gnovm/tests/files/make16.gno new file mode 100644 index 00000000000..913cab7a09e --- /dev/null +++ b/gnovm/tests/files/make16.gno @@ -0,0 +1,9 @@ +package main + +func main() { + l := -1 + _ = make([]int, l) +} + +// Error: +// runtime error: makeslice: len out of range diff --git a/gnovm/tests/files/make17.gno b/gnovm/tests/files/make17.gno new file mode 100644 index 00000000000..2efb97d6662 --- /dev/null +++ b/gnovm/tests/files/make17.gno @@ -0,0 +1,9 @@ +package main + +func main() { + c := -1 + _ = make([]int, 0, c) +} + +// Error: +// runtime error: makeslice: cap out of range diff --git a/gnovm/tests/files/make18.gno b/gnovm/tests/files/make18.gno new file mode 100644 index 00000000000..1b65afac087 --- /dev/null +++ b/gnovm/tests/files/make18.gno @@ -0,0 +1,9 @@ +package main + +func main() { + l := -1 + _ = make([]int, l, 10) +} + +// Error: +// runtime error: makeslice: len out of range diff --git a/gnovm/tests/files/recover16.gno b/gnovm/tests/files/recover16.gno new file mode 100644 index 00000000000..adec0d3a09b --- /dev/null +++ b/gnovm/tests/files/recover16.gno @@ -0,0 +1,15 @@ +package main + +func main() { + defer func() { + if r := recover(); r != nil { + println("recovered") + } + + }() + l := 2 + _ = make([]int, l, 1) +} + +// Output: +// recovered diff --git a/gnovm/tests/files/types/varg_4.gno b/gnovm/tests/files/types/varg_4.gno index 7d5442a0182..6c3865aaf5a 100644 --- a/gnovm/tests/files/types/varg_4.gno +++ b/gnovm/tests/files/types/varg_4.gno @@ -9,4 +9,4 @@ func main() { } // Error: -// makeslice: cap out of range +// runtime error: makeslice: cap out of range From bd506eea2411dbaa23da19a46ffed67da43319ff Mon Sep 17 00:00:00 2001 From: Jae Kwon <53785+jaekwon@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:03:28 -0700 Subject: [PATCH 65/92] fix(gnovm): use static type in doOpRef for correct interface pointer types (#5474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary - Fix doOpRef (& operator) to use the preprocessor's static type instead of the runtime concrete type when building pointer types. var i interface{} = 42; &i now correctly produces *interface{} instead of *int. - Fix doOpStar (* operator) to preserve the concrete type when dereferencing a pointer-to-interface, preventing type corruption exposed by the doOpRef fix. - Cache ATTR_TYPEOF_VALUE on RefExpr.X at all four creation sites: TRANS_LEAVE handler for user-written &x, and three synthetic RefExpr sites (auto-address for pointer receivers, deref trail, implicit &{} composite literals). - Remove stale XXX comments from values.go and op_expressions.go, fix incorrect comment about *((*Foo)(&Bar{})) in doOpStar. - Document preprocessor-to-runtime attribute contracts for ATTR_TYPEOF_VALUE and ATTR_SHIFT_RHS. Test plan - 23 new filetests (ref0–ref22) covering concrete types, interfaces (nil, holding value, holding pointer, named), struct fields, slice/array elements, closures, type assertions, pointer conversions, and method dispatch through interface pointers - 8 new realm filetests (zrealm_ref0–zrealm_ref7) covering the same scenarios in realm context - go test ./gnovm/pkg/gnolang/ -run Files -test.short - go test ./gno.land/pkg/sdk/vm/ -run Gas - go test ./gno.land/pkg/integration/ -run txtar --------- Co-authored-by: Morgan Bazalgette Co-authored-by: ltzMaxwell (cherry picked from commit 659bbb4951fc449313cb68ff57d12cd2228cbf91) --- gnovm/pkg/gnolang/nodes.go | 7 ++-- gnovm/pkg/gnolang/op_call.go | 3 ++ gnovm/pkg/gnolang/op_expressions.go | 28 +++++++++---- gnovm/pkg/gnolang/op_types.go | 16 +++++--- gnovm/pkg/gnolang/preprocess.go | 49 ++++++++++++++++------- gnovm/pkg/gnolang/values.go | 2 - gnovm/tests/files/addressable_7a_err.gno | 2 +- gnovm/tests/files/ref0.gno | 13 ++++++ gnovm/tests/files/ref1.gno | 15 +++++++ gnovm/tests/files/ref10.gno | 17 ++++++++ gnovm/tests/files/ref11.gno | 14 +++++++ gnovm/tests/files/ref12.gno | 18 +++++++++ gnovm/tests/files/ref13.gno | 18 +++++++++ gnovm/tests/files/ref14.gno | 19 +++++++++ gnovm/tests/files/ref15.gno | 16 ++++++++ gnovm/tests/files/ref16.gno | 18 +++++++++ gnovm/tests/files/ref17.gno | 17 ++++++++ gnovm/tests/files/ref18.gno | 16 ++++++++ gnovm/tests/files/ref19.gno | 16 ++++++++ gnovm/tests/files/ref2.gno | 19 +++++++++ gnovm/tests/files/ref20.gno | 23 +++++++++++ gnovm/tests/files/ref21.gno | 21 ++++++++++ gnovm/tests/files/ref22.gno | 29 ++++++++++++++ gnovm/tests/files/ref23.gno | 20 ++++++++++ gnovm/tests/files/ref24.gno | 23 +++++++++++ gnovm/tests/files/ref3.gno | 15 +++++++ gnovm/tests/files/ref4.gno | 16 ++++++++ gnovm/tests/files/ref5.gno | 13 ++++++ gnovm/tests/files/ref6.gno | 15 +++++++ gnovm/tests/files/ref7.gno | 17 ++++++++ gnovm/tests/files/ref8.gno | 22 +++++++++++ gnovm/tests/files/ref9.gno | 21 ++++++++++ gnovm/tests/files/zrealm_ref0.gno | 20 ++++++++++ gnovm/tests/files/zrealm_ref1.gno | 24 ++++++++++++ gnovm/tests/files/zrealm_ref2.gno | 20 ++++++++++ gnovm/tests/files/zrealm_ref3.gno | 19 +++++++++ gnovm/tests/files/zrealm_ref4.gno | 16 ++++++++ gnovm/tests/files/zrealm_ref5.gno | 26 ++++++++++++ gnovm/tests/files/zrealm_ref6.gno | 27 +++++++++++++ gnovm/tests/files/zrealm_ref7.gno | 23 +++++++++++ gnovm/tests/files/zrealm_ref8.gno | 50 ++++++++++++++++++++++++ gnovm/tests/files/zrealm_ref9.gno | 33 ++++++++++++++++ 42 files changed, 782 insertions(+), 34 deletions(-) create mode 100644 gnovm/tests/files/ref0.gno create mode 100644 gnovm/tests/files/ref1.gno create mode 100644 gnovm/tests/files/ref10.gno create mode 100644 gnovm/tests/files/ref11.gno create mode 100644 gnovm/tests/files/ref12.gno create mode 100644 gnovm/tests/files/ref13.gno create mode 100644 gnovm/tests/files/ref14.gno create mode 100644 gnovm/tests/files/ref15.gno create mode 100644 gnovm/tests/files/ref16.gno create mode 100644 gnovm/tests/files/ref17.gno create mode 100644 gnovm/tests/files/ref18.gno create mode 100644 gnovm/tests/files/ref19.gno create mode 100644 gnovm/tests/files/ref2.gno create mode 100644 gnovm/tests/files/ref20.gno create mode 100644 gnovm/tests/files/ref21.gno create mode 100644 gnovm/tests/files/ref22.gno create mode 100644 gnovm/tests/files/ref23.gno create mode 100644 gnovm/tests/files/ref24.gno create mode 100644 gnovm/tests/files/ref3.gno create mode 100644 gnovm/tests/files/ref4.gno create mode 100644 gnovm/tests/files/ref5.gno create mode 100644 gnovm/tests/files/ref6.gno create mode 100644 gnovm/tests/files/ref7.gno create mode 100644 gnovm/tests/files/ref8.gno create mode 100644 gnovm/tests/files/ref9.gno create mode 100644 gnovm/tests/files/zrealm_ref0.gno create mode 100644 gnovm/tests/files/zrealm_ref1.gno create mode 100644 gnovm/tests/files/zrealm_ref2.gno create mode 100644 gnovm/tests/files/zrealm_ref3.gno create mode 100644 gnovm/tests/files/zrealm_ref4.gno create mode 100644 gnovm/tests/files/zrealm_ref5.gno create mode 100644 gnovm/tests/files/zrealm_ref6.gno create mode 100644 gnovm/tests/files/zrealm_ref7.gno create mode 100644 gnovm/tests/files/zrealm_ref8.gno create mode 100644 gnovm/tests/files/zrealm_ref9.gno diff --git a/gnovm/pkg/gnolang/nodes.go b/gnovm/pkg/gnolang/nodes.go index aeb0641e537..6a68f8b1293 100644 --- a/gnovm/pkg/gnolang/nodes.go +++ b/gnovm/pkg/gnolang/nodes.go @@ -139,9 +139,10 @@ const ( ATTR_LAST_BLOCK_STMT GnoAttribute = "ATTR_LAST_BLOCK_STMT" ATTR_PACKAGE_REF GnoAttribute = "ATTR_PACKAGE_REF" ATTR_PACKAGE_DECL GnoAttribute = "ATTR_PACKAGE_DECL" - ATTR_PACKAGE_PATH GnoAttribute = "ATTR_PACKAGE_PATH" // if name expr refers to package. - ATTR_FIX_FROM GnoAttribute = "ATTR_FIX_FROM" // gno fix this version. - ATTR_LOOPVAR_SKIP GnoAttribute = "ATTR_LOOPVAR_SKIP" // temp only + ATTR_PACKAGE_PATH GnoAttribute = "ATTR_PACKAGE_PATH" // if name expr refers to package. + ATTR_FIX_FROM GnoAttribute = "ATTR_FIX_FROM" // gno fix this version. + ATTR_LOOPVAR_SKIP GnoAttribute = "ATTR_LOOPVAR_SKIP" // temp only + ATTR_REF_ELEM_TYPE GnoAttribute = "ATTR_REF_ELEM_TYPE" // static element type of &x, set on the RefExpr node during preprocessing. // For top level declarations, a map[Name]struct{} of other dependencies ATTR_DECL_DEPS GnoAttribute = "ATTR_DECL_DEPS" ) diff --git a/gnovm/pkg/gnolang/op_call.go b/gnovm/pkg/gnolang/op_call.go index a4c0c7b535e..55197438422 100644 --- a/gnovm/pkg/gnolang/op_call.go +++ b/gnovm/pkg/gnolang/op_call.go @@ -66,6 +66,9 @@ func (m *Machine) doOpPrecall() { // Do not pop type yet. // No need for frames. xv := m.PeekValue(1) + // When the preprocessor wraps a shift RHS in uint(), + // it sets ATTR_SHIFT_RHS so we can reject negative + // values before the conversion. if cx.GetAttribute(ATTR_SHIFT_RHS) == true { if xv.Sign() < 0 { m.Panic(typedString(fmt.Sprintf("runtime error: negative shift amount: %v", xv))) diff --git a/gnovm/pkg/gnolang/op_expressions.go b/gnovm/pkg/gnolang/op_expressions.go index 8cfa2620f2c..3e0e5a97e88 100644 --- a/gnovm/pkg/gnolang/op_expressions.go +++ b/gnovm/pkg/gnolang/op_expressions.go @@ -160,9 +160,17 @@ func (m *Machine) doOpStar() { ro := m.IsReadonly(xv) pvtv := (*pv.TV).WithReadonly(ro) if xpt, ok := baseOf(xv.T).(*PointerType); ok { - // e.g. type Foo; type Bar; - // *((*Foo)(&Bar{})) should be Bar, not Foo. - pvtv.T = xpt.Elem() + // When a pointer was converted to a different + // declared pointer type, the dereferenced value + // should have the element type of the pointer, + // not the original stored type. + // e.g. type Foo struct{X int}; type Bar struct{X int}; + // *((*Foo)(&Bar{})) is Foo, not Bar. + // But do not overwrite for interface element + // types; the concrete type must be preserved. + if xpt.Elem().Kind() != InterfaceKind { + pvtv.T = xpt.Elem() + } } m.PushValue(pvtv) } @@ -177,13 +185,19 @@ func (m *Machine) doOpStar() { } } -// XXX this is wrong, for var i interface{}; &i is *interface{}. +// doOpRef implements the & (address-of) operator. +// The element type for the resulting pointer is taken from +// ATTR_REF_ELEM_TYPE on the RefExpr, not from the runtime +// xv.TV.T. This distinction matters for interface variables: +// var i interface{} = 42; &i must yield *interface{}, not *int. +// ATTR_REF_ELEM_TYPE is set during preprocessing in +// TRANS_LEAVE *RefExpr and at each synthetic RefExpr site. func (m *Machine) doOpRef() { rx := m.PopExpr().(*RefExpr) xv, ro := m.PopAsPointer2(rx.X) - elt := xv.TV.T - if elt == DataByteType { - elt = xv.TV.V.(DataByteValue).ElemType + elt, ok := rx.GetAttribute(ATTR_REF_ELEM_TYPE).(Type) + if !ok { + panic("ATTR_REF_ELEM_TYPE not set during preprocessing") } m.Alloc.AllocatePointer() m.PushValue(TypedValue{ diff --git a/gnovm/pkg/gnolang/op_types.go b/gnovm/pkg/gnolang/op_types.go index 3e2c03a7c8d..bb83ade5e2a 100644 --- a/gnovm/pkg/gnolang/op_types.go +++ b/gnovm/pkg/gnolang/op_types.go @@ -186,6 +186,10 @@ func (m *Machine) doOpStaticTypeOf() { m.PushValue(asValue(UntypedBoolType)) } case *CallExpr: + // ATTR_TYPEOF_VALUE must already be set on every CallExpr + // during preprocessing: for type conversions (TRANS_LEAVE + // *CallExpr conversion paths), for generic/specialized + // calls, and for plain function calls via the general case. t := getTypeOf(x) m.PushValue(asValue(t)) case *IndexExpr: @@ -399,12 +403,12 @@ func (m *Machine) doOpStaticTypeOf() { panic("unexpected star expression") } case *RefExpr: - start := len(m.Values) - m.PushOp(OpHalt) - m.PushExpr(x.X) - m.PushOp(OpStaticTypeOf) - m.Run(StageRun) - xt := m.ReapValues(start)[0].GetType() + // The static type of &x is *typeof(x). + // ATTR_REF_ELEM_TYPE is set during preprocessing. + xt, ok := x.GetAttribute(ATTR_REF_ELEM_TYPE).(Type) + if !ok { + panic("ATTR_REF_ELEM_TYPE not set during preprocessing") + } m.PushValue(asValue(&PointerType{Elt: xt})) case *TypeAssertExpr: if x.HasOK { diff --git a/gnovm/pkg/gnolang/preprocess.go b/gnovm/pkg/gnolang/preprocess.go index 5f2193acc8e..b1d0a5842f7 100644 --- a/gnovm/pkg/gnolang/preprocess.go +++ b/gnovm/pkg/gnolang/preprocess.go @@ -1367,6 +1367,8 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { Op: n.Op, Right: rn, } + // Mark the uint() conversion as a shift RHS so + // doOpCall can assert non-negative at runtime. n2.Right.SetAttribute(ATTR_SHIFT_RHS, true) resn := Preprocess(store, last, n2) return resn, TRANS_CONTINUE @@ -2290,6 +2292,18 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { panic(fmt.Sprintf("invalid operation: cannot indirect %s (variable of type %s)", n.X.String(), xt.String())) } // TRANS_LEAVE ----------------------- + case *RefExpr: + // Cache the static type of X as ATTR_REF_ELEM_TYPE + // on the RefExpr so doOpRef can use it (instead of + // the runtime type, which is wrong for interface variables). + xt := evalStaticTypeOf(store, last, n.X) + if tt, ok := xt.(*tupleType); ok { + panic(fmt.Sprintf( + "cannot take address of multi-value call (results: %s)", + tt.String())) + } + n.SetAttribute(ATTR_REF_ELEM_TYPE, xt) + // TRANS_LEAVE ----------------------- case *SelectorExpr: xt := evalStaticTypeOf(store, last, n.X) @@ -2343,7 +2357,9 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { // value: t.Mp is equivalent to (&t).Mp." // // convert to (&x).m, but leave xt as is. - n.X = &RefExpr{X: n.X} + rx := &RefExpr{X: n.X} + rx.SetAttribute(ATTR_REF_ELEM_TYPE, nxt2) + n.X = rx setPreprocessed(n.X) switch tr[len(tr)-1].Type { case VPDerefPtrMethod: @@ -2369,7 +2385,9 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { // Case 2: If tr[0] is deref type, but xt // is not pointer type, replace n.X with // &RefExpr{X: n.X}. - n.X = &RefExpr{X: n.X} + rx := &RefExpr{X: n.X} + rx.SetAttribute(ATTR_REF_ELEM_TYPE, nxt2) + n.X = rx setPreprocessed(n.X) } // bound method or underlying. @@ -5471,6 +5489,7 @@ func elideCompositeExpr(last BlockNode, x *Expr, t Type) { if t.Kind() == PointerKind { clx.Type = toConstTypeExpr(last, tx, t.Elem()) refx := &RefExpr{X: clx} + refx.SetAttribute(ATTR_REF_ELEM_TYPE, t.Elem()) refx.SetSpan(clx.GetSpan()) *x = refx elideCompositeElements(last, clx, t.Elem()) // recurse @@ -5587,20 +5606,20 @@ func codaInitOrderDeps(pn *PackageNode, fn *FileNode) { // resolveEffectiveDeps then walks GetB's body to find B. switch n.Path.Type { case VPValMethod, VPPtrMethod, VPDerefValMethod, VPDerefPtrMethod: - // Get the receiver type from the cached ATTR_TYPEOF_VALUE. - // Two cases for RefExpr: - // (a) user-written &T{}: n.X is the RefExpr with type *T - // stored directly on the RefExpr node. - // (b) auto-generated &x (pointer-receiver auto-address): - // preprocessing wraps the original expression in a - // RefExpr AFTER caching the type on the inner node, - // so n.X (the RefExpr) has no cached type but n.X.X - // does. - xt, ok := n.X.GetAttribute(ATTR_TYPEOF_VALUE).(Type) + // Get the receiver type from ATTR_REF_ELEM_TYPE + // on the RefExpr, or ATTR_TYPEOF_VALUE on n.X. + // + // For auto-addressed receivers (n.X is a synthetic *RefExpr), + // read from ATTR_REF_ELEM_TYPE. For already-pointer receivers + // (VPDerefValMethod/VPDerefPtrMethod), n.X is not a RefExpr; + // fall back to ATTR_TYPEOF_VALUE on the expression itself. + var xt Type + var ok bool + if rx, ok2 := n.X.(*RefExpr); ok2 { + xt, ok = rx.GetAttribute(ATTR_REF_ELEM_TYPE).(Type) + } if !ok { - if re, ok2 := n.X.(*RefExpr); ok2 { - xt, ok = re.X.GetAttribute(ATTR_TYPEOF_VALUE).(Type) - } + xt, ok = n.X.GetAttribute(ATTR_TYPEOF_VALUE).(Type) } if !ok { break diff --git a/gnovm/pkg/gnolang/values.go b/gnovm/pkg/gnolang/values.go index e79277d8c7b..f322e4f7fd5 100644 --- a/gnovm/pkg/gnolang/values.go +++ b/gnovm/pkg/gnolang/values.go @@ -1,7 +1,5 @@ package gnolang -// XXX TODO address "this is wrong, for var i interface{}; &i is *interface{}." - import ( "encoding/binary" "fmt" diff --git a/gnovm/tests/files/addressable_7a_err.gno b/gnovm/tests/files/addressable_7a_err.gno index 811e1a07daf..564e20a5556 100644 --- a/gnovm/tests/files/addressable_7a_err.gno +++ b/gnovm/tests/files/addressable_7a_err.gno @@ -9,7 +9,7 @@ func main() { } // Error: -// main/addressable_7a_err.gno:8:2-12: getTypeOf() only supports *CallExpr with 1 result, got ([]int,[]string) +// main/addressable_7a_err.gno:8:6-12: cannot take address of multi-value call (results: ([]int,[]string)) // TypeCheckError: // main/addressable_7a_err.gno:8:7: multiple-value foo() (value of type ([]int, []string)) in single-value context diff --git a/gnovm/tests/files/ref0.gno b/gnovm/tests/files/ref0.gno new file mode 100644 index 00000000000..45ca99f4d16 --- /dev/null +++ b/gnovm/tests/files/ref0.gno @@ -0,0 +1,13 @@ +package main + +// ref of a concrete int variable. +// xv.TV.T is int, which is correct. + +func main() { + var a int = 42 + b := &a + println(*b) +} + +// Output: +// 42 diff --git a/gnovm/tests/files/ref1.gno b/gnovm/tests/files/ref1.gno new file mode 100644 index 00000000000..83f02d1564f --- /dev/null +++ b/gnovm/tests/files/ref1.gno @@ -0,0 +1,15 @@ +package main + +// ref of a declared type variable. +// xv.TV.T is main.myint, which is correct. + +type myint int + +func main() { + var a myint = 42 + b := &a + println(*b) +} + +// Output: +// (42 main.myint) diff --git a/gnovm/tests/files/ref10.gno b/gnovm/tests/files/ref10.gno new file mode 100644 index 00000000000..e69dbabaf19 --- /dev/null +++ b/gnovm/tests/files/ref10.gno @@ -0,0 +1,17 @@ +package main + +import "fmt" + +// ref of a slice element that is an interface type. + +func main() { + s := []interface{}{1, "two", 3.0} + p := &s[0] + fmt.Printf("%T\n", p) + *p = "replaced" + fmt.Println(s[0]) +} + +// Output: +// *interface {} +// replaced diff --git a/gnovm/tests/files/ref11.gno b/gnovm/tests/files/ref11.gno new file mode 100644 index 00000000000..6af04ea1b23 --- /dev/null +++ b/gnovm/tests/files/ref11.gno @@ -0,0 +1,14 @@ +package main + +import "fmt" + +// nested pointer roundtrip: *&x where x is interface{}. + +func main() { + var i interface{} = 42 + v := *&i + fmt.Printf("%T %v\n", v, v) +} + +// Output: +// int 42 diff --git a/gnovm/tests/files/ref12.gno b/gnovm/tests/files/ref12.gno new file mode 100644 index 00000000000..b1a54b88f8b --- /dev/null +++ b/gnovm/tests/files/ref12.gno @@ -0,0 +1,18 @@ +package main + +import "fmt" + +// passing &interface_var to function expecting *interface{} (non-realm). + +func setIt(p *interface{}, val interface{}) { + *p = val +} + +func main() { + var i interface{} = 100 + setIt(&i, "changed") + fmt.Println(i) +} + +// Output: +// changed diff --git a/gnovm/tests/files/ref13.gno b/gnovm/tests/files/ref13.gno new file mode 100644 index 00000000000..59b6aafd195 --- /dev/null +++ b/gnovm/tests/files/ref13.gno @@ -0,0 +1,18 @@ +package main + +import "fmt" + +// ref of a nil named interface. + +type Stringer interface { + String() string +} + +func main() { + var x Stringer + p := &x + fmt.Printf("%T\n", p) +} + +// Output: +// *main.Stringer diff --git a/gnovm/tests/files/ref14.gno b/gnovm/tests/files/ref14.gno new file mode 100644 index 00000000000..bfde8cf168f --- /dev/null +++ b/gnovm/tests/files/ref14.gno @@ -0,0 +1,19 @@ +package main + +import "fmt" + +// ref of an array element that is an interface type. + +func main() { + var a [3]interface{} + a[0] = 42 + a[1] = "hello" + p := &a[1] + fmt.Printf("%T\n", p) + *p = 99 + fmt.Println(a[1]) +} + +// Output: +// *interface {} +// 99 diff --git a/gnovm/tests/files/ref15.gno b/gnovm/tests/files/ref15.gno new file mode 100644 index 00000000000..5703a4bd486 --- /dev/null +++ b/gnovm/tests/files/ref15.gno @@ -0,0 +1,16 @@ +package main + +import "fmt" + +// Deref of *interface{} when the interface holds nil. +// *p should be a nil interface{}, not a typed nil. + +func main() { + var i interface{} + p := &i + v := *p + fmt.Println(v == nil) +} + +// Output: +// true diff --git a/gnovm/tests/files/ref16.gno b/gnovm/tests/files/ref16.gno new file mode 100644 index 00000000000..3fd866921ea --- /dev/null +++ b/gnovm/tests/files/ref16.gno @@ -0,0 +1,18 @@ +package main + +import "fmt" + +// Deref of a converted pointer type. +// *((*Foo)(&Bar{})) should produce Foo, not Bar. + +type Foo struct{ X int } +type Bar struct{ X int } + +func main() { + b := Bar{X: 1} + v := *((*Foo)(&b)) + fmt.Printf("%T %v\n", v, v) +} + +// Output: +// main.Foo {1} diff --git a/gnovm/tests/files/ref17.gno b/gnovm/tests/files/ref17.gno new file mode 100644 index 00000000000..c8a140b0144 --- /dev/null +++ b/gnovm/tests/files/ref17.gno @@ -0,0 +1,17 @@ +package main + +import "fmt" + +// Deref of a converted pointer type with primitive underlying type. + +type Meters int +type Feet int + +func main() { + f := Feet(3) + v := *((*Meters)(&f)) + fmt.Printf("%T %v\n", v, v) +} + +// Output: +// main.Meters 3 diff --git a/gnovm/tests/files/ref18.gno b/gnovm/tests/files/ref18.gno new file mode 100644 index 00000000000..2f173a3c500 --- /dev/null +++ b/gnovm/tests/files/ref18.gno @@ -0,0 +1,16 @@ +package main + +import "fmt" + +// Interface holding a pointer value: &i should still be *interface{}. + +type S struct{ X int } + +func main() { + var i interface{} = &S{X: 1} + p := &i + fmt.Printf("%T\n", p) +} + +// Output: +// *interface {} diff --git a/gnovm/tests/files/ref19.gno b/gnovm/tests/files/ref19.gno new file mode 100644 index 00000000000..9ef3f58b9fc --- /dev/null +++ b/gnovm/tests/files/ref19.gno @@ -0,0 +1,16 @@ +package main + +import "fmt" + +// Type assertion through interface pointer. +// *p must produce a proper interface{} for the assertion to work. + +func main() { + var i interface{} = 42 + p := &i + v := (*p).(int) + fmt.Println(v) +} + +// Output: +// 42 diff --git a/gnovm/tests/files/ref2.gno b/gnovm/tests/files/ref2.gno new file mode 100644 index 00000000000..6a3c7b03f56 --- /dev/null +++ b/gnovm/tests/files/ref2.gno @@ -0,0 +1,19 @@ +package main + +// ref of a struct variable. +// xv.TV.T is main.MyStruct, which is correct. + +type MyStruct struct { + A int +} + +func main() { + var s MyStruct + s.A = 1 + p := &s + p.A = 2 + println(s) +} + +// Output: +// (struct{(2 int)} main.MyStruct) diff --git a/gnovm/tests/files/ref20.gno b/gnovm/tests/files/ref20.gno new file mode 100644 index 00000000000..dd671d06829 --- /dev/null +++ b/gnovm/tests/files/ref20.gno @@ -0,0 +1,23 @@ +package main + +import "fmt" + +// Deref of *Stringer preserves concrete type. + +type Stringer interface { + String() string +} + +type mystr struct{ s string } + +func (m mystr) String() string { return m.s } + +func main() { + var x Stringer = mystr{"hello"} + p := &x + v := *p + fmt.Println(v.String()) +} + +// Output: +// hello diff --git a/gnovm/tests/files/ref21.gno b/gnovm/tests/files/ref21.gno new file mode 100644 index 00000000000..4f1f4ee1ae4 --- /dev/null +++ b/gnovm/tests/files/ref21.gno @@ -0,0 +1,21 @@ +package main + +import "fmt" + +// Closure capturing &i where i is interface{}. +// Exercises heap-use path in PopAsPointer2. + +func main() { + var i interface{} = 42 + f := func() { + p := &i + fmt.Printf("%T\n", p) + *p = "hello" + } + f() + fmt.Println(i) +} + +// Output: +// *interface {} +// hello diff --git a/gnovm/tests/files/ref22.gno b/gnovm/tests/files/ref22.gno new file mode 100644 index 00000000000..8e43531ed2b --- /dev/null +++ b/gnovm/tests/files/ref22.gno @@ -0,0 +1,29 @@ +package main + +import "fmt" + +// Reassign different concrete type through *Stringer pointer. + +type Stringer interface { + String() string +} + +type A struct{} + +func (A) String() string { return "A" } + +type B struct{} + +func (B) String() string { return "B" } + +func main() { + var x Stringer = A{} + p := &x + fmt.Println((*p).String()) + *p = B{} + fmt.Println((*p).String()) +} + +// Output: +// A +// B diff --git a/gnovm/tests/files/ref23.gno b/gnovm/tests/files/ref23.gno new file mode 100644 index 00000000000..31f7d4e0355 --- /dev/null +++ b/gnovm/tests/files/ref23.gno @@ -0,0 +1,20 @@ +package main + +import "fmt" + +// ref of a []byte element (DataByteType path). +// The static type of s[i] is uint8, so &s[i] must be *uint8. +// Previously doOpRef had explicit DataByteType handling; now the +// static-type path (getTypeOf) already returns uint8. + +func main() { + s := []byte("hello") + p := &s[0] + fmt.Printf("%T\n", p) + *p = 'H' + fmt.Println(string(s)) +} + +// Output: +// *uint8 +// Hello diff --git a/gnovm/tests/files/ref24.gno b/gnovm/tests/files/ref24.gno new file mode 100644 index 00000000000..cdaff8c0f4d --- /dev/null +++ b/gnovm/tests/files/ref24.gno @@ -0,0 +1,23 @@ +package main + +import "fmt" + +// Auto-address for pointer receiver via selector expression. +// When t.PointerMethod() is called and t is addressable, the +// preprocessor synthesizes &t, setting ATTR_TYPEOF_VALUE on t +// before wrapping it in RefExpr. Exercises the setPreprocessed +// path in SelectorExpr TRANS_LEAVE. + +type Counter struct{ n int } + +func (c *Counter) Inc() { c.n++ } + +func main() { + var c Counter + c.Inc() + c.Inc() + fmt.Println(c.n) +} + +// Output: +// 2 diff --git a/gnovm/tests/files/ref3.gno b/gnovm/tests/files/ref3.gno new file mode 100644 index 00000000000..6a11d55280e --- /dev/null +++ b/gnovm/tests/files/ref3.gno @@ -0,0 +1,15 @@ +package main + +import "fmt" + +// ref of a concrete type; use fmt to print the pointer type. +// This verifies the type of the pointer itself, not just the deref. + +func main() { + var a int = 1 + b := &a + fmt.Printf("%T\n", b) +} + +// Output: +// *int diff --git a/gnovm/tests/files/ref4.gno b/gnovm/tests/files/ref4.gno new file mode 100644 index 00000000000..34d44f1500d --- /dev/null +++ b/gnovm/tests/files/ref4.gno @@ -0,0 +1,16 @@ +package main + +import "fmt" + +// ref of a declared type; verify the pointer type string. + +type myint int + +func main() { + var a myint = 1 + b := &a + fmt.Printf("%T\n", b) +} + +// Output: +// *main.myint diff --git a/gnovm/tests/files/ref5.gno b/gnovm/tests/files/ref5.gno new file mode 100644 index 00000000000..e16ab6f1c0a --- /dev/null +++ b/gnovm/tests/files/ref5.gno @@ -0,0 +1,13 @@ +package main + +// ref of a nil interface{} variable, assign through pointer. + +func main() { + var i interface{} + p := &i + *p = 42 + println(i) +} + +// Output: +// 42 diff --git a/gnovm/tests/files/ref6.gno b/gnovm/tests/files/ref6.gno new file mode 100644 index 00000000000..7238e91d3c5 --- /dev/null +++ b/gnovm/tests/files/ref6.gno @@ -0,0 +1,15 @@ +package main + +import "fmt" + +// ref of an interface{} variable holding a concrete value. +// In Go, &i where i is interface{} always gives *interface{}. + +func main() { + var i interface{} = 42 + p := &i + fmt.Printf("%T\n", p) +} + +// Output: +// *interface {} diff --git a/gnovm/tests/files/ref7.gno b/gnovm/tests/files/ref7.gno new file mode 100644 index 00000000000..9864536a6ab --- /dev/null +++ b/gnovm/tests/files/ref7.gno @@ -0,0 +1,17 @@ +package main + +import "fmt" + +// ref of an interface{} variable, then assign a different type through the pointer. +// In Go this is valid: *p = "hello" changes i from int to string. +// If the bug makes &i produce *int, then assigning a string would fail. + +func main() { + var i interface{} = 42 + p := &i + *p = "hello" + fmt.Println(i) +} + +// Output: +// hello diff --git a/gnovm/tests/files/ref8.gno b/gnovm/tests/files/ref8.gno new file mode 100644 index 00000000000..2ea9de14b32 --- /dev/null +++ b/gnovm/tests/files/ref8.gno @@ -0,0 +1,22 @@ +package main + +import "fmt" + +// ref of a named interface variable holding a concrete value. + +type Stringer interface { + String() string +} + +type mystr struct{ s string } + +func (m mystr) String() string { return m.s } + +func main() { + var x Stringer = mystr{"hello"} + p := &x + fmt.Printf("%T\n", p) +} + +// Output: +// *main.Stringer diff --git a/gnovm/tests/files/ref9.gno b/gnovm/tests/files/ref9.gno new file mode 100644 index 00000000000..86c9bf10e8a --- /dev/null +++ b/gnovm/tests/files/ref9.gno @@ -0,0 +1,21 @@ +package main + +import "fmt" + +// ref of a struct field that is an interface type. + +type S struct { + F interface{} +} + +func main() { + s := S{F: 42} + p := &s.F + fmt.Printf("%T\n", p) + *p = "hello" + fmt.Println(s.F) +} + +// Output: +// *interface {} +// hello diff --git a/gnovm/tests/files/zrealm_ref0.gno b/gnovm/tests/files/zrealm_ref0.gno new file mode 100644 index 00000000000..5a69da8ab84 --- /dev/null +++ b/gnovm/tests/files/zrealm_ref0.gno @@ -0,0 +1,20 @@ +// PKGPATH: gno.land/r/test +package test + +// Realm filetest: ref of a concrete type in a realm. +// This works correctly since xv.TV.T matches the static type. + +var x int + +func init() { + x = 1 +} + +func main(cur realm) { + p := &x + *p = 2 + println(x) +} + +// Output: +// 2 diff --git a/gnovm/tests/files/zrealm_ref1.gno b/gnovm/tests/files/zrealm_ref1.gno new file mode 100644 index 00000000000..86132992d7a --- /dev/null +++ b/gnovm/tests/files/zrealm_ref1.gno @@ -0,0 +1,24 @@ +// PKGPATH: gno.land/r/test +package test + +// Realm filetest: ref of a struct field via pointer in a realm. +// This works correctly since the struct type is concrete. + +type MyStruct struct { + A int +} + +var s MyStruct + +func init() { + s.A = 10 +} + +func main(cur realm) { + p := &s + p.A = 20 + println(s) +} + +// Output: +// (struct{(20 int)} gno.land/r/test.MyStruct) diff --git a/gnovm/tests/files/zrealm_ref2.gno b/gnovm/tests/files/zrealm_ref2.gno new file mode 100644 index 00000000000..428444816ea --- /dev/null +++ b/gnovm/tests/files/zrealm_ref2.gno @@ -0,0 +1,20 @@ +// PKGPATH: gno.land/r/test +package test + +import "fmt" + +// Realm filetest: ref of an interface{} var holding a concrete value. + +var i interface{} + +func init() { + i = 42 +} + +func main(cur realm) { + p := &i + fmt.Printf("%T\n", p) +} + +// Output: +// *interface {} diff --git a/gnovm/tests/files/zrealm_ref3.gno b/gnovm/tests/files/zrealm_ref3.gno new file mode 100644 index 00000000000..8efcf4a450e --- /dev/null +++ b/gnovm/tests/files/zrealm_ref3.gno @@ -0,0 +1,19 @@ +// PKGPATH: gno.land/r/test +package test + +// Realm filetest: ref of an interface{} var, mutation through pointer. + +var i interface{} + +func init() { + i = 42 +} + +func main(cur realm) { + p := &i + *p = "hello" + println(i) +} + +// Output: +// hello diff --git a/gnovm/tests/files/zrealm_ref4.gno b/gnovm/tests/files/zrealm_ref4.gno new file mode 100644 index 00000000000..c706dc1de31 --- /dev/null +++ b/gnovm/tests/files/zrealm_ref4.gno @@ -0,0 +1,16 @@ +// PKGPATH: gno.land/r/test +package test + +import "fmt" + +// Realm filetest: ref of a nil interface{} var. + +var i interface{} + +func main(cur realm) { + p := &i + fmt.Printf("%T\n", p) +} + +// Output: +// *interface {} diff --git a/gnovm/tests/files/zrealm_ref5.gno b/gnovm/tests/files/zrealm_ref5.gno new file mode 100644 index 00000000000..d737c29349e --- /dev/null +++ b/gnovm/tests/files/zrealm_ref5.gno @@ -0,0 +1,26 @@ +// PKGPATH: gno.land/r/test +package test + +import "fmt" + +// Realm filetest: function takes *interface{} parameter. +// Tests that the pointer type is checked correctly +// when passed to a function expecting *interface{}. + +var i interface{} + +func init() { + i = 100 +} + +func setViaPtr(p *interface{}, val interface{}) { + *p = val +} + +func main(cur realm) { + setViaPtr(&i, "changed") + fmt.Println(i) +} + +// Output: +// changed diff --git a/gnovm/tests/files/zrealm_ref6.gno b/gnovm/tests/files/zrealm_ref6.gno new file mode 100644 index 00000000000..6ebd9dbafe3 --- /dev/null +++ b/gnovm/tests/files/zrealm_ref6.gno @@ -0,0 +1,27 @@ +// PKGPATH: gno.land/r/test +package test + +import "fmt" + +// Realm filetest: ref of a struct field that is an interface type. + +type S struct { + F interface{} +} + +var s S + +func init() { + s.F = 42 +} + +func main(cur realm) { + p := &s.F + fmt.Printf("%T\n", p) + *p = "hello" + fmt.Println(s.F) +} + +// Output: +// *interface {} +// hello diff --git a/gnovm/tests/files/zrealm_ref7.gno b/gnovm/tests/files/zrealm_ref7.gno new file mode 100644 index 00000000000..c9cb5760348 --- /dev/null +++ b/gnovm/tests/files/zrealm_ref7.gno @@ -0,0 +1,23 @@ +// PKGPATH: gno.land/r/test +package test + +import "fmt" + +// Realm filetest: ref of a slice element that is an interface type. + +var s []interface{} + +func init() { + s = []interface{}{1, "two", 3.0} +} + +func main(cur realm) { + p := &s[0] + fmt.Printf("%T\n", p) + *p = "replaced" + fmt.Println(s[0]) +} + +// Output: +// *interface {} +// replaced diff --git a/gnovm/tests/files/zrealm_ref8.gno b/gnovm/tests/files/zrealm_ref8.gno new file mode 100644 index 00000000000..2588386f41d --- /dev/null +++ b/gnovm/tests/files/zrealm_ref8.gno @@ -0,0 +1,50 @@ +// PKGPATH: gno.land/r/test +package test + +// Realm filetest: pointer-receiver method call on a package-level +// value type via auto-address (&x).Method(), both direct and +// inside a closure callback. Mirrors the boards2 pattern where +// gFlaggingThresholds.Set() is called inside crossingFn(func(){...}). + +type Tree struct { + size int +} + +func (t *Tree) Set(k string, v int) { + t.size += v +} + +func (t *Tree) Size() int { + return t.size +} + +var gTree Tree + +func withCallback(fn func()) { + fn() +} + +func init() { + // Direct auto-address: (&gTree).Set(...) + gTree.Set("a", 1) + + // Auto-address inside closure callback (like crossingFn pattern) + withCallback(func() { + gTree.Set("b", 2) + }) +} + +func main(cur realm) { + // Auto-address after realm persistence + gTree.Set("c", 3) + + // Also inside closure after persistence + withCallback(func() { + gTree.Set("d", 4) + }) + + println(gTree.Size()) +} + +// Output: +// 10 diff --git a/gnovm/tests/files/zrealm_ref9.gno b/gnovm/tests/files/zrealm_ref9.gno new file mode 100644 index 00000000000..109bb4c87a2 --- /dev/null +++ b/gnovm/tests/files/zrealm_ref9.gno @@ -0,0 +1,33 @@ +// PKGPATH: gno.land/r/test +package test + +// Realm filetest: direct pointer-receiver method call on +// package-level value type (no closure). Tests that auto-address +// (&gTree).Set() works after realm persistence. + +type Tree struct { + size int +} + +func (t *Tree) Set(k string, v int) { + t.size += v +} + +func (t *Tree) Size() int { + return t.size +} + +var gTree Tree + +func init() { + gTree.Set("a", 1) + gTree.Set("b", 2) +} + +func main(cur realm) { + gTree.Set("c", 3) + println(gTree.Size()) +} + +// Output: +// 6 From 9546120b2ac783d19abc44f1794bfb63ad4f7356 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:36:57 +0200 Subject: [PATCH 66/92] chore(contribs/tx-archive): register chain stdlib amino types (#5535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of the relevant code change from [gnolang/tx-archive@de762ac](https://github.com/gnolang/tx-archive/commit/de762ac5c0ed8e921614f97eaec3c99a97d81160). That commit updated tx-archive for newer Gno API signatures. Most of it (`context.Background()` on `Status` / `BlockResults` / `BroadcastTxSync`) is already applied in the in-tree copy; the only missing piece is a blank import of \`gnovm/stdlibs/chain\` so amino can decode events emitted by chain-stdlib syscalls. Added to both \`backup/backup.go\` and \`restore/client/http/http.go\`. Companion to the ongoing \`contribs/tx-archive\` sync: - https://github.com/gnolang/tx-archive/pull/72 — archives the standalone repo - https://github.com/gnolang/gno/pull/5533 — adds hardfork replay metadata fields - https://github.com/gnolang/gno/pull/5486 — glue PR using the updated tx-archive ## AI disclosure Prepared with assistance from Claude Code. (cherry picked from commit 5ffdbd6807e0610ae391175c8763f40038dea083) --- contribs/tx-archive/backup/backup.go | 3 ++- contribs/tx-archive/restore/client/http/http.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/contribs/tx-archive/backup/backup.go b/contribs/tx-archive/backup/backup.go index d3df9573589..9e2cc9aa453 100644 --- a/contribs/tx-archive/backup/backup.go +++ b/contribs/tx-archive/backup/backup.go @@ -7,7 +7,8 @@ import ( "time" "github.com/gnolang/gno/gno.land/pkg/gnoland" - _ "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + _ "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // amino types + _ "github.com/gnolang/gno/gnovm/stdlibs/chain" // amino types "github.com/gnolang/gno/contribs/tx-archive/backup/client" "github.com/gnolang/gno/contribs/tx-archive/backup/writer" diff --git a/contribs/tx-archive/restore/client/http/http.go b/contribs/tx-archive/restore/client/http/http.go index 253f04acfc0..d3da3b6ac27 100644 --- a/contribs/tx-archive/restore/client/http/http.go +++ b/contribs/tx-archive/restore/client/http/http.go @@ -9,7 +9,8 @@ import ( rpcClient "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" "github.com/gnolang/gno/tm2/pkg/std" - _ "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + _ "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // amino types + _ "github.com/gnolang/gno/gnovm/stdlibs/chain" // amino types ) // Client is the TM2 HTTP client From ec916407b4d1ff56323f5a2c1fbb8cc0cb34568e Mon Sep 17 00:00:00 2001 From: ltzmaxwell Date: Fri, 17 Apr 2026 23:10:45 +0800 Subject: [PATCH 67/92] fix(gnovm): assert index non-negative for const in preprocess. (#5141) depends on #5010 . --------- Co-authored-by: Morgan (cherry picked from commit c6d9832f09759ca9f9c2f0fed0c97b2cc387fb04) --- gnovm/pkg/gnolang/preprocess.go | 23 +++++++++++++++++++---- gnovm/tests/files/index1.gno | 10 ++++++++++ gnovm/tests/files/index10.gno | 11 +++++++++++ gnovm/tests/files/index11.gno | 11 +++++++++++ gnovm/tests/files/index2.gno | 12 ++++++++++++ gnovm/tests/files/index3.gno | 17 +++++++++++++++++ gnovm/tests/files/index4.gno | 12 ++++++++++++ gnovm/tests/files/index5.gno | 12 ++++++++++++ gnovm/tests/files/index6.gno | 12 ++++++++++++ gnovm/tests/files/index7.gno | 12 ++++++++++++ gnovm/tests/files/index8.gno | 14 ++++++++++++++ gnovm/tests/files/index9.gno | 11 +++++++++++ gnovm/tests/files/recover13.gno | 19 ++++++++----------- 13 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 gnovm/tests/files/index1.gno create mode 100644 gnovm/tests/files/index10.gno create mode 100644 gnovm/tests/files/index11.gno create mode 100644 gnovm/tests/files/index2.gno create mode 100644 gnovm/tests/files/index3.gno create mode 100644 gnovm/tests/files/index4.gno create mode 100644 gnovm/tests/files/index5.gno create mode 100644 gnovm/tests/files/index6.gno create mode 100644 gnovm/tests/files/index7.gno create mode 100644 gnovm/tests/files/index8.gno create mode 100644 gnovm/tests/files/index9.gno diff --git a/gnovm/pkg/gnolang/preprocess.go b/gnovm/pkg/gnolang/preprocess.go index b1d0a5842f7..2cbb5084353 100644 --- a/gnovm/pkg/gnolang/preprocess.go +++ b/gnovm/pkg/gnolang/preprocess.go @@ -2121,7 +2121,7 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { case StringKind, ArrayKind, SliceKind: // Replace const index with int *ConstExpr, // or if not const, assert integer type.. - checkOrConvertIntegerKind(store, last, n, n.Index) + checkOrConvertIndexKind(store, last, n, n.Index) case MapKind: mt := baseOf(dt).(*MapType) checkOrConvertType(store, last, n, &n.Index, mt.Key) @@ -2135,9 +2135,9 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { case *SliceExpr: // Replace const L/H/M with int *ConstExpr, // or if not const, assert integer type.. - checkOrConvertIntegerKind(store, last, n, n.Low) - checkOrConvertIntegerKind(store, last, n, n.High) - checkOrConvertIntegerKind(store, last, n, n.Max) + checkOrConvertIndexKind(store, last, n, n.Low) + checkOrConvertIndexKind(store, last, n, n.High) + checkOrConvertIndexKind(store, last, n, n.Max) t := evalStaticTypeOf(store, last, n.X) switch t.Kind() { @@ -2220,11 +2220,17 @@ func preprocess1(store Store, ctx BlockNode, n Node) Node { } case *ArrayType: for i := range n.Elts { + if cx, ok := n.Elts[i].Key.(*ConstExpr); ok && cx.TypedValue.Sign() < 0 { + panic(fmt.Sprintf("invalid argument: index must not be negative: %v", cx.TypedValue)) + } convertType(store, last, n, &n.Elts[i].Key, IntType) checkOrConvertType(store, last, n, &n.Elts[i].Value, cclt.Elt) } case *SliceType: for i := range n.Elts { + if cx, ok := n.Elts[i].Key.(*ConstExpr); ok && cx.TypedValue.Sign() < 0 { + panic(fmt.Sprintf("invalid argument: index must not be negative: %v", cx.TypedValue)) + } convertType(store, last, n, &n.Elts[i].Key, IntType) checkOrConvertType(store, last, n, &n.Elts[i].Value, cclt.Elt) } @@ -4839,6 +4845,15 @@ func checkBoolKind(xt Type) { } } +// checkOrConvertIndexKind ensures the expression evaluates to an integer +// and, if it is a constant, ensures it is non-negative. +func checkOrConvertIndexKind(store Store, last BlockNode, n Node, x Expr) { + if cx, ok := x.(*ConstExpr); ok && cx.TypedValue.Sign() < 0 { + panic(fmt.Sprintf("invalid argument: index must not be negative: %v", cx.TypedValue)) + } + checkOrConvertIntegerKind(store, last, n, x) +} + // like checkOrConvertType() but for any typed integer kind. func checkOrConvertIntegerKind(store Store, last BlockNode, n Node, x Expr) { if cx, ok := x.(*ConstExpr); ok { diff --git a/gnovm/tests/files/index1.gno b/gnovm/tests/files/index1.gno new file mode 100644 index 00000000000..1d0505117fa --- /dev/null +++ b/gnovm/tests/files/index1.gno @@ -0,0 +1,10 @@ +package main + +func main() { + var a [1024]byte + i := -1 + _ = a[i] +} + +// Error: +// runtime error: index out of range [-1] diff --git a/gnovm/tests/files/index10.gno b/gnovm/tests/files/index10.gno new file mode 100644 index 00000000000..4b484aaea84 --- /dev/null +++ b/gnovm/tests/files/index10.gno @@ -0,0 +1,11 @@ +package main + +func main() { + _ = []int{-1: 5} +} + +// Error: +// main/index10.gno:4:6-18: invalid argument: index must not be negative: (-1 bigint) + +// TypeCheckError: +// main/index10.gno:4:12: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/index11.gno b/gnovm/tests/files/index11.gno new file mode 100644 index 00000000000..4ed3467d675 --- /dev/null +++ b/gnovm/tests/files/index11.gno @@ -0,0 +1,11 @@ +package main + +func main() { + _ = [5]int{-1: 5} +} + +// Error: +// main/index11.gno:4:6-19: invalid argument: index must not be negative: (-1 bigint) + +// TypeCheckError: +// main/index11.gno:4:13: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/index2.gno b/gnovm/tests/files/index2.gno new file mode 100644 index 00000000000..9e87df0a00b --- /dev/null +++ b/gnovm/tests/files/index2.gno @@ -0,0 +1,12 @@ +package main + +func main() { + var a [1024]byte + _ = a[-1] +} + +// Error: +// main/index2.gno:5:6-11: invalid argument: index must not be negative: (-1 bigint) + +// TypeCheckError: +// main/index2.gno:5:8: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/index3.gno b/gnovm/tests/files/index3.gno new file mode 100644 index 00000000000..7c6b03eab94 --- /dev/null +++ b/gnovm/tests/files/index3.gno @@ -0,0 +1,17 @@ +package main + +func main() { + defer func() { + r := recover() + println("recover:", r) // not recoverable. + }() + + s := []int{1, 2, 3} + _ = s[-1:] // Panics because of negative index +} + +// Error: +// main/index3.gno:10:6-12: invalid argument: index must not be negative: (-1 bigint) + +// TypeCheckError: +// main/index3.gno:10:8: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/index4.gno b/gnovm/tests/files/index4.gno new file mode 100644 index 00000000000..94268b4441c --- /dev/null +++ b/gnovm/tests/files/index4.gno @@ -0,0 +1,12 @@ +package main + +func main() { + var a = []int{1, 2, 3} + _ = a[-1] +} + +// Error: +// main/index4.gno:5:6-11: invalid argument: index must not be negative: (-1 bigint) + +// TypeCheckError: +// main/index4.gno:5:8: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/index5.gno b/gnovm/tests/files/index5.gno new file mode 100644 index 00000000000..03e791bd3d0 --- /dev/null +++ b/gnovm/tests/files/index5.gno @@ -0,0 +1,12 @@ +package main + +func main() { + s := "hello" + _ = s[-1] +} + +// Error: +// main/index5.gno:5:6-11: invalid argument: index must not be negative: (-1 bigint) + +// TypeCheckError: +// main/index5.gno:5:8: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/index6.gno b/gnovm/tests/files/index6.gno new file mode 100644 index 00000000000..23dadd004e8 --- /dev/null +++ b/gnovm/tests/files/index6.gno @@ -0,0 +1,12 @@ +package main + +func main() { + s := []int{1, 2, 3} + _ = s[:-1:2] // Panics because of negative index +} + +// Error: +// main/index6.gno:5:6-14: invalid argument: index must not be negative: (-1 bigint) + +// TypeCheckError: +// main/index6.gno:5:9: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/index7.gno b/gnovm/tests/files/index7.gno new file mode 100644 index 00000000000..4c6a080293b --- /dev/null +++ b/gnovm/tests/files/index7.gno @@ -0,0 +1,12 @@ +package main + +func main() { + s := []int{1, 2, 3} + _ = s[:1:-1] // Panics because of negative index +} + +// Error: +// main/index7.gno:5:6-14: invalid argument: index must not be negative: (-1 bigint) + +// TypeCheckError: +// main/index7.gno:5:11: invalid argument: index -1 (constant of type int) must not be negative diff --git a/gnovm/tests/files/index8.gno b/gnovm/tests/files/index8.gno new file mode 100644 index 00000000000..e9663064988 --- /dev/null +++ b/gnovm/tests/files/index8.gno @@ -0,0 +1,14 @@ +package main + +const c = -1 + +func main() { + var a [5]int + _ = a[c] +} + +// Error: +// main/index8.gno:7:6-10: invalid argument: index must not be negative: (-1 bigint) + +// TypeCheckError: +// main/index8.gno:7:8: invalid argument: index c (constant -1 of type int) must not be negative diff --git a/gnovm/tests/files/index9.gno b/gnovm/tests/files/index9.gno new file mode 100644 index 00000000000..878f8620226 --- /dev/null +++ b/gnovm/tests/files/index9.gno @@ -0,0 +1,11 @@ +package main + +import "fmt" + +func main() { + m := map[int]int{-1: 1} + fmt.Println(m[-1]) +} + +// Output: +// 1 diff --git a/gnovm/tests/files/recover13.gno b/gnovm/tests/files/recover13.gno index f1e3973f487..0c2307f3ce5 100644 --- a/gnovm/tests/files/recover13.gno +++ b/gnovm/tests/files/recover13.gno @@ -1,18 +1,15 @@ package main - func main() { - defer func() { - r := recover() - println("recover:", r) - }() - - arr := []int{1, 2, 3} - _ = arr[-1:] // Panics because of negative index + defer func() { + r := recover() + println("recover:", r) + }() + + arr := []int{1, 2, 3} + idx := -1 + _ = arr[idx:] // Panics because of negative index } // Output: // recover: runtime error: invalid slice index -1 (index must be non-negative) - -// TypeCheckError: -// main/recover13.gno:11:13: invalid argument: index -1 (constant of type int) must not be negative From b92d3ff391b0fcb6353e64be313f7dc8a6207a7f Mon Sep 17 00:00:00 2001 From: piux2 <90544084+piux2@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:09:54 -0700 Subject: [PATCH 68/92] fix: false cycle detection for valid multi-value var declarations (#5336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #5328 Fixes a bug where valid package-level multi-value variable declarations triggered a false recursive value panic. For example: ``` var ( A, B = C, 2 C = B + 1 ) ``` Previously panicked with invalid recursive value: A -> C -> A -> B -> C even though the dependency graph is a clean DAG (A -> C -> B). The correct output is A=3, B=2, C=3. ### Root cause In PredefineFileSet, when a multi-value ValueDecl like var A, B = C, D is split into individual declarations, predefineRecursively was called on each part before fn.Decls was updated with the split. So when resolving C (which A depends on), GetDeclFor(B) still found the original var A, B = C, D — placing both A and B into the defining set. When B was then encountered, the detector saw it already in defining and reported a cycle that didn't exist. ### Fix Apply fn.Decls = append(split...) before calling predefineRecursively on each part. GetDeclFor(B) then finds the standalone var B = 2, and the cycle detector sees the true dependency graph. This only affects package-level variables. Local var blocks are processed sequentially and do not support forward references. ### Tests Added 10 filetests to gnovm/tests/files/: - var36–var40: valid patterns that previously panicked — cross-dependency, chained multi-value decls, deep independent chains, and 3-variable decls - var41–var45: genuinely cyclic var declarations that must still be rejected — self-reference, 2-cycle, 3-cycle, multi-value swap, and multi-value feedback loop --------- Co-authored-by: Morgan (cherry picked from commit 2f6166a725b2a0e366c0903093afbf529352e8bf) --- gnovm/pkg/gnolang/preprocess.go | 24 ++++++++++++++++++------ gnovm/tests/files/var36.gno | 16 ++++++++++++++++ gnovm/tests/files/var37.gno | 16 ++++++++++++++++ gnovm/tests/files/var38.gno | 17 +++++++++++++++++ gnovm/tests/files/var39.gno | 18 ++++++++++++++++++ gnovm/tests/files/var40.gno | 17 +++++++++++++++++ gnovm/tests/files/var41.gno | 17 +++++++++++++++++ gnovm/tests/files/var42.gno | 14 ++++++++++++++ gnovm/tests/files/var43.gno | 18 ++++++++++++++++++ gnovm/tests/files/var44.gno | 16 ++++++++++++++++ gnovm/tests/files/var45.gno | 19 +++++++++++++++++++ 11 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 gnovm/tests/files/var36.gno create mode 100644 gnovm/tests/files/var37.gno create mode 100644 gnovm/tests/files/var38.gno create mode 100644 gnovm/tests/files/var39.gno create mode 100644 gnovm/tests/files/var40.gno create mode 100644 gnovm/tests/files/var41.gno create mode 100644 gnovm/tests/files/var42.gno create mode 100644 gnovm/tests/files/var43.gno create mode 100644 gnovm/tests/files/var44.gno create mode 100644 gnovm/tests/files/var45.gno diff --git a/gnovm/pkg/gnolang/preprocess.go b/gnovm/pkg/gnolang/preprocess.go index 2cbb5084353..932702171d9 100644 --- a/gnovm/pkg/gnolang/preprocess.go +++ b/gnovm/pkg/gnolang/preprocess.go @@ -136,7 +136,7 @@ func PredefineFileSet(store Store, pn *PackageNode, fset *FileSet) { } } } - split := make([]Decl, len(vd.NameExprs)) + split := make([]Decl, 0, len(vd.NameExprs)) for j := range vd.NameExprs { part := &ValueDecl{ NameExprs: NameExprs{{ @@ -152,10 +152,19 @@ func PredefineFileSet(store Store, pn *PackageNode, fset *FileSet) { if iota_ != nil { part.SetAttribute(ATTR_IOTA, iota_) } - predefineRecursively(store, fn, part) - split[j] = part + split = append(split, part) + } + // Apply the split to fn.Decls BEFORE calling predefineRecursively, + // so that GetDeclFor resolves each split name to its own individual + // decl rather than the original multi-value decl, avoiding false + // cycle detection. + fn.Decls = append(fn.Decls[:i], append(split, fn.Decls[i+1:]...)...) + for j := range split { + if split[j].GetAttribute(ATTR_PREDEFINED) == true { + continue + } + predefineRecursively(store, fn, split[j]) } - fn.Decls = append(fn.Decls[:i], append(split, fn.Decls[i+1:]...)...) //nolint:makezero i += len(vd.NameExprs) - 1 continue } else { @@ -4914,8 +4923,11 @@ func predefineRecursively(store Store, last BlockNode, d Decl) bool { func predefineRecursively2(store Store, last BlockNode, d Decl, stack []Name, defining map[Name]struct{}, direct bool) bool { pkg := packageOf(last) - // NOTE: predefine fileset breaks up circular definitions like - // `var a, b, c = 1, a, b` which is only legal at the file level. + // NOTE: PredefineFileSet splits multi-value decls like `var a, b = c, d` + // into individual decls before calling this function, so that GetDeclFor + // resolves each name to its own standalone decl. Without the split, + // all names in the original multi-value decl would be added to `defining`, + // causing false cycle detection for valid DAG dependencies. for _, dn := range d.GetDeclNames() { if isUverseName(dn) { panic(fmt.Sprintf( diff --git a/gnovm/tests/files/var36.gno b/gnovm/tests/files/var36.gno new file mode 100644 index 00000000000..9230a9e6686 --- /dev/null +++ b/gnovm/tests/files/var36.gno @@ -0,0 +1,16 @@ +// Multi-value var decl where one name depends on the other via a single-value decl. +// A and B are declared together; C depends on B, and A gets C. +// This triggered a false cycle detection before the fix. +package main + +var ( + A, B = C, 2 + C = B + 1 +) + +func main() { + println(A, B, C) // 3 2 3 +} + +// Output: +// 3 2 3 diff --git a/gnovm/tests/files/var37.gno b/gnovm/tests/files/var37.gno new file mode 100644 index 00000000000..31e5cd40dbd --- /dev/null +++ b/gnovm/tests/files/var37.gno @@ -0,0 +1,16 @@ +// Multi-value decl with an extra level of indirection. +// A gets C, B gets D; C depends on B (cross-dependency via arithmetic). +package main + +var ( + A, B = C, D + C = B + 1 + D = 2 +) + +func main() { + println(A, B, C) // 3 2 3 +} + +// Output: +// 3 2 3 diff --git a/gnovm/tests/files/var38.gno b/gnovm/tests/files/var38.gno new file mode 100644 index 00000000000..0d6269caddd --- /dev/null +++ b/gnovm/tests/files/var38.gno @@ -0,0 +1,17 @@ +// Two chained multi-value decls — both must be split correctly. +// var A, B = C, D and var C, D = E, F are both one-to-one multi-value decls. +package main + +var ( + A, B = C, D + C, D = E, F + E = 7 + F = 8 +) + +func main() { + println(A, B, C, D) // 7 8 7 8 +} + +// Output: +// 7 8 7 8 diff --git a/gnovm/tests/files/var39.gno b/gnovm/tests/files/var39.gno new file mode 100644 index 00000000000..c5a3b077a83 --- /dev/null +++ b/gnovm/tests/files/var39.gno @@ -0,0 +1,18 @@ +// Multi-value decl where each name has a deeper independent chain. +// A -> C -> E, B -> D -> F — two separate chains, no false cycle. +package main + +var ( + A, B = C, D + C = E + D = F + E = 10 + F = 20 +) + +func main() { + println(A, B) // 10 20 +} + +// Output: +// 10 20 diff --git a/gnovm/tests/files/var40.gno b/gnovm/tests/files/var40.gno new file mode 100644 index 00000000000..b15dabe0e1f --- /dev/null +++ b/gnovm/tests/files/var40.gno @@ -0,0 +1,17 @@ +// Three-variable multi-value decl where each depends on a separate chain. +// A gets D, B gets E, C gets F — D -> E -> F creates a linear dependency. +package main + +var ( + A, B, C = D, E, F + D = 1 + E = D + F = E +) + +func main() { + println(A, B, C) // 1 1 1 +} + +// Output: +// 1 1 1 diff --git a/gnovm/tests/files/var41.gno b/gnovm/tests/files/var41.gno new file mode 100644 index 00000000000..760eb6e9037 --- /dev/null +++ b/gnovm/tests/files/var41.gno @@ -0,0 +1,17 @@ +// Genuinely cyclic var declarations must still be rejected. +package main + +var ( + A = B + B = A +) + +func main() { + println(A, B) +} + +// Error: +// main/var41.gno:5:2-7: invalid recursive value: A -> B -> A + +// TypeCheckError: +// main/var41.gno:5:2: initialization cycle for A; main/var41.gno:5:2: A refers to B; main/var41.gno:6:2: B refers to A diff --git a/gnovm/tests/files/var42.gno b/gnovm/tests/files/var42.gno new file mode 100644 index 00000000000..5ad830d3816 --- /dev/null +++ b/gnovm/tests/files/var42.gno @@ -0,0 +1,14 @@ +// A var that references itself is a genuine cycle. +package main + +var A = A + +func main() { + println(A) +} + +// Error: +// main/var42.gno:4:5-10: invalid recursive value: A -> A + +// TypeCheckError: +// main/var42.gno:4:5: initialization cycle: A refers to itself diff --git a/gnovm/tests/files/var43.gno b/gnovm/tests/files/var43.gno new file mode 100644 index 00000000000..1d3d138c951 --- /dev/null +++ b/gnovm/tests/files/var43.gno @@ -0,0 +1,18 @@ +// Three-var cycle: A -> B -> C -> A must be rejected. +package main + +var ( + A = B + B = C + C = A +) + +func main() { + println(A) +} + +// Error: +// main/var43.gno:5:2-7: invalid recursive value: A -> B -> C -> A + +// TypeCheckError: +// main/var43.gno:5:2: initialization cycle for A; main/var43.gno:5:2: A refers to B; main/var43.gno:6:2: B refers to C; main/var43.gno:7:2: C refers to A diff --git a/gnovm/tests/files/var44.gno b/gnovm/tests/files/var44.gno new file mode 100644 index 00000000000..edc874856a4 --- /dev/null +++ b/gnovm/tests/files/var44.gno @@ -0,0 +1,16 @@ +// Multi-value decl where the two names swap — a genuine cycle. +package main + +var ( + A, B = B, A +) + +func main() { + println(A, B) +} + +// Error: +// main/var44.gno:2:1-10:2: invalid recursive value: A -> B -> A + +// TypeCheckError: +// main/var44.gno:5:2: initialization cycle for A; main/var44.gno:5:2: A refers to B; main/var44.gno:5:5: B refers to A diff --git a/gnovm/tests/files/var45.gno b/gnovm/tests/files/var45.gno new file mode 100644 index 00000000000..e6a1e09a5ac --- /dev/null +++ b/gnovm/tests/files/var45.gno @@ -0,0 +1,19 @@ +// Multi-value decl where one split name feeds back into the same decl. +// A gets C, but C depends on A — genuine cycle, not a false positive. +package main + +var ( + A, B = C, D + C = A + D = 2 +) + +func main() { + println(A, B, C) +} + +// Error: +// main/var45.gno:3:1-13:2: invalid recursive value: A -> C -> A + +// TypeCheckError: +// main/var45.gno:6:2: initialization cycle for A; main/var45.gno:6:2: A refers to C; main/var45.gno:7:2: C refers to A From e6508790e01cafe50a5790b1dea814cc31073f05 Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 21 Apr 2026 23:12:44 +0200 Subject: [PATCH 69/92] feat(deployments/gnoland-1): govDAO T1 rotation migration + repair valset-reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional post-replay T1 rotation and repairs the pre-existing valset-reset migration. Both produced as additional MsgRun txs at the end of hardfork replay via --migration-tx, authoritatively signed under --skip-genesis-sig-verification with the .msg[0].caller field patched to the desired on-chain actor. ### T1 govDAO membership rotation (new) Motivation: at gnoland1 genesis, govdao_prop1.gno registered manfred (g1manfred47kzduec920z88wfr64ylksmdcedlf5) as the sole T1 member with InvitationPoints=3. For test13 the operator wants to replace manfred with a different address they control, cleanly, as part of the fork. Constraint: r/gov/dao/v3 requires 66.66% supermajority for WithdrawMember proposals, and OriginCaller is fixed per MsgRun tx — so the rotation can't be a single tx. Split into 3 migrations that run sequentially after the historical replay: 02_add_t1_member.gno.tmpl caller=OLD_T1 — AddMember(NEW_T1, T1). 100% (1/1) supermajority. 03_withdraw_manfred_propose caller=OLD_T1 — propose WithdrawMember(OLD_T1) and vote YES. Stays open at 50% (1/2, below 66.66%). 04_withdraw_manfred_execute caller=NEW_T1 — locate the open proposal by (Author==OLD_T1, Title= 'Member Withdrawal Proposal'), vote YES, execute. 100%. The execute tx (04) doesn't need the proposal ID passed in — it walks GetProposal(pid) from 0 upward and matches on author+title. Cheap at fork time (tens of proposals) and avoids cross-tx state plumbing in build.sh. Gated on NEW_T1_ADDR being non-empty; when unset, only the valset-reset migration is emitted (backwards-compatible with the existing flow). ### Dynamic OLD_ADDRS for valset-reset (new) Motivation: the existing 01_reset_valset migration hardcoded OLD_ADDRS as gnoland1's INITIAL_VALSET (7 validators). But post-genesis govDAO proposals on gnoland1 both ADDED validators not in INITIAL_VALSET and REMOVED some that were — so by the fork point r/sys/validators/v2 contains a different set (8 validators at time of writing). Calling removeValidator on an address not currently registered panics with 'validator doesn't exist', which aborted the entire reset migration. build.sh now queries the source chain via abci_query vm/qeval (gno.land/r/sys/validators/v2.GetValidators()) and regex-extracts addresses from the response. Regex 'g1[0-9a-z]{38}' is safe against false positives from gpub1... pubkey strings because bech32's data-charset (qpzry9x8gf2tvdw0s3jn54khce6mua7l) excludes '1', so the sequence 'g1' only appears at the start of address values. The hardcoded INITIAL_VALSET is kept as FALLBACK_OLD_ADDRS for when RPC_URL is unset, but with a warning printed to stderr — almost every real fork point will have drifted. ### Bugs fixed along the way 01_reset_valset.gno.tmpl was calling the 3-arg form dao.MustVoteOnProposal(cross, id, dao.YesVote). r/gov/dao/proxy.gno's current signature is MustVoteOnProposal(cur, VoteRequest), so the template didn't compile. Swapped to dao.VoteRequest{Option: dao.YesVote, ProposalID: id}. build.sh's gnokey add --recover stdin order was reversed: it wrote '\\n\\n' expecting passphrase-first, but gnokey prompts for the mnemonic first (io.GetString) and only after that for the passphrase. The empty line was being consumed as the mnemonic, triggering 'invalid bip39 mnemonic'. Fixed to '\\n\\n' (mnemonic, then empty passphrase; empty passphrase skips the confirmation prompt). misc/hf-glue/Makefile smoketest target pointed at misc/hardfork/ which no longer exists — the tool moved to contribs/gnogenesis fork test. Updated. ### build.sh refactor Since there are now up to 4 migrations to emit, inlined the maketx+sign+wrap pipeline into two reusable helpers: render_tx — maketx run, patch msg[0].caller, sign, wrap as {tx: {...}} render_template ... — awk-based placeholder substitution (BSD sed can't handle newlines / special chars in the replacement reliably) The single ephemeral signing key (test1 mnemonic) is set up once and reused for all txs. The key's derived address is irrelevant at replay since --skip-genesis-sig-verification is on and OriginCaller comes from msg[0].caller (patched post-maketx, pre-sign). ### Env vars added RPC_URL source-chain RPC for OLD_ADDRS derivation (new, plumbed through hf-glue/scripts/migrate.sh) NEW_T1_ADDR bech32 address to install as the sole post-fork T1 (rotation is gated on this being set) T1_PORTFOLIO proposal portfolio text for the AddMember proposal (required by r/gov/dao/v3/impl.NewAddMemberRequest; defaults to 'post-hardfork T1 rotation') T1_WITHDRAW_REASON reason text for the WithdrawMember proposal (required for T1 removals by r/gov/dao/v3/impl; defaults to 'replaced by NEW_T1_ADDR as part of hardfork') Exposed as Makefile vars in misc/hf-glue/Makefile and forwarded through scripts/migrate.sh. ### Verification Full E2E with NEW_T1_ADDR=g1u7y667z64x2h7vc6fmpcprgey4ck233jaww9zq: make fetch produces out/migrations.jsonl with 4 txs (01 caller=manfred, 02/03 caller=manfred, 04 caller=NEW_T1_ADDR). make smoketest (via contribs/gnogenesis fork test) reports Genesis replay report mode=strict total=2719 ok=2700 failed=0 skipped_failed=19 The 19 skipped failures are pre-existing historical txs that also failed on gnoland1 (non-zero source_height with source-side error); all 4 migration txs [OK]. ### Files misc/deployments/gnoland-1/migrations/01_reset_valset.gno.tmpl — compile fix misc/deployments/gnoland-1/migrations/02_add_t1_member.gno.tmpl — new misc/deployments/gnoland-1/migrations/03_withdraw_manfred_propose.gno.tmpl — new misc/deployments/gnoland-1/migrations/04_withdraw_manfred_execute.gno.tmpl — new misc/deployments/gnoland-1/migrations/build.sh — major rework misc/hf-glue/Makefile — env vars, smoketest path misc/hf-glue/scripts/migrate.sh — env passthrough --- .../migrations/01_reset_valset.gno.tmpl | 2 +- .../migrations/02_add_t1_member.gno.tmpl | 32 ++ .../03_withdraw_manfred_propose.gno.tmpl | 33 ++ .../04_withdraw_manfred_execute.gno.tmpl | 61 ++++ .../deployments/gnoland-1/migrations/build.sh | 291 +++++++++++++----- misc/hf-glue/Makefile | 12 +- misc/hf-glue/scripts/migrate.sh | 7 +- 7 files changed, 355 insertions(+), 83 deletions(-) create mode 100644 misc/deployments/gnoland-1/migrations/02_add_t1_member.gno.tmpl create mode 100644 misc/deployments/gnoland-1/migrations/03_withdraw_manfred_propose.gno.tmpl create mode 100644 misc/deployments/gnoland-1/migrations/04_withdraw_manfred_execute.gno.tmpl diff --git a/misc/deployments/gnoland-1/migrations/01_reset_valset.gno.tmpl b/misc/deployments/gnoland-1/migrations/01_reset_valset.gno.tmpl index 46e917dbe56..cfbaa8b0f03 100644 --- a/misc/deployments/gnoland-1/migrations/01_reset_valset.gno.tmpl +++ b/misc/deployments/gnoland-1/migrations/01_reset_valset.gno.tmpl @@ -39,6 +39,6 @@ func main() { ) id := dao.MustCreateProposal(cross, req) - dao.MustVoteOnProposal(cross, id, dao.YesVote) + dao.MustVoteOnProposal(cross, dao.VoteRequest{Option: dao.YesVote, ProposalID: id}) dao.ExecuteProposal(cross, id) } diff --git a/misc/deployments/gnoland-1/migrations/02_add_t1_member.gno.tmpl b/misc/deployments/gnoland-1/migrations/02_add_t1_member.gno.tmpl new file mode 100644 index 00000000000..fb63cf532a9 --- /dev/null +++ b/misc/deployments/gnoland-1/migrations/02_add_t1_member.gno.tmpl @@ -0,0 +1,32 @@ +// 02_add_t1_member.gno.tmpl — run AT THE END of the hardfork replay. +// +// Step 1 of the T1 rotation: the sole pre-fork T1 member (manfred) invites +// NEW_T1_ADDR into T1 via a govDAO proposal. Because manfred is the only +// T1 at this point, his single YES vote == 100% supermajority and the +// proposal executes immediately. +// +// Placeholders (replaced by migrations/build.sh before signing): +// NEW_T1_ADDR placeholder — new T1 member's bech32 address +// PORTFOLIO placeholder — human-readable justification (required) +// +// Caller: sole T1 member (manfred) — patched into msg[0].caller by build.sh. +package main + +import ( + "gno.land/r/gov/dao" + "gno.land/r/gov/dao/v3/impl" + "gno.land/r/gov/dao/v3/memberstore" +) + +func main() { + req := impl.NewAddMemberRequest( + cross, + address("{{NEW_T1_ADDR}}"), + memberstore.T1, + `{{PORTFOLIO}}`, + ) + + id := dao.MustCreateProposal(cross, req) + dao.MustVoteOnProposal(cross, dao.VoteRequest{Option: dao.YesVote, ProposalID: id}) + dao.ExecuteProposal(cross, id) +} diff --git a/misc/deployments/gnoland-1/migrations/03_withdraw_manfred_propose.gno.tmpl b/misc/deployments/gnoland-1/migrations/03_withdraw_manfred_propose.gno.tmpl new file mode 100644 index 00000000000..c76e5ce4ae8 --- /dev/null +++ b/misc/deployments/gnoland-1/migrations/03_withdraw_manfred_propose.gno.tmpl @@ -0,0 +1,33 @@ +// 03_withdraw_manfred_propose.gno.tmpl — run AT THE END of the hardfork replay. +// +// Step 2 of the T1 rotation: manfred proposes his own withdrawal from T1 and +// votes YES. The memberstore now has two T1 members (manfred + NEW_T1_ADDR), +// so manfred's single vote is only 50% — below the 66.66% supermajority, +// so the proposal stays OPEN at the end of this tx. +// +// Step 3 (04_withdraw_manfred_execute.gno.tmpl) is run in a separate tx by +// NEW_T1_ADDR to cast the second YES vote and execute. +// +// Placeholders (replaced by migrations/build.sh before signing): +// OLD_T1_ADDR placeholder — address being withdrawn (manfred) +// WITHDRAW_REASON placeholder — required reason string for T1 removals +// +// Caller: manfred (OLD_T1_ADDR) — patched into msg[0].caller by build.sh. +package main + +import ( + "gno.land/r/gov/dao" + "gno.land/r/gov/dao/v3/impl" +) + +func main() { + req := impl.NewWithdrawMemberRequest( + cross, + address("{{OLD_T1_ADDR}}"), + `{{WITHDRAW_REASON}}`, + ) + + id := dao.MustCreateProposal(cross, req) + dao.MustVoteOnProposal(cross, dao.VoteRequest{Option: dao.YesVote, ProposalID: id}) + // No ExecuteProposal here: supermajority not reached yet (50%). +} diff --git a/misc/deployments/gnoland-1/migrations/04_withdraw_manfred_execute.gno.tmpl b/misc/deployments/gnoland-1/migrations/04_withdraw_manfred_execute.gno.tmpl new file mode 100644 index 00000000000..05d265de2c9 --- /dev/null +++ b/misc/deployments/gnoland-1/migrations/04_withdraw_manfred_execute.gno.tmpl @@ -0,0 +1,61 @@ +// 04_withdraw_manfred_execute.gno.tmpl — run AT THE END of the hardfork replay. +// +// Step 3 (final) of the T1 rotation: NEW_T1_ADDR votes YES on the open +// WithdrawMember proposal created in step 2 and executes it. With both T1 +// members voting YES, supermajority is 100% and manfred is removed. +// +// Finding the pid: +// The proposal was created by the PREVIOUS migration tx (signed as +// manfred). It's the highest-pid proposal whose author == OLD_T1_ADDR +// and title == "Member Withdrawal Proposal". We iterate from pid=0 +// upward to find it — cheap, since the chain has only a handful of +// proposals at genesis time. +// +// Placeholders (replaced by migrations/build.sh before signing): +// OLD_T1_ADDR placeholder — address being withdrawn (manfred) +// +// Caller: NEW_T1_ADDR (the freshly-added T1 member) — patched into +// msg[0].caller by build.sh. +package main + +import ( + "gno.land/r/gov/dao" +) + +const ( + oldT1Addr = address("{{OLD_T1_ADDR}}") + withdrawalTitle = "Member Withdrawal Proposal" +) + +func main() { + pid, ok := findWithdrawProposal() + if !ok { + panic("could not locate the WithdrawMember proposal authored by " + oldT1Addr.String()) + } + + dao.MustVoteOnProposal(cross, dao.VoteRequest{Option: dao.YesVote, ProposalID: pid}) + dao.ExecuteProposal(cross, pid) +} + +// findWithdrawProposal walks the proposal tree and returns the highest-pid +// proposal whose author matches OLD_T1_ADDR and whose title matches the +// WithdrawMember one. Stops on the first GetProposal error (= pid not +// allocated yet). +func findWithdrawProposal() (dao.ProposalID, bool) { + var ( + found bool + best dao.ProposalID + ) + for i := int64(0); ; i++ { + pid := dao.ProposalID(i) + p, err := dao.GetProposal(cross, pid) + if err != nil || p == nil { + break + } + if p.Author() == oldT1Addr && p.Title() == withdrawalTitle { + best = pid + found = true + } + } + return best, found +} diff --git a/misc/deployments/gnoland-1/migrations/build.sh b/misc/deployments/gnoland-1/migrations/build.sh index 3b222d89e9b..67a21454858 100755 --- a/misc/deployments/gnoland-1/migrations/build.sh +++ b/misc/deployments/gnoland-1/migrations/build.sh @@ -6,7 +6,7 @@ # # What it does # ============ -# 1. Fills 01_reset_valset.gno.tmpl with: +# 1. Valset reset — fills 01_reset_valset.gno.tmpl with: # - OLD_VALIDATORS_GO = voting_power=0 entries for all INITIAL_VALSET # entries of gnoland1 (removes them from # r/sys/validators/v2) @@ -14,33 +14,71 @@ # $NEW_VALSET_JSON (produced by hf-glue # init-node.sh, or by a manual list for a # coordinated hardfork) -# 2. Wraps the rendered .gno body in a MsgRun tx signed by any local key; -# the tx's `caller` field is set to $CALLER (a govDAO T1 member, e.g. -# g1manfred...) so the proposal executes as that member when -# --skip-genesis-sig-verification kicks in at replay. -# 3. Emits one `{tx: {...}}` per migration into $OUT_JSONL. +# Signed by $CALLER (sole T1, manfred). +# +# 2. T1 rotation (optional, enabled when $NEW_T1_ADDR is set). 3 additional +# txs in the jsonl: +# - 02 AddMember(NEW_T1_ADDR, T1) — manfred proposes, votes, executes +# (100% supermajority as sole T1) +# - 03 WithdrawMember(manfred) — manfred proposes + votes YES +# (50% of 2 T1s, not executed yet) +# - 04 (caller=NEW_T1_ADDR) finds the open Withdraw proposal, votes YES, +# executes (100% with both voting YES) +# +# 3. Wraps each rendered .gno body in a MsgRun tx signed by a local ephemeral +# key; the tx's `caller` field is patched to the appropriate T1 member so +# --skip-genesis-sig-verification at replay uses that as OriginCaller. +# 4. Emits one `{tx: {...}}` per migration into $OUT_JSONL. # # Env # === -# CALLER govDAO T1 member address (required) -# default: g1manfred47kzduec920z88wfr64ylksmdcedlf5 -# NEW_VALSET_JSON path to JSON with new validators, format: -# [{"address": "g1...", "pub_key": "gpub1...", -# "voting_power": 10, "name": "hf-local"}, ...] -# default: synthesised from a priv_validator_key.json if -# $PV_KEY is set. -# PV_KEY alternate: path to a priv_validator_key.json; if set -# and $NEW_VALSET_JSON is empty, a single-validator set -# is derived from it (power=10, name=hf-local). -# OUT_JSONL output path (default: ./migrations.jsonl) -# GNOKEY_BIN gnokey binary (auto-built if missing) -# REPO_ROOT repo root (auto-detected) +# CALLER govDAO T1 member address for the valset-reset + add-member +# + withdraw-propose txs (required) +# default: g1manfred47kzduec920z88wfr64ylksmdcedlf5 +# RPC_URL source-chain RPC (required). Queried once via +# abci_query vm/qeval to derive OLD_ADDRS from the +# *current* r/sys/validators/v2 state, so the valset +# reset only attempts to remove validators that actually +# exist at fork time. When unset, falls back to the +# hardcoded gnoland1 INITIAL_VALSET below (may be stale +# — will panic at replay if the source chain removed any +# of them via historical govDAO proposals). +# default: (unset — uses hardcoded fallback) +# NEW_T1_ADDR address to install as the sole T1 member of the +# post-fork govDAO. When set, three extra migration txs +# are appended (see 2. above). When empty, only the +# valset reset is emitted. +# T1_PORTFOLIO human-readable portfolio/justification attached to the +# AddMember proposal (required when NEW_T1_ADDR is set). +# default: "post-hardfork T1 rotation" +# T1_WITHDRAW_REASON reason string attached to the WithdrawMember proposal +# (r/gov/dao requires this for T1 removals). +# default: "replaced by NEW_T1_ADDR as part of hardfork" +# NEW_VALSET_JSON path to JSON with new validators, format: +# [{"address": "g1...", "pub_key": "gpub1...", +# "voting_power": 10, "name": "hf-local"}, ...] +# default: synthesised from a priv_validator_key.json if +# $PV_KEY is set. +# PV_KEY alternate: path to a priv_validator_key.json; if set +# and $NEW_VALSET_JSON is empty, a single-validator set +# is derived from it (power=10, name=hf-local). +# OUT_JSONL output path (default: ./migrations.jsonl) +# GNOKEY_BIN gnokey binary (auto-built if missing) +# REPO_ROOT repo root (auto-detected) # -# Example -# ======= +# Example (valset-only) +# ===================== # CALLER=g1manfred47kzduec920z88wfr64ylksmdcedlf5 \ # PV_KEY=/path/to/priv_validator_key.json \ # ./build.sh +# +# Example (valset + T1 rotation) +# ============================== +# CALLER=g1manfred47kzduec920z88wfr64ylksmdcedlf5 \ +# PV_KEY=/path/to/priv_validator_key.json \ +# NEW_T1_ADDR=g1yournewcontrolleraddresshere \ +# T1_PORTFOLIO="Core dev, handing over from moul" \ +# ./build.sh set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -49,11 +87,15 @@ OUT_JSONL="${OUT_JSONL:-$SCRIPT_DIR/migrations.jsonl}" CALLER="${CALLER:-g1manfred47kzduec920z88wfr64ylksmdcedlf5}" CHAIN_ID="${CHAIN_ID:-gnoland-1}" -# Initial gnoland1 valset (mirrors INITIAL_VALSET in -# misc/deployments/gnoland1/gen-genesis.sh). All seven are removed by this -# migration — add to this list if the source chain's valset changed post- -# genesis and should also be removed. -OLD_ADDRS=( +NEW_T1_ADDR="${NEW_T1_ADDR:-}" +T1_PORTFOLIO="${T1_PORTFOLIO:-post-hardfork T1 rotation}" +T1_WITHDRAW_REASON="${T1_WITHDRAW_REASON:-replaced by NEW_T1_ADDR as part of hardfork}" + +# Hardcoded fallback: gnoland1 INITIAL_VALSET (mirrors misc/deployments/ +# gnoland1/gen-genesis.sh). Used only when $RPC_URL is unset. Likely stale at +# any real fork point because govDAO proposals may have added/removed valset +# entries post-genesis — prefer RPC-derived OLD_ADDRS whenever possible. +FALLBACK_OLD_ADDRS=( "g1vta7dwp4guuhkfzksenfcheky4xf9hue8mgne4" "g1d5hh9fw3l00gugfzafskaxqlmsyvxfaj6l2q60" "g1uhv7wr7nku89se3t7v8fpquc7n5sf8rfkywxpc" @@ -63,6 +105,41 @@ OLD_ADDRS=( "g10j90aqjv6uju3dksq8m08s6u47x59glkdxqzm2" ) +# ---- resolve OLD_ADDRS from live r/sys/validators/v2, or fall back ---- +# abci_query vm/qeval returns the amino-printed slice of +# gno.land/p/sys/validators.Validator values. We don't parse the full amino +# pretty-print — we just regex-extract every bech32 g1 address from the +# decoded payload. This is safe because the bech32 data-charset for gpub1 +# pubkeys excludes the digit `1`, so the sequence `g1` only occurs at the +# start of address values, never inside a pubkey. +query_current_valset_addrs() { + local rpc="$1" + local data_b64 resp data + data_b64=$(printf '%s' 'gno.land/r/sys/validators/v2.GetValidators()' | openssl base64 -A) + resp=$(curl -fsS -X POST -H 'Content-Type: application/json' \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"abci_query\",\"params\":{\"path\":\"vm/qeval\",\"data\":\"$data_b64\"}}" \ + "$rpc") || return 1 + data=$(jq -r '.result.response.ResponseBase.Data // empty' <<<"$resp") + [[ -n "$data" ]] || return 1 + printf '%s' "$data" | openssl base64 -d -A | grep -oE 'g1[0-9a-z]{38}' +} + +OLD_ADDRS=() +if [[ -n "${RPC_URL:-}" ]]; then + while IFS= read -r addr; do + [[ -n "$addr" ]] && OLD_ADDRS+=("$addr") + done < <(query_current_valset_addrs "$RPC_URL") + if [[ ${#OLD_ADDRS[@]} -eq 0 ]]; then + echo "ERROR: failed to derive OLD_ADDRS from RPC $RPC_URL (empty response or no g1 addresses)" >&2 + exit 1 + fi + echo " valset source: RPC $RPC_URL (${#OLD_ADDRS[@]} validator(s))" +else + OLD_ADDRS=("${FALLBACK_OLD_ADDRS[@]}") + echo " valset source: FALLBACK (hardcoded INITIAL_VALSET, ${#OLD_ADDRS[@]} validator(s))" + echo " WARNING: no RPC_URL set — valset reset may panic if source chain removed any of these." >&2 +fi + # ---- resolve GNOKEY_BIN ---- if [[ -z "${GNOKEY_BIN:-}" ]]; then if command -v gnokey >/dev/null 2>&1; then @@ -86,7 +163,10 @@ if [[ -z "${NEW_VALSET_JSON:-}" ]]; then # stores the raw base64 under pub_key.value. Use `gnoland secrets get` to convert. SECRETS_DIR="$(dirname "$PV_KEY")" BECH_PUBKEY="$(go run -C "$REPO_ROOT" ./gno.land/cmd/gnoland secrets get validator_key.pub_key --raw -data-dir "$SECRETS_DIR" | tail -n 1 | tr -d '[:space:]')" - [[ "$BECH_PUBKEY" == gpub1* ]] || { echo "ERROR: failed to derive bech32 pubkey from $PV_KEY (got: $BECH_PUBKEY)" >&2; exit 1; } + [[ "$BECH_PUBKEY" == gpub1* ]] || { + echo "ERROR: failed to derive bech32 pubkey from $PV_KEY (got: $BECH_PUBKEY)" >&2 + exit 1 + } ADDR="$(jq -r '.address' "$PV_KEY")" NEW_VALSET_JSON="$WORK/new_valset.json" jq -n --arg addr "$ADDR" --arg pub "$BECH_PUBKEY" '[{ @@ -94,10 +174,73 @@ if [[ -z "${NEW_VALSET_JSON:-}" ]]; then pub_key: $pub, voting_power: 10, name: "hf-local" - }]' > "$NEW_VALSET_JSON" + }]' >"$NEW_VALSET_JSON" fi -# ---- render template ---- +# ---- set up ephemeral signing key (used for all migration txs) ---- +GK_HOME="$WORK/gnokey-home" +mkdir -p "$GK_HOME" +EPHEMERAL_MNEMONIC="source bonus chronic canvas draft south burst lottery vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast" +# stdin order for `gnokey add --recover --insecure-password-stdin`: +# 1. mnemonic +# 2. passphrase (empty line = no passphrase, skips confirm prompt) +printf '%s\n\n' "$EPHEMERAL_MNEMONIC" | + "$GNOKEY_BIN" add --recover --insecure-password-stdin --home "$GK_HOME" ephemeral >/dev/null + +# ---- helper: render .gno file from template, wrap into a signed tx, patch caller ---- +# Args: +# Prints the resulting {tx: {...}} line on stdout. +render_tx() { + local gno_path="$1" caller="$2" + local tx_json="$WORK/$(basename "$gno_path" .gno).tx.json" + + "$GNOKEY_BIN" maketx run \ + --gas-wanted 100000000 \ + --gas-fee 1ugnot \ + --chainid "$CHAIN_ID" \ + --home "$GK_HOME" \ + ephemeral \ + "$gno_path" >"$tx_json" + + # Patch the caller field so the MsgRun executes as $caller (not ephemeral). + jq --arg caller "$caller" '.msg[0].caller = $caller' "$tx_json" >"$tx_json.patched" + mv "$tx_json.patched" "$tx_json" + + # Sign (bogus sig, skipped at replay — but the tx format requires one). + echo "" | "$GNOKEY_BIN" sign \ + --tx-path "$tx_json" \ + --chainid "$CHAIN_ID" \ + --account-number 0 \ + --account-sequence 0 \ + --home "$GK_HOME" \ + --insecure-password-stdin \ + ephemeral >/dev/null + + # Wrap as {tx: {...}} — TxWithMetadata accepts this with empty metadata; + # BlockHeight is forced to 0 by gnogenesis readMigrationTxs. + jq -c '{tx: .}' "$tx_json" +} + +# ---- helper: render a template with placeholder=value pairs ---- +# Args: [ ...] +render_template() { + local tmpl="$1" out="$2" + shift 2 + cp "$tmpl" "$out" + local spec name val + for spec in "$@"; do + name="${spec%%=*}" + val="${spec#*=}" + # awk-based substitution (BSD sed can't reliably handle newlines + special chars in replacement). + PH_NAME="$name" PH_VAL="$val" awk ' + BEGIN { name = ENVIRON["PH_NAME"]; val = ENVIRON["PH_VAL"] } + { gsub("\\{\\{" name "\\}\\}", val); print } + ' "$out" >"$out.tmp" + mv "$out.tmp" "$out" + done +} + +# ---- 1. valset reset tx (caller=manfred) ---- OLD_GO="" for a in "${OLD_ADDRS[@]}"; do OLD_GO+="{Address: \"$a\", VotingPower: 0},"$'\n\t\t\t\t' @@ -105,54 +248,46 @@ done NEW_GO=$(jq -r '.[] | "{Address: \"\(.address)\", PubKey: \"\(.pub_key)\", VotingPower: \(.voting_power)},"' "$NEW_VALSET_JSON" | awk 'BEGIN{ORS="\n\t\t\t\t"}{print}') -RENDERED="$WORK/01_reset_valset.gno" -# awk-based substitution (BSD sed can't handle newlines in replacement). -OLD_GO="$OLD_GO" NEW_GO="$NEW_GO" awk ' - { gsub(/\{\{OLD_VALIDATORS_GO\}\}/, ENVIRON["OLD_GO"]) - gsub(/\{\{NEW_VALIDATORS_GO\}\}/, ENVIRON["NEW_GO"]) - print } -' "$SCRIPT_DIR/01_reset_valset.gno.tmpl" > "$RENDERED" - -# ---- build the MsgRun tx ---- -# We use any local ephemeral key to sign; only the serialized form matters -# because --skip-genesis-sig-verification is on at replay. The msg's -# `caller` field is what the VM reads (runtime.OriginCaller), so we patch -# it to $CALLER after maketx. -GK_HOME="$WORK/gnokey-home" -mkdir -p "$GK_HOME" -EPHEMERAL_MNEMONIC="source bonus chronic canvas draft south burst lottery vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast" -# stdin order for `gnokey add --recover --insecure-password-stdin`: -# 1. passphrase (empty line = no passphrase, no confirm prompt) -# 2. mnemonic -printf '\n%s\n' "$EPHEMERAL_MNEMONIC" | \ - "$GNOKEY_BIN" add --recover --insecure-password-stdin --home "$GK_HOME" ephemeral >/dev/null +RENDERED_01="$WORK/01_reset_valset.gno" +render_template "$SCRIPT_DIR/01_reset_valset.gno.tmpl" "$RENDERED_01" \ + "OLD_VALIDATORS_GO=$OLD_GO" "NEW_VALIDATORS_GO=$NEW_GO" + +: >"$OUT_JSONL" +render_tx "$RENDERED_01" "$CALLER" >>"$OUT_JSONL" +printf ' migration: %-38s caller=%s\n' "$(basename "$RENDERED_01")" "$CALLER" + +# ---- 2-4. T1 rotation (optional) ---- +if [[ -n "$NEW_T1_ADDR" ]]; then + # Basic sanity checks on NEW_T1_ADDR. + [[ "$NEW_T1_ADDR" =~ ^g1[0-9a-z]{38}$ ]] || { + echo "ERROR: NEW_T1_ADDR does not look like a valid bech32 address: $NEW_T1_ADDR" >&2 + exit 1 + } + [[ "$NEW_T1_ADDR" != "$CALLER" ]] || { + echo "ERROR: NEW_T1_ADDR must differ from CALLER (no-op rotation)" >&2 + exit 1 + } + + # 02 — manfred adds NEW_T1_ADDR as T1 (100% supermajority, passes). + RENDERED_02="$WORK/02_add_t1_member.gno" + render_template "$SCRIPT_DIR/02_add_t1_member.gno.tmpl" "$RENDERED_02" \ + "NEW_T1_ADDR=$NEW_T1_ADDR" "PORTFOLIO=$T1_PORTFOLIO" + render_tx "$RENDERED_02" "$CALLER" >>"$OUT_JSONL" + printf ' migration: %-38s caller=%s\n' "$(basename "$RENDERED_02")" "$CALLER" + + # 03 — manfred proposes his own withdrawal + votes YES (50%, not executed). + RENDERED_03="$WORK/03_withdraw_manfred_propose.gno" + render_template "$SCRIPT_DIR/03_withdraw_manfred_propose.gno.tmpl" "$RENDERED_03" \ + "OLD_T1_ADDR=$CALLER" "WITHDRAW_REASON=$T1_WITHDRAW_REASON" + render_tx "$RENDERED_03" "$CALLER" >>"$OUT_JSONL" + printf ' migration: %-38s caller=%s\n' "$(basename "$RENDERED_03")" "$CALLER" + + # 04 — NEW_T1_ADDR votes YES on the open withdraw prop + executes. + RENDERED_04="$WORK/04_withdraw_manfred_execute.gno" + render_template "$SCRIPT_DIR/04_withdraw_manfred_execute.gno.tmpl" "$RENDERED_04" \ + "OLD_T1_ADDR=$CALLER" + render_tx "$RENDERED_04" "$NEW_T1_ADDR" >>"$OUT_JSONL" + printf ' migration: %-38s caller=%s\n' "$(basename "$RENDERED_04")" "$NEW_T1_ADDR" +fi -TX_JSON="$WORK/tx.json" -"$GNOKEY_BIN" maketx run \ - --gas-wanted 100000000 \ - --gas-fee 1ugnot \ - --chainid "$CHAIN_ID" \ - --home "$GK_HOME" \ - ephemeral \ - "$RENDERED" > "$TX_JSON" - -# Patch the caller field so the MsgRun executes as $CALLER (not ephemeral). -jq --arg caller "$CALLER" '.msg[0].caller = $caller' "$TX_JSON" > "$TX_JSON.patched" -mv "$TX_JSON.patched" "$TX_JSON" - -# Sign (bogus sig, skipped at replay — but the tx format requires one). -echo "" | "$GNOKEY_BIN" sign \ - --tx-path "$TX_JSON" \ - --chainid "$CHAIN_ID" \ - --account-number 0 \ - --account-sequence 0 \ - --home "$GK_HOME" \ - --insecure-password-stdin \ - ephemeral >/dev/null - -# Wrap as {tx: {...}} — TxWithMetadata accepts this with empty metadata; -# BlockHeight is forced to 0 by gnogenesis readMigrationTxs. -jq -c '{tx: .}' "$TX_JSON" > "$OUT_JSONL" - -printf ' migration: %s (caller=%s)\n' "$(basename "$RENDERED")" "$CALLER" printf ' written: %s\n' "$OUT_JSONL" diff --git a/misc/hf-glue/Makefile b/misc/hf-glue/Makefile index b0741cd3f8d..83b4c610a6a 100644 --- a/misc/hf-glue/Makefile +++ b/misc/hf-glue/Makefile @@ -18,8 +18,14 @@ TXS_JSONL ?= # Space-separated PKGPATH=SRCDIR entries. Set to empty to disable. # (migrate.sh always patches r/sys/params from examples/; use this for more.) PATCH_REALMS ?= +# Optional: rotate the govDAO sole T1 member to a different address as part +# of the post-replay migration (see scripts/migrate.sh step 5 and +# misc/deployments/gnoland-1/migrations/build.sh). Empty = no rotation. +NEW_T1_ADDR ?= +T1_PORTFOLIO ?= +T1_WITHDRAW_REASON ?= -export SOURCE RPC_URL ORIGINAL_CHAIN_ID CHAIN_ID HALT_HEIGHT VALIDATOR_NAME NODE_DIR TXS_JSONL PATCH_REALMS +export SOURCE RPC_URL ORIGINAL_CHAIN_ID CHAIN_ID HALT_HEIGHT VALIDATOR_NAME NODE_DIR TXS_JSONL PATCH_REALMS NEW_T1_ADDR T1_PORTFOLIO T1_WITHDRAW_REASON # ---- paths ----------------------------------------------------------------- HERE := $(abspath .) @@ -103,9 +109,9 @@ reset: down ## stop and wipe ALL generated state (genesis, keys, db) @echo "out/ removed." .PHONY: smoketest -smoketest: ## run 'hardfork test' in-memory against out/genesis.json +smoketest: ## run 'gnogenesis fork test' in-memory against out/genesis.json @test -f $(OUT)/genesis.json || { echo "missing out/genesis.json — run 'make fetch' first"; exit 1; } - cd $(REPO)/misc/hardfork && go run . test --genesis $(OUT)/genesis.json --verbose + cd $(REPO)/contribs/gnogenesis && go run . fork test --genesis $(OUT)/genesis.json --verbose .PHONY: replay-log replay-log: ## run in-process genesis replay, tee full log to out/replay.log diff --git a/misc/hf-glue/scripts/migrate.sh b/misc/hf-glue/scripts/migrate.sh index e856ec23ef2..a1525bfccaf 100755 --- a/misc/hf-glue/scripts/migrate.sh +++ b/misc/hf-glue/scripts/migrate.sh @@ -101,11 +101,16 @@ hf_topup_balance "g1r929wt2qplfawe4lvqv9zuwfdcz4vxdun7qh8l" "1000000000ugnot" \ PV_KEY_DEFAULT="$OUT/gnoland-home/secrets/priv_validator_key.json" PV_KEY="${PV_KEY:-$PV_KEY_DEFAULT}" if [[ -f "$PV_KEY" ]]; then - hf_banner "step 5 — post-replay migration (valset swap)" + hf_banner "step 5 — post-replay migration (valset swap${NEW_T1_ADDR:+ + T1 rotation})" hf_kv "pv_key" "$PV_KEY" + [[ -n "${NEW_T1_ADDR:-}" ]] && hf_kv "new T1 addr" "$NEW_T1_ADDR" MIG_JSONL="$OUT/migrations.jsonl" CALLER="${CALLER:-g1manfred47kzduec920z88wfr64ylksmdcedlf5}" \ PV_KEY="$PV_KEY" \ + RPC_URL="$RPC_URL" \ + NEW_T1_ADDR="${NEW_T1_ADDR:-}" \ + T1_PORTFOLIO="${T1_PORTFOLIO:-}" \ + T1_WITHDRAW_REASON="${T1_WITHDRAW_REASON:-}" \ OUT_JSONL="$MIG_JSONL" \ CHAIN_ID="$CHAIN_ID" \ REPO_ROOT="$REPO" \ From d42bdddc8bc3237ed20b222e1f5d82118d22bf86 Mon Sep 17 00:00:00 2001 From: aeddi Date: Wed, 22 Apr 2026 08:32:47 +0200 Subject: [PATCH 70/92] fix(hf-glue): make init-node.sh idempotent --- misc/hf-glue/scripts/init-node.sh | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/misc/hf-glue/scripts/init-node.sh b/misc/hf-glue/scripts/init-node.sh index 45b2b2c242c..017682e8ed4 100755 --- a/misc/hf-glue/scripts/init-node.sh +++ b/misc/hf-glue/scripts/init-node.sh @@ -22,7 +22,7 @@ SECRETS_DIR="$HOME_DIR/secrets" PV_KEY="$SECRETS_DIR/priv_validator_key.json" if [[ ! -f "$GENESIS" ]]; then - echo "missing $GENESIS — run 'make fetch' first" >&2 + echo "missing $GENESIS — run 'make migrate' first" >&2 exit 1 fi @@ -49,15 +49,19 @@ go run -C "$REPO/misc/hf-glue/fixvalidator" . \ # ---- 3. write config.toml so RPC binds to 0.0.0.0 (accessible from host) ---- CONFIG_DIR="$HOME_DIR/config" +CONFIG_FILE="$CONFIG_DIR/config.toml" mkdir -p "$CONFIG_DIR" -go run -C "$REPO" ./gno.land/cmd/gnoland config init -config-path "$CONFIG_DIR/config.toml" -# Patch the generated config to bind to 0.0.0.0 (accessible from Docker host) -if command -v sed >/dev/null 2>&1; then - sed -i.bak 's|tcp://127.0.0.1:26657|tcp://0.0.0.0:26657|' "$CONFIG_DIR/config.toml" - sed -i.bak 's|tcp://127.0.0.1:26656|tcp://0.0.0.0:26656|' "$CONFIG_DIR/config.toml" - rm -f "$CONFIG_DIR/config.toml.bak" +if [[ -f "$CONFIG_FILE" ]]; then + echo " config already present at $CONFIG_FILE — reusing" +else + go run -C "$REPO" ./gno.land/cmd/gnoland config init -config-path "$CONFIG_FILE" + if command -v sed >/dev/null 2>&1; then + sed -i.bak 's|tcp://127.0.0.1:26657|tcp://0.0.0.0:26657|' "$CONFIG_FILE" + sed -i.bak 's|tcp://127.0.0.1:26656|tcp://0.0.0.0:26656|' "$CONFIG_FILE" + rm -f "$CONFIG_FILE.bak" + fi + echo " config written to $CONFIG_FILE" fi -echo " config written to $CONFIG_DIR/config.toml" # ---- 4. stage genesis.json next to the node data ---- cp "$GENESIS" "$HOME_DIR/genesis.json" From 3e38a0e4243604f5bc7322d61947f56680c8cd0f Mon Sep 17 00:00:00 2001 From: aeddi Date: Wed, 22 Apr 2026 08:33:46 +0200 Subject: [PATCH 71/92] fix(hf-glue): auto-restage genesis on make up via init dependency --- misc/hf-glue/Makefile | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/misc/hf-glue/Makefile b/misc/hf-glue/Makefile index 83b4c610a6a..0ded5de2c24 100644 --- a/misc/hf-glue/Makefile +++ b/misc/hf-glue/Makefile @@ -76,9 +76,7 @@ init: ## generate single-validator secrets and patch out/genesis.json $(HERE)/scripts/init-node.sh .PHONY: up -up: ## start the gnoland node in docker (requires fetch+init first) - @test -f $(OUT)/genesis.json || { echo "missing out/genesis.json — run 'make fetch init' first"; exit 1; } - @test -f $(OUT)/gnoland-home/secrets/priv_validator_key.json || { echo "missing secrets — run 'make init' first"; exit 1; } +up: init ## start the gnoland node in docker (init is idempotent and re-stages genesis if changed) docker compose up -d --build @echo "" @echo "Node RPC: http://localhost:26657" From d07ff4a7810bff7276769d880a4326d30a8f4c6a Mon Sep 17 00:00:00 2001 From: aeddi Date: Thu, 23 Apr 2026 15:13:16 +0200 Subject: [PATCH 72/92] feat(hf-glue): support keyless genesis via VALIDATOR_ADDR/VALIDATOR_PUBKEY Adds a `make genesis` target that produces out/genesis.json from a manual bech32 (address, pubkey) pair instead of a local priv_validator_key.json. Needed for validators using a remote signer (e.g. gnokms) where the secret never lives on the build host. - Makefile: new `genesis` target + VALIDATOR_ADDR/VALIDATOR_PUBKEY vars. - fixvalidator: accepts either --priv-key or --address/--pubkey; verifies the pubkey derives the supplied address. - scripts/migrate.sh: when PV_KEY is absent but both VALIDATOR_ADDR and VALIDATOR_PUBKEY are set, builds NEW_VALSET_JSON from those strings so build.sh skips its priv_validator_key.json path. --- misc/hf-glue/Makefile | 33 +++++++++++++++- misc/hf-glue/fixvalidator/main.go | 63 ++++++++++++++++++++++++------- misc/hf-glue/scripts/migrate.sh | 25 +++++++++++- 3 files changed, 105 insertions(+), 16 deletions(-) diff --git a/misc/hf-glue/Makefile b/misc/hf-glue/Makefile index 0ded5de2c24..b2f68a25752 100644 --- a/misc/hf-glue/Makefile +++ b/misc/hf-glue/Makefile @@ -11,6 +11,10 @@ ORIGINAL_CHAIN_ID ?= gnoland1 CHAIN_ID ?= gnoland-1 HALT_HEIGHT ?= VALIDATOR_NAME ?= hf-glue-local +# Manual validator identity (used by `make genesis`, not by `make init/up`). +# Pass bech32 strings instead of providing a priv_validator_key.json. +VALIDATOR_ADDR ?= +VALIDATOR_PUBKEY ?= # Only used by `make fetch-from-dir` (alternative local-source flow). NODE_DIR ?= TXS_JSONL ?= @@ -25,7 +29,7 @@ NEW_T1_ADDR ?= T1_PORTFOLIO ?= T1_WITHDRAW_REASON ?= -export SOURCE RPC_URL ORIGINAL_CHAIN_ID CHAIN_ID HALT_HEIGHT VALIDATOR_NAME NODE_DIR TXS_JSONL PATCH_REALMS NEW_T1_ADDR T1_PORTFOLIO T1_WITHDRAW_REASON +export SOURCE RPC_URL ORIGINAL_CHAIN_ID CHAIN_ID HALT_HEIGHT VALIDATOR_NAME VALIDATOR_ADDR VALIDATOR_PUBKEY NODE_DIR TXS_JSONL PATCH_REALMS NEW_T1_ADDR T1_PORTFOLIO T1_WITHDRAW_REASON # ---- paths ----------------------------------------------------------------- HERE := $(abspath .) @@ -58,6 +62,33 @@ migrate: ## run scripts/migrate.sh — declarative hardfork recipe (fetch + patc OUT=$(OUT) REPO=$(REPO) \ $(HERE)/scripts/migrate.sh +.PHONY: genesis +genesis: ## produce out/genesis.json only, from manual VALIDATOR_ADDR+VALIDATOR_PUBKEY (no secrets, no docker) + @test -n "$(VALIDATOR_ADDR)" || { echo "VALIDATOR_ADDR required (bech32 g1...)"; exit 1; } + @test -n "$(VALIDATOR_PUBKEY)" || { echo "VALIDATOR_PUBKEY required (bech32 gpub1...)"; exit 1; } + @test -n "$(HALT_HEIGHT)" || { echo "HALT_HEIGHT required"; exit 1; } + @mkdir -p $(OUT) + @SOURCE=$(SOURCE) \ + RPC_URL=$(RPC_URL) \ + ORIGINAL_CHAIN_ID=$(ORIGINAL_CHAIN_ID) \ + CHAIN_ID=$(CHAIN_ID) \ + HALT_HEIGHT=$(HALT_HEIGHT) \ + VALIDATOR_ADDR=$(VALIDATOR_ADDR) \ + VALIDATOR_PUBKEY=$(VALIDATOR_PUBKEY) \ + VALIDATOR_NAME=$(VALIDATOR_NAME) \ + PATCH_REALMS='$(PATCH_REALMS)' \ + OUT=$(OUT) REPO=$(REPO) \ + $(HERE)/scripts/migrate.sh + @go run -C $(REPO)/misc/hf-glue/fixvalidator . \ + --address $(VALIDATOR_ADDR) --pubkey $(VALIDATOR_PUBKEY) \ + --genesis $(OUT)/genesis.json \ + --name $(VALIDATOR_NAME) --power 10 + @echo "" + @echo "Genesis ready: $(OUT)/genesis.json" + @echo " SHA256: $$(shasum -a 256 $(OUT)/genesis.json | cut -d' ' -f1)" + @echo " tx count: $$(jq '.app_state.txs | length' $(OUT)/genesis.json)" + @echo " chain_id: $$(jq -r '.chain_id' $(OUT)/genesis.json)" + .PHONY: fetch-from-dir fetch-from-dir: ## (alt) build hardfork genesis from a local gnoland data dir ($NODE_DIR, $TXS_JSONL, $HALT_HEIGHT required) @mkdir -p $(OUT) diff --git a/misc/hf-glue/fixvalidator/main.go b/misc/hf-glue/fixvalidator/main.go index 4061dd1619b..580bdb39e27 100644 --- a/misc/hf-glue/fixvalidator/main.go +++ b/misc/hf-glue/fixvalidator/main.go @@ -1,9 +1,11 @@ // fixvalidator rewrites the validator set in a gnoland genesis.json to a -// single validator loaded from a priv_validator_key.json file. +// single validator. Input can be either a priv_validator_key.json file, or +// a pair of bech32 strings (address + pubkey) for key-less environments. // // Usage: // -// fixvalidator --priv-key --genesis [--name NAME] [--power N] +// fixvalidator --priv-key --genesis [--name NAME] [--power N] +// fixvalidator --address g1... --pubkey gpub1... --genesis [--name NAME] [--power N] // // This is testbed glue (misc/hf-glue). Not intended to be installed. package main @@ -17,39 +19,74 @@ import ( "github.com/gnolang/gno/tm2/pkg/amino" signer "github.com/gnolang/gno/tm2/pkg/bft/privval/signer/local" bft "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/crypto" ) func main() { var ( privPath string + addrStr string + pubkeyStr string genesisPath string name string power int64 ) - flag.StringVar(&privPath, "priv-key", "", "path to priv_validator_key.json") + flag.StringVar(&privPath, "priv-key", "", "path to priv_validator_key.json (alternative to --address/--pubkey)") + flag.StringVar(&addrStr, "address", "", "validator bech32 address g1... (alternative to --priv-key; requires --pubkey)") + flag.StringVar(&pubkeyStr, "pubkey", "", "validator bech32 pubkey gpub1... (required with --address)") flag.StringVar(&genesisPath, "genesis", "", "path to genesis.json to rewrite in place") flag.StringVar(&name, "name", "hf-glue-local", "validator name") flag.Int64Var(&power, "power", 10, "validator voting power") flag.Parse() - if privPath == "" || genesisPath == "" { - fmt.Fprintln(os.Stderr, "both --priv-key and --genesis are required") + if genesisPath == "" { + fmt.Fprintln(os.Stderr, "--genesis is required") os.Exit(2) } - if err := run(privPath, genesisPath, name, power); err != nil { + address, pubkey, err := resolveValidator(privPath, addrStr, pubkeyStr) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(2) + } + + if err := run(genesisPath, address, pubkey, name, power); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } -func run(privPath, genesisPath, name string, power int64) error { - pv, err := signer.LoadFileKey(privPath) +// resolveValidator returns (address, pubkey) from either a priv_validator_key.json +// path or an explicit (bech32 address, bech32 pubkey) pair. When both are +// supplied, --priv-key wins. When using --address/--pubkey, the address is +// verified to match the pubkey's derived address. +func resolveValidator(privPath, addrStr, pubkeyStr string) (crypto.Address, crypto.PubKey, error) { + if privPath != "" { + pv, err := signer.LoadFileKey(privPath) + if err != nil { + return crypto.Address{}, nil, fmt.Errorf("load priv key: %w", err) + } + return pv.Address, pv.PubKey, nil + } + if addrStr == "" || pubkeyStr == "" { + return crypto.Address{}, nil, fmt.Errorf("either --priv-key OR (--address AND --pubkey) is required") + } + address, err := crypto.AddressFromBech32(addrStr) + if err != nil { + return crypto.Address{}, nil, fmt.Errorf("parse address %q: %w", addrStr, err) + } + pubkey, err := crypto.PubKeyFromBech32(pubkeyStr) if err != nil { - return fmt.Errorf("load priv key: %w", err) + return crypto.Address{}, nil, fmt.Errorf("parse pubkey %q: %w", pubkeyStr, err) } + if derived := pubkey.Address(); address != derived { + return crypto.Address{}, nil, fmt.Errorf("--address %s does not match --pubkey (derives %s)", address, derived) + } + return address, pubkey, nil +} +func run(genesisPath string, address crypto.Address, pubkey crypto.PubKey, name string, power int64) error { genDoc, err := bft.GenesisDocFromFile(genesisPath) if err != nil { return fmt.Errorf("load genesis: %w", err) @@ -57,8 +94,8 @@ func run(privPath, genesisPath, name string, power int64) error { oldCount := len(genDoc.Validators) genDoc.Validators = []bft.GenesisValidator{{ - Address: pv.Address, - PubKey: pv.PubKey, + Address: address, + PubKey: pubkey, Power: power, Name: name, }} @@ -76,8 +113,8 @@ func run(privPath, genesisPath, name string, power int64) error { } fmt.Printf("replaced %d validator(s) with single validator:\n", oldCount) - fmt.Printf(" address: %s\n", pv.Address.String()) - fmt.Printf(" pubkey: %s\n", pv.PubKey.String()) + fmt.Printf(" address: %s\n", address.String()) + fmt.Printf(" pubkey: %s\n", pubkey.String()) fmt.Printf(" name: %s\n", name) fmt.Printf(" power: %d\n", power) return nil diff --git a/misc/hf-glue/scripts/migrate.sh b/misc/hf-glue/scripts/migrate.sh index a1525bfccaf..08c0634a755 100755 --- a/misc/hf-glue/scripts/migrate.sh +++ b/misc/hf-glue/scripts/migrate.sh @@ -100,13 +100,34 @@ hf_topup_balance "g1r929wt2qplfawe4lvqv9zuwfdcz4vxdun7qh8l" "1000000000ugnot" \ # produces a signed jsonl under $OUT/migrations.jsonl. PV_KEY_DEFAULT="$OUT/gnoland-home/secrets/priv_validator_key.json" PV_KEY="${PV_KEY:-$PV_KEY_DEFAULT}" +VALIDATOR_ADDR="${VALIDATOR_ADDR:-}" +VALIDATOR_PUBKEY="${VALIDATOR_PUBKEY:-}" + +MIG_VALSET_SOURCE="" +MIG_VALSET_JSON="" if [[ -f "$PV_KEY" ]]; then + MIG_VALSET_SOURCE="pv_key=$PV_KEY" +elif [[ -n "$VALIDATOR_ADDR" && -n "$VALIDATOR_PUBKEY" ]]; then + # Build a NEW_VALSET_JSON from raw strings (no priv_validator_key.json + # needed). build.sh skips its PV_KEY path when NEW_VALSET_JSON is set. + MIG_VALSET_SOURCE="addr+pubkey (manual)" + MIG_VALSET_JSON="$OUT/new_valset.json" + jq -n --arg addr "$VALIDATOR_ADDR" --arg pub "$VALIDATOR_PUBKEY" --arg name "${VALIDATOR_NAME:-hf-local}" '[{ + address: $addr, + pub_key: $pub, + voting_power: 10, + name: $name + }]' >"$MIG_VALSET_JSON" +fi + +if [[ -n "$MIG_VALSET_SOURCE" ]]; then hf_banner "step 5 — post-replay migration (valset swap${NEW_T1_ADDR:+ + T1 rotation})" - hf_kv "pv_key" "$PV_KEY" + hf_kv "valset source" "$MIG_VALSET_SOURCE" [[ -n "${NEW_T1_ADDR:-}" ]] && hf_kv "new T1 addr" "$NEW_T1_ADDR" MIG_JSONL="$OUT/migrations.jsonl" CALLER="${CALLER:-g1manfred47kzduec920z88wfr64ylksmdcedlf5}" \ PV_KEY="$PV_KEY" \ + NEW_VALSET_JSON="$MIG_VALSET_JSON" \ RPC_URL="$RPC_URL" \ NEW_T1_ADDR="${NEW_T1_ADDR:-}" \ T1_PORTFOLIO="${T1_PORTFOLIO:-}" \ @@ -118,7 +139,7 @@ if [[ -f "$PV_KEY" ]]; then hf_migration_tx "$MIG_JSONL" else hf_banner "step 5 — post-replay migration (skipped)" - hf_kv "reason" "no priv_validator_key.json at $PV_KEY — run 'make init' first" + hf_kv "reason" "no $PV_KEY and no VALIDATOR_ADDR/VALIDATOR_PUBKEY set" fi # ------------------------------------------------------------------------- From b1c288186a1c9771b4cab635f575ea23af9f555e Mon Sep 17 00:00:00 2001 From: aeddi Date: Thu, 23 Apr 2026 15:13:22 +0200 Subject: [PATCH 73/92] feat(hf-glue): add chunked tx-archive fetch script for flaky RPCs tx-archive has no resume: a single long-range fetch that drops mid-run has to restart from block 1. This script splits the 1..HALT_HEIGHT range into fixed-size chunks, retries each chunk with backoff, caches completed chunks on disk, and concatenates them into out/source/txs.jsonl. Lets a multi-hour archive fetch make forward progress over an unreliable link. --- misc/hf-glue/scripts/fetch-txs-chunked.sh | 80 +++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100755 misc/hf-glue/scripts/fetch-txs-chunked.sh diff --git a/misc/hf-glue/scripts/fetch-txs-chunked.sh b/misc/hf-glue/scripts/fetch-txs-chunked.sh new file mode 100755 index 00000000000..35d6ed2d469 --- /dev/null +++ b/misc/hf-glue/scripts/fetch-txs-chunked.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Fetch historical txs from an RPC in chunks with retries, concatenate into +# out/source/txs.jsonl. Use when the RPC is flaky and tx-archive's single +# run can't complete — tx-archive has no resume, so chunking is the only way +# to make progress on unreliable connections. +# +# Inputs (env): +# RPC_URL (default https://rpc.gno.land) +# HALT_HEIGHT (required) +# OUT (default misc/hf-glue/out) +# REPO (required — repo root, to locate contribs/tx-archive) +# CHUNK (default 20000 blocks per fetch) +# MAX_RETRIES (default 8) +set -euo pipefail + +: "${REPO:?REPO is required}" +: "${HALT_HEIGHT:?HALT_HEIGHT is required}" +RPC_URL="${RPC_URL:-https://rpc.gno.land}" +OUT="${OUT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/out}" +CHUNK="${CHUNK:-20000}" +MAX_RETRIES="${MAX_RETRIES:-8}" + +STAGE_DIR="$OUT/source" +FINAL="$STAGE_DIR/txs.jsonl" +CHUNK_DIR="$STAGE_DIR/txs-chunks" +mkdir -p "$CHUNK_DIR" + +echo "── chunked RPC fetch ──────────────────────────────────────────" +echo " rpc $RPC_URL" +echo " range 1..$HALT_HEIGHT" +echo " chunk size $CHUNK blocks" +echo " chunk dir $CHUNK_DIR" +echo "" + +fetch_chunk() { + local from="$1" to="$2" out_path="$3" + local attempt=1 + while ((attempt <= MAX_RETRIES)); do + echo " [$from..$to] attempt $attempt/$MAX_RETRIES" + if (cd "$REPO/contribs/tx-archive" && go run ./cmd backup \ + -remote "$RPC_URL" \ + -from-block "$from" \ + -to-block "$to" \ + -batch 100 \ + -output-path "$out_path" \ + -overwrite); then + return 0 + fi + echo " [$from..$to] failed, sleeping $((attempt * 5))s" + sleep $((attempt * 5)) + ((attempt++)) + done + return 1 +} + +from=1 +while ((from <= HALT_HEIGHT)); do + to=$((from + CHUNK - 1)) + ((to > HALT_HEIGHT)) && to=$HALT_HEIGHT + chunk_file="$CHUNK_DIR/${from}-${to}.jsonl" + if [[ -s "$chunk_file" ]] || [[ -f "$chunk_file.done" ]]; then + echo " [$from..$to] cached ($(wc -l <"$chunk_file" 2>/dev/null | tr -d ' ') txs)" + else + if ! fetch_chunk "$from" "$to" "$chunk_file"; then + echo "ERROR: chunk $from..$to failed after $MAX_RETRIES attempts" >&2 + exit 1 + fi + touch "$chunk_file.done" + fi + from=$((to + 1)) +done + +echo "" +echo "── assembling final txs.jsonl ─────────────────────────────────" +: >"$FINAL" +for f in $(ls "$CHUNK_DIR"/*.jsonl | sort -t- -k1 -n); do + cat "$f" >>"$FINAL" +done +echo " wrote $FINAL" +echo " total txs: $(wc -l <"$FINAL" | tr -d ' ')" From 256454169b0ed1e81e256486303ed33ddac0ad60 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:45:10 +0200 Subject: [PATCH 74/92] feat: valset updates via VM params keeper (v3) Introduces a new on-chain validator set management implementation (r/sys/validators/v3) that propagates valset changes through the VM params keeper instead of events + VM query-back. Key changes: - EndBlocker reads `new_updates_available`, `valset_prev`, `valset_new` from the params keeper; no more VMKeeperI dependency. - ValidatorUpdates.UpdatesFrom computes the minimal diff between two valsets. - WillSetParam validates valset_new entries at write time. - SDKParams gains getter methods (GetString, GetBool, GetStrings, etc.). - ValsetRealmPath added to vm.Params, configurable via governance. - r/sys/validators/v3 realm updated to use chain/params stdlib. - events.go and validators.go (event-collector approach) removed. This is a recycled and rebased version of PR #3999. Generated with AI assistance (Claude Sonnet 4.6). See gno.land/adr/prxxxx_valset_params.md. --- examples/gno.land/r/sys/validators/v3/doc.gno | 4 + .../gno.land/r/sys/validators/v3/gnomod.toml | 5 + .../gno.land/r/sys/validators/v3/init.gno | 15 + examples/gno.land/r/sys/validators/v3/poc.gno | 128 ++++ .../r/sys/validators/v3/validators.gno | 23 + gno.land/adr/prxxxx_valset_params.md | 84 +++ gno.land/pkg/gnoland/app.go | 190 +++--- gno.land/pkg/gnoland/app_test.go | 554 ++++-------------- gno.land/pkg/gnoland/events.go | 51 -- gno.land/pkg/gnoland/mock_test.go | 102 +++- gno.land/pkg/gnoland/validators.go | 61 -- gno.land/pkg/sdk/vm/builtins.go | 36 ++ gno.land/pkg/sdk/vm/params.go | 87 ++- gno.land/pkg/sdk/vm/params_test.go | 104 +++- tm2/pkg/bft/abci/types/util.go | 55 +- tm2/pkg/bft/abci/types/util_test.go | 97 +++ 16 files changed, 950 insertions(+), 646 deletions(-) create mode 100644 examples/gno.land/r/sys/validators/v3/doc.gno create mode 100644 examples/gno.land/r/sys/validators/v3/gnomod.toml create mode 100644 examples/gno.land/r/sys/validators/v3/init.gno create mode 100644 examples/gno.land/r/sys/validators/v3/poc.gno create mode 100644 examples/gno.land/r/sys/validators/v3/validators.gno create mode 100644 gno.land/adr/prxxxx_valset_params.md delete mode 100644 gno.land/pkg/gnoland/events.go delete mode 100644 gno.land/pkg/gnoland/validators.go create mode 100644 tm2/pkg/bft/abci/types/util_test.go diff --git a/examples/gno.land/r/sys/validators/v3/doc.gno b/examples/gno.land/r/sys/validators/v3/doc.gno new file mode 100644 index 00000000000..a50eb8f688a --- /dev/null +++ b/examples/gno.land/r/sys/validators/v3/doc.gno @@ -0,0 +1,4 @@ +// Package validators implements on-chain validator set management through Proof of Authority. +// The realm exposes a public executor for GovDAO proposals to suggest validator set changes. +// Validator set changes are propagated out through the VM params keeper (v3 approach). +package validators diff --git a/examples/gno.land/r/sys/validators/v3/gnomod.toml b/examples/gno.land/r/sys/validators/v3/gnomod.toml new file mode 100644 index 00000000000..8a53ec14b8d --- /dev/null +++ b/examples/gno.land/r/sys/validators/v3/gnomod.toml @@ -0,0 +1,5 @@ +module = "gno.land/r/sys/validators/v3" +gno = "0.9" + +[addpkg] + creator = "g1r929wt2qplfawe4lvqv9zuwfdcz4vxdun7qh8l" diff --git a/examples/gno.land/r/sys/validators/v3/init.gno b/examples/gno.land/r/sys/validators/v3/init.gno new file mode 100644 index 00000000000..ff294647b53 --- /dev/null +++ b/examples/gno.land/r/sys/validators/v3/init.gno @@ -0,0 +1,15 @@ +package validators + +import ( + "chain/params" + + "gno.land/p/nt/poa/v0" +) + +func init() { + // The default valset protocol is PoA. + vp = poa.NewPoA() + + // Explicitly initialize the previous valset as empty. + params.SetStrings(valsetPrevKey, []string{}) +} diff --git a/examples/gno.land/r/sys/validators/v3/poc.gno b/examples/gno.land/r/sys/validators/v3/poc.gno new file mode 100644 index 00000000000..14eec9f840f --- /dev/null +++ b/examples/gno.land/r/sys/validators/v3/poc.gno @@ -0,0 +1,128 @@ +package validators + +import ( + "strings" + + "chain/params" + "chain/runtime" + + "gno.land/p/nt/ufmt/v0" + "gno.land/p/sys/validators" + "gno.land/r/gov/dao" +) + +// Keep in sync with gno.land/pkg/gnoland/app.go +const ( + // newUpdatesAvailableKey is a flag indicating the chain valset should be updated. + // Set by the contract, but reset by the chain (EndBlocker). + newUpdatesAvailableKey = "new_updates_available" + + // valsetNewKey is the param that holds the new proposed valset. Set by the contract, + // and read (but never modified) by the chain. + valsetNewKey = "valset_new" + + // valsetPrevKey is the param that holds the latest applied valset. Initially set by + // the contract (init), but later only written by the chain (EndBlocker). + valsetPrevKey = "valset_prev" +) + +const errNoChangesProposed = "no set changes proposed" + +// NewValsetChangeExecutor creates a new GovDAO executor for proposing valset changes. +func NewValsetChangeExecutor(changesFn func() []validators.Validator) dao.Executor { + if changesFn == nil { + panic(errNoChangesProposed) + } + + callback := func(cur realm) error { + changes := changesFn() + if len(changes) == 0 { + panic(errNoChangesProposed) + } + + // Apply each change to the on-chain valset. + for _, change := range changes { + if change.VotingPower == 0 { + // Voting power == 0 means remove the validator. + removeValidator(change.Address) + continue + } + + // Otherwise, add or update the validator. + addValidator(change) + } + + // Propagate the new valset through the VM params keeper. + params.SetBool(newUpdatesAvailableKey, true) + params.SetStrings(valsetNewKey, serializeValset(vp.GetValidators())) + + return nil + } + + return dao.NewSimpleExecutor(callback, "") +} + +// serializeValset serializes the validator set for storage in the params keeper. +// Format: "
::" +func serializeValset(valset []validators.Validator) []string { + serialized := make([]string, 0, len(valset)) + + for _, v := range valset { + serialized = append(serialized, ufmt.Sprintf( + "%s:%s:%d", + v.Address.String(), + v.PubKey, + v.VotingPower, + )) + } + + return serialized +} + +// IsValidator returns true if the given address is part of the validator set. +func IsValidator(addr address) bool { + return vp.IsValidator(addr) +} + +// GetValidator returns the validator with the given address. +// Panics if the validator is not found. +func GetValidator(addr address) validators.Validator { + v, err := vp.GetValidator(addr) + if err != nil { + panic("validator not found") + } + + return v +} + +// GetValidators returns the current validator set. +func GetValidators() []validators.Validator { + return vp.GetValidators() +} + +// Render displays the current validator set. +func Render(string) string { + var ( + sb strings.Builder + h = runtime.ChainHeight() + set = GetValidators() + ) + + sb.WriteString(ufmt.Sprintf("## Valset at height %d\n\n", h)) + + if len(set) == 0 { + sb.WriteString("Valset is empty.\n") + return sb.String() + } + + for i, v := range set { + sb.WriteString(ufmt.Sprintf( + "- #%d: %s (%d)\n", + i, + v.Address.String(), + v.VotingPower, + )) + } + + return sb.String() +} diff --git a/examples/gno.land/r/sys/validators/v3/validators.gno b/examples/gno.land/r/sys/validators/v3/validators.gno new file mode 100644 index 00000000000..14f3a9e6b09 --- /dev/null +++ b/examples/gno.land/r/sys/validators/v3/validators.gno @@ -0,0 +1,23 @@ +package validators + +import ( + "gno.land/p/sys/validators" +) + +var vp validators.ValsetProtocol // vp is the underlying validator set protocol + +// addValidator adds a new validator to the validator set. +// If the validator is already present, the method panics. +func addValidator(validator validators.Validator) { + if _, err := vp.AddValidator(validator.Address, validator.PubKey, validator.VotingPower); err != nil { + panic(err) + } +} + +// removeValidator removes the given validator from the set. +// If the validator is not present in the set, the method panics. +func removeValidator(addr address) { + if _, err := vp.RemoveValidator(addr); err != nil { + panic(err) + } +} diff --git a/gno.land/adr/prxxxx_valset_params.md b/gno.land/adr/prxxxx_valset_params.md new file mode 100644 index 00000000000..72bc617e2dc --- /dev/null +++ b/gno.land/adr/prxxxx_valset_params.md @@ -0,0 +1,84 @@ +# ADR: Valset Updates via VM Params Keeper (v3) + +## Context + +This PR introduces the third iteration of on-chain validator set management +(`r/sys/validators/v3`). Previous iterations: + +- **v1**: Valset changes emitted as events, caught by `EndBlocker`. +- **v2**: Events triggered a VM query in `EndBlocker` to scrape on-chain state + via `GetChanges(from, to)` — still event-driven. + +Both v1 and v2 required the `EndBlocker` to: +1. Listen to on-chain events via an event collector (`collector[validatorUpdate]`). +2. Call back into the GnoVM to fetch the actual changes (v2), or parse event + payloads (v1). + +Problems with those approaches: +- **Coupling**: The `EndBlocker` needed a `VMKeeperI` reference solely to query + the valset realm. +- **Fragility**: Regex-based parsing of typed GnoVM response strings. +- **Indirection**: An event triggers a VM query, which returns data that was + already computed on-chain. + +## Decision + +Replace the event-based approach with a **params-keeper-based** approach: + +1. The valset realm (`r/sys/validators/v3`) writes changes directly into the + VM params keeper under realm-scoped keys. +2. `EndBlocker` reads those keys from the params keeper, computes the diff + between `valset_prev` and `valset_new`, and propagates the changes to + consensus. + +### Params keys (prefix: `vm:gno.land/r/sys/validators/v3:`) + +| Key | Written by | Read by | Description | +|------------------------|-------------|-------------|--------------------------------------------| +| `new_updates_available`| realm | EndBlocker | Flag: set true when valset changed | +| `valset_new` | realm | EndBlocker | Serialized proposed valset | +| `valset_prev` | EndBlocker | EndBlocker | Serialized previously applied valset | + +Serialization format: `::` + +### Valset diff + +A new `ValidatorUpdates.UpdatesFrom(v2)` method on `tm2/pkg/bft/abci/types` +computes the minimal diff between two validator sets: +- Additions: in v2 but not prev. +- Removals: in prev but not v2 (emitted with `Power=0`). +- Power changes: in both but with different power. + +### Validation + +`WillSetParam` in `VMKeeper` validates `valset_new` updates at write time, +ensuring each entry is well-formed (address/pubkey match, valid power). + +The `EndBlocker` still filters out updates with disallowed pubkey types. + +### Active valset realm path + +The realm path is configurable via `vm:p:valset_realm_path` (default: +`gno.land/r/sys/validators/v3`). This allows future upgrades without changing +the `EndBlocker` code. + +## Alternatives Considered + +- **Keep v2 approach**: Simpler for the realm (no params awareness) but + requires `EndBlocker` to call back into the VM. Rejected because of the + coupling and fragility. +- **ABCI events with typed payloads**: Would require extending the GnoVM's + event system with typed values. More invasive; params keeper already exists. + +## Consequences + +**Positive**: +- `EndBlocker` no longer needs a `VMKeeperI` reference. +- No regex parsing of VM responses. +- Validation happens at write time (fail fast). +- Realm path is configurable. + +**Negative / Tradeoffs**: +- The realm must know about the params keeper API. +- The param keys must be kept in sync between the realm and `app.go`. +- Existing v2 realm/chain state is not migrated (v3 is a fresh start). diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index 213bc17582e..03b0cf0c73e 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -8,6 +8,7 @@ import ( "path/filepath" "slices" "strconv" + "strings" "time" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" @@ -177,20 +178,12 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { } }) - // Set up the event collector - c := newCollector[validatorUpdate]( - cfg.EventSwitch, // global event switch filled by the node - validatorEventFilter, // filter fn that keeps the collector valid - ) - // Set EndBlocker baseApp.SetEndBlocker( EndBlocker( - c, + prmk, acck, gpk, - vmk, - prmk, baseApp, ), ) @@ -567,22 +560,44 @@ func isPastChainID(pastChainIDs []string, chainID string) bool { return slices.Contains(pastChainIDs, chainID) } +// Keep in sync with examples/gno.land/r/sys/validators/v3/poc.gno +const ( + vmModulePrefix = "vm" + + // newUpdatesAvailableKey is a flag indicating the chain valset should be updated. + // Set by the contract, but reset by the chain (EndBlocker). + newUpdatesAvailableKey = "new_updates_available" + + // valsetNewKey is the param that holds the new proposed valset. Set by the contract, + // and read (but never modified) by the chain. + valsetNewKey = "valset_new" + + // valsetPrevKey is the param that holds the latest applied valset. Initially set by + // the contract (init), but later only written by the chain (EndBlocker). + valsetPrevKey = "valset_prev" +) + +// valsetParamPath constructs the full param key for a valset-realm-scoped param: +// +// vm:: +func valsetParamPath(valsetRealm, key string) string { + return fmt.Sprintf("%s:%s:%s", vmModulePrefix, valsetRealm, key) +} + // EndBlocker defines the logic executed after every block. -// Currently, it parses events that happened during execution to calculate -// validator set changes, and checks for a governance-requested chain halt. +// It reads valset changes from the VM params keeper, checks for a +// governance-requested chain halt, and propagates updates to consensus. func EndBlocker( - collector *collector[validatorUpdate], + prmk params.ParamsKeeperI, acck auth.AccountKeeperI, gpk auth.GasPriceKeeperI, - vmk vm.VMKeeperI, - prmk params.ParamsKeeperI, app endBlockerApp, ) func( ctx sdk.Context, req abci.RequestEndBlock, ) abci.ResponseEndBlock { return func(ctx sdk.Context, req abci.RequestEndBlock) abci.ResponseEndBlock { - // set the auth params value in the ctx. The EndBlocker will use InitialGasPrice in + // Set the auth params value in the ctx. The EndBlocker will use InitialGasPrice in // the params to calculate the updated gas price. if acck != nil { ctx = ctx.WithValue(auth.AuthParamsContextKey{}, acck.GetParams(ctx)) @@ -609,65 +624,86 @@ func EndBlocker( } } - // Check if there was a valset change - if len(collector.getEvents()) == 0 { - // No valset updates + // Determine which realm is responsible for valset management. + valsetRealm := vm.ValsetRealmDefault + prmk.GetString(ctx, vm.ValsetRealmParamPath, &valsetRealm) + + // Check if there are any pending valset changes. + updatesAvailable := false + prmk.GetBool(ctx, valsetParamPath(valsetRealm, newUpdatesAvailableKey), &updatesAvailable) + + if !updatesAvailable { return abci.ResponseEndBlock{} } - // Run the VM to get the validator changes for the last committed block. - lastHeight := app.LastBlockHeight() - response, err := vmk.QueryEval( - ctx, - valRealm, - fmt.Sprintf("%s(%d,%d)", valChangesFn, lastHeight, lastHeight), + var ( + prevValset []string + proposedValset []string + + prevValsetPath = valsetParamPath(valsetRealm, valsetPrevKey) + proposedValsetPath = valsetParamPath(valsetRealm, valsetNewKey) ) + + prmk.GetStrings(ctx, prevValsetPath, &prevValset) + prmk.GetStrings(ctx, proposedValsetPath, &proposedValset) + + // Parse the previous set. + prevSet, err := extractUpdatesFromParams(prevValset) if err != nil { - app.Logger().Error("unable to call VM during EndBlocker", "err", err) + app.Logger().Error( + "unable to parse prev valset in EndBlocker", + "err", err, + ) return abci.ResponseEndBlock{} } - // Extract the updates from the VM response - updates, err := extractUpdatesFromResponse(response) + // Parse the proposed set. + proposedSet, err := extractUpdatesFromParams(proposedValset) if err != nil { - app.Logger().Error("unable to extract updates from response", "err", err) + app.Logger().Error( + "unable to parse proposed valset in EndBlocker", + "err", err, + ) return abci.ResponseEndBlock{} } + // Compute the diff between prev and proposed. + updates := prevSet.UpdatesFrom(proposedSet) + + app.Logger().Info( + "valset changes to be applied", + "count", len(updates), + ) + + // Advance prevValset to match proposedValset. + prmk.SetStrings(ctx, prevValsetPath, proposedValset) + + // Clear the pending-updates flag. + prmk.SetBool(ctx, valsetParamPath(valsetRealm, newUpdatesAvailableKey), false) + allowedKeyTypes := ctx.ConsensusParams().Validator.PubKeyTypeURLs - // Filter out the updates that are not valid + // Filter out updates that fail consensus-level validation. updates = slices.DeleteFunc(updates, func(u abci.ValidatorUpdate) bool { - // Make sure the power is valid - if u.Power < 0 { - app.Logger().Error( - "valset update invalid; voting power < 0", - "address", u.Address.String(), - "power", u.Power, - ) - - return true // delete it + // Power == 0 means removal; skip further validation for removals. + if u.Power == 0 { + return false } - // Make sure the public key matches the address - if u.PubKey.Address().Compare(u.Address) != 0 { + // Make sure the public key is an allowed consensus key type. + if !slices.Contains(allowedKeyTypes, amino.GetTypeURL(u.PubKey)) { app.Logger().Error( - "valset update invalid; pubkey + address mismatch", + "valset update invalid; unsupported pubkey type", "address", u.Address.String(), - "pubkey", u.PubKey.String(), + "pubkey_type", amino.GetTypeURL(u.PubKey), ) return true // delete it } - // Make sure the public key is an allowed consensus key type - if !slices.Contains(allowedKeyTypes, amino.GetTypeURL(u.PubKey)) { - return true // delete it - } - - return false // keep it, update is valid + return false // keep it }) return abci.ResponseEndBlock{ @@ -676,52 +712,44 @@ func EndBlocker( } } -// extractUpdatesFromResponse extracts the validator set updates -// from the VM response. +// extractUpdatesFromParams parses serialized validator updates from the params keeper. +// Each entry is expected to be in the form: // -// This method is not ideal, but currently there is no mechanism -// in place to parse typed VM responses -func extractUpdatesFromResponse(response string) ([]abci.ValidatorUpdate, error) { - // Find the submatches - matches := valRegexp.FindAllStringSubmatch(response, -1) - if len(matches) == 0 { - // No changes to extract - return nil, nil - } - - updates := make([]abci.ValidatorUpdate, 0, len(matches)) - for _, match := range matches { - var ( - addressRaw = match[1] - pubKeyRaw = match[2] - powerRaw = match[3] - ) +//
:: +// +// A voting power of 0 indicates a validator removal. +func extractUpdatesFromParams(changes []string) (abci.ValidatorUpdates, error) { + updates := make(abci.ValidatorUpdates, 0, len(changes)) + + for _, change := range changes { + parts := strings.Split(change, ":") + if len(parts) != 3 { + return nil, fmt.Errorf( + "valset update is not in the format
::, but %q", + change, + ) + } - // Parse the address - address, err := crypto.AddressFromBech32(addressRaw) + address, err := crypto.AddressFromBech32(parts[0]) if err != nil { - return nil, fmt.Errorf("unable to parse address, %w", err) + return nil, fmt.Errorf("invalid validator address: %w", err) } - // Parse the public key - pubKey, err := crypto.PubKeyFromBech32(pubKeyRaw) + pubKey, err := crypto.PubKeyFromBech32(parts[1]) if err != nil { - return nil, fmt.Errorf("unable to parse public key, %w", err) + return nil, fmt.Errorf("invalid validator pubkey: %w", err) } - // Parse the voting power - power, err := strconv.ParseInt(powerRaw, 10, 64) + votingPower, err := strconv.ParseInt(parts[2], 10, 64) if err != nil { - return nil, fmt.Errorf("unable to parse voting power, %w", err) + return nil, fmt.Errorf("invalid voting power: %w", err) } - update := abci.ValidatorUpdate{ + updates = append(updates, abci.ValidatorUpdate{ Address: address, PubKey: pubKey, - Power: power, - } - - updates = append(updates, update) + Power: votingPower, + }) } return updates, nil diff --git a/gno.land/pkg/gnoland/app_test.go b/gno.land/pkg/gnoland/app_test.go index 6d38d876431..6ba80b0aef6 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -2,10 +2,9 @@ package gnoland import ( "context" - "errors" "fmt" "path/filepath" - "strings" + "sort" "testing" "time" @@ -14,7 +13,6 @@ import ( "github.com/gnolang/gno/gno.land/pkg/sdk/vm" "github.com/gnolang/gno/gnovm/pkg/gnolang" - "github.com/gnolang/gno/gnovm/stdlibs/chain" "github.com/gnolang/gno/tm2/pkg/amino" abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" bftCfg "github.com/gnolang/gno/tm2/pkg/bft/config" @@ -538,455 +536,196 @@ func TestInitChainer_MetadataTxs(t *testing.T) { func TestEndBlocker(t *testing.T) { t.Parallel() - constructVMResponse := func(updates []abci.ValidatorUpdate) string { - var builder strings.Builder - - builder.WriteString("(slice[") - - for i, update := range updates { - builder.WriteString( - fmt.Sprintf( - "(struct{(%q std.Address),(%q string),(%d uint64)} gno.land/p/sys/validators.Validator)", - update.Address, - update.PubKey, - update.Power, - ), - ) - - if i < len(updates)-1 { - builder.WriteString(",") - } - } - - builder.WriteString("] []gno.land/p/sys/validators.Validator)") - - return builder.String() - } - - newCommonEvSwitch := func() *mockEventSwitch { - var cb events.EventCallback - - return &mockEventSwitch{ - addListenerFn: func(_ string, callback events.EventCallback) { - cb = callback - }, - fireEventFn: func(event events.Event) { - cb(event) - }, - } - } - - t.Run("no collector events", func(t *testing.T) { - t.Parallel() - - noFilter := func(_ events.Event) []validatorUpdate { - return []validatorUpdate{} - } - - // Create the collector - c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) - - // Create the EndBlocker - eb := EndBlocker(c, nil, nil, nil, nil, &mockEndBlockerApp{}) - - // Run the EndBlocker - res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ - Validator: &abci.ValidatorParams{ - PubKeyTypeURLs: []string{"/tm.PubKeySecp256k1"}, - }, - }), abci.RequestEndBlock{}) - - // Verify the response was empty - assert.Equal(t, abci.ResponseEndBlock{}, res) - }) - - t.Run("invalid VM call", func(t *testing.T) { + t.Run("no valset changes", func(t *testing.T) { t.Parallel() var ( - noFilter = func(_ events.Event) []validatorUpdate { - return make([]validatorUpdate, 1) // 1 update - } - - vmCalled bool - - mockEventSwitch = newCommonEvSwitch() - - mockVMKeeper = &mockVMKeeper{ - queryFn: func(_ sdk.Context, pkgPath, expr string) (string, error) { - vmCalled = true - - require.Equal(t, valRealm, pkgPath) - require.NotEmpty(t, expr) - - return "", errors.New("random call error") + mockParamsKeeper = &mockParamsKeeper{ + getStringFn: func(_ sdk.Context, key string, ptr *string) { + // valset realm lookup - return default + }, + getBoolFn: func(_ sdk.Context, key string, ptr *bool) { + // updatesAvailable stays false (default) }, } - ) - - // Create the collector - c := newCollector[validatorUpdate](mockEventSwitch, noFilter) - // Fire a GnoVM event - mockEventSwitch.FireEvent(chain.Event{}) + mockApp = &mockEndBlockerApp{} + ) - // Create the EndBlocker - eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) + eb := EndBlocker(mockParamsKeeper, nil, nil, mockApp) - // Run the EndBlocker res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ Validator: &abci.ValidatorParams{ PubKeyTypeURLs: []string{"/tm.PubKeySecp256k1"}, }, }), abci.RequestEndBlock{}) - // Verify the response was empty assert.Equal(t, abci.ResponseEndBlock{}, res) - - // Make sure the VM was called - assert.True(t, vmCalled) }) - t.Run("empty VM response", func(t *testing.T) { + t.Run("invalid valset changes in prev", func(t *testing.T) { t.Parallel() var ( - noFilter = func(_ events.Event) []validatorUpdate { - return make([]validatorUpdate, 1) // 1 update - } - - vmCalled bool - - mockEventSwitch = newCommonEvSwitch() - - mockVMKeeper = &mockVMKeeper{ - queryFn: func(_ sdk.Context, pkgPath, expr string) (string, error) { - vmCalled = true - - require.Equal(t, valRealm, pkgPath) - require.NotEmpty(t, expr) - - return constructVMResponse([]abci.ValidatorUpdate{}), nil + updateFlag = true + paramUpdates []string + + mockParamsKeeper = &mockParamsKeeper{ + getStringFn: func(_ sdk.Context, key string, ptr *string) {}, + getStringsFn: func(_ sdk.Context, key string, ptr *[]string) { + switch key { + case valsetParamPath(vm.ValsetRealmDefault, valsetPrevKey): + *ptr = []string{"totally invalid format"} + case valsetParamPath(vm.ValsetRealmDefault, valsetNewKey): + // empty proposed set + } + }, + getBoolFn: func(_ sdk.Context, key string, ptr *bool) { + if key == valsetParamPath(vm.ValsetRealmDefault, newUpdatesAvailableKey) { + *ptr = updateFlag + } + }, + setBoolFn: func(_ sdk.Context, key string, value bool) { + updateFlag = value + }, + setStringsFn: func(_ sdk.Context, key string, value []string) { + paramUpdates = value }, } - ) - // Create the collector - c := newCollector[validatorUpdate](mockEventSwitch, noFilter) - - // Fire a GnoVM event - mockEventSwitch.FireEvent(chain.Event{}) + mockApp = &mockEndBlockerApp{} + ) - // Create the EndBlocker - eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) + eb := EndBlocker(mockParamsKeeper, nil, nil, mockApp) - // Run the EndBlocker res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ Validator: &abci.ValidatorParams{ PubKeyTypeURLs: []string{"/tm.PubKeySecp256k1"}, }, }), abci.RequestEndBlock{}) - // Verify the response was empty assert.Equal(t, abci.ResponseEndBlock{}, res) - - // Make sure the VM was called - assert.True(t, vmCalled) + // Flag was not cleared, updates were not saved + assert.True(t, updateFlag) + assert.Empty(t, paramUpdates) }) - t.Run("multiple valset updates", func(t *testing.T) { + t.Run("valid valset changes", func(t *testing.T) { t.Parallel() - var ( - changes = generateValidatorUpdates(t, 100) - - mockEventSwitch = newCommonEvSwitch() - - mockVMKeeper = &mockVMKeeper{ - queryFn: func(_ sdk.Context, pkgPath, expr string) (string, error) { - require.Equal(t, valRealm, pkgPath) - require.NotEmpty(t, expr) - - return constructVMResponse(changes), nil - }, - } - ) - - // Create the collector - c := newCollector[validatorUpdate](mockEventSwitch, validatorEventFilter) - - // Construct the GnoVM events - vmEvents := make([]abci.Event, 0, len(changes)) - for index := range changes { - event := chain.Event{ - Type: validatorAddedEvent, - PkgPath: valRealm, - } - - // Make half the changes validator removes - if index%2 == 0 { - changes[index].Power = 0 - - event = chain.Event{ - Type: validatorRemovedEvent, - PkgPath: valRealm, - } - } - - vmEvents = append(vmEvents, event) - } + updates := generateValidatorUpdates(t, 10) - // Fire the tx result event - txEvent := bft.EventTx{ - Result: bft.TxResult{ - Response: abci.ResponseDeliverTx{ - ResponseBase: abci.ResponseBase{ - Events: vmEvents, - }, - }, - }, + serializeUpdate := func(u abci.ValidatorUpdate) string { + return fmt.Sprintf("%s:%s:%d", u.Address.String(), u.PubKey, u.Power) } - mockEventSwitch.FireEvent(txEvent) - - // Create the EndBlocker - eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) - - // Run the EndBlocker - res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ - Validator: &abci.ValidatorParams{ - PubKeyTypeURLs: []string{"/tm.PubKeySecp256k1"}, - }, - }), abci.RequestEndBlock{}) - - // Verify the response was not empty - require.Len(t, res.ValidatorUpdates, len(changes)) - - for index, update := range res.ValidatorUpdates { - assert.Equal(t, changes[index].Address, update.Address) - assert.True(t, changes[index].PubKey.Equals(update.PubKey)) - assert.Equal(t, changes[index].Power, update.Power) - } - }) - - t.Run("negative power filtered out", func(t *testing.T) { - t.Parallel() - var ( - keys = generateDummyKeys(t, 2) - - validUpdate = abci.ValidatorUpdate{ - Address: keys[0].PubKey().Address(), - PubKey: keys[0].PubKey(), - Power: 1, - } - - invalidUpdate = abci.ValidatorUpdate{ - Address: keys[1].PubKey().Address(), - PubKey: keys[1].PubKey(), - Power: -1, // Invalid negative power - } - - updates = []abci.ValidatorUpdate{validUpdate, invalidUpdate} - - mockEventSwitch = newCommonEvSwitch() - - mockVMKeeper = &mockVMKeeper{ - queryFn: func(_ sdk.Context, pkgPath, expr string) (string, error) { - require.Equal(t, valRealm, pkgPath) - require.NotEmpty(t, expr) - - return constructVMResponse(updates), nil + updateFlag = true + paramUpdates []string + + mockParamsKeeper = &mockParamsKeeper{ + getStringFn: func(_ sdk.Context, key string, ptr *string) {}, + getStringsFn: func(_ sdk.Context, key string, ptr *[]string) { + switch key { + case valsetParamPath(vm.ValsetRealmDefault, valsetPrevKey): + *ptr = []string{} // empty prev set + case valsetParamPath(vm.ValsetRealmDefault, valsetNewKey): + serialized := make([]string, 0, len(updates)) + for _, u := range updates { + serialized = append(serialized, serializeUpdate(u)) + } + *ptr = serialized + } }, - } - - vmEvents = []abci.Event{ - chain.Event{ - Type: validatorAddedEvent, - PkgPath: valRealm, + getBoolFn: func(_ sdk.Context, key string, ptr *bool) { + if key == valsetParamPath(vm.ValsetRealmDefault, newUpdatesAvailableKey) { + *ptr = updateFlag + } }, - chain.Event{ - Type: validatorAddedEvent, - PkgPath: valRealm, + setBoolFn: func(_ sdk.Context, key string, value bool) { + updateFlag = value }, - } - txEvent = bft.EventTx{ - Result: bft.TxResult{ - Response: abci.ResponseDeliverTx{ - ResponseBase: abci.ResponseBase{ - Events: vmEvents, - }, - }, + setStringsFn: func(_ sdk.Context, key string, value []string) { + paramUpdates = value }, } + + mockApp = &mockEndBlockerApp{} ) - c := newCollector[validatorUpdate](mockEventSwitch, validatorEventFilter) - mockEventSwitch.FireEvent(txEvent) + eb := EndBlocker(mockParamsKeeper, nil, nil, mockApp) - eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ Validator: &abci.ValidatorParams{ PubKeyTypeURLs: []string{"/tm.PubKeySecp256k1"}, }, }), abci.RequestEndBlock{}) - require.Len(t, res.ValidatorUpdates, 1) - assert.Equal(t, validUpdate.Address, res.ValidatorUpdates[0].Address) - assert.Equal(t, validUpdate.Power, res.ValidatorUpdates[0].Power) - }) - t.Run("pubkey address mismatch filtered out", func(t *testing.T) { - t.Parallel() - - var ( - keys = generateDummyKeys(t, 3) - - validUpdate = abci.ValidatorUpdate{ - Address: keys[0].PubKey().Address(), - PubKey: keys[0].PubKey(), - Power: 1, - } - - invalidUpdate = abci.ValidatorUpdate{ - Address: keys[1].PubKey().Address(), // Address from key1 - PubKey: keys[2].PubKey(), // PubKey from key2 (mismatch) - Power: 1, - } - - updates = []abci.ValidatorUpdate{validUpdate, invalidUpdate} - - mockEventSwitch = newCommonEvSwitch() - - mockVMKeeper = &mockVMKeeper{ - queryFn: func(_ sdk.Context, pkgPath, expr string) (string, error) { - require.Equal(t, valRealm, pkgPath) - require.NotEmpty(t, expr) - - return constructVMResponse(updates), nil - }, - } + require.Len(t, res.ValidatorUpdates, len(updates)) - vmEvents = []abci.Event{ - chain.Event{ - Type: validatorAddedEvent, - PkgPath: valRealm, - }, - chain.Event{ - Type: validatorAddedEvent, - PkgPath: valRealm, - }, - } - txEvent = bft.EventTx{ - Result: bft.TxResult{ - Response: abci.ResponseDeliverTx{ - ResponseBase: abci.ResponseBase{ - Events: vmEvents, - }, - }, - }, - } - ) + // Sort both for comparison + sort.Slice(updates, func(i, j int) bool { + return updates[i].Address.Compare(updates[j].Address) < 0 + }) + sort.Slice(res.ValidatorUpdates, func(i, j int) bool { + return res.ValidatorUpdates[i].Address.Compare(res.ValidatorUpdates[j].Address) < 0 + }) - c := newCollector[validatorUpdate](mockEventSwitch, validatorEventFilter) - mockEventSwitch.FireEvent(txEvent) - eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) - res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ - Validator: &abci.ValidatorParams{ - PubKeyTypeURLs: []string{"/tm.PubKeySecp256k1"}, - }, - }), abci.RequestEndBlock{}) + for i, u := range updates { + assert.Equal(t, u.Address.String(), res.ValidatorUpdates[i].Address.String()) + assert.True(t, u.PubKey.Equals(res.ValidatorUpdates[i].PubKey)) + assert.Equal(t, u.Power, res.ValidatorUpdates[i].Power) + } - // Verify only the valid update is returned - require.Len(t, res.ValidatorUpdates, 1) - assert.Equal(t, validUpdate.Address, res.ValidatorUpdates[0].Address) - assert.True(t, validUpdate.PubKey.Equals(res.ValidatorUpdates[0].PubKey)) + // Flag cleared, prev updated + assert.False(t, updateFlag) + assert.NotEmpty(t, paramUpdates) }) - t.Run("wrong pubkey type", func(t *testing.T) { + t.Run("wrong pubkey type filtered out", func(t *testing.T) { t.Parallel() - var ( - key1 = getDummyKey(t) + updates := generateValidatorUpdates(t, 1) - updates = []abci.ValidatorUpdate{ - { - Address: key1.PubKey().Address(), - PubKey: key1.PubKey(), - Power: 1, - }, - } - - mockEventSwitch = newCommonEvSwitch() - - mockVMKeeper = &mockVMKeeper{ - queryFn: func(_ sdk.Context, pkgPath, expr string) (string, error) { - require.Equal(t, valRealm, pkgPath) - require.NotEmpty(t, expr) + serializeUpdate := func(u abci.ValidatorUpdate) string { + return fmt.Sprintf("%s:%s:%d", u.Address.String(), u.PubKey, u.Power) + } - return constructVMResponse(updates), nil + var ( + updateFlag = true + + mockParamsKeeper = &mockParamsKeeper{ + getStringFn: func(_ sdk.Context, key string, ptr *string) {}, + getStringsFn: func(_ sdk.Context, key string, ptr *[]string) { + switch key { + case valsetParamPath(vm.ValsetRealmDefault, valsetPrevKey): + *ptr = []string{} + case valsetParamPath(vm.ValsetRealmDefault, valsetNewKey): + *ptr = []string{serializeUpdate(updates[0])} + } }, - } - txEvent = bft.EventTx{ - Result: bft.TxResult{ - Response: abci.ResponseDeliverTx{ - ResponseBase: abci.ResponseBase{ - Events: []abci.Event{ - chain.Event{ - Type: validatorAddedEvent, - PkgPath: valRealm, - }, - }, - }, - }, + getBoolFn: func(_ sdk.Context, key string, ptr *bool) { + if key == valsetParamPath(vm.ValsetRealmDefault, newUpdatesAvailableKey) { + *ptr = updateFlag + } }, + setBoolFn: func(_ sdk.Context, _ string, value bool) { updateFlag = value }, + setStringsFn: func(_ sdk.Context, _ string, _ []string) {}, } - ) - - c := newCollector[validatorUpdate](mockEventSwitch, validatorEventFilter) - mockEventSwitch.FireEvent(txEvent) - eb := EndBlocker(c, nil, nil, mockVMKeeper, nil, &mockEndBlockerApp{}) - res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ - Validator: &abci.ValidatorParams{ - PubKeyTypeURLs: []string{"/tm.PubKeyEd25519"}, - }, - }), abci.RequestEndBlock{}) - // Verify only the valid update is returned - require.Len(t, res.ValidatorUpdates, 0) - }) - - t.Run("extract updates error", func(t *testing.T) { - t.Parallel() - - var ( - noFilter = func(_ events.Event) []validatorUpdate { - return make([]validatorUpdate, 1) // 1 update - } - - mockEventSwitchInner = newCommonEvSwitch() - - mockVMKeeperInner = &mockVMKeeper{ - queryFn: func(_ sdk.Context, pkgPath, expr string) (string, error) { - require.Equal(t, valRealm, pkgPath) - // Return a response that matches the regex but has an invalid bech32 address. - // This causes extractUpdatesFromResponse to return an error. - return `{("notabech32" std.Address),("notapubkey" string),(1 uint64)}`, nil - }, - } + mockApp = &mockEndBlockerApp{} ) - c := newCollector[validatorUpdate](mockEventSwitchInner, noFilter) - mockEventSwitchInner.FireEvent(chain.Event{}) + eb := EndBlocker(mockParamsKeeper, nil, nil, mockApp) - eb := EndBlocker(c, nil, nil, mockVMKeeperInner, nil, &mockEndBlockerApp{}) res := eb(sdk.Context{}.WithConsensusParams(&abci.ConsensusParams{ Validator: &abci.ValidatorParams{ - PubKeyTypeURLs: []string{"/tm.PubKeySecp256k1"}, + PubKeyTypeURLs: []string{"/tm.PubKeyEd25519"}, // wrong type }, }), abci.RequestEndBlock{}) - // Error from extractUpdatesFromResponse → EndBlocker returns empty response - assert.Equal(t, abci.ResponseEndBlock{}, res) + // The update is filtered out due to wrong pubkey type + assert.Empty(t, res.ValidatorUpdates) }) } @@ -1195,20 +934,12 @@ func newGasPriceTestApp(t *testing.T) abci.Application { }, ) - // Set up the event collector - c := newCollector[validatorUpdate]( - cfg.EventSwitch, // global event switch filled by the node - validatorEventFilter, // filter fn that keeps the collector valid - ) - // Set EndBlocker baseApp.SetEndBlocker( EndBlocker( - c, + prmk, acck, gpk, - nil, - nil, baseApp, ), ) @@ -2550,8 +2281,6 @@ func TestCheckNodeStartupParams(t *testing.T) { func TestEndBlockerHalt(t *testing.T) { t.Parallel() - noFilter := func(_ events.Event) []validatorUpdate { return nil } - t.Run("halts at exact height", func(t *testing.T) { t.Parallel() @@ -2563,8 +2292,7 @@ func TestEndBlockerHalt(t *testing.T) { int64s: map[string]int64{nodeParamHaltHeight: 100}, } - c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) - eb := EndBlocker(c, nil, nil, nil, mockPrmk, mockApp) + eb := EndBlocker(mockPrmk, nil, nil, mockApp) eb(sdk.Context{}, abci.RequestEndBlock{Height: 100}) assert.Equal(t, uint64(100), haltSet, "SetHaltHeight should be called with halt_height") @@ -2581,8 +2309,7 @@ func TestEndBlockerHalt(t *testing.T) { int64s: map[string]int64{nodeParamHaltHeight: 100}, } - c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) - eb := EndBlocker(c, nil, nil, nil, mockPrmk, mockApp) + eb := EndBlocker(mockPrmk, nil, nil, mockApp) eb(sdk.Context{}, abci.RequestEndBlock{Height: 99}) assert.Equal(t, uint64(0), haltSet, "SetHaltHeight should NOT be called before halt height") @@ -2599,8 +2326,7 @@ func TestEndBlockerHalt(t *testing.T) { int64s: map[string]int64{nodeParamHaltHeight: 100}, } - c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) - eb := EndBlocker(c, nil, nil, nil, mockPrmk, mockApp) + eb := EndBlocker(mockPrmk, nil, nil, mockApp) // After restart at height 101, halt_height=100 still in params but == doesn't re-fire eb(sdk.Context{}, abci.RequestEndBlock{Height: 101}) @@ -2618,47 +2344,9 @@ func TestEndBlockerHalt(t *testing.T) { int64s: map[string]int64{nodeParamHaltHeight: 0}, } - c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) - eb := EndBlocker(c, nil, nil, nil, mockPrmk, mockApp) + eb := EndBlocker(mockPrmk, nil, nil, mockApp) eb(sdk.Context{}, abci.RequestEndBlock{Height: 100}) assert.Equal(t, uint64(0), haltSet, "SetHaltHeight should NOT be called when halt_height=0 (cancelled)") }) } - -func TestExtractUpdatesFromResponse(t *testing.T) { - t.Parallel() - - t.Run("empty response returns nil", func(t *testing.T) { - t.Parallel() - updates, err := extractUpdatesFromResponse("") - require.NoError(t, err) - assert.Nil(t, updates) - }) - - t.Run("no regex match returns nil", func(t *testing.T) { - t.Parallel() - updates, err := extractUpdatesFromResponse("some random string with no validator data") - require.NoError(t, err) - assert.Nil(t, updates) - }) - - t.Run("invalid address", func(t *testing.T) { - t.Parallel() - // The regex captures any quoted string as the address, so we can inject an invalid bech32. - response := `{("notabech32" std.Address),("notapubkey" string),(1 uint64)}` - _, err := extractUpdatesFromResponse(response) - require.Error(t, err) - assert.Contains(t, err.Error(), "unable to parse address") - }) - - t.Run("invalid pubkey", func(t *testing.T) { - t.Parallel() - // Valid bech32 address, but invalid pubkey string. - addr := crypto.AddressFromPreimage([]byte("test")) - response := fmt.Sprintf(`{(%q std.Address),("notapubkey" string),(1 uint64)}`, addr) - _, err := extractUpdatesFromResponse(response) - require.Error(t, err) - assert.Contains(t, err.Error(), "unable to parse public key") - }) -} diff --git a/gno.land/pkg/gnoland/events.go b/gno.land/pkg/gnoland/events.go deleted file mode 100644 index ba78c40979e..00000000000 --- a/gno.land/pkg/gnoland/events.go +++ /dev/null @@ -1,51 +0,0 @@ -package gnoland - -import ( - "github.com/gnolang/gno/tm2/pkg/events" - "github.com/rs/xid" -) - -// filterFn is the filter method for incoming events -type filterFn[T any] func(events.Event) []T - -// collector is the generic in-memory event collector -type collector[T any] struct { - events []T // temporary event storage - filter filterFn[T] // method used for filtering events -} - -// newCollector creates a new event collector -func newCollector[T any]( - evsw events.EventSwitch, - filter filterFn[T], -) *collector[T] { - c := &collector[T]{ - events: make([]T, 0), - filter: filter, - } - - // Register the listener - evsw.AddListener(xid.New().String(), func(e events.Event) { - c.updateWith(e) - }) - - return c -} - -// updateWith updates the collector with the given event -func (c *collector[T]) updateWith(event events.Event) { - if extracted := c.filter(event); extracted != nil { - c.events = append(c.events, extracted...) - } -} - -// getEvents returns the filtered events, -// and resets the collector store -func (c *collector[T]) getEvents() []T { - capturedEvents := make([]T, len(c.events)) - copy(capturedEvents, c.events) - - c.events = c.events[:0] - - return capturedEvents -} diff --git a/gno.land/pkg/gnoland/mock_test.go b/gno.land/pkg/gnoland/mock_test.go index bd8ceb9d766..bde81e1075c 100644 --- a/gno.land/pkg/gnoland/mock_test.go +++ b/gno.land/pkg/gnoland/mock_test.go @@ -173,21 +173,93 @@ func (m *mockAuthKeeper) IterateAccounts(ctx sdk.Context, process func(std.Accou func (m *mockAuthKeeper) InitGenesis(ctx sdk.Context, data auth.GenesisState) {} func (m *mockAuthKeeper) GetParams(ctx sdk.Context) auth.Params { return auth.Params{} } -type mockParamsKeeper struct{} - -func (m *mockParamsKeeper) GetString(ctx sdk.Context, key string, ptr *string) {} -func (m *mockParamsKeeper) GetInt64(ctx sdk.Context, key string, ptr *int64) {} -func (m *mockParamsKeeper) GetUint64(ctx sdk.Context, key string, ptr *uint64) {} -func (m *mockParamsKeeper) GetBool(ctx sdk.Context, key string, ptr *bool) {} -func (m *mockParamsKeeper) GetBytes(ctx sdk.Context, key string, ptr *[]byte) {} -func (m *mockParamsKeeper) GetStrings(ctx sdk.Context, key string, ptr *[]string) {} - -func (m *mockParamsKeeper) SetString(ctx sdk.Context, key string, value string) {} -func (m *mockParamsKeeper) SetInt64(ctx sdk.Context, key string, value int64) {} -func (m *mockParamsKeeper) SetUint64(ctx sdk.Context, key string, value uint64) {} -func (m *mockParamsKeeper) SetBool(ctx sdk.Context, key string, value bool) {} -func (m *mockParamsKeeper) SetBytes(ctx sdk.Context, key string, value []byte) {} -func (m *mockParamsKeeper) SetStrings(ctx sdk.Context, key string, value []string) {} +type mockParamsKeeper struct { + getStringFn func(sdk.Context, string, *string) + getInt64Fn func(sdk.Context, string, *int64) + getUint64Fn func(sdk.Context, string, *uint64) + getBoolFn func(sdk.Context, string, *bool) + getBytesFn func(sdk.Context, string, *[]byte) + getStringsFn func(sdk.Context, string, *[]string) + + setStringFn func(sdk.Context, string, string) + setInt64Fn func(sdk.Context, string, int64) + setUint64Fn func(sdk.Context, string, uint64) + setBoolFn func(sdk.Context, string, bool) + setBytesFn func(sdk.Context, string, []byte) + setStringsFn func(sdk.Context, string, []string) +} + +func (m *mockParamsKeeper) GetString(ctx sdk.Context, key string, ptr *string) { + if m.getStringFn != nil { + m.getStringFn(ctx, key, ptr) + } +} + +func (m *mockParamsKeeper) GetInt64(ctx sdk.Context, key string, ptr *int64) { + if m.getInt64Fn != nil { + m.getInt64Fn(ctx, key, ptr) + } +} + +func (m *mockParamsKeeper) GetUint64(ctx sdk.Context, key string, ptr *uint64) { + if m.getUint64Fn != nil { + m.getUint64Fn(ctx, key, ptr) + } +} + +func (m *mockParamsKeeper) GetBool(ctx sdk.Context, key string, ptr *bool) { + if m.getBoolFn != nil { + m.getBoolFn(ctx, key, ptr) + } +} + +func (m *mockParamsKeeper) GetBytes(ctx sdk.Context, key string, ptr *[]byte) { + if m.getBytesFn != nil { + m.getBytesFn(ctx, key, ptr) + } +} + +func (m *mockParamsKeeper) GetStrings(ctx sdk.Context, key string, ptr *[]string) { + if m.getStringsFn != nil { + m.getStringsFn(ctx, key, ptr) + } +} + +func (m *mockParamsKeeper) SetString(ctx sdk.Context, key string, value string) { + if m.setStringFn != nil { + m.setStringFn(ctx, key, value) + } +} + +func (m *mockParamsKeeper) SetInt64(ctx sdk.Context, key string, value int64) { + if m.setInt64Fn != nil { + m.setInt64Fn(ctx, key, value) + } +} + +func (m *mockParamsKeeper) SetUint64(ctx sdk.Context, key string, value uint64) { + if m.setUint64Fn != nil { + m.setUint64Fn(ctx, key, value) + } +} + +func (m *mockParamsKeeper) SetBool(ctx sdk.Context, key string, value bool) { + if m.setBoolFn != nil { + m.setBoolFn(ctx, key, value) + } +} + +func (m *mockParamsKeeper) SetBytes(ctx sdk.Context, key string, value []byte) { + if m.setBytesFn != nil { + m.setBytesFn(ctx, key, value) + } +} + +func (m *mockParamsKeeper) SetStrings(ctx sdk.Context, key string, value []string) { + if m.setStringsFn != nil { + m.setStringsFn(ctx, key, value) + } +} func (m *mockParamsKeeper) Has(ctx sdk.Context, key string) bool { return false } func (m *mockParamsKeeper) GetStruct(ctx sdk.Context, key string, strctPtr any) {} diff --git a/gno.land/pkg/gnoland/validators.go b/gno.land/pkg/gnoland/validators.go deleted file mode 100644 index 8097215a559..00000000000 --- a/gno.land/pkg/gnoland/validators.go +++ /dev/null @@ -1,61 +0,0 @@ -package gnoland - -import ( - "regexp" - - "github.com/gnolang/gno/gnovm/stdlibs/chain" - "github.com/gnolang/gno/tm2/pkg/bft/types" - "github.com/gnolang/gno/tm2/pkg/events" -) - -const ( - valRealm = "gno.land/r/sys/validators/v2" // XXX: make it configurable from GovDAO - valChangesFn = "GetChanges" - - validatorAddedEvent = "ValidatorAdded" - validatorRemovedEvent = "ValidatorRemoved" -) - -// XXX: replace with amino-based clean approach -var valRegexp = regexp.MustCompile(`{\("([^"]*)"\s[^)]+\),\("((?:[^"]|\\")*)"\s[^)]+\),\((\d+)\s[^)]+\)}`) - -// validatorUpdate is a type being used for "notifying" -// that a validator change happened on-chain. The events from `r/sys/validators` -// do not pass data related to validator add / remove instances (who, what, how) -type validatorUpdate struct{} - -// validatorEventFilter filters the given event to determine if it -// is tied to a validator update -func validatorEventFilter(event events.Event) []validatorUpdate { - // Make sure the event is a new TX event - txResult, ok := event.(types.EventTx) - if !ok { - return nil - } - - // Make sure an add / remove event happened - for _, ev := range txResult.Result.Response.Events { - // Make sure the event is a GnoVM event - gnoEv, ok := ev.(chain.Event) - if !ok { - continue - } - - // Make sure the event is from `r/sys/validators` - if gnoEv.PkgPath != valRealm { - continue - } - - // Make sure the event is either an add / remove - switch gnoEv.Type { - case validatorAddedEvent, validatorRemovedEvent: - // We don't pass data around with the events, but a single - // notification is enough to "trigger" a VM scrape - return []validatorUpdate{{}} - default: - continue - } - } - - return nil -} diff --git a/gno.land/pkg/sdk/vm/builtins.go b/gno.land/pkg/sdk/vm/builtins.go index 127ea524b16..dc576d11c1a 100644 --- a/gno.land/pkg/sdk/vm/builtins.go +++ b/gno.land/pkg/sdk/vm/builtins.go @@ -142,6 +142,42 @@ func (prm *SDKParams) UpdateStrings(key string, vals []string, add bool) { prm.SetStrings(key, updatedList) } +func (prm *SDKParams) GetString(key string) (string, bool) { + var val string + prm.pmk.GetString(prm.ctx, key, &val) + return val, true // impossible to determine if value is missing or just empty +} + +func (prm *SDKParams) GetBool(key string) (bool, bool) { + var val bool + prm.pmk.GetBool(prm.ctx, key, &val) + return val, true // impossible to determine if value is missing or just false +} + +func (prm *SDKParams) GetInt64(key string) (int64, bool) { + var val int64 + prm.pmk.GetInt64(prm.ctx, key, &val) + return val, true // impossible to determine if value is missing or just 0 +} + +func (prm *SDKParams) GetUint64(key string) (uint64, bool) { + var val uint64 + prm.pmk.GetUint64(prm.ctx, key, &val) + return val, true // impossible to determine if value is missing or just 0 +} + +func (prm *SDKParams) GetBytes(key string) ([]byte, bool) { + var val []byte + prm.pmk.GetBytes(prm.ctx, key, &val) + return val, val != nil +} + +func (prm *SDKParams) GetStrings(key string) ([]string, bool) { + var val []string + prm.pmk.GetStrings(prm.ctx, key, &val) + return val, val != nil +} + func (prm *SDKParams) setWithCheck(key string, set func()) { prm.mustHaveModuleKeeper(key) set() diff --git a/gno.land/pkg/sdk/vm/params.go b/gno.land/pkg/sdk/vm/params.go index 515ba5711e1..d6bb381940c 100644 --- a/gno.land/pkg/sdk/vm/params.go +++ b/gno.land/pkg/sdk/vm/params.go @@ -3,6 +3,7 @@ package vm import ( "fmt" "regexp" + "strconv" "strings" gno "github.com/gnolang/gno/gnovm/pkg/gnolang" @@ -20,6 +21,10 @@ const ( depositDefault = "600000000ugnot" storagePriceDefault = "100ugnot" // cost per byte (1 gnot per 10KB) 1B GNOT == 10TB storageFeeCollectorNameDefault = "storage_fee_collector" + + // ValsetRealmDefault is the default realm path for on-chain validator set management. + // Keep in sync with examples/gno.land/r/sys/validators/v3/poc.gno + ValsetRealmDefault = "gno.land/r/sys/validators/v3" ) var ASCIIDomain = regexp.MustCompile(`^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$`) @@ -32,6 +37,7 @@ type Params struct { DefaultDeposit string `json:"default_deposit" yaml:"default_deposit"` StoragePrice string `json:"storage_price" yaml:"storage_price"` StorageFeeCollector crypto.Address `json:"storage_fee_collector" yaml:"storage_fee_collector"` + ValsetRealmPath string `json:"valset_realm_path" yaml:"valset_realm_path"` } // NewParams creates a new Params object @@ -43,6 +49,7 @@ func NewParams(namesPkgPath, claPkgPath, chainDomain, defaultDeposit, storagePri DefaultDeposit: defaultDeposit, StoragePrice: storagePrice, StorageFeeCollector: storageFeeCollector, + ValsetRealmPath: ValsetRealmDefault, } } @@ -62,6 +69,7 @@ func (p Params) String() string { sb.WriteString(fmt.Sprintf("DefaultDeposit: %q\n", p.DefaultDeposit)) sb.WriteString(fmt.Sprintf("StoragePrice: %q\n", p.StoragePrice)) sb.WriteString(fmt.Sprintf("StorageFeeCollector: %q\n", p.StorageFeeCollector.String())) + sb.WriteString(fmt.Sprintf("ValsetRealmPath: %q\n", p.ValsetRealmPath)) return sb.String() } @@ -86,6 +94,9 @@ func (p Params) Validate() error { if p.StorageFeeCollector.IsZero() { return fmt.Errorf("invalid storage fee collector, cannot be empty") } + if p.ValsetRealmPath != "" && !gno.IsRealmPath(p.ValsetRealmPath) { + return fmt.Errorf("invalid valset realm path %q", p.ValsetRealmPath) + } return nil } @@ -109,9 +120,15 @@ func (vm *VMKeeper) GetParams(ctx sdk.Context) Params { } const ( - sysUsersPkgParamPath = "vm:p:sysnames_pkgpath" - sysCLAPkgParamPath = "vm:p:syscla_pkgpath" - chainDomainParamPath = "vm:p:chain_domain" + moduleParamPrefix = "vm" + + sysUsersPkgParamPath = moduleParamPrefix + ":p:sysnames_pkgpath" + sysCLAPkgParamPath = moduleParamPrefix + ":p:syscla_pkgpath" + chainDomainParamPath = moduleParamPrefix + ":p:chain_domain" + + // ValsetRealmParamPath is the param key that stores the path of the + // realm responsible for on-chain validator set management. + ValsetRealmParamPath = moduleParamPrefix + ":p:valset_realm_path" ) func (vm *VMKeeper) getChainDomainParam(ctx sdk.Context) string { @@ -132,6 +149,12 @@ func (vm *VMKeeper) getSysCLAPkgParam(ctx sdk.Context) string { return sysCLAPkg } +func (vm *VMKeeper) getValsetRealmParam(ctx sdk.Context) string { + valsetRealm := ValsetRealmDefault + vm.prmk.GetString(ctx, ValsetRealmParamPath, &valsetRealm) + return valsetRealm +} + func (vm *VMKeeper) WillSetParam(ctx sdk.Context, key string, value any) { params := vm.GetParams(ctx) switch key { @@ -152,10 +175,27 @@ func (vm *VMKeeper) WillSetParam(ctx sdk.Context, key string, value any) { panic(fmt.Sprintf("invalid storage_fee_collector address: %v", err)) } params.StorageFeeCollector = addr + case "p:valset_realm_path": + params.ValsetRealmPath = sdkparams.MustParamString("valset_realm_path", value) default: if strings.HasPrefix(key, "p:") { panic(fmt.Sprintf("unknown vm param key: %q", key)) } + // Validate valset updates if the key targets the valset realm's valset_new param. + valsetRealm := vm.getValsetRealmParam(ctx) + if strings.HasPrefix(key, valsetRealm+":valset_new") { + changes, ok := value.([]string) + if !ok { + panic(fmt.Sprintf( + "value for VM param %s update is an invalid type (%T)", + key, + value, + )) + } + if err := validateValsetUpdate(changes); err != nil { + panic(err) + } + } // Allow realm-scoped params through without validation. return } @@ -163,3 +203,44 @@ func (vm *VMKeeper) WillSetParam(ctx sdk.Context, key string, value any) { panic("invalid param: " + err.Error()) } } + +// validateValsetUpdate validates the validator set updates, +// which are serialized in the form: +// -
:: +// - voting power == 0 => validator removal +// - voting power != 0 => validator power update / validator addition +func validateValsetUpdate(changes []string) error { + for _, change := range changes { + changeParts := strings.Split(change, ":") + if len(changeParts) != 3 { + return fmt.Errorf( + "valset update is not in the format
::, but %q", + change, + ) + } + + address, err := crypto.AddressFromBech32(changeParts[0]) + if err != nil { + return fmt.Errorf("invalid validator address: %w", err) + } + + pubKey, err := crypto.PubKeyFromBech32(changeParts[1]) + if err != nil { + return fmt.Errorf("invalid validator pubkey: %w", err) + } + + if pubKey.Address().Compare(address) != 0 { + return fmt.Errorf( + "address (%s) does not match public key address (%s)", + address, + pubKey.Address(), + ) + } + + if _, err = strconv.ParseUint(changeParts[2], 10, 64); err != nil { + return fmt.Errorf("invalid voting power: %w", err) + } + } + + return nil +} diff --git a/gno.land/pkg/sdk/vm/params_test.go b/gno.land/pkg/sdk/vm/params_test.go index 945aebaef8d..5ee971f21fe 100644 --- a/gno.land/pkg/sdk/vm/params_test.go +++ b/gno.land/pkg/sdk/vm/params_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/gnolang/gno/tm2/pkg/crypto/secp256k1" "github.com/stretchr/testify/assert" ) @@ -24,7 +25,8 @@ func TestParamsString(t *testing.T) { fmt.Sprintf("ChainDomain: %q\n", p.ChainDomain) + fmt.Sprintf("DefaultDeposit: %q\n", p.DefaultDeposit) + fmt.Sprintf("StoragePrice: %q\n", p.StoragePrice) + - fmt.Sprintf("StorageFeeCollector: %q\n", p.StorageFeeCollector) + fmt.Sprintf("StorageFeeCollector: %q\n", p.StorageFeeCollector) + + fmt.Sprintf("ValsetRealmPath: %q\n", p.ValsetRealmPath) // Assert: check if the result matches the expected string. if result != expected { @@ -233,6 +235,106 @@ func TestWillSetParamExhaustive(t *testing.T) { } } +func TestWillSetParam_ValsetUpdate(t *testing.T) { + t.Parallel() + + // valsetNewPath returns the raw key (without vm: prefix) for valset_new param. + // "valset_new" must be kept in sync with examples/gno.land/r/sys/validators/v3/poc.gno. + valsetNewPath := func() string { + return ValsetRealmDefault + ":valset_new" + } + + t.Run("non-valset key passes through", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + ctx := env.vmk.MakeGnoTransactionStore(env.ctx) + + assert.NotPanics(t, func() { + env.vmk.WillSetParam(ctx, "some_realm:arbitrary_key", nil) + }) + }) + + t.Run("invalid value type", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + ctx := env.vmk.MakeGnoTransactionStore(env.ctx) + + assert.Panics(t, func() { + env.vmk.WillSetParam(ctx, valsetNewPath(), "not a slice") + }) + }) + + t.Run("malformed entry", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + ctx := env.vmk.MakeGnoTransactionStore(env.ctx) + + assert.Panics(t, func() { + env.vmk.WillSetParam(ctx, valsetNewPath(), []string{"addr:pubkey:power:extra"}) + }) + }) + + t.Run("invalid address", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + ctx := env.vmk.MakeGnoTransactionStore(env.ctx) + key := secp256k1.GenPrivKey() + + assert.Panics(t, func() { + env.vmk.WillSetParam(ctx, valsetNewPath(), []string{ + fmt.Sprintf("notabech32:%s:10", key.PubKey()), + }) + }) + }) + + t.Run("address pubkey mismatch", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + ctx := env.vmk.MakeGnoTransactionStore(env.ctx) + key1 := secp256k1.GenPrivKey() + key2 := secp256k1.GenPrivKey() + + assert.Panics(t, func() { + env.vmk.WillSetParam(ctx, valsetNewPath(), []string{ + fmt.Sprintf("%s:%s:10", key1.PubKey().Address(), key2.PubKey()), + }) + }) + }) + + t.Run("invalid voting power", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + ctx := env.vmk.MakeGnoTransactionStore(env.ctx) + key := secp256k1.GenPrivKey() + + assert.Panics(t, func() { + env.vmk.WillSetParam(ctx, valsetNewPath(), []string{ + fmt.Sprintf("%s:%s:notanumber", key.PubKey().Address(), key.PubKey()), + }) + }) + }) + + t.Run("valid valset update", func(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + ctx := env.vmk.MakeGnoTransactionStore(env.ctx) + key := secp256k1.GenPrivKey() + + assert.NotPanics(t, func() { + env.vmk.WillSetParam(ctx, valsetNewPath(), []string{ + fmt.Sprintf("%s:%s:10", key.PubKey().Address(), key.PubKey()), + }) + }) + }) +} + func TestParamsValidate(t *testing.T) { valid := DefaultParams() diff --git a/tm2/pkg/bft/abci/types/util.go b/tm2/pkg/bft/abci/types/util.go index 603e59b2950..a16aa8e8191 100644 --- a/tm2/pkg/bft/abci/types/util.go +++ b/tm2/pkg/bft/abci/types/util.go @@ -33,7 +33,60 @@ func (v ValidatorUpdates) Swap(i, j int) { v[j] = v1 } -//---------------------------------------- +// UpdatesFrom compares this ValidatorUpdates set with another (v2) and returns +// a new ValidatorUpdates containing only the changes needed to go from the +// receiver to v2. It includes: +// 1. Removals: validators present in the receiver but missing in v2 (Power = 0). +// 2. Power changes: validators present in both but whose Power differs. +// 3. Additions: validators present in v2 but missing in the receiver. +func (v ValidatorUpdates) UpdatesFrom(v2 ValidatorUpdates) ValidatorUpdates { + prevMap := make(map[string]ValidatorUpdate, len(v)) + for _, val := range v { + prevMap[val.Address.String()] = val + } + + propMap := make(map[string]ValidatorUpdate, len(v2)) + for _, val := range v2 { + propMap[val.Address.String()] = val + } + + // Worst-case: all in v removed + all in v2 added + diffs := make(ValidatorUpdates, 0, len(v)+len(v2)) + + // Find all removals and updates + for addr, prev := range prevMap { + if prop, ok := propMap[addr]; ok { + // If it exists in both -> check for power change + if prop.Power != prev.Power { + diffs = append(diffs, ValidatorUpdate{ + Address: prop.Address, + PubKey: prop.PubKey, + Power: prop.Power, + }) + } + + continue + } + + // If it's in prev but not in proposed -> removal + diffs = append(diffs, ValidatorUpdate{ + Address: prev.Address, + PubKey: prev.PubKey, + Power: 0, + }) + } + + // Find additions (new validators) + for addr, prop := range propMap { + if _, seen := prevMap[addr]; !seen { + diffs = append(diffs, prop) + } + } + + return diffs +} + +// ---------------------------------------- // ValidatorUpdate func (vu ValidatorUpdate) Equals(vu2 ValidatorUpdate) bool { diff --git a/tm2/pkg/bft/abci/types/util_test.go b/tm2/pkg/bft/abci/types/util_test.go new file mode 100644 index 00000000000..69d7e441da3 --- /dev/null +++ b/tm2/pkg/bft/abci/types/util_test.go @@ -0,0 +1,97 @@ +package abci + +import ( + "testing" + + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/crypto/ed25519" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdatesFrom(t *testing.T) { + t.Parallel() + + newVU := func(key crypto.PubKey, power int64) ValidatorUpdate { + return ValidatorUpdate{ + Address: key.Address(), + PubKey: key, + Power: power, + } + } + + generatePubKeys := func(count int) []crypto.PubKey { + keys := make([]crypto.PubKey, 0, count) + + for range count { + keys = append(keys, ed25519.GenPrivKey().PubKey()) + } + + return keys + } + + validatorsKeys := generatePubKeys(4) + + tests := []struct { + name string + prev, proposed ValidatorUpdates + expectedUpdates ValidatorUpdates + }{ + { + name: "no changes", + prev: ValidatorUpdates{newVU(validatorsKeys[0], 8)}, + proposed: ValidatorUpdates{newVU(validatorsKeys[0], 8)}, + expectedUpdates: nil, + }, + { + name: "removal", + prev: ValidatorUpdates{newVU(validatorsKeys[0], 10)}, + proposed: nil, + expectedUpdates: ValidatorUpdates{newVU(validatorsKeys[0], 0)}, + }, + { + name: "addition", + prev: nil, + proposed: ValidatorUpdates{newVU(validatorsKeys[0], 20)}, + expectedUpdates: ValidatorUpdates{newVU(validatorsKeys[0], 20)}, + }, + { + name: "power change", + prev: ValidatorUpdates{newVU(validatorsKeys[0], 5)}, + proposed: ValidatorUpdates{newVU(validatorsKeys[0], 7)}, + expectedUpdates: ValidatorUpdates{newVU(validatorsKeys[0], 7)}, + }, + { + name: "mixed", + prev: ValidatorUpdates{ + newVU(validatorsKeys[0], 1), + newVU(validatorsKeys[1], 2), + newVU(validatorsKeys[2], 3), + }, + proposed: ValidatorUpdates{ + newVU(validatorsKeys[1], 20), // modified + newVU(validatorsKeys[3], 4), // new + }, + expectedUpdates: ValidatorUpdates{ + newVU(validatorsKeys[0], 0), // removed + newVU(validatorsKeys[1], 20), // changed + newVU(validatorsKeys[2], 0), // removed + newVU(validatorsKeys[3], 4), // added + }, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + updates := testCase.prev.UpdatesFrom(testCase.proposed) + + // Make sure the contents match + require.ElementsMatch(t, testCase.expectedUpdates, updates) + + // Make sure the lengths match + assert.Len(t, updates, len(testCase.expectedUpdates)) + }) + } +} From 8135d8ddc0e3bd8fa9ecd9b4cf762b1cf3a4b909 Mon Sep 17 00:00:00 2001 From: aeddi Date: Thu, 23 Apr 2026 17:25:26 +0200 Subject: [PATCH 75/92] feat(deployments/test13.gno.land): add add-validator.sh targeting r/sys/validators/v3 --- .../govdao-scripts/add-validator.sh | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100755 misc/deployments/test13.gno.land/govdao-scripts/add-validator.sh diff --git a/misc/deployments/test13.gno.land/govdao-scripts/add-validator.sh b/misc/deployments/test13.gno.land/govdao-scripts/add-validator.sh new file mode 100755 index 00000000000..44e90b167e5 --- /dev/null +++ b/misc/deployments/test13.gno.land/govdao-scripts/add-validator.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Add a validator to test-13 via govDAO proposal, using r/sys/validators/v3. +# +# v3 propagates valset changes through the VM params keeper (instead of events + +# VM query-back in v2), so EndBlocker picks up updates directly from params. +# +# Usage: +# ./add-validator.sh
[voting_power] +# +# Environment: +# GNOKEY_NAME - gnokey key name (default: moul) +# CHAIN_ID - chain ID (default: test-13) +# REMOTE - RPC endpoint (default: http://127.0.0.1:26657) +# GAS_WANTED - gas limit (default: 50000000) +# GAS_FEE - gas fee (default: 1000000ugnot) +set -eo pipefail + +GNOKEY_NAME="${GNOKEY_NAME:-moul}" +CHAIN_ID="${CHAIN_ID:-test-13}" +REMOTE="${REMOTE:-http://127.0.0.1:26657}" +GAS_WANTED="${GAS_WANTED:-50000000}" +GAS_FEE="${GAS_FEE:-1000000ugnot}" + +if [ $# -lt 2 ]; then + echo "Usage: $0
[voting_power]" + echo "" + echo "Example:" + echo " $0 g1abc...xyz gpub1pggj7... 1" + exit 1 +fi + +ADDR="$1" +PUB_KEY="$2" +POWER="${3:-1}" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/add_validator.gno" < Date: Thu, 23 Apr 2026 17:25:53 +0200 Subject: [PATCH 76/92] feat(deployments/gnoland-1): addpkg r/sys/validators/v3 in post-fork migration --- .../deployments/gnoland-1/migrations/build.sh | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/misc/deployments/gnoland-1/migrations/build.sh b/misc/deployments/gnoland-1/migrations/build.sh index 67a21454858..714a34d1678 100755 --- a/misc/deployments/gnoland-1/migrations/build.sh +++ b/misc/deployments/gnoland-1/migrations/build.sh @@ -290,4 +290,43 @@ if [[ -n "$NEW_T1_ADDR" ]]; then printf ' migration: %-38s caller=%s\n' "$(basename "$RENDERED_04")" "$NEW_T1_ADDR" fi +# ---- 5. addpkg r/sys/validators/v3 (MsgAddPackage, caller=manfred) ---- +# v3 introduces the params-keeper-driven valset flow (see PR #5485). Mainnet +# never had it, so a fresh addpkg is needed post-fork for govDAO proposals to +# drive the valset via r/sys/validators/v3 (the pre-fork v2 realm stays +# addressable but its event-collector EndBlocker path is gone). +V3_PKGDIR="${V3_PKGDIR:-$REPO_ROOT/examples/gno.land/r/sys/validators/v3}" +[[ -d "$V3_PKGDIR" ]] || { + echo "ERROR: v3 pkgdir not found: $V3_PKGDIR" >&2 + exit 1 +} + +RENDERED_05="$WORK/05_addpkg_validators_v3.tx.json" + +"$GNOKEY_BIN" maketx addpkg \ + --gas-wanted 100000000 \ + --gas-fee 1ugnot \ + --pkgpath "gno.land/r/sys/validators/v3" \ + --pkgdir "$V3_PKGDIR" \ + --chainid "$CHAIN_ID" \ + --home "$GK_HOME" \ + ephemeral >"$RENDERED_05" + +# Patch the creator field (MsgAddPackage uses `creator`, not `caller`) so the +# addpkg executes as manfred/T1 under --skip-genesis-sig-verification. +jq --arg creator "$CALLER" '.msg[0].creator = $creator' "$RENDERED_05" >"$RENDERED_05.patched" +mv "$RENDERED_05.patched" "$RENDERED_05" + +echo "" | "$GNOKEY_BIN" sign \ + --tx-path "$RENDERED_05" \ + --chainid "$CHAIN_ID" \ + --account-number 0 \ + --account-sequence 0 \ + --home "$GK_HOME" \ + --insecure-password-stdin \ + ephemeral >/dev/null + +jq -c '{tx: .}' "$RENDERED_05" >>"$OUT_JSONL" +printf ' migration: %-38s caller=%s\n' "05_addpkg_validators_v3" "$CALLER" + printf ' written: %s\n' "$OUT_JSONL" From ec3e2837fc80163dd9d59b9fb5f2f72d6415004a Mon Sep 17 00:00:00 2001 From: aeddi Date: Thu, 23 Apr 2026 17:26:04 +0200 Subject: [PATCH 77/92] feat(hf-glue): support multi-validator genesis via --valset-list --- misc/hf-glue/Makefile | 36 +++-- misc/hf-glue/fixvalidator/main.go | 195 +++++++++++++++++++------ misc/hf-glue/fixvalidator/main_test.go | 59 ++++++++ misc/hf-glue/scripts/migrate.sh | 11 +- 4 files changed, 244 insertions(+), 57 deletions(-) create mode 100644 misc/hf-glue/fixvalidator/main_test.go diff --git a/misc/hf-glue/Makefile b/misc/hf-glue/Makefile index b2f68a25752..70eee04318b 100644 --- a/misc/hf-glue/Makefile +++ b/misc/hf-glue/Makefile @@ -15,6 +15,10 @@ VALIDATOR_NAME ?= hf-glue-local # Pass bech32 strings instead of providing a priv_validator_key.json. VALIDATOR_ADDR ?= VALIDATOR_PUBKEY ?= +# Alternative to VALIDATOR_ADDR/VALIDATOR_PUBKEY: path to a multi-validator +# list file, one entry per line as ' ' (matches +# gno-cluster's INITIAL_VALSET output). Mutually exclusive with VALIDATOR_ADDR. +VALIDATOR_LIST ?= # Only used by `make fetch-from-dir` (alternative local-source flow). NODE_DIR ?= TXS_JSONL ?= @@ -29,7 +33,7 @@ NEW_T1_ADDR ?= T1_PORTFOLIO ?= T1_WITHDRAW_REASON ?= -export SOURCE RPC_URL ORIGINAL_CHAIN_ID CHAIN_ID HALT_HEIGHT VALIDATOR_NAME VALIDATOR_ADDR VALIDATOR_PUBKEY NODE_DIR TXS_JSONL PATCH_REALMS NEW_T1_ADDR T1_PORTFOLIO T1_WITHDRAW_REASON +export SOURCE RPC_URL ORIGINAL_CHAIN_ID CHAIN_ID HALT_HEIGHT VALIDATOR_NAME VALIDATOR_ADDR VALIDATOR_PUBKEY VALIDATOR_LIST NODE_DIR TXS_JSONL PATCH_REALMS NEW_T1_ADDR T1_PORTFOLIO T1_WITHDRAW_REASON # ---- paths ----------------------------------------------------------------- HERE := $(abspath .) @@ -63,10 +67,16 @@ migrate: ## run scripts/migrate.sh — declarative hardfork recipe (fetch + patc $(HERE)/scripts/migrate.sh .PHONY: genesis -genesis: ## produce out/genesis.json only, from manual VALIDATOR_ADDR+VALIDATOR_PUBKEY (no secrets, no docker) - @test -n "$(VALIDATOR_ADDR)" || { echo "VALIDATOR_ADDR required (bech32 g1...)"; exit 1; } - @test -n "$(VALIDATOR_PUBKEY)" || { echo "VALIDATOR_PUBKEY required (bech32 gpub1...)"; exit 1; } - @test -n "$(HALT_HEIGHT)" || { echo "HALT_HEIGHT required"; exit 1; } +genesis: ## produce out/genesis.json only — single val (VALIDATOR_ADDR+VALIDATOR_PUBKEY) OR multi val (VALIDATOR_LIST) + @test -n "$(HALT_HEIGHT)" || { echo "HALT_HEIGHT required"; exit 1; } + @if [ -n "$(VALIDATOR_LIST)" ] && [ -n "$(VALIDATOR_ADDR)$(VALIDATOR_PUBKEY)" ]; then \ + echo "VALIDATOR_LIST is mutually exclusive with VALIDATOR_ADDR/VALIDATOR_PUBKEY"; exit 1; fi + @if [ -z "$(VALIDATOR_LIST)" ]; then \ + test -n "$(VALIDATOR_ADDR)" || { echo "VALIDATOR_ADDR required (or set VALIDATOR_LIST)"; exit 1; }; \ + test -n "$(VALIDATOR_PUBKEY)" || { echo "VALIDATOR_PUBKEY required (or set VALIDATOR_LIST)"; exit 1; }; \ + else \ + test -f "$(VALIDATOR_LIST)" || { echo "VALIDATOR_LIST path not found: $(VALIDATOR_LIST)"; exit 1; }; \ + fi @mkdir -p $(OUT) @SOURCE=$(SOURCE) \ RPC_URL=$(RPC_URL) \ @@ -75,19 +85,27 @@ genesis: ## produce out/genesis.json only, from manual VALIDATOR_ADDR+VALIDATOR_ HALT_HEIGHT=$(HALT_HEIGHT) \ VALIDATOR_ADDR=$(VALIDATOR_ADDR) \ VALIDATOR_PUBKEY=$(VALIDATOR_PUBKEY) \ + VALIDATOR_LIST=$(VALIDATOR_LIST) \ VALIDATOR_NAME=$(VALIDATOR_NAME) \ PATCH_REALMS='$(PATCH_REALMS)' \ OUT=$(OUT) REPO=$(REPO) \ $(HERE)/scripts/migrate.sh - @go run -C $(REPO)/misc/hf-glue/fixvalidator . \ - --address $(VALIDATOR_ADDR) --pubkey $(VALIDATOR_PUBKEY) \ - --genesis $(OUT)/genesis.json \ - --name $(VALIDATOR_NAME) --power 10 + @if [ -n "$(VALIDATOR_LIST)" ]; then \ + go run -C $(REPO)/misc/hf-glue/fixvalidator . \ + --valset-list $(VALIDATOR_LIST) \ + --genesis $(OUT)/genesis.json ; \ + else \ + go run -C $(REPO)/misc/hf-glue/fixvalidator . \ + --address $(VALIDATOR_ADDR) --pubkey $(VALIDATOR_PUBKEY) \ + --genesis $(OUT)/genesis.json \ + --name $(VALIDATOR_NAME) --power 10 ; \ + fi @echo "" @echo "Genesis ready: $(OUT)/genesis.json" @echo " SHA256: $$(shasum -a 256 $(OUT)/genesis.json | cut -d' ' -f1)" @echo " tx count: $$(jq '.app_state.txs | length' $(OUT)/genesis.json)" @echo " chain_id: $$(jq -r '.chain_id' $(OUT)/genesis.json)" + @echo " valset: $$(jq '.validators | length' $(OUT)/genesis.json) validator(s)" .PHONY: fetch-from-dir fetch-from-dir: ## (alt) build hardfork genesis from a local gnoland data dir ($NODE_DIR, $TXS_JSONL, $HALT_HEIGHT required) diff --git a/misc/hf-glue/fixvalidator/main.go b/misc/hf-glue/fixvalidator/main.go index 580bdb39e27..da31cb51b74 100644 --- a/misc/hf-glue/fixvalidator/main.go +++ b/misc/hf-glue/fixvalidator/main.go @@ -1,19 +1,33 @@ -// fixvalidator rewrites the validator set in a gnoland genesis.json to a -// single validator. Input can be either a priv_validator_key.json file, or -// a pair of bech32 strings (address + pubkey) for key-less environments. +// fixvalidator rewrites the validator set in a gnoland genesis.json. +// Input modes (mutually exclusive): +// - priv_validator_key.json path (single validator) +// - bech32 address + pubkey pair (single validator, key-less environments) +// - valset-list file (multi-validator, gno-cluster-style lines) // // Usage: // -// fixvalidator --priv-key --genesis [--name NAME] [--power N] -// fixvalidator --address g1... --pubkey gpub1... --genesis [--name NAME] [--power N] +// fixvalidator --priv-key --genesis [--name NAME] [--power N] +// fixvalidator --address g1... --pubkey gpub1... --genesis [--name NAME] [--power N] +// fixvalidator --valset-list --genesis +// +// valset-list format: one validator per line, three whitespace-separated +// fields — " ". Blank lines and lines starting with '#' +// are ignored. Addresses are derived from pubkeys. This matches gno-cluster's +// INITIAL_VALSET output (strip the `INITIAL_VALSET=(` wrapper and quotes +// before feeding it in). // // This is testbed glue (misc/hf-glue). Not intended to be installed. package main import ( + "bufio" + "encoding/json" "flag" "fmt" + "io" "os" + "strconv" + "strings" _ "github.com/gnolang/gno/gno.land/pkg/gnoland" // register GnoGenesisState amino type "github.com/gnolang/gno/tm2/pkg/amino" @@ -27,78 +41,166 @@ func main() { privPath string addrStr string pubkeyStr string + valsetList string genesisPath string name string power int64 + emitJSON bool ) - flag.StringVar(&privPath, "priv-key", "", "path to priv_validator_key.json (alternative to --address/--pubkey)") - flag.StringVar(&addrStr, "address", "", "validator bech32 address g1... (alternative to --priv-key; requires --pubkey)") - flag.StringVar(&pubkeyStr, "pubkey", "", "validator bech32 pubkey gpub1... (required with --address)") - flag.StringVar(&genesisPath, "genesis", "", "path to genesis.json to rewrite in place") - flag.StringVar(&name, "name", "hf-glue-local", "validator name") - flag.Int64Var(&power, "power", 10, "validator voting power") + flag.StringVar(&privPath, "priv-key", "", "path to priv_validator_key.json (single-validator mode)") + flag.StringVar(&addrStr, "address", "", "validator bech32 address g1... (requires --pubkey; single-validator mode)") + flag.StringVar(&pubkeyStr, "pubkey", "", "validator bech32 pubkey gpub1... (requires --address)") + flag.StringVar(&valsetList, "valset-list", "", "path to valset-list file (multi-validator mode; ' ' per line)") + flag.StringVar(&genesisPath, "genesis", "", "path to genesis.json to rewrite in place (omit with --emit-json)") + flag.StringVar(&name, "name", "hf-glue-local", "validator name (single-validator mode only)") + flag.Int64Var(&power, "power", 10, "validator voting power (single-validator mode only)") + flag.BoolVar(&emitJSON, "emit-json", false, "print the resolved valset as hf-glue's NEW_VALSET_JSON format to stdout and exit (no --genesis needed)") flag.Parse() - if genesisPath == "" { - fmt.Fprintln(os.Stderr, "--genesis is required") + if !emitJSON && genesisPath == "" { + fmt.Fprintln(os.Stderr, "--genesis is required (or use --emit-json)") os.Exit(2) } - address, pubkey, err := resolveValidator(privPath, addrStr, pubkeyStr) + validators, err := resolveValidators(privPath, addrStr, pubkeyStr, valsetList, name, power) if err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(2) } - if err := run(genesisPath, address, pubkey, name, power); err != nil { + if emitJSON { + if err := emitNewValsetJSON(os.Stdout, validators); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + return + } + + if err := run(genesisPath, validators); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } -// resolveValidator returns (address, pubkey) from either a priv_validator_key.json -// path or an explicit (bech32 address, bech32 pubkey) pair. When both are -// supplied, --priv-key wins. When using --address/--pubkey, the address is -// verified to match the pubkey's derived address. -func resolveValidator(privPath, addrStr, pubkeyStr string) (crypto.Address, crypto.PubKey, error) { - if privPath != "" { +// emitNewValsetJSON writes the validator set as a JSON array in the same +// shape hf-glue's migrate.sh and build.sh expect for NEW_VALSET_JSON — +// {address, pub_key, voting_power, name}. +func emitNewValsetJSON(w io.Writer, validators []bft.GenesisValidator) error { + type entry struct { + Address string `json:"address"` + PubKey string `json:"pub_key"` + VotingPower int64 `json:"voting_power"` + Name string `json:"name"` + } + entries := make([]entry, len(validators)) + for i, v := range validators { + entries[i] = entry{ + Address: v.Address.String(), + PubKey: v.PubKey.String(), + VotingPower: v.Power, + Name: v.Name, + } + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(entries) +} + +// resolveValidators chooses the input mode and returns the new valset. Modes +// are mutually exclusive; priv-key > address+pubkey > valset-list in precedence. +func resolveValidators(privPath, addrStr, pubkeyStr, valsetList, name string, power int64) ([]bft.GenesisValidator, error) { + switch { + case privPath != "": pv, err := signer.LoadFileKey(privPath) if err != nil { - return crypto.Address{}, nil, fmt.Errorf("load priv key: %w", err) + return nil, fmt.Errorf("load priv key: %w", err) } - return pv.Address, pv.PubKey, nil - } - if addrStr == "" || pubkeyStr == "" { - return crypto.Address{}, nil, fmt.Errorf("either --priv-key OR (--address AND --pubkey) is required") + return []bft.GenesisValidator{{Address: pv.Address, PubKey: pv.PubKey, Power: power, Name: name}}, nil + + case addrStr != "" || pubkeyStr != "": + if addrStr == "" || pubkeyStr == "" { + return nil, fmt.Errorf("--address and --pubkey must be provided together") + } + address, err := crypto.AddressFromBech32(addrStr) + if err != nil { + return nil, fmt.Errorf("parse address %q: %w", addrStr, err) + } + pubkey, err := crypto.PubKeyFromBech32(pubkeyStr) + if err != nil { + return nil, fmt.Errorf("parse pubkey %q: %w", pubkeyStr, err) + } + if derived := pubkey.Address(); address != derived { + return nil, fmt.Errorf("--address %s does not match --pubkey (derives %s)", address, derived) + } + return []bft.GenesisValidator{{Address: address, PubKey: pubkey, Power: power, Name: name}}, nil + + case valsetList != "": + f, err := os.Open(valsetList) + if err != nil { + return nil, fmt.Errorf("open valset-list %q: %w", valsetList, err) + } + defer f.Close() + return parseValsetList(f) + + default: + return nil, fmt.Errorf("one of --priv-key, --address/--pubkey, or --valset-list is required") } - address, err := crypto.AddressFromBech32(addrStr) - if err != nil { - return crypto.Address{}, nil, fmt.Errorf("parse address %q: %w", addrStr, err) +} + +// parseValsetList reads the multi-validator list format. Format per line: +// +// +// +// Blank lines and lines whose first non-space character is '#' are ignored. +// Pubkey addresses are derived via crypto.PubKey.Address(). +func parseValsetList(r io.Reader) ([]bft.GenesisValidator, error) { + var out []bft.GenesisValidator + scanner := bufio.NewScanner(r) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) != 3 { + return nil, fmt.Errorf("line %d: want 3 fields ' ', got %d", lineNo, len(fields)) + } + name, powerStr, pubkeyStr := fields[0], fields[1], fields[2] + power, err := strconv.ParseInt(powerStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("line %d: invalid power %q: %w", lineNo, powerStr, err) + } + pubkey, err := crypto.PubKeyFromBech32(pubkeyStr) + if err != nil { + return nil, fmt.Errorf("line %d: invalid pubkey %q: %w", lineNo, pubkeyStr, err) + } + out = append(out, bft.GenesisValidator{ + Address: pubkey.Address(), + PubKey: pubkey, + Power: power, + Name: name, + }) } - pubkey, err := crypto.PubKeyFromBech32(pubkeyStr) - if err != nil { - return crypto.Address{}, nil, fmt.Errorf("parse pubkey %q: %w", pubkeyStr, err) + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan valset-list: %w", err) } - if derived := pubkey.Address(); address != derived { - return crypto.Address{}, nil, fmt.Errorf("--address %s does not match --pubkey (derives %s)", address, derived) + if len(out) == 0 { + return nil, fmt.Errorf("valset-list is empty") } - return address, pubkey, nil + return out, nil } -func run(genesisPath string, address crypto.Address, pubkey crypto.PubKey, name string, power int64) error { +func run(genesisPath string, validators []bft.GenesisValidator) error { genDoc, err := bft.GenesisDocFromFile(genesisPath) if err != nil { return fmt.Errorf("load genesis: %w", err) } oldCount := len(genDoc.Validators) - genDoc.Validators = []bft.GenesisValidator{{ - Address: address, - PubKey: pubkey, - Power: power, - Name: name, - }} + genDoc.Validators = validators if err := genDoc.ValidateAndComplete(); err != nil { return fmt.Errorf("validate genesis after rewrite: %w", err) @@ -112,10 +214,9 @@ func run(genesisPath string, address crypto.Address, pubkey crypto.PubKey, name return fmt.Errorf("write genesis: %w", err) } - fmt.Printf("replaced %d validator(s) with single validator:\n", oldCount) - fmt.Printf(" address: %s\n", address.String()) - fmt.Printf(" pubkey: %s\n", pubkey.String()) - fmt.Printf(" name: %s\n", name) - fmt.Printf(" power: %d\n", power) + fmt.Printf("replaced %d validator(s) with %d new validator(s):\n", oldCount, len(validators)) + for i, v := range validators { + fmt.Printf(" [%d] %s (%s), power=%d, name=%q\n", i, v.Address, v.PubKey, v.Power, v.Name) + } return nil } diff --git a/misc/hf-glue/fixvalidator/main_test.go b/misc/hf-glue/fixvalidator/main_test.go new file mode 100644 index 00000000000..cffe31d7293 --- /dev/null +++ b/misc/hf-glue/fixvalidator/main_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "strings" + "testing" +) + +func TestParseValsetList(t *testing.T) { + // Three distinct pubkeys so Address() derivations are distinct too. Values + // are real bech32 pubkeys from gnoland ed25519 keys; they're not + // round-tripped here, just parsed. + in := ` +# comment line, ignored +node-1 1 gpub1pgfj7ard9eg82cjtv4u4xetrwqer2dntxyfzxz3pq0wau58zgeg7g9z5hn9k9p4emkjckpnfxhg5h30s7h08yza4dffwxqc8fqd + +node-2 5 gpub1pgfj7ard9eg82cjtv4u4xetrwqer2dntxyfzxz3pq0wau58zgeg7g9z5hn9k9p4emkjckpnfxhg5h30s7h08yza4dffwxqc8fqd +node-3 10 gpub1pgfj7ard9eg82cjtv4u4xetrwqer2dntxyfzxz3pq0wau58zgeg7g9z5hn9k9p4emkjckpnfxhg5h30s7h08yza4dffwxqc8fqd +` + got, err := parseValsetList(strings.NewReader(in)) + if err != nil { + t.Fatalf("parseValsetList: unexpected error: %v", err) + } + if len(got) != 3 { + t.Fatalf("want 3 validators, got %d", len(got)) + } + wantNames := []string{"node-1", "node-2", "node-3"} + wantPowers := []int64{1, 5, 10} + for i, v := range got { + if v.Name != wantNames[i] { + t.Errorf("[%d] Name = %q, want %q", i, v.Name, wantNames[i]) + } + if v.Power != wantPowers[i] { + t.Errorf("[%d] Power = %d, want %d", i, v.Power, wantPowers[i]) + } + if v.PubKey == nil { + t.Errorf("[%d] PubKey is nil", i) + continue + } + if v.Address != v.PubKey.Address() { + t.Errorf("[%d] Address %s != derived %s", i, v.Address, v.PubKey.Address()) + } + } +} + +func TestParseValsetList_BadLines(t *testing.T) { + cases := map[string]string{ + "too-few-fields": "node-1 1\n", + "bad-power": "node-1 NaN gpub1pgfj7ard9eg82cjtv4u4xetrwqer2dntxyfzxz3pq0wau58zgeg7g9z5hn9k9p4emkjckpnfxhg5h30s7h08yza4dffwxqc8fqd\n", + "bad-pubkey": "node-1 1 not-a-pubkey\n", + "empty": "\n\n# just comments\n", + } + for name, in := range cases { + t.Run(name, func(t *testing.T) { + if _, err := parseValsetList(strings.NewReader(in)); err == nil { + t.Fatalf("expected error, got nil") + } + }) + } +} diff --git a/misc/hf-glue/scripts/migrate.sh b/misc/hf-glue/scripts/migrate.sh index 08c0634a755..2b39a4890b3 100755 --- a/misc/hf-glue/scripts/migrate.sh +++ b/misc/hf-glue/scripts/migrate.sh @@ -102,11 +102,20 @@ PV_KEY_DEFAULT="$OUT/gnoland-home/secrets/priv_validator_key.json" PV_KEY="${PV_KEY:-$PV_KEY_DEFAULT}" VALIDATOR_ADDR="${VALIDATOR_ADDR:-}" VALIDATOR_PUBKEY="${VALIDATOR_PUBKEY:-}" +VALIDATOR_LIST="${VALIDATOR_LIST:-}" MIG_VALSET_SOURCE="" MIG_VALSET_JSON="" if [[ -f "$PV_KEY" ]]; then MIG_VALSET_SOURCE="pv_key=$PV_KEY" +elif [[ -n "$VALIDATOR_LIST" ]]; then + # Build a multi-validator NEW_VALSET_JSON from a " " + # list file (one entry per line). Addresses are derived by fixvalidator, + # but build.sh needs explicit addresses in the JSON — so we shell out to + # gnokey / fixvalidator's parsing logic via an inline jq + awk pipeline. + MIG_VALSET_SOURCE="valset-list=$VALIDATOR_LIST" + MIG_VALSET_JSON="$OUT/new_valset.json" + go run -C "$REPO/misc/hf-glue/fixvalidator" . --valset-list "$VALIDATOR_LIST" --emit-json >"$MIG_VALSET_JSON" elif [[ -n "$VALIDATOR_ADDR" && -n "$VALIDATOR_PUBKEY" ]]; then # Build a NEW_VALSET_JSON from raw strings (no priv_validator_key.json # needed). build.sh skips its PV_KEY path when NEW_VALSET_JSON is set. @@ -139,7 +148,7 @@ if [[ -n "$MIG_VALSET_SOURCE" ]]; then hf_migration_tx "$MIG_JSONL" else hf_banner "step 5 — post-replay migration (skipped)" - hf_kv "reason" "no $PV_KEY and no VALIDATOR_ADDR/VALIDATOR_PUBKEY set" + hf_kv "reason" "no $PV_KEY, VALIDATOR_LIST, or VALIDATOR_ADDR/VALIDATOR_PUBKEY set" fi # ------------------------------------------------------------------------- From 095cee6512811430b46e2e1f0e0686969ce68df8 Mon Sep 17 00:00:00 2001 From: aeddi Date: Thu, 23 Apr 2026 17:59:07 +0200 Subject: [PATCH 78/92] fix(deployments/gnoland-1): wrap v3 addpkg with sysnames-check disable/restore Direct addpkg of r/sys/validators/v3 fails at the namespace-permission check because gnoland1 has r/sys/names.enabled=true at halt height and no address matches the 'sys' namespace. Splits the v3 deploy into three txs (govDAO proposals that empty/restore vm:p:sysnames_pkgpath around the addpkg), so the realm ends up on-chain without permanently weakening namespace authz. --- .../migrations/05_disable_sysnames.gno.tmpl | 27 ++++++++++ .../migrations/07_restore_sysnames.gno.tmpl | 21 ++++++++ .../deployments/gnoland-1/migrations/build.sh | 51 ++++++++++++++----- 3 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 misc/deployments/gnoland-1/migrations/05_disable_sysnames.gno.tmpl create mode 100644 misc/deployments/gnoland-1/migrations/07_restore_sysnames.gno.tmpl diff --git a/misc/deployments/gnoland-1/migrations/05_disable_sysnames.gno.tmpl b/misc/deployments/gnoland-1/migrations/05_disable_sysnames.gno.tmpl new file mode 100644 index 00000000000..4cc22d2d001 --- /dev/null +++ b/misc/deployments/gnoland-1/migrations/05_disable_sysnames.gno.tmpl @@ -0,0 +1,27 @@ +// 05_disable_sysnames.gno.tmpl — MsgRun body that temporarily disables the +// r/sys/names namespace-permission check via a govDAO proposal. +// +// Why: gnoland1's r/sys/names.enabled is `true` at halt height, which means +// only addresses whose bech32 matches a namespace can deploy under that +// namespace. The `sys` namespace can never match any real address, so +// step 06 (addpkg r/sys/validators/v3) would fail with "unauthorized user". +// Setting the VM param `vm:p:sysnames_pkgpath` to "" makes +// checkNamespacePermission skip the check (keeper.go: sysNamesPkg == "" → +// return nil). Step 07 restores the path so the check is re-enabled. +// +// Caller: current govDAO sole T1 (manfred pre-rotation, or $T1_CALLER +// post-rotation). Sig verify is skipped at genesis-replay via +// --skip-genesis-sig-verification. +package main + +import ( + "gno.land/r/gov/dao" + "gno.land/r/sys/params" +) + +func main() { + r := params.NewSysParamStringPropRequest("vm", "p", "sysnames_pkgpath", "") + id := dao.MustCreateProposal(cross, r) + dao.MustVoteOnProposal(cross, dao.VoteRequest{Option: dao.YesVote, ProposalID: id}) + dao.ExecuteProposal(cross, id) +} diff --git a/misc/deployments/gnoland-1/migrations/07_restore_sysnames.gno.tmpl b/misc/deployments/gnoland-1/migrations/07_restore_sysnames.gno.tmpl new file mode 100644 index 00000000000..3aa13692b3d --- /dev/null +++ b/misc/deployments/gnoland-1/migrations/07_restore_sysnames.gno.tmpl @@ -0,0 +1,21 @@ +// 07_restore_sysnames.gno.tmpl — MsgRun body that re-enables the r/sys/names +// namespace-permission check after step 06 (addpkg r/sys/validators/v3). +// +// Pairs with 05_disable_sysnames: after v3 is deployed under r/sys/*, we +// restore the default `vm:p:sysnames_pkgpath = "gno.land/r/sys/names"` so +// subsequent addpkgs continue to enforce namespace authz. +// +// Caller: current govDAO sole T1 (same as step 05). +package main + +import ( + "gno.land/r/gov/dao" + "gno.land/r/sys/params" +) + +func main() { + r := params.NewSysParamStringPropRequest("vm", "p", "sysnames_pkgpath", "gno.land/r/sys/names") + id := dao.MustCreateProposal(cross, r) + dao.MustVoteOnProposal(cross, dao.VoteRequest{Option: dao.YesVote, ProposalID: id}) + dao.ExecuteProposal(cross, id) +} diff --git a/misc/deployments/gnoland-1/migrations/build.sh b/misc/deployments/gnoland-1/migrations/build.sh index 714a34d1678..8ddf14958b0 100755 --- a/misc/deployments/gnoland-1/migrations/build.sh +++ b/misc/deployments/gnoland-1/migrations/build.sh @@ -290,19 +290,38 @@ if [[ -n "$NEW_T1_ADDR" ]]; then printf ' migration: %-38s caller=%s\n' "$(basename "$RENDERED_04")" "$NEW_T1_ADDR" fi -# ---- 5. addpkg r/sys/validators/v3 (MsgAddPackage, caller=manfred) ---- +# ---- 5-7. deploy r/sys/validators/v3 ---- # v3 introduces the params-keeper-driven valset flow (see PR #5485). Mainnet -# never had it, so a fresh addpkg is needed post-fork for govDAO proposals to -# drive the valset via r/sys/validators/v3 (the pre-fork v2 realm stays -# addressable but its event-collector EndBlocker path is gone). +# never had it, so a fresh addpkg is needed post-fork. gnoland1's r/sys/names +# namespace check is enabled at halt height, so a direct addpkg under +# r/sys/* returns "unauthorized user". Strategy: wrap the addpkg with a +# temporary VM-param flip — steps 05/07 disable/restore the namespace check +# via govDAO proposals (the only authorized path to set vm:p:sysnames_pkgpath +# is `r/sys/params.NewSysParamStringPropRequest`). V3_PKGDIR="${V3_PKGDIR:-$REPO_ROOT/examples/gno.land/r/sys/validators/v3}" [[ -d "$V3_PKGDIR" ]] || { echo "ERROR: v3 pkgdir not found: $V3_PKGDIR" >&2 exit 1 } -RENDERED_05="$WORK/05_addpkg_validators_v3.tx.json" +# Current sole T1 member at this point in the migration sequence. If T1 +# rotation ran (steps 02-04), manfred is no longer T1; NEW_T1_ADDR is. The +# govDAO proposals in 05/07 need supermajority from the current T1. +if [[ -n "$NEW_T1_ADDR" ]]; then + T1_CALLER="$NEW_T1_ADDR" +else + T1_CALLER="$CALLER" +fi +# 05 — disable namespace check (govDAO proposal, caller=T1_CALLER). +RENDERED_05="$WORK/05_disable_sysnames.gno" +cp "$SCRIPT_DIR/05_disable_sysnames.gno.tmpl" "$RENDERED_05" +render_tx "$RENDERED_05" "$T1_CALLER" >>"$OUT_JSONL" +printf ' migration: %-38s caller=%s\n' "$(basename "$RENDERED_05")" "$T1_CALLER" + +# 06 — addpkg r/sys/validators/v3 (MsgAddPackage, creator=manfred; sig-skip +# applies since this is a genesis-mode migration tx). +RENDERED_06="$WORK/06_addpkg_validators_v3.tx.json" "$GNOKEY_BIN" maketx addpkg \ --gas-wanted 100000000 \ --gas-fee 1ugnot \ @@ -310,15 +329,15 @@ RENDERED_05="$WORK/05_addpkg_validators_v3.tx.json" --pkgdir "$V3_PKGDIR" \ --chainid "$CHAIN_ID" \ --home "$GK_HOME" \ - ephemeral >"$RENDERED_05" + ephemeral >"$RENDERED_06" -# Patch the creator field (MsgAddPackage uses `creator`, not `caller`) so the -# addpkg executes as manfred/T1 under --skip-genesis-sig-verification. -jq --arg creator "$CALLER" '.msg[0].creator = $creator' "$RENDERED_05" >"$RENDERED_05.patched" -mv "$RENDERED_05.patched" "$RENDERED_05" +# MsgAddPackage uses `creator` (not `caller`). Patch to manfred so the +# addpkg runs as him under --skip-genesis-sig-verification. +jq --arg creator "$CALLER" '.msg[0].creator = $creator' "$RENDERED_06" >"$RENDERED_06.patched" +mv "$RENDERED_06.patched" "$RENDERED_06" echo "" | "$GNOKEY_BIN" sign \ - --tx-path "$RENDERED_05" \ + --tx-path "$RENDERED_06" \ --chainid "$CHAIN_ID" \ --account-number 0 \ --account-sequence 0 \ @@ -326,7 +345,13 @@ echo "" | "$GNOKEY_BIN" sign \ --insecure-password-stdin \ ephemeral >/dev/null -jq -c '{tx: .}' "$RENDERED_05" >>"$OUT_JSONL" -printf ' migration: %-38s caller=%s\n' "05_addpkg_validators_v3" "$CALLER" +jq -c '{tx: .}' "$RENDERED_06" >>"$OUT_JSONL" +printf ' migration: %-38s caller=%s\n' "06_addpkg_validators_v3" "$CALLER" + +# 07 — restore namespace check (govDAO proposal, caller=T1_CALLER). +RENDERED_07="$WORK/07_restore_sysnames.gno" +cp "$SCRIPT_DIR/07_restore_sysnames.gno.tmpl" "$RENDERED_07" +render_tx "$RENDERED_07" "$T1_CALLER" >>"$OUT_JSONL" +printf ' migration: %-38s caller=%s\n' "$(basename "$RENDERED_07")" "$T1_CALLER" printf ' written: %s\n' "$OUT_JSONL" From 281d51ed99fc3dc33a5a6b008ee10519cc55bf7e Mon Sep 17 00:00:00 2001 From: aeddi Date: Thu, 23 Apr 2026 20:12:27 +0200 Subject: [PATCH 79/92] fix(r/sys/validators/v3): evaluate changesFn eagerly in NewValsetChangeExecutor The lazy-eval pattern closed the executor callback over changesFn itself, which pulls the caller's ephemeral /e//run realm into the proposal state. dao.MustCreateProposal then panics when persisting the executor: cannot persist function or method from the private realm gno.land/e//run Evaluate changesFn at Executor construction time and capture only the resulting []validators.Validator slice (plain data) in the callback. This mirrors v2's NewPropRequest, which has always used the eager pattern. Reproducer: run any MsgRun that calls NewValsetChangeExecutor + govDAO propose/vote/execute in a single main(); the propose tx fails. After this change, the same flow lands and the valset update propagates through the VM params keeper to consensus. --- examples/gno.land/r/sys/validators/v3/poc.gno | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/examples/gno.land/r/sys/validators/v3/poc.gno b/examples/gno.land/r/sys/validators/v3/poc.gno index 14eec9f840f..9b366ad2715 100644 --- a/examples/gno.land/r/sys/validators/v3/poc.gno +++ b/examples/gno.land/r/sys/validators/v3/poc.gno @@ -1,10 +1,9 @@ package validators import ( - "strings" - "chain/params" "chain/runtime" + "strings" "gno.land/p/nt/ufmt/v0" "gno.land/p/sys/validators" @@ -29,17 +28,21 @@ const ( const errNoChangesProposed = "no set changes proposed" // NewValsetChangeExecutor creates a new GovDAO executor for proposing valset changes. +// changesFn is evaluated eagerly; the resulting slice is captured by the returned +// Executor. Evaluating lazily would close over the caller's ephemeral realm and +// fail to persist via dao.MustCreateProposal ("cannot persist function from the +// private realm"). func NewValsetChangeExecutor(changesFn func() []validators.Validator) dao.Executor { if changesFn == nil { panic(errNoChangesProposed) } - callback := func(cur realm) error { - changes := changesFn() - if len(changes) == 0 { - panic(errNoChangesProposed) - } + changes := changesFn() + if len(changes) == 0 { + panic(errNoChangesProposed) + } + callback := func(cur realm) error { // Apply each change to the on-chain valset. for _, change := range changes { if change.VotingPower == 0 { From f6a7cdd79fc86e5fb9668d60c3ee1321461f962c Mon Sep 17 00:00:00 2001 From: aeddi Date: Thu, 23 Apr 2026 20:12:49 +0200 Subject: [PATCH 80/92] feat(deployments/gnoland-1): set vm:p:valset_realm_path to v3 in migration The hardforked chain inherits mainnet state where vm.Params was initialized before the ValsetRealmPath field existed, so the param reads as "". tm2's EndBlocker reads it via: valsetRealm := vm.ValsetRealmDefault prmk.GetString(ctx, vm.ValsetRealmParamPath, &valsetRealm) GetString overwrites the default with whatever is stored, including the empty string, so EndBlocker ends up checking params at `vm::new_updates_available` rather than `vm:gno.land/r/sys/validators/v3:new_updates_available`. Every update r/sys/validators/v3 writes is invisible to consensus, and the valset never moves post-fork. Fix: add migration step 08 (MsgRun) that submits a govDAO proposal to set `vm:p:valset_realm_path = "gno.land/r/sys/validators/v3"`, auto-voting and executing as the current sole T1 (manfred, or NEW_T1_ADDR post-rotation). Runs after step 07 restores the namespace check; the params proposal is namespace-agnostic, so ordering is driven by clarity, not dependency. With this step in place, after fork: - step 06 addpkg deploys the v3 realm code - step 08 points EndBlocker at v3 - subsequent govDAO proposals through r/sys/validators/v3 propagate to tm2 --- .../migrations/08_set_valset_realm.gno.tmpl | 34 +++++++++++++++++++ .../deployments/gnoland-1/migrations/build.sh | 9 +++++ 2 files changed, 43 insertions(+) create mode 100644 misc/deployments/gnoland-1/migrations/08_set_valset_realm.gno.tmpl diff --git a/misc/deployments/gnoland-1/migrations/08_set_valset_realm.gno.tmpl b/misc/deployments/gnoland-1/migrations/08_set_valset_realm.gno.tmpl new file mode 100644 index 00000000000..02a30907e27 --- /dev/null +++ b/misc/deployments/gnoland-1/migrations/08_set_valset_realm.gno.tmpl @@ -0,0 +1,34 @@ +// 08_set_valset_realm.gno.tmpl — MsgRun body that sets the VM param +// `vm:p:valset_realm_path` to the v3 realm path. +// +// Why: the mainnet chain state was initialized before v3 existed, so the +// ValsetRealmPath field on vm.Params isn't populated — queries return "". +// EndBlocker reads this param to know which realm owns valset state: +// +// valsetRealm := vm.ValsetRealmDefault // "gno.land/r/sys/validators/v3" +// prmk.GetString(ctx, vm.ValsetRealmParamPath, &valsetRealm) +// +// GetString overwrites the default with whatever is stored, including "". +// Without this step, EndBlocker reads valsetRealm="" and checks params at +// `vm::new_updates_available` instead of `vm:gno.land/r/sys/validators/v3:*`, +// so updates written by r/sys/validators/v3 never propagate to tm2 consensus. +// +// Pairs with step 06 (addpkg r/sys/validators/v3). Must run after step 07 +// restores the namespace check, since the govDAO proposal itself doesn't need +// namespace authz but any later r/sys/* changes should go through the check. +// +// Caller: current govDAO sole T1 ($T1_CALLER — manfred pre-rotation, or +// NEW_T1_ADDR after step 04's rotation). +package main + +import ( + "gno.land/r/gov/dao" + "gno.land/r/sys/params" +) + +func main() { + r := params.NewSysParamStringPropRequest("vm", "p", "valset_realm_path", "gno.land/r/sys/validators/v3") + id := dao.MustCreateProposal(cross, r) + dao.MustVoteOnProposal(cross, dao.VoteRequest{Option: dao.YesVote, ProposalID: id}) + dao.ExecuteProposal(cross, id) +} diff --git a/misc/deployments/gnoland-1/migrations/build.sh b/misc/deployments/gnoland-1/migrations/build.sh index 8ddf14958b0..e36bfab4571 100755 --- a/misc/deployments/gnoland-1/migrations/build.sh +++ b/misc/deployments/gnoland-1/migrations/build.sh @@ -354,4 +354,13 @@ cp "$SCRIPT_DIR/07_restore_sysnames.gno.tmpl" "$RENDERED_07" render_tx "$RENDERED_07" "$T1_CALLER" >>"$OUT_JSONL" printf ' migration: %-38s caller=%s\n' "$(basename "$RENDERED_07")" "$T1_CALLER" +# 08 — point vm:p:valset_realm_path at the v3 realm (govDAO proposal, +# caller=T1_CALLER). Without this, EndBlocker reads valsetRealm="" from +# pre-v3 mainnet state and never picks up the updates r/sys/validators/v3 +# writes to its params. +RENDERED_08="$WORK/08_set_valset_realm.gno" +cp "$SCRIPT_DIR/08_set_valset_realm.gno.tmpl" "$RENDERED_08" +render_tx "$RENDERED_08" "$T1_CALLER" >>"$OUT_JSONL" +printf ' migration: %-38s caller=%s\n' "$(basename "$RENDERED_08")" "$T1_CALLER" + printf ' written: %s\n' "$OUT_JSONL" From 5c29119492af6457e8aebc13ff9d79f32235aaf5 Mon Sep 17 00:00:00 2001 From: aeddi Date: Thu, 23 Apr 2026 21:51:08 +0200 Subject: [PATCH 81/92] feat(hf-glue): add assert-migrations script verifying post-replay state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under --skip-failing-genesis-txs the InitChainer absorbs any failing migration tx silently, so a defective rc can produce a genesis where the chain boots and looks fine while leaving the T1 rotation, v3 deploy, or VM-param flips unapplied. There is currently no positive check that each migration's intended effect actually landed. This adds misc/hf-glue/scripts/assert-migrations.sh and a matching `make assert-migrations` target. The script queries a live post-replay node via gnokey and asserts the end-state of every migration: - 05+07: vm:p:sysnames_pkgpath restored to gno.land/r/sys/names - 06: r/sys/validators/v3 deployed (NewValsetChangeExecutor, GetValidators exported) - 08: vm:p:valset_realm_path = gno.land/r/sys/validators/v3 - 02-04: govDAO T1 tier has exactly one member matching $EXPECTED_T1 (NEW_T1_ADDR), manfred withdrawn when rotation configured - meta: r/sys/names.IsEnabled() stayed true, v3 has no new_updates_available unconsumed by EndBlocker Migration 01 is intentionally downgraded to a WARN-only informational check: r/sys/validators/v2 is vestigial after PR #5485 (EndBlocker no longer reads from it) and the reset_valset migration is known to partial-apply when a batched removeValidator hits an address that was already removed on the source chain — the whole proposal panics and the subsequent removes + the new-validator add are skipped. Consensus is unaffected because it's driven by GenesisDoc.Validators and v3. T1 membership is resolved via the public /r/gov/dao/v3/memberstore render endpoints rather than memberstore.Get() because Get() carries an ACL that rejects calls from qeval's empty-realm context. Usage: # After `make up` has replayed genesis REMOTE=http://127.0.0.1:26657 \ EXPECTED_T1=g1... \ EXPECTED_VALSET_ADDRS="g1a g1b g1c" \ make -C misc/hf-glue assert-migrations Exits 0 if every mandatory check passes, 1 on failure, 2 on prerequisite error (gnokey missing, RPC unreachable). --- misc/hf-glue/Makefile | 7 + misc/hf-glue/scripts/assert-migrations.sh | 195 ++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100755 misc/hf-glue/scripts/assert-migrations.sh diff --git a/misc/hf-glue/Makefile b/misc/hf-glue/Makefile index 70eee04318b..2e91e01409a 100644 --- a/misc/hf-glue/Makefile +++ b/misc/hf-glue/Makefile @@ -160,6 +160,13 @@ smoketest: ## run 'gnogenesis fork test' in-memory against out/genesis.json @test -f $(OUT)/genesis.json || { echo "missing out/genesis.json — run 'make fetch' first"; exit 1; } cd $(REPO)/contribs/gnogenesis && go run . fork test --genesis $(OUT)/genesis.json --verbose +.PHONY: assert-migrations +assert-migrations: ## assert live node's post-replay state matches migration intent (overrides: REMOTE= EXPECTED_T1= EXPECTED_VALSET_ADDRS=) + @REMOTE="$${REMOTE:-http://localhost:26657}" \ + EXPECTED_T1="$${EXPECTED_T1:-$(NEW_T1_ADDR)}" \ + EXPECTED_VALSET_ADDRS="$${EXPECTED_VALSET_ADDRS:-$(VALIDATOR_ADDR)}" \ + $(HERE)/scripts/assert-migrations.sh + .PHONY: replay-log replay-log: ## run in-process genesis replay, tee full log to out/replay.log @$(HERE)/scripts/replay-log.sh diff --git a/misc/hf-glue/scripts/assert-migrations.sh b/misc/hf-glue/scripts/assert-migrations.sh new file mode 100755 index 00000000000..50d687e5dc5 --- /dev/null +++ b/misc/hf-glue/scripts/assert-migrations.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# assert-migrations.sh — verify that the hf-glue post-replay state matches +# the intent of every migration step (see +# misc/deployments/gnoland-1/migrations/). Run against a live node after +# genesis replay has completed. +# +# Why: with --skip-failing-genesis-txs, any migration that silently panics is +# absorbed without aborting the chain, so a defective rc can boot and look +# fine while leaving the T1 rotation, v3 deploy, or param flips unapplied. +# This script is the positive check that catches silent migration misfires. +# +# Env +# === +# REMOTE RPC endpoint to query (default http://localhost:26657) +# EXPECTED_T1 bech32 address expected to be the sole post-rotation +# T1 member. When rotation is configured, this is the +# value of NEW_T1_ADDR used at genesis build time. +# Defaults to gnoland-1's production T1 (g1aeddlft…), +# override when building from a different CALLER. +# EXPECTED_VALSET_ADDRS Space-separated list of bech32 g1 addresses that +# should appear in r/sys/validators/v2 after +# migration 01. Leave empty to skip the v2-valset +# check. +# GNOKEY_BIN gnokey binary (default: gnokey on $PATH) +# +# Exit status +# =========== +# 0 — all assertions passed +# 1 — one or more assertions failed (details printed to stdout) +# 2 — prerequisite error (bad tool, RPC unreachable, etc.) +set -euo pipefail + +REMOTE="${REMOTE:-http://localhost:26657}" +EXPECTED_T1="${EXPECTED_T1:-g1aeddlftlfk27ret5rf750d7w5dume3kcsm8r8m}" +EXPECTED_VALSET_ADDRS="${EXPECTED_VALSET_ADDRS:-}" +GNOKEY_BIN="${GNOKEY_BIN:-gnokey}" + +# Manfred is the pre-rotation sole T1 on gnoland1; compared against +# $EXPECTED_T1 to decide whether to assert manfred was withdrawn. +readonly MANFRED="g1manfred47kzduec920z88wfr64ylksmdcedlf5" + +command -v "$GNOKEY_BIN" >/dev/null 2>&1 || { + echo "gnokey not found on PATH (set GNOKEY_BIN=...)" >&2 + exit 2 +} + +fail=0 +pass=0 + +# ---- Helpers + +# Extracts the `data:` line from a `gnokey query` response, stripping the +# leading `data: ` prefix. Single-line extraction — if the response body spans +# multiple lines, only the first is returned. +extract_data() { + awk 'NR==FNR{next}/^data:/{sub(/^data: /,""); print; exit}' /dev/null "$@" +} + +query_param() { + local key="$1" + "$GNOKEY_BIN" query -remote "$REMOTE" "params/$key" 2>/dev/null | + awk '/^data:/{sub(/^data: /,""); print; exit}' +} + +query_qeval() { + local expr="$1" + "$GNOKEY_BIN" query -remote "$REMOTE" "vm/qeval" --data "$expr" 2>/dev/null | + awk '/^data:/{sub(/^data: /,""); print; exit}' +} + +query_qfuncs_raw() { + local pkg="$1" + "$GNOKEY_BIN" query -remote "$REMOTE" "vm/qfuncs" --data "$pkg" 2>/dev/null +} + +check_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$actual" == "$expected" ]]; then + printf ' [OK] %s\n' "$desc" + pass=$((pass + 1)) + else + printf ' [FAIL] %s\n want=%s\n got =%s\n' "$desc" "$expected" "$actual" + fail=$((fail + 1)) + fi +} + +check_contains() { + local desc="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + printf ' [OK] %s\n' "$desc" + pass=$((pass + 1)) + else + printf ' [FAIL] %s\n want substring=%s\n in=%s\n' "$desc" "$needle" "$haystack" + fail=$((fail + 1)) + fi +} + +check_not_contains() { + local desc="$1" needle="$2" haystack="$3" + if [[ "$haystack" != *"$needle"* ]]; then + printf ' [OK] %s\n' "$desc" + pass=$((pass + 1)) + else + printf ' [FAIL] %s\n did NOT want substring=%s\n in=%s\n' "$desc" "$needle" "$haystack" + fail=$((fail + 1)) + fi +} + +echo "━━━ assert-migrations against $REMOTE ━━━" +echo " expected T1: $EXPECTED_T1" +[[ -n "$EXPECTED_VALSET_ADDRS" ]] && echo " expected v2: $EXPECTED_VALSET_ADDRS" +echo + +# ---- Sanity: node reachable +if ! "$GNOKEY_BIN" query -remote "$REMOTE" ".app/version" >/dev/null 2>&1; then + echo " RPC unreachable at $REMOTE" >&2 + exit 2 +fi + +# ---- Migration 05 + 07 (sysnames namespace check disable/restore) +# Step 05 sets the vm param to "", step 07 restores it. If 06 (addpkg v3) +# fails silently before 07 runs, the path stays empty and the authz check +# is permanently off — exactly the silent-failure case this guards against. +check_eq 'migration 05+07: vm:p:sysnames_pkgpath restored to r/sys/names' \ + '"gno.land/r/sys/names"' \ + "$(query_param 'vm:p:sysnames_pkgpath')" + +# r/sys/names internal flag stayed true through the restore (we never touched +# .enabled, only the VM param pointing at the pkg). +check_eq 'r/sys/names.IsEnabled() still true after migration' \ + '(true bool)' \ + "$(query_qeval 'gno.land/r/sys/names.IsEnabled()')" + +# ---- Migration 06 (addpkg r/sys/validators/v3) +v3funcs="$(query_qfuncs_raw 'gno.land/r/sys/validators/v3')" +check_contains 'migration 06: v3 realm deployed (NewValsetChangeExecutor exported)' \ + 'NewValsetChangeExecutor' "$v3funcs" +check_contains 'migration 06: v3 realm deployed (GetValidators exported)' \ + 'GetValidators' "$v3funcs" + +# ---- Migration 08 (valset_realm_path points at v3) +check_eq 'migration 08: vm:p:valset_realm_path = v3' \ + '"gno.land/r/sys/validators/v3"' \ + "$(query_param 'vm:p:valset_realm_path')" + +# ---- v3 pending-update drain +# If new_updates_available=true, EndBlocker hasn't consumed the last proposal +# yet — either the chain isn't producing blocks or the param path is wrong. +# valset_prev reflects the last applied valset; no strict assertion because +# it legitimately evolves as add-validator proposals land. +check_eq 'v3: no pending valset update unconsumed by EndBlocker' \ + 'false' \ + "$(query_param 'vm:gno.land/r/sys/validators/v3:new_updates_available')" + +# ---- Migration 01 (v2 valset swapped) — informational only +# v2 is vestigial after PR #5485: EndBlocker no longer reads from it. The +# reset_valset migration is cosmetic and is known to partial-apply when a +# removed-validator exists in the batch (the whole proposal panics on the +# first missing entry and leaves subsequent removes + the new-validator add +# unapplied). Surface the current state for visual inspection but do not +# fail the check — consensus is driven by GenesisDoc.Validators and v3. +if [[ -n "$EXPECTED_VALSET_ADDRS" ]]; then + v2out="$(query_qeval 'gno.land/r/sys/validators/v2.GetValidators()')" + for addr in $EXPECTED_VALSET_ADDRS; do + if [[ "$v2out" == *"$addr"* ]]; then + printf ' [OK] migration 01 (informational): r/sys/validators/v2 has %s\n' "$addr" + pass=$((pass + 1)) + else + printf ' [WARN] migration 01 (informational): r/sys/validators/v2 missing %s (v2 is dead code post-PR#5485)\n' "$addr" + fi + done +fi + +# ---- Migration 02-04 (T1 rotation) +# memberstore.Get() has an ACL that rejects calls from qeval's empty-realm +# context, so membership is read from the public render endpoints: +# /r/gov/dao/v3/memberstore — tier summary ("Tier T1 contains N members") +# /r/gov/dao/v3/memberstore:members — tabular member list with T1/T2/T3 rows +summary="$("$GNOKEY_BIN" query -remote "$REMOTE" vm/qrender --data 'gno.land/r/gov/dao/v3/memberstore:' 2>/dev/null)" +members="$("$GNOKEY_BIN" query -remote "$REMOTE" vm/qrender --data 'gno.land/r/gov/dao/v3/memberstore:members' 2>/dev/null)" + +t1_count="$(printf '%s\n' "$summary" | grep -oE 'Tier T1 contains [0-9]+ members' | grep -oE '[0-9]+' | head -1)" +check_eq 'migration 02-04: govDAO T1 tier size = 1' '1' "${t1_count:-}" + +check_contains "migration 02-04: $EXPECTED_T1 is T1 member" \ + "| $EXPECTED_T1 |" "$members" + +if [[ "$EXPECTED_T1" != "$MANFRED" ]]; then + check_not_contains 'migration 03-04: manfred withdrawn from T1' \ + "| $MANFRED |" "$members" +fi + +echo +printf 'Results: %d ok, %d fail\n' "$pass" "$fail" +exit "$fail" From 6765715aff939272b01c458151f0e1173c478bcd Mon Sep 17 00:00:00 2001 From: aeddi Date: Thu, 23 Apr 2026 21:55:51 +0200 Subject: [PATCH 82/92] feat(hf-glue): add verify-txs-jsonl integrity check vs source-chain RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tx-archive fetches historical txs from the source chain's RPC and writes them to out/source/txs.jsonl. There is no integrity check of its own: a partial fetch, a flaky RPC stream, or a bug in a future tx-archive version can silently drop txs and produce a file that looks fine under `wc -l` but diverges from the chain's real history. The hardforked genesis replays from this file, so any missing tx is missing state post-fork — and because we run under --skip-failing-genesis-txs, the divergence is invisible at replay time. Production needs a positive check that runs before we ship a genesis. This adds misc/hf-glue/scripts/verify-txs-jsonl.sh and `make verify-txs-jsonl`. The script runs two checks: 1. Cardinality. Block header at HALT_HEIGHT carries a cumulative total_txs field on tm2; that must equal the non-blank line count in txs.jsonl. Any diff is a hard fail. 2. Spot-check at random heights that actually carry txs. Empty blocks trivially match 0==0 on both sides and don't test anything useful, so we sample BlockHeights present in txs.jsonl (Fisher-Yates shuffle seeded from $SPOT_SEED for reproducibility) and compare each sampled height's jsonl count to the source chain's num_txs. One extra randomly-chosen empty-block height is appended as an anchor to confirm the script can observe zero correctly. Usage: HALT_HEIGHT=813642 make -C misc/hf-glue verify-txs-jsonl # or for a specific seed / sample size: HALT_HEIGHT=... SPOT_COUNT=25 SPOT_SEED=42 \ make -C misc/hf-glue verify-txs-jsonl Exits 0 on match, 1 on divergence (hard fail), 2 on prerequisite error (missing file, unreachable RPC, missing jq/curl). --- misc/hf-glue/Makefile | 8 ++ misc/hf-glue/scripts/verify-txs-jsonl.sh | 160 +++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100755 misc/hf-glue/scripts/verify-txs-jsonl.sh diff --git a/misc/hf-glue/Makefile b/misc/hf-glue/Makefile index 2e91e01409a..d2f6f695a3e 100644 --- a/misc/hf-glue/Makefile +++ b/misc/hf-glue/Makefile @@ -167,6 +167,14 @@ assert-migrations: ## assert live node's post-replay state matches migration int EXPECTED_VALSET_ADDRS="$${EXPECTED_VALSET_ADDRS:-$(VALIDATOR_ADDR)}" \ $(HERE)/scripts/assert-migrations.sh +.PHONY: verify-txs-jsonl +verify-txs-jsonl: ## compare out/source/txs.jsonl against source-chain RPC (cardinality + spot-check at random heights with txs) + @test -n "$(HALT_HEIGHT)" || { echo "HALT_HEIGHT required"; exit 1; } + @RPC_URL="$(RPC_URL)" \ + HALT_HEIGHT=$(HALT_HEIGHT) \ + OUT=$(OUT) \ + $(HERE)/scripts/verify-txs-jsonl.sh + .PHONY: replay-log replay-log: ## run in-process genesis replay, tee full log to out/replay.log @$(HERE)/scripts/replay-log.sh diff --git a/misc/hf-glue/scripts/verify-txs-jsonl.sh b/misc/hf-glue/scripts/verify-txs-jsonl.sh new file mode 100755 index 00000000000..3e5c96ec7da --- /dev/null +++ b/misc/hf-glue/scripts/verify-txs-jsonl.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# verify-txs-jsonl.sh — assert our cached historical tx export in +# $OUT/source/txs.jsonl matches the source chain's own book-keeping through +# HALT_HEIGHT. +# +# Why: tx-archive has no integrity check of its own. A partial fetch, a +# flaky RPC, or a bug in a future tx-archive version can silently drop txs +# and produce an export that looks fine in `wc -l` but diverges from +# reality. The hardforked genesis replays from this export, so any missing +# tx means missing state post-fork. +# +# Checks +# ====== +# 1. Cardinality: block `total_txs` at HALT_HEIGHT (cumulative, from the +# block header) must equal the number of non-blank lines in txs.jsonl. +# Mismatch here is a hard fail. +# 2. Spot-check ($SPOT_COUNT random heights in 1..HALT_HEIGHT): for each, +# compare the source chain's `num_txs` to the count of jsonl entries +# with matching metadata.BlockHeight. Any mismatch is a hard fail. +# +# Env +# === +# RPC_URL source-chain RPC (default https://rpc.gno.land) +# HALT_HEIGHT cutoff height (required; last height included in replay) +# TXS_JSONL path to txs.jsonl (default $OUT/source/txs.jsonl; OUT auto- +# resolved from this script's location when not provided) +# SPOT_COUNT how many random heights to sample (default 10) +# SPOT_SEED RNG seed for spot-check reproducibility (default: $RANDOM) +# +# Exit status +# =========== +# 0 — everything matches +# 1 — divergence detected (details printed) +# 2 — prerequisite error (missing file, unreachable RPC, missing jq) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT="${OUT:-$(cd "$SCRIPT_DIR/.." && pwd)/out}" + +RPC_URL="${RPC_URL:-https://rpc.gno.land}" +TXS_JSONL="${TXS_JSONL:-$OUT/source/txs.jsonl}" +SPOT_COUNT="${SPOT_COUNT:-10}" +SPOT_SEED="${SPOT_SEED:-$RANDOM}" + +: "${HALT_HEIGHT:?HALT_HEIGHT is required}" + +command -v jq >/dev/null 2>&1 || { + echo "jq not found on PATH" >&2 + exit 2 +} +command -v curl >/dev/null 2>&1 || { + echo "curl not found on PATH" >&2 + exit 2 +} +[[ -f "$TXS_JSONL" ]] || { + echo "txs.jsonl not found at $TXS_JSONL" >&2 + exit 2 +} + +echo "━━━ verify-txs-jsonl ━━━" +echo " rpc $RPC_URL" +echo " halt_height $HALT_HEIGHT" +echo " txs.jsonl $TXS_JSONL" +echo " spot count $SPOT_COUNT" +echo " spot seed $SPOT_SEED" +echo + +fail=0 + +# ---- Helper: fetch block header field at a given height +rpc_block_header_field() { + local height="$1" field="$2" + curl -sf --max-time 30 "${RPC_URL%/}/block?height=$height" | + jq -r ".result.block.header.$field // empty" +} + +# ---- Check 1: cardinality +local_count="$(grep -cve '^[[:space:]]*$' "$TXS_JSONL")" +rpc_total="$(rpc_block_header_field "$HALT_HEIGHT" total_txs)" + +if [[ -z "$rpc_total" ]]; then + echo " [FAIL] RPC returned no total_txs at height=$HALT_HEIGHT" >&2 + exit 1 +fi + +printf ' cardinality: rpc=%s local=%s ' "$rpc_total" "$local_count" +if [[ "$rpc_total" == "$local_count" ]]; then + echo '[OK]' +else + diff=$((rpc_total - local_count)) + printf '[FAIL] diff=%+d (rpc - local)\n' "$diff" + fail=1 +fi + +# ---- Check 2: spot-check random heights with txs +# Pick heights from the set of BlockHeights that actually have at least one +# tx in the jsonl. This is where divergence can hide — empty-block heights +# are trivially 0==0 on both sides and don't test anything. We also include +# one height with no txs as a sanity anchor (ensures the script can observe +# zero correctly). +# +# srand with explicit seed → deterministic sampling for reproducibility. +heights_csv="$(awk -v seed="$SPOT_SEED" -v n="$SPOT_COUNT" -v hi="$HALT_HEIGHT" ' + BEGIN { srand(seed) } + /"block_height":/ { + match($0, /"block_height":"[0-9]+"/) + if (RSTART) { + v = substr($0, RSTART, RLENGTH) + gsub(/[^0-9]/, "", v) + h[v]++ + } + } + END { + # Collect unique heights with txs, shuffle, pick first n-1 + i = 0 + for (v in h) keys[i++] = v + # Fisher-Yates + for (j = i - 1; j > 0; j--) { + k = int(rand() * (j + 1)) + tmp = keys[j]; keys[j] = keys[k]; keys[k] = tmp + } + need = (n - 1 < i) ? n - 1 : i + for (j = 0; j < need; j++) printf "%d ", keys[j] + # Anchor: one random empty-block height (no entry in jsonl) + anchor = int(rand() * hi) + 1 + while (h[anchor]) anchor = int(rand() * hi) + 1 + printf "%d", anchor + }' "$TXS_JSONL")" + +echo +echo ' spot-check (last entry is a known-empty-block anchor):' +for h in $heights_csv; do + rpc_num="$(rpc_block_header_field "$h" num_txs)" + local_num="$(awk -v h="$h" -F'"' ' + /"block_height":/ { + # Extract the numeric value after "block_height":" + match($0, /"block_height":"[0-9]+"/) + if (RSTART) { + val = substr($0, RSTART, RLENGTH) + gsub(/[^0-9]/, "", val) + if (val == h) c++ + } + } + END { print c+0 }' "$TXS_JSONL")" + + if [[ "$rpc_num" == "$local_num" ]]; then + printf ' height=%-8d rpc=%s local=%s [OK]\n' "$h" "$rpc_num" "$local_num" + else + printf ' height=%-8d rpc=%s local=%s [FAIL]\n' "$h" "$rpc_num" "$local_num" + fail=1 + fi +done + +echo +if [[ $fail -eq 0 ]]; then + echo ' Result: all checks passed' +else + echo ' Result: divergence detected — do not ship this txs.jsonl' +fi +exit "$fail" From 3e107b024a59501f1ea6557f17e4c09056e5d4b6 Mon Sep 17 00:00:00 2001 From: aeddi Date: Thu, 23 Apr 2026 22:14:39 +0200 Subject: [PATCH 83/92] feat(hf-glue): add state-diff tool comparing replay vs source-chain realms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2605 replay failures absorbed by --skip-failing-genesis-txs can silently drop state on any of our dozen or so canonical realms. Before shipping a genesis we need a positive check that the realms we care about on the post-replay chain actually match what the source chain had at halt_height. Nothing in hf-glue did this. This adds misc/hf-glue/scripts/state-diff.sh and `make state-diff`. For each realm in the REALMS list, the script queries vm/qrender from both: • SOURCE_RPC pinned at `-height HALT_HEIGHT` • REPLAY_RPC at REPLAY_HEIGHT (default 0 = current tip) Both outputs pass through a shared normalizer that strips ephemeral rendering bits (SVG chart blobs, http(s) URLs, timestamps, current block-height markers, absolute /src/... stack-trace paths) so only semantic content survives the diff. Byte-identical results = PASS, anything else = FAIL with the first DIFF_CONTEXT lines of unified diff recorded in out/STATE-DIFF.md for triage. The default REALMS list covers the realms most likely to carry state that matters post-fork: r/sys/validators/v2 and v3 adjacent, r/sys/names, r/sys/params, r/gov/dao, r/gov/dao/v3/memberstore, r/gnoland/home, r/gnoland/blog, r/gnoland/users. Caller can extend via newline-separated REALMS env. Usage: # Immediately after genesis replay completes, before any post-fork # activity can drift the state: HALT_HEIGHT=813642 REPLAY_RPC=http://127.0.0.1:36657 \ make -C misc/hf-glue state-diff What this doesn't catch: private map entries / unexported state that a realm's Render function doesn't surface. For those add a companion qeval check in a follow-up; Render coverage is pragmatically the biggest-win first pass. Exits 0 when every realm matches after normalization, 1 on any diff (production launch gate — investigate before shipping), 2 on prerequisite error (missing gnokey/diff, unreachable RPC). --- misc/hf-glue/Makefile | 10 ++ misc/hf-glue/scripts/state-diff.sh | 198 +++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100755 misc/hf-glue/scripts/state-diff.sh diff --git a/misc/hf-glue/Makefile b/misc/hf-glue/Makefile index d2f6f695a3e..a2fc5489fb0 100644 --- a/misc/hf-glue/Makefile +++ b/misc/hf-glue/Makefile @@ -175,6 +175,16 @@ verify-txs-jsonl: ## compare out/source/txs.jsonl against source-chain RPC (card OUT=$(OUT) \ $(HERE)/scripts/verify-txs-jsonl.sh +.PHONY: state-diff +state-diff: ## diff realm render output source-chain@halt_height vs replay (overrides: SOURCE_RPC= REPLAY_RPC= REALMS= REPLAY_HEIGHT=); writes out/STATE-DIFF.md + @test -n "$(HALT_HEIGHT)" || { echo "HALT_HEIGHT required"; exit 1; } + @SOURCE_RPC="$${SOURCE_RPC:-$(RPC_URL)}" \ + REPLAY_RPC="$${REPLAY_RPC:-http://localhost:26657}" \ + HALT_HEIGHT=$(HALT_HEIGHT) \ + REALMS="$${REALMS:-}" \ + OUT=$(OUT) \ + $(HERE)/scripts/state-diff.sh + .PHONY: replay-log replay-log: ## run in-process genesis replay, tee full log to out/replay.log @$(HERE)/scripts/replay-log.sh diff --git a/misc/hf-glue/scripts/state-diff.sh b/misc/hf-glue/scripts/state-diff.sh new file mode 100755 index 00000000000..6a0f192558d --- /dev/null +++ b/misc/hf-glue/scripts/state-diff.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# state-diff.sh — diff realm render output between the source chain at +# halt_height and our post-replay node. Catches silent state divergence +# introduced by the 2605 "Unable to deliver genesis tx" failures that +# --skip-failing-genesis-txs absorbs. +# +# Approach +# ======== +# For each realm in REALMS (one pkgpath[:subpath] per line), fetch +# vm/qrender output from both sides: +# source chain: `gnokey query -remote $SOURCE_RPC -height $HALT_HEIGHT` +# replay: `gnokey query -remote $REPLAY_RPC` +# Normalize out obviously-ephemeral bits (absolute paths, wall clock, +# SVG blobs, current block height, etc.), then byte-compare. Emit a +# STATE-DIFF.md with per-realm pass/fail; for failing realms include the +# first N lines of the unified diff. +# +# What this can't catch +# ===================== +# A realm's Render function only surfaces what it chooses to. State +# stored in maps the render ignores, private fields, etc., are invisible. +# For those realms, add a companion qeval check in a follow-up. +# +# Env +# === +# SOURCE_RPC source chain RPC (default https://rpc.gno.land) +# REPLAY_RPC post-replay node RPC (default http://localhost:26657) +# HALT_HEIGHT source-chain height to query (required; same value used +# at genesis build time) +# REALMS newline-separated pkgpath[:subpath] list; defaults to +# the canonical set (valset v2, govDAO, memberstore, +# users, home, blog, sys/params, sys/names) +# OUT misc/hf-glue/out (auto-resolved) +# DIFF_CONTEXT lines of context per failing diff (default 20) +# GNOKEY_BIN gnokey binary (default: gnokey on $PATH) +# +# Exit status +# =========== +# 0 — every realm matches after normalization +# 1 — at least one realm diverges (see STATE-DIFF.md) +# 2 — prerequisite error +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT="${OUT:-$(cd "$SCRIPT_DIR/.." && pwd)/out}" +mkdir -p "$OUT" + +SOURCE_RPC="${SOURCE_RPC:-https://rpc.gno.land}" +REPLAY_RPC="${REPLAY_RPC:-http://localhost:26657}" +DIFF_CONTEXT="${DIFF_CONTEXT:-20}" +GNOKEY_BIN="${GNOKEY_BIN:-gnokey}" + +: "${HALT_HEIGHT:?HALT_HEIGHT is required (pin the source-chain height to query)}" + +# The replay node can be queried at historical heights (0 = current tip). +# Production recipe: run this script against a FRESH post-replay node +# before any on-chain activity can drift the state — the default tip +# matches initial_height in that case. Override REPLAY_HEIGHT to pin a +# specific historical height; requires the node to still retain state at +# that height (no pruning). +REPLAY_HEIGHT="${REPLAY_HEIGHT:-0}" + +# Default realm set. Each entry is a pkgpath with optional :subpath passed +# to vm/qrender. Extend via REALMS env (newline-separated) for ad-hoc runs. +REALMS="${REALMS:-$( + cat <<'EOF' +gno.land/r/sys/validators/v2: +gno.land/r/sys/names: +gno.land/r/sys/params: +gno.land/r/gov/dao: +gno.land/r/gov/dao/v3/memberstore:members +gno.land/r/gnoland/home: +gno.land/r/gnoland/blog: +gno.land/r/gnoland/users: +EOF +)}" + +command -v "$GNOKEY_BIN" >/dev/null 2>&1 || { + echo "gnokey not found on PATH (set GNOKEY_BIN=...)" >&2 + exit 2 +} +command -v diff >/dev/null 2>&1 || { + echo "diff not found on PATH" >&2 + exit 2 +} + +REPORT="$OUT/STATE-DIFF.md" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# ---- Normalization +# Ephemeral render bits that differ between mainnet and our replay even for +# logically-equivalent state: +# • embedded SVG charts (base64 blobs with sizes in px → drop wholesale) +# • absolute URLs pointing at the chain's own rpc/web (host differs) +# • wall-clock timestamps ("Generated at ...") +# • "height" / "block" markers that reference the chain's current tip +# • trailing whitespace +# The goal is to preserve semantically-meaningful text (addresses, names, +# ids, counts, titles, descriptions) and drop everything transient. +normalize() { + sed \ + -e '/data:image\/svg+xml;base64,/d' \ + -e 's|https://[^[:space:])]*||g' \ + -e 's|http://[^[:space:])]*||g' \ + -e 's|rpc\.gno\.land||g' \ + -e 's|latest_block_height[^[:space:]]*||g' \ + -e 's|height=[0-9]*|height=|g' \ + -e 's|Generated [^\\n]*|Generated