diff --git a/cmd/slave/README.md b/cmd/slave/README.md index d90ac8ec2435..4779305468ac 100644 --- a/cmd/slave/README.md +++ b/cmd/slave/README.md @@ -115,6 +115,69 @@ The running devnet config works the same way: prints `hash: 0x5ad443efb7cf5246a3d1bbc1734bd02bf3a5d83bedeccfcfe707d0ebee03780d`. +### `slave inspect` + +Read-only and config-free: scan `--datadir` for shard chaindb directories +(`shard-0x{full_shard_id}/`), open each in read-only mode, and print the stored +minor genesis block and chain head. A shard that cannot be opened, read, or +validated is reported without aborting the others, and the exit status is +non-zero if any shard failed. A running slave holds its chaindb locks (each shard +then reports `resource temporarily unavailable`), so inspect a stopped node. The +report goes to stdout; log lines go to stderr. + +``` +./build/bin/slave inspect --datadir ./qkc-data/devnet +``` + +``` +shard 0x00000001 (qkc-data/devnet/shard-0x00000001): + genesis block: 0x661b12d25851f510519f8b157b2b76c95ea8ba4faf2a78f047c12c0bec792667 + height: 0 + state root: 0x76b7e413ee8a10d27ad5158ce91b8b8e61d6af8805b965a4ce11b93db0286ed1 + coinbase: 0x000000000000000000000000000000000000000000000001 + coinbase amount: token 35760 = 3250000000000000000 + evm_gas_limit: 12000000 + evm_xshard_gas_limit: 6000000 + hash_prev_root_block: 0x5ad443efb7cf5246a3d1bbc1734bd02bf3a5d83bedeccfcfe707d0ebee03780d + xshard cursor: root=0 minor=0 deposit=0 + chain id: 110001 + fork schedule: byzantium=0 constantinople=0 eip150=0 eip155=0 eip158=0 homestead=0 petersburg=0 + head block: none recorded (stub chain persists no head) +shard 0x00040001 (qkc-data/devnet/shard-0x00040001): + ... +2 shard(s) inspected, 0 failed +``` + +Nothing is printed for a shard until everything the report would assert has been +checked, so a database that fails is described by its error alone rather than +having its fields presented as that shard's genesis: + +- **The block must hold together.** A minor block's hash is its header's hash + alone, and without the cluster config there is no derived encoding to compare + against, so the meta is rehashed and checked against the `hash_meta` the header + commits to. Otherwise a database whose meta was replaced would report the + original, authentic-looking block hash next to a substituted state root. Block 0 + must also be block 0, with an empty body. +- **The block must belong here.** The stored block names its own shard through its + branch; a chaindb sitting in another shard's directory is reported as a misplaced + chaindb. +- **A missing block must be the only thing missing.** The genesis block is written + last, once the chain stands, so its absence is an interrupted bootstrap — + `genesis block: none (bootstrap never completed; next boot re-initializes)`, and + the next `slave` run re-runs the fresh path. A head pointer with no genesis under + it is not a state this lifecycle produces, and is reported rather than described + as safely re-initializable. + +The EVM rule set is stored apart from the genesis block, keyed by its hash, and is the +other half of what a reopen is checked against — so it is reported too: the shard's +chain id (`BASE_ETH_CHAIN_ID + CHAIN_ID + 1`) and its fork schedule, which for every +QuarkChain shard sits entirely at block 0. A datadir initialized before the rule set was +written prints `rule set: none stored`; that one is recoverable, and the next `slave` +run warns and writes it rather than refusing to boot. + +A report that cannot be written fails the command instead of being summarized as a +success. + ## Fixtures and pyquarkchain cross-validation Both real (singularity) cluster configs are checked in under @@ -133,3 +196,35 @@ genesis block hash, byte-identical to pyquarkchain's its allocated state are derived by [`qkc.CreateMinorBlock`](../../qkc/genesis.go) and can be cross-validated the same way as the root genesis (see that README's [Pinned minor-genesis values](../../qkc/config/singularity/README.md#pinned-minor-genesis-values)). + +## Follow-up integration checklist + +The slave provides the process, per-shard database ownership, and the lifecycle +around a stub chain. The following replacement points are deliberate: + +- **Real shard chain:** when the geth-core shard chain + ([#1](https://github.com/QuarkChain/goshard/issues/1)) exists, inject its + `ChainService` from `cmd/slave` instead of the stub. The seam already takes the + arguments `NewBlockChain` needs; `Stop` must drain every chain-owned goroutine + before the shard database closes, and the chain must refuse to open on a + missing head state. +- **Genesis storage:** the same task retires the single `QKC-genesis-block` key — + it is scaffolding, the block under it is not. Block 0 moves into the chain's own + block storage (canonical-hash mapping and head pointers), and the key is dropped + rather than migrated: the databases hold genesis-level data only, so a clean + re-bootstrap is the whole migration. `slave inspect` then reads block 0 and the + real head through those accessors. +- **Compatibility check placement:** geth checks rule-set compatibility against + the persisted head header *before* constructing the chain, and answers an + incompatibility by rewinding rather than refusing to start. Both become reachable + once the chain persists a head; move `ReconcileChainConfig` ahead of construction + then. +- **Integration tests:** move the boot/reopen, inspect, mismatch, and goleak + coverage onto the real chain, and add a case where the rule set changes without + changing block 0. Keep the goleak allowlist empty for slave-owned goroutines and + fix their `Stop` path instead of ignoring them. +- **Master-driven creation:** when the cluster protocol + ([#5](https://github.com/QuarkChain/goshard/issues/5)) lands, replace eager shard + creation with the pyquarkchain-compatible `PING(root_tip)` trigger while keeping + partial-boot rollback, idempotent blocking shutdown, and the `DBDirName` datadir + convention. diff --git a/cmd/slave/inspect_test.go b/cmd/slave/inspect_test.go new file mode 100644 index 000000000000..f6940e5335f3 --- /dev/null +++ b/cmd/slave/inspect_test.go @@ -0,0 +1,353 @@ +// Copyright 2026-2027, QuarkChain. + +package main + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/ethdb/pebble" + "github.com/ethereum/go-ethereum/internal/reexec" + "github.com/ethereum/go-ethereum/qkc/config" + "github.com/ethereum/go-ethereum/qkc/shard" + "github.com/ethereum/go-ethereum/qkc/types" +) + +// initDataDir boots S0 from a fixture into dbRoot and stops it, leaving behind +// initialized shard chaindbs to inspect. +func initDataDir(t *testing.T, fixturePath, dbRoot string) { + t.Helper() + cfg, err := config.LoadClusterConfig(writeFixtureWithDBRoot(t, fixturePath, dbRoot)) + if err != nil { + t.Fatalf("load config: %v", err) + } + backend, err := bootSlave(cfg, "S0") + if err != nil { + t.Fatalf("bootSlave: %v", err) + } + if err := backend.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } +} + +// TODO(#1): add a case where the rule set changes without changing block 0, once +// the real chain executes above genesis. +// TestRunGenesisMismatchExitsLoudly initializes a datadir from the mainnet +// config, then reruns the slave against the same datadir with the devnet +// config: the run must exit 1 and say which genesis is stored, which one the +// config derives, and which db holds the stored one. +func TestRunGenesisMismatchExitsLoudly(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + devnetCfg := writeFixtureWithDBRoot(t, fixtures[1].path, dbRoot) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, reexec.Self(), "--cluster_config", devnetCfg, "--node_id", "S0") + cmd.Args[0] = "slave-test" + out, err := cmd.CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 { + t.Fatalf("slave exited with %v, want exit 1; output:\n%s", err, out) + } + for _, want := range []string{ + "stored genesis 0x", + "does not match config genesis 0x", + "cluster config changed since initialization", + filepath.Join(dbRoot, "shard-0x00000001"), + } { + if !strings.Contains(string(out), want) { + t.Errorf("mismatch report missing %q:\n%s", want, out) + } + } +} + +// TestInspectDataDir pins the report of a datadir initialized from mainnet +// against qkc/testdata/minor_genesis_golden.json: what inspect prints for chain +// 0's shard is pyquarkchain's own create_minor_block() output, not a value +// derived a second way. +func TestInspectDataDir(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + + var buf bytes.Buffer + if err := inspectDataDir(&buf, dbRoot); err != nil { + t.Fatalf("inspectDataDir: %v\noutput:\n%s", err, buf.String()) + } + out := buf.String() + for _, want := range []string{ + "shard 0x00000001 (", + "shard 0x00040001 (", + "genesis block: 0x04493a3c06261af970ca4fc33caa585fbcef11cdb73bb1e3be2a9f6b828a7a0f", + "height: 0", + "state root: 0x699737e3597ea304b7d2e2f4ecbf8ab6348688287c59cec8599cf7a4f7c82153", + "coinbase: 0x000000000000000000000000000000000000000000000001", + "coinbase amount: token 35760 = 3250000000000000000", + "evm_gas_limit: 12000000", + "evm_xshard_gas_limit: 6000000", + "hash_prev_root_block: 0x4036783e441eb5057bf2be96bf1fd4585ac49824de15c0d92a4c14a97886ca51", + "xshard cursor: root=0 minor=0 deposit=0", + // The rule set is stored apart from the block: BASE_ETH_CHAIN_ID + chain 0 + 1, + // and the Petersburg-only schedule every QKC shard fork sits at block 0 of. + "chain id: 100001", + "fork schedule: byzantium=0 constantinople=0 eip150=0 eip155=0 eip158=0 homestead=0 petersburg=0", + "head block: none recorded", + "2 shard(s) inspected, 0 failed", + } { + if !strings.Contains(out, want) { + t.Errorf("inspect output missing %q:\n%s", want, out) + } + } +} + +// rewriteGenesisBlock reopens an initialized shard chaindb writable and replaces its +// stored genesis block, standing in for a database that was corrupted underneath the +// slave. +func rewriteGenesisBlock(t *testing.T, dbPath string, mutate func(*types.MinorBlock)) { + t.Helper() + kv, err := pebble.New(dbPath, 16, 16, "qkc/test/", false) + if err != nil { + t.Fatalf("open %s: %v", dbPath, err) + } + defer kv.Close() + block, err := shard.ReadGenesisBlock(kv) + if err != nil || block == nil { + t.Fatalf("read genesis block: %v (block %v)", err, block) + } + mutate(block) + if err := shard.WriteGenesisBlock(kv, block); err != nil { + t.Fatalf("write genesis block: %v", err) + } +} + +// dropChainConfig deletes the rule set an initialized shard chaindb stores under its +// genesis hash, standing in for a datadir initialized before the rule set was written. +func dropChainConfig(t *testing.T, dbPath string) { + t.Helper() + kv, err := pebble.New(dbPath, 16, 16, "qkc/test/", false) + if err != nil { + t.Fatalf("open %s: %v", dbPath, err) + } + defer kv.Close() + block, err := shard.ReadGenesisBlock(kv) + if err != nil || block == nil { + t.Fatalf("read genesis block: %v (block %v)", err, block) + } + if err := kv.Delete(append(bytes.Clone(configPrefix), block.Hash().Bytes()...)); err != nil { + t.Fatalf("delete chain config: %v", err) + } +} + +// TestInspectReportsMissingRuleSet covers the one anomaly the boot path treats as +// recoverable: a genesis that was stored before its rule set was. The slave answers it +// by warning and writing one, so inspect reports it rather than failing — but it does +// report it, instead of leaving the shard looking complete. +func TestInspectReportsMissingRuleSet(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + dropChainConfig(t, filepath.Join(dbRoot, "shard-0x00000001")) + + var buf bytes.Buffer + if err := inspectDataDir(&buf, dbRoot); err != nil { + t.Fatalf("inspectDataDir: %v\noutput:\n%s", err, buf.String()) + } + out := buf.String() + for _, want := range []string{ + "rule set: none stored", + "chain id: 100005", // shard 0x00040001, untouched, still reports its rule set + "2 shard(s) inspected, 0 failed", + } { + if !strings.Contains(out, want) { + t.Errorf("inspect output missing %q:\n%s", want, out) + } + } +} + +// TestInspectRejectsInconsistentGenesis is the reason inspect validates before it +// prints. A minor block's hash is its header's hash alone, so a database whose meta +// was replaced still holds the original, authentic-looking block hash — and inspect, +// being config-free, has no config-derived encoding to compare against. Each mutation +// here leaves the header untouched, so only recomputing what the header commits to +// can catch it. +func TestInspectRejectsInconsistentGenesis(t *testing.T) { + for _, tc := range []struct { + name string + want string + mutate func(*types.MinorBlock) + }{ + { + name: "substituted state root", + want: "stored genesis meta hashes to", + mutate: func(b *types.MinorBlock) { b.Meta.Root = common.HexToHash("0xdeadbeef") }, + }, + { + name: "rewound xshard cursor", + want: "stored genesis meta hashes to", + mutate: func(b *types.MinorBlock) { b.Meta.XShardTxCursor.MinorBlockIndex = 7 }, + }, + { + name: "transactions in the genesis body", + want: "the genesis body is empty", + mutate: func(b *types.MinorBlock) { b.TrackingData = []byte{0x01} }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + dbPath := filepath.Join(dbRoot, "shard-0x00000001") + rewriteGenesisBlock(t, dbPath, tc.mutate) + + var buf bytes.Buffer + err := inspectDataDir(&buf, dbRoot) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("inspectDataDir err = %v, want %q", err, tc.want) + } + if !strings.Contains(err.Error(), "corrupt chaindb") { + t.Errorf("error does not name the cause: %v", err) + } + out := buf.String() + // No field of the mutated block may be presented as that shard's genesis: + // its report opens with the error line, not with the "shard 0x… (path):" + // header that introduces a field block. The healthy shard still prints. + if strings.Contains(out, "shard 0x00000001 ("+dbPath) { + t.Errorf("printed fields of a block that does not hold together:\n%s", out) + } + for _, want := range []string{ + "shard 0x00000001: ", + "shard 0x00040001 (", + "2 shard(s) inspected, 1 failed", + } { + if !strings.Contains(out, want) { + t.Errorf("inspect output missing %q:\n%s", want, out) + } + } + }) + } +} + +// TestInspectRejectsHeadWithoutGenesis plants a head pointer in a chaindb that has no +// genesis block. The slave writes the genesis block last, so a missing block means an +// interrupted bootstrap — but only on a database holding nothing else. Reporting this +// one as safely re-initializable would be wrong twice over: the state is unreachable +// for this lifecycle, and the directory may not be a shard chaindb at all. +func TestInspectRejectsHeadWithoutGenesis(t *testing.T) { + dbRoot := t.TempDir() + dbPath := filepath.Join(dbRoot, "shard-0x00000001") + kv, err := pebble.New(dbPath, 16, 16, "qkc/test/", false) + if err != nil { + t.Fatal(err) + } + rawdb.WriteHeadBlockHash(kv, common.HexToHash("0xabc")) + if err := kv.Close(); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + err = inspectDataDir(&buf, dbRoot) + if err == nil || !strings.Contains(err.Error(), "with no genesis block under it") { + t.Fatalf("inspectDataDir err = %v, want head-without-genesis failure", err) + } + if strings.Contains(buf.String(), "next boot re-initializes") { + t.Errorf("claimed a safe re-initialization for an impossible state:\n%s", buf.String()) + } +} + +// TestInspectReportsWriteFailure pins that a report which never reached the reader +// fails the command instead of being summarized as a success. +func TestInspectReportsWriteFailure(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + if err := inspectDataDir(errWriter{}, dbRoot); !errors.Is(err, io.ErrClosedPipe) { + t.Errorf("inspectDataDir err = %v, want the write error", err) + } +} + +type errWriter struct{} + +func (errWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +// TestInspectReportsMisplacedChaindb renames an initialized shard's chaindb to +// another shard's directory name. The stored block names its own shard through +// its branch, so inspect must report the directory as misplaced rather than +// present the block as that shard's genesis. +func TestInspectReportsMisplacedChaindb(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + if err := os.Rename(filepath.Join(dbRoot, "shard-0x00040001"), filepath.Join(dbRoot, "shard-0x00080001")); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + err := inspectDataDir(&buf, dbRoot) + if err == nil || !strings.Contains(err.Error(), "misplaced chaindb") { + t.Fatalf("inspectDataDir err = %v, want misplaced chaindb", err) + } + for _, want := range []string{ + "stored genesis belongs to shard 0x00040001", + "the directory name says 0x00080001", + "2 shard(s) inspected, 1 failed", + } { + if !strings.Contains(buf.String(), want) { + t.Errorf("inspect output missing %q:\n%s", want, buf.String()) + } + } +} + +// TestInspectReportsBrokenShardAndContinues plants a shard-named directory that +// is not a chaindb next to two healthy shards: the broken one is reported, the +// healthy ones still print, and the joined error is non-nil. +func TestInspectReportsBrokenShardAndContinues(t *testing.T) { + dbRoot := t.TempDir() + initDataDir(t, fixtures[0].path, dbRoot) + if err := os.Mkdir(filepath.Join(dbRoot, "shard-0xdeadbeef"), 0o755); err != nil { + t.Fatal(err) + } + // Neither a stray file with a shard name nor an unrelated directory is a + // shard chaindb; both must be skipped silently. + if err := os.WriteFile(filepath.Join(dbRoot, "shard-0x000000ff"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dbRoot, "not-a-shard"), 0o755); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + err := inspectDataDir(&buf, dbRoot) + if err == nil || !strings.Contains(err.Error(), "0xdeadbeef") { + t.Fatalf("inspectDataDir err = %v, want failure naming 0xdeadbeef", err) + } + out := buf.String() + for _, want := range []string{ + "shard 0x00000001 (", + "shard 0x00040001 (", + "shard 0xdeadbeef:", + "3 shard(s) inspected, 1 failed", + } { + if !strings.Contains(out, want) { + t.Errorf("inspect output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "0x000000ff") { + t.Errorf("stray file inspected as a shard:\n%s", out) + } +} + +func TestInspectRejectsUnusableDatadir(t *testing.T) { + var buf bytes.Buffer + if err := inspectDataDir(&buf, t.TempDir()); err == nil || !strings.Contains(err.Error(), "no shard chaindbs") { + t.Errorf("empty datadir err = %v, want no-shard-chaindbs error", err) + } + if err := inspectDataDir(&buf, filepath.Join(t.TempDir(), "missing")); err == nil { + t.Error("missing datadir did not error") + } +} diff --git a/cmd/slave/inspectcmd.go b/cmd/slave/inspectcmd.go new file mode 100644 index 000000000000..bda3139e4219 --- /dev/null +++ b/cmd/slave/inspectcmd.go @@ -0,0 +1,389 @@ +// Copyright 2026-2027, QuarkChain. + +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/ethdb/pebble" + "github.com/ethereum/go-ethereum/params" + qkcCommon "github.com/ethereum/go-ethereum/qkc/common" + "github.com/ethereum/go-ethereum/qkc/serialize" + "github.com/ethereum/go-ethereum/qkc/shard" + "github.com/ethereum/go-ethereum/qkc/types" + "github.com/urfave/cli/v2" +) + +var datadirFlag = &cli.StringFlag{ + Name: "datadir", + Usage: "Directory holding the per-shard chaindbs (the cluster config's DB_PATH_ROOT)", +} + +var inspectCommand = &cli.Command{ + Name: "inspect", + Usage: "Print the stored genesis block and head of every shard chaindb under a datadir", + ArgsUsage: " ", + Flags: []cli.Flag{ + datadirFlag, + }, + Description: `Read-only and config-free: scans --datadir for shard chaindb directories +(shard-0x{full_shard_id}/), opens each in read-only mode, and prints the stored +minor genesis block and chain head once the block is shown to hold together. A +shard that cannot be opened, read, or validated is reported inline without +aborting the remaining shards; the exit status is non-zero if any shard failed. A +running slave holds its chaindb locks, so inspect a stopped node.`, + Action: runInspect, +} + +func runInspect(ctx *cli.Context) error { + datadir := ctx.String(datadirFlag.Name) + if datadir == "" { + return fmt.Errorf("--%s is required", datadirFlag.Name) + } + return inspectDataDir(os.Stdout, datadir) +} + +// inspectDataDir prints one block per shard chaindb found under datadir, in +// directory-name order. A shard that fails to open, read or validate is reported +// inline and folded into the returned error; the other shards still print. +func inspectDataDir(out io.Writer, datadir string) error { + entries, err := os.ReadDir(datadir) + if err != nil { + return err + } + report := &reportWriter{w: out} + var ( + inspected int + errs []error + ) + for _, entry := range entries { + id, ok := shard.ParseDBDirName(entry.Name()) + if !ok || !entry.IsDir() { + continue + } + inspected++ + if err := inspectShardDB(report, filepath.Join(datadir, entry.Name()), id); err != nil { + fmt.Fprintf(report, "shard 0x%08x: %v\n", id, err) + errs = append(errs, fmt.Errorf("shard 0x%08x: %w", id, err)) + } + // A report that did not reach the reader is not a report: stop rather than + // walk the remaining shards and summarize findings nobody can see. + if err := report.err; err != nil { + return fmt.Errorf("write report: %w", err) + } + } + if inspected == 0 { + return fmt.Errorf("no shard chaindbs (shard-0x{full_shard_id}/) under %s", datadir) + } + fmt.Fprintf(report, "%d shard(s) inspected, %d failed\n", inspected, len(errs)) + if err := report.err; err != nil { + return fmt.Errorf("write report: %w", err) + } + return errors.Join(errs...) +} + +// reportWriter latches the first write error so the printers below stay free of +// error plumbing while a truncated report still fails the command. Nothing in +// this file writes to the caller's writer directly. +type reportWriter struct { + w io.Writer + err error +} + +func (r *reportWriter) Write(p []byte) (int, error) { + if r.err != nil { + return 0, r.err + } + n, err := r.w.Write(p) + if err != nil { + r.err = err + } + return n, err +} + +// Modest fixed sizing for a short-lived read-only open. +const ( + inspectDBCacheMB = 16 + inspectDBHandles = 16 +) + +// inspectShardDB opens one shard chaindb read-only and prints its stored genesis +// block and chain head. An absent block is not an error on its own: it is the +// expected state of a chaindb whose bootstrap was interrupted before the block was +// committed (the next boot re-runs the fresh path). +// +// Nothing is printed until everything the report would assert has been checked, so +// a database that fails is described by its error alone. Printing a block's fields +// first and reporting the trouble after would put a state root nobody should trust +// under a block hash that looks authentic. +func inspectShardDB(out io.Writer, path string, id uint32) error { + kv, err := pebble.New(path, inspectDBCacheMB, inspectDBHandles, fmt.Sprintf("qkc/inspect/0x%08x/", id), true) + if err != nil { + return fmt.Errorf("open chaindb %s: %w", path, err) + } + defer kv.Close() + + // TODO(#1): the genesis block lives under one scaffolding key until the real + // shard chain owns block storage; read it through the chain's canonical-hash + // accessors then, and report the real head alongside it. + block, err := shard.ReadGenesisBlock(kv) + if err != nil { + return fmt.Errorf("read genesis block (db %s): %w", path, err) + } + head, err := readHeadBlockHash(kv) + if err != nil { + return fmt.Errorf("read head block hash (db %s): %w", path, err) + } + + if block == nil { + // The slave writes the genesis block last, once the chain stands, so a missing + // block means an interrupted bootstrap — but only on a database that holds + // nothing else. A head pointer with no genesis under it is not a state this + // lifecycle can produce; it is a database that lost its genesis, or one that + // was never this shard's. + // + // TODO(#1): the real chain writes block 0 and its head pointer together, ahead + // of the scaffolding key, which makes this pairing a legitimate interrupted + // state — this check moves into the chain's own reopen path then. + if head != (common.Hash{}) { + return fmt.Errorf("head block %s is recorded with no genesis block under it (db %s) — not an interrupted bootstrap; corrupt or foreign chaindb", head, path) + } + } else { + if err := checkGenesisSelfConsistent(block); err != nil { + return fmt.Errorf("%w (db %s) — corrupt chaindb", err, path) + } + // The stored block names its own shard through its branch; a chaindb holding + // another shard's genesis is a misplaced directory, not a config change. + if storedID := block.Header.Branch.GetFullShardID(); storedID != id { + return fmt.Errorf("stored genesis belongs to shard 0x%08x but the directory name says 0x%08x (db %s) — misplaced chaindb", storedID, id, path) + } + } + + // The rule set is keyed by the genesis hash, so there is one to look for only once + // the block it hangs off has been read and vouched for. + var rules storedRules + if block != nil { + if rules, err = readChainConfig(kv, block.Hash()); err != nil { + return fmt.Errorf("read chain config (db %s): %w", path, err) + } + } + + fmt.Fprintf(out, "shard 0x%08x (%s):\n", id, path) + if block == nil { + fmt.Fprintln(out, " genesis block: none (bootstrap never completed; next boot re-initializes)") + } else { + printGenesisBlock(out, block) + printChainConfig(out, rules) + } + if head != (common.Hash{}) { + fmt.Fprintf(out, " head block: %s\n", head) + } else { + fmt.Fprintln(out, " head block: none recorded (stub chain persists no head)") + } + return nil +} + +// checkGenesisSelfConsistent verifies what a reader without the cluster config can: +// that the stored block holds together. Whether it is the genesis a given config +// derives is a different question, and the boot path answers it by comparing the +// stored encoding (shard.ReconcileGenesisBlock); these invariants hold for every QKC +// minor genesis block regardless of config. +// +// The meta hash is the check that earns its place. A minor block's hash is its +// header's hash alone, so it says nothing about the meta hanging off that header: a +// database whose meta was replaced still reports the original block hash next to the +// substituted state root, and inspect has no config-derived encoding to compare +// against. Recomputing the meta's hash is what closes that gap. +func checkGenesisSelfConsistent(block *types.MinorBlock) error { + // Every field read below hangs off one of these two. A decode that succeeded + // should have allocated both, but this is the one place that answers to bytes + // nobody derived, so it does not assume it. + if block.Header == nil || block.Meta == nil { + return errors.New("stored genesis has no header or no meta") + } + if block.Header.Number != 0 { + return fmt.Errorf("stored genesis is block %d, not block 0", block.Header.Number) + } + if got, want := block.Meta.Hash(), block.Header.MetaHash; got != want { + return fmt.Errorf("stored genesis meta hashes to %s but its header commits to %s", got, want) + } + // The meta's tx merkle root comes from GENESIS.HASH_MERKLE_ROOT rather than from + // the body, so it cannot be recomputed here; that the body is empty is the part + // this can check, and pyquarkchain seals no transactions into block 0. + if len(block.Transactions) != 0 || len(block.TrackingData) != 0 { + return fmt.Errorf("stored genesis carries %d transaction(s) and %d tracking byte(s), but the genesis body is empty", + len(block.Transactions), len(block.TrackingData)) + } + return nil +} + +// storedRules is the EVM rule set found under a shard's genesis hash: the parsed +// config, and the encoding it was parsed from. Both are kept because the schedule is +// rendered from the encoding rather than from a fixed list of fork fields. +type storedRules struct { + config *params.ChainConfig + raw []byte +} + +// configPrefix mirrors geth's unexported rawdb.configPrefix (core/rawdb/schema.go). +// +// rawdb.ReadChainConfig answers a failed read and a malformed encoding alike with a +// nil config, which inspect would print as "none stored" — the same false claim about +// a database it could not read that ReadHeadBlockHash would make about the head. +var configPrefix = []byte("ethereum-config-") + +// readChainConfig returns the rule set stored under genesisHash, or a zero +// storedRules if none is stored. +func readChainConfig(db ethdb.KeyValueReader, genesisHash common.Hash) (storedRules, error) { + key := append(bytes.Clone(configPrefix), genesisHash.Bytes()...) + has, err := db.Has(key) + if err != nil || !has { + return storedRules{}, err + } + data, err := db.Get(key) + if err != nil { + return storedRules{}, err + } + config := new(params.ChainConfig) + if err := json.Unmarshal(data, config); err != nil { + return storedRules{}, fmt.Errorf("decode chain config: %w", err) + } + return storedRules{config: config, raw: data}, nil +} + +// printChainConfig reports the rule set a reopen is checked against. It is stored +// apart from the genesis block, so it can be missing on a datadir initialized before +// it was written — recoverable, and the boot path answers it by warning and writing +// one, but worth saying out loud here rather than leaving to a log line. +func printChainConfig(out io.Writer, rules storedRules) { + if rules.config == nil { + fmt.Fprintln(out, " rule set: none stored (recoverable; the next boot warns and writes it)") + return + } + fmt.Fprintf(out, " chain id: %s\n", rules.config.ChainID) + fmt.Fprintf(out, " fork schedule: %s\n", formatForkSchedule(rules.raw)) +} + +// formatForkSchedule renders the fork activations out of the stored encoding rather +// than a fixed field list, so a rule set carrying a fork this build does not know +// about is still shown instead of silently dropped. Ordering is by activation, which +// is how a schedule is read; every QKC shard fork sits at block 0. +func formatForkSchedule(raw []byte) string { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return "unreadable" + } + type fork struct { + name string + at uint64 + } + var forks []fork + for name, value := range fields { + if name == "chainId" { + continue + } + var at uint64 + // Anything that is not a plain number is not a scheduled fork: an engine + // section, a boolean switch, a total difficulty. + if err := json.Unmarshal(value, &at); err != nil { + continue + } + forks = append(forks, fork{strings.TrimSuffix(name, "Block"), at}) + } + if len(forks) == 0 { + return "none scheduled" + } + sort.Slice(forks, func(i, j int) bool { + if forks[i].at != forks[j].at { + return forks[i].at < forks[j].at + } + return forks[i].name < forks[j].name + }) + parts := make([]string, 0, len(forks)) + for _, f := range forks { + parts = append(parts, fmt.Sprintf("%s=%d", f.name, f.at)) + } + return strings.Join(parts, " ") +} + +// headBlockKey mirrors geth's unexported rawdb.headBlockKey (core/rawdb/schema.go). +// +// rawdb.ReadHeadBlockHash discards the error from its Get and answers a failed read +// with the zero hash, which inspect would print as "none recorded" — a claim about a +// database it could not actually read. A short value would likewise be padded into a +// plausible-looking hash. This key is part of the on-disk schema, so reproducing it +// is stable in the way that accessor's error handling is not. +var headBlockKey = []byte("LastBlock") + +// readHeadBlockHash returns the recorded head block hash, the zero hash if none is +// recorded, and an error if the database cannot answer. +func readHeadBlockHash(db ethdb.KeyValueReader) (common.Hash, error) { + has, err := db.Has(headBlockKey) + if err != nil { + return common.Hash{}, err + } + if !has { + return common.Hash{}, nil + } + data, err := db.Get(headBlockKey) + if err != nil { + return common.Hash{}, err + } + if len(data) != common.HashLength { + return common.Hash{}, fmt.Errorf("head block hash is %d bytes, want %d", len(data), common.HashLength) + } + return common.BytesToHash(data), nil +} + +// printGenesisBlock prints the identity a reopened datadir is reconciled against: +// the block hash, the state root its meta commits to, and the root-chain linkage +// and cross-shard cursor the block was derived from. +func printGenesisBlock(out io.Writer, block *types.MinorBlock) { + h, m := block.Header, block.Meta + fmt.Fprintf(out, " genesis block: %s\n", block.Hash()) + fmt.Fprintf(out, " height: %d\n", h.Number) + fmt.Fprintf(out, " state root: %s\n", m.Root) + fmt.Fprintf(out, " coinbase: %s\n", h.Coinbase.ToHex()) + fmt.Fprintf(out, " coinbase amount: %s\n", formatTokenBalances(h.CoinbaseAmount)) + fmt.Fprintf(out, " evm_gas_limit: %s\n", formatUint256(h.GasLimit)) + fmt.Fprintf(out, " evm_xshard_gas_limit: %s\n", formatUint256(m.XShardGasLimit)) + fmt.Fprintf(out, " hash_prev_root_block: %s\n", h.PrevRootBlockHash) + fmt.Fprintf(out, " xshard cursor: root=%d minor=%d deposit=%d\n", + m.XShardTxCursor.RootBlockHeight, m.XShardTxCursor.MinorBlockIndex, m.XShardTxCursor.XShardDepositIndex) +} + +// formatTokenBalances renders a coinbase amount map in ascending token-id order, +// so two shards' reports are comparable line by line. +func formatTokenBalances(b *qkcCommon.TokenBalances) string { + if b == nil || b.Len() == 0 { + return "none" + } + balances := b.GetBalanceMap() + ids := make([]uint64, 0, len(balances)) + for id := range balances { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + parts := make([]string, 0, len(ids)) + for _, id := range ids { + parts = append(parts, fmt.Sprintf("token %d = %s", id, balances[id])) + } + return strings.Join(parts, ", ") +} + +func formatUint256(v *serialize.Uint256) string { + if v == nil || v.Value == nil { + return "unset" + } + return v.Value.String() +} diff --git a/cmd/slave/main.go b/cmd/slave/main.go index c3af9d4a3d5c..04a7cee8af22 100644 --- a/cmd/slave/main.go +++ b/cmd/slave/main.go @@ -85,6 +85,7 @@ func newApp() *cli.App { app.Commands = []*cli.Command{ configCommand, genesisCommand, + inspectCommand, } return app } diff --git a/qkc/slave/main_test.go b/qkc/slave/main_test.go new file mode 100644 index 000000000000..8b86b9935476 --- /dev/null +++ b/qkc/slave/main_test.go @@ -0,0 +1,15 @@ +// Copyright 2026-2027, QuarkChain. + +package slave + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + // When the real chain is injected into these smoke tests, keep the allowlist + // empty for slave-owned goroutines and fix their blocking Stop path instead. + goleak.VerifyTestMain(m) +}