diff --git a/sei-db/tools/cmd/seidb/main.go b/sei-db/tools/cmd/seidb/main.go index 87b2cf971c..fc863c1123 100644 --- a/sei-db/tools/cmd/seidb/main.go +++ b/sei-db/tools/cmd/seidb/main.go @@ -31,7 +31,8 @@ func main() { operations.ReplayChangelogCmd(), operations.TraceProfileReportCmd(), operations.MigrateEvmStatusCmd(), - operations.EvmLogicalDigestCmd()) + operations.EvmLogicalDigestCmd(), + operations.HashLogCmd()) if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) diff --git a/sei-db/tools/cmd/seidb/operations/hashlog.go b/sei-db/tools/cmd/seidb/operations/hashlog.go new file mode 100644 index 0000000000..881d351119 --- /dev/null +++ b/sei-db/tools/cmd/seidb/operations/hashlog.go @@ -0,0 +1,392 @@ +package operations + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "sort" + "strconv" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" + "github.com/spf13/cobra" +) + +// HashLogCmd is the parent "hashlog" command. It groups read-only tools for inspecting the on-disk hash log +// archives produced by the hashlogger, wrapping the reader utilities in sc/hashlog so an operator can pull a +// single block's hashes or diff two archives without writing Go. +func HashLogCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "hashlog", + Short: "Inspect hash log archives produced by the hashlogger", + Long: "Read-only tools for hash log archives. Use 'get-block' to print every hash recorded for a " + + "single block, or 'compare' to find blocks whose hashes differ between two archives.", + } + cmd.AddCommand(hashLogGetBlockCmd(), hashLogCompareCmd()) + return cmd +} + +func hashLogGetBlockCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get-block ", + Short: "Print every hash recorded for a single block in a hash log archive", + Args: cobra.ExactArgs(2), + Run: executeHashLogGetBlock, + } + cmd.PersistentFlags().Bool("json", false, "Emit JSON instead of human-readable text") + return cmd +} + +func executeHashLogGetBlock(cmd *cobra.Command, args []string) { + archive := args[0] + block, err := strconv.ParseUint(args[1], 10, 64) + if err != nil { + panic(fmt.Errorf("invalid block number %q: %w", args[1], err)) + } + asJSON, _ := cmd.Flags().GetBool("json") + + logs, err := hashlog.ReadHashForBlock(archive, block) + if err != nil { + panic(fmt.Errorf("read hash for block %d: %w", block, err)) + } + + if err := renderGetBlock(cmd.OutOrStdout(), block, logs, asJSON); err != nil { + panic(err) + } +} + +func hashLogCompareCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "compare ", + Short: "Compare two hash log archives and report blocks whose hashes differ", + Args: cobra.ExactArgs(2), + Run: executeHashLogCompare, + } + cmd.PersistentFlags().Uint64("low", 0, "Lowest block to compare (inclusive); requires --high") + cmd.PersistentFlags().Uint64("high", 0, "Highest block to compare (inclusive); requires --low") + cmd.PersistentFlags().Int("max-diffs", -1, "Maximum number of differing blocks to report, or -1 for all") + cmd.PersistentFlags().Bool("full", false, + "Show every column for each differing block (default shows only the columns that differ)") + cmd.PersistentFlags().Bool("json", false, "Emit JSON instead of human-readable text") + return cmd +} + +func executeHashLogCompare(cmd *cobra.Command, args []string) { + archiveA := args[0] + archiveB := args[1] + maxDiffs, _ := cmd.Flags().GetInt("max-diffs") + full, _ := cmd.Flags().GetBool("full") + asJSON, _ := cmd.Flags().GetBool("json") + + result := compareResult{archiveA: archiveA, archiveB: archiveB, maxDiffs: maxDiffs} + + // --low/--high are optional, but must be supplied together: a one-sided range is almost certainly a mistake. + lowSet := cmd.Flags().Changed("low") + highSet := cmd.Flags().Changed("high") + var diffs []*hashlog.HashLogPair + var err error + if lowSet || highSet { + if !lowSet || !highSet { + panic("Must provide both --low and --high to compare a block range") + } + result.ranged = true + result.low, _ = cmd.Flags().GetUint64("low") + result.high, _ = cmd.Flags().GetUint64("high") + diffs, err = hashlog.CompareHashesInRange(archiveA, archiveB, result.low, result.high, maxDiffs) + } else { + diffs, err = hashlog.CompareHashes(archiveA, archiveB, maxDiffs) + } + if err != nil { + panic(fmt.Errorf("compare hash archives: %w", err)) + } + result.diffs = diffs + + if err := renderCompare(cmd.OutOrStdout(), result, asJSON, full); err != nil { + panic(err) + } +} + +// compareResult bundles everything renderCompare needs: the inputs that shape the human-readable header plus the +// diffs themselves. +type compareResult struct { + archiveA string + archiveB string + ranged bool + low uint64 + high uint64 + maxDiffs int + diffs []*hashlog.HashLogPair +} + +// errWriter funnels a sequence of formatted writes through a single retained error, so the many small writes in +// the text renderers below need only one error check at the end rather than one per line. +type errWriter struct { + w io.Writer + err error +} + +func (e *errWriter) printf(format string, args ...any) { + if e.err != nil { + return + } + _, e.err = fmt.Fprintf(e.w, format, args...) +} + +// renderGetBlock writes the hashes recorded for a single block, either as JSON or human-readable text. +func renderGetBlock(w io.Writer, block uint64, logs []*hashlog.HashLog, asJSON bool) error { + if asJSON { + return encodeJSON(w, toHashLogJSONSlice(logs, nil)) + } + + ew := &errWriter{w: w} + if len(logs) == 0 { + ew.printf("No records for block %d.\n", block) + return ew.err + } + // More than one record means the block was executed multiple times (e.g. the chain rolled back and replayed + // it); each execution's hashes are reported separately. + if len(logs) > 1 { + ew.printf("Block %d has %d records (the block was executed more than once, e.g. after a rollback):\n", + block, len(logs)) + } + for i, log := range logs { + if len(logs) > 1 { + ew.printf("record %d (block %d):\n", i+1, log.BlockNumber) + } else { + ew.printf("block %d:\n", log.BlockNumber) + } + writeHashLogText(ew, log, " ") + } + return ew.err +} + +// renderCompare writes the result of comparing two archives, either as JSON or human-readable text. The default +// is compact (only the columns that differ); full includes every column for both sides. This applies to both +// text and JSON output. +func renderCompare(w io.Writer, result compareResult, asJSON bool, full bool) error { + if asJSON { + out := make([]hashLogPairJSON, 0, len(result.diffs)) + for _, pair := range result.diffs { + out = append(out, toHashLogPairJSON(pair, full)) + } + return encodeJSON(w, out) + } + + ew := &errWriter{w: w} + ew.printf("Comparing archive A (%s) against archive B (%s)\n", result.archiveA, result.archiveB) + if result.ranged { + ew.printf("Restricted to blocks [%d, %d]\n", result.low, result.high) + } + if len(result.diffs) == 0 { + ew.printf("Archives are identical over the compared range.\n") + return ew.err + } + for _, pair := range result.diffs { + if full { + writeFullPair(ew, pair) + } else { + writeCompactPair(ew, pair) + } + } + ew.printf("%d differing block(s) reported.\n", len(result.diffs)) + // CompareHashes stops as soon as it has collected maxDiffs diffs, so an exactly-full result may be a + // truncation rather than the true total. Warn so the operator knows to widen the cap if needed. + if result.maxDiffs >= 0 && len(result.diffs) == result.maxDiffs { + ew.printf("Output truncated at --max-diffs=%d; there may be more differing blocks.\n", result.maxDiffs) + } + return ew.err +} + +// writeFullPair renders both sides of a differing block in full: every column of every record. This is the +// behaviour behind --full, and the only sensible rendering when record counts differ (a rollback), since there +// is no single pair of records to diff column-by-column. +func writeFullPair(ew *errWriter, pair *hashlog.HashLogPair) { + ew.printf("block %d differs:\n", pairBlock(pair)) + ew.printf(" archive A:\n") + writeHashLogSet(ew, pair.HashesFromA, " ") + ew.printf(" archive B:\n") + writeHashLogSet(ew, pair.HashesFromB, " ") +} + +// writeCompactPair renders only the columns that differ between the two sides. Column-level diffing is only +// well-defined when each side holds exactly one record; when the record counts differ (a rollback re-executed +// the block a different number of times) there is no record pairing to diff, so we report the counts and defer +// to --full for the details. +func writeCompactPair(ew *errWriter, pair *hashlog.HashLogPair) { + block := pairBlock(pair) + if len(pair.HashesFromA) != 1 || len(pair.HashesFromB) != 1 { + ew.printf("block %d differs: %d record(s) in A vs %d in B (use --full to see them)\n", + block, len(pair.HashesFromA), len(pair.HashesFromB)) + return + } + a := pair.HashesFromA[0].Hashes + b := pair.HashesFromB[0].Hashes + columns := unionKeys(a, b) + differing := make([]string, 0, len(columns)) + for _, column := range columns { + if !bytes.Equal(a[column], b[column]) { + differing = append(differing, column) + } + } + ew.printf("block %d differs (%d of %d columns):\n", block, len(differing), len(columns)) + for _, column := range differing { + ew.printf(" %s:\n", column) + ew.printf(" A: %s\n", hexOrNone(a[column])) + ew.printf(" B: %s\n", hexOrNone(b[column])) + } +} + +// writeHashLogText renders one record's version (when present) and its hashes, sorted by hash type for stable +// output. A nil hash (the type was registered but not recorded for this block) prints as "". +func writeHashLogText(ew *errWriter, log *hashlog.HashLog, indent string) { + if log.Version != "" { + ew.printf("%sversion: %s\n", indent, log.Version) + } + for _, hashType := range sortedKeys(log.Hashes) { + ew.printf("%s%s: %s\n", indent, hashType, hexOrNone(log.Hashes[hashType])) + } +} + +// hexOrNone hex-encodes a hash, or returns "" for a nil hash (the type was registered but not recorded). +func hexOrNone(hash []byte) string { + if hash == nil { + return "" + } + return hex.EncodeToString(hash) +} + +// sortedKeys returns the keys of a hash map in sorted order, for stable output. +func sortedKeys(hashes map[string][]byte) []string { + keys := make([]string, 0, len(hashes)) + for key := range hashes { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// unionKeys returns the sorted union of the keys of two hash maps. +func unionKeys(a map[string][]byte, b map[string][]byte) []string { + set := make(map[string]struct{}, len(a)+len(b)) + for key := range a { + set[key] = struct{}{} + } + for key := range b { + set[key] = struct{}{} + } + keys := make([]string, 0, len(set)) + for key := range set { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// writeHashLogSet renders one side of a comparison, which may hold several records (rollback) or none at all (the +// block is present only in the other archive). +func writeHashLogSet(ew *errWriter, logs []*hashlog.HashLog, indent string) { + if len(logs) == 0 { + ew.printf("%s\n", indent) + return + } + for i, log := range logs { + if len(logs) > 1 { + ew.printf("%srecord %d:\n", indent, i+1) + writeHashLogText(ew, log, indent+" ") + continue + } + writeHashLogText(ew, log, indent) + } +} + +// pairBlock returns the block number a diff pair refers to. A pair always has at least one populated side, so we +// take the block from whichever side has records. +func pairBlock(pair *hashlog.HashLogPair) uint64 { + if len(pair.HashesFromA) > 0 { + return pair.HashesFromA[0].BlockNumber + } + if len(pair.HashesFromB) > 0 { + return pair.HashesFromB[0].BlockNumber + } + return 0 +} + +// hashLogJSON is the JSON shape for a single record. Hashes map to a hex string, or null when the hash was not +// recorded for that type. +type hashLogJSON struct { + BlockNumber uint64 `json:"block_number"` + Version string `json:"version,omitempty"` + Hashes map[string]*string `json:"hashes"` +} + +// hashLogPairJSON is the JSON shape for one differing block. +type hashLogPairJSON struct { + Block uint64 `json:"block"` + HashesFromA []hashLogJSON `json:"hashes_from_a"` + HashesFromB []hashLogJSON `json:"hashes_from_b"` +} + +// toHashLogJSON converts a record to its JSON shape. When keep is non-nil, only those columns are emitted (used +// for compact compare output); a nil keep emits every column. +func toHashLogJSON(log *hashlog.HashLog, keep map[string]struct{}) hashLogJSON { + hashes := make(map[string]*string, len(log.Hashes)) + for hashType, hash := range log.Hashes { + if keep != nil { + if _, ok := keep[hashType]; !ok { + continue + } + } + if hash == nil { + hashes[hashType] = nil + continue + } + encoded := hex.EncodeToString(hash) + hashes[hashType] = &encoded + } + return hashLogJSON{BlockNumber: log.BlockNumber, Version: log.Version, Hashes: hashes} +} + +func toHashLogJSONSlice(logs []*hashlog.HashLog, keep map[string]struct{}) []hashLogJSON { + out := make([]hashLogJSON, 0, len(logs)) + for _, log := range logs { + out = append(out, toHashLogJSON(log, keep)) + } + return out +} + +func toHashLogPairJSON(pair *hashlog.HashLogPair, full bool) hashLogPairJSON { + keep := diffColumnSet(pair, full) + return hashLogPairJSON{ + Block: pairBlock(pair), + HashesFromA: toHashLogJSONSlice(pair.HashesFromA, keep), + HashesFromB: toHashLogJSONSlice(pair.HashesFromB, keep), + } +} + +// diffColumnSet returns the set of columns to emit for a compact diff, or nil to emit every column. It returns +// nil (all columns) when full is requested, or when the record counts differ (a rollback) and there is no single +// pair of records to diff column-by-column — matching the text renderer's fallback to the full record set. +func diffColumnSet(pair *hashlog.HashLogPair, full bool) map[string]struct{} { + if full || len(pair.HashesFromA) != 1 || len(pair.HashesFromB) != 1 { + return nil + } + a := pair.HashesFromA[0].Hashes + b := pair.HashesFromB[0].Hashes + keep := make(map[string]struct{}) + for _, column := range unionKeys(a, b) { + if !bytes.Equal(a[column], b[column]) { + keep[column] = struct{}{} + } + } + return keep +} + +func encodeJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return fmt.Errorf("encode json: %w", err) + } + return nil +} diff --git a/sei-db/tools/cmd/seidb/operations/hashlog_test.go b/sei-db/tools/cmd/seidb/operations/hashlog_test.go new file mode 100644 index 0000000000..b9d7eebe66 --- /dev/null +++ b/sei-db/tools/cmd/seidb/operations/hashlog_test.go @@ -0,0 +1,290 @@ +package operations + +import ( + "bytes" + "encoding/json" + "path/filepath" + "sort" + "testing" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" + "github.com/stretchr/testify/require" +) + +func hl(block uint64, version string, hashes map[string][]byte) *hashlog.HashLog { + return &hashlog.HashLog{BlockNumber: block, Version: version, Hashes: hashes} +} + +func TestRenderGetBlockTextSingle(t *testing.T) { + var buf bytes.Buffer + logs := []*hashlog.HashLog{hl(7, "v1", map[string][]byte{"root": {0xab}, "flatKV": {0xcd}})} + require.NoError(t, renderGetBlock(&buf, 7, logs, false)) + + out := buf.String() + require.Contains(t, out, "block 7:") + require.Contains(t, out, "version: v1") + require.Contains(t, out, "flatKV: cd") + require.Contains(t, out, "root: ab") + require.NotContains(t, out, "record 1") +} + +func TestRenderGetBlockTextMultipleRollback(t *testing.T) { + var buf bytes.Buffer + logs := []*hashlog.HashLog{ + hl(5, "v1", map[string][]byte{"root": {0x05}}), + hl(5, "v1", map[string][]byte{"root": {0x99}}), + } + require.NoError(t, renderGetBlock(&buf, 5, logs, false)) + + out := buf.String() + require.Contains(t, out, "Block 5 has 2 records") + require.Contains(t, out, "rollback") + require.Contains(t, out, "record 1 (block 5):") + require.Contains(t, out, "record 2 (block 5):") + require.Contains(t, out, "root: 05") + require.Contains(t, out, "root: 99") +} + +func TestRenderGetBlockTextEmpty(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, renderGetBlock(&buf, 42, nil, false)) + require.Contains(t, buf.String(), "No records for block 42.") +} + +func TestRenderGetBlockTextNilHash(t *testing.T) { + var buf bytes.Buffer + logs := []*hashlog.HashLog{hl(1, "", map[string][]byte{"root": nil})} + require.NoError(t, renderGetBlock(&buf, 1, logs, false)) + + out := buf.String() + require.Contains(t, out, "root: ") + require.NotContains(t, out, "version:") +} + +func TestRenderGetBlockJSON(t *testing.T) { + var buf bytes.Buffer + logs := []*hashlog.HashLog{hl(3, "v1", map[string][]byte{"root": {0x0a}, "flatKV": nil})} + require.NoError(t, renderGetBlock(&buf, 3, logs, true)) + + var got []hashLogJSON + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + require.Len(t, got, 1) + require.Equal(t, uint64(3), got[0].BlockNumber) + require.Equal(t, "v1", got[0].Version) + require.NotNil(t, got[0].Hashes["root"]) + require.Equal(t, "0a", *got[0].Hashes["root"]) + // A nil hash must serialize to JSON null, distinguishable from an absent type. + val, ok := got[0].Hashes["flatKV"] + require.True(t, ok) + require.Nil(t, val) +} + +func TestRenderCompareTextIdentical(t *testing.T) { + var buf bytes.Buffer + result := compareResult{archiveA: "a", archiveB: "b", maxDiffs: -1} + require.NoError(t, renderCompare(&buf, result, false, false)) + + out := buf.String() + require.Contains(t, out, "Comparing archive A (a) against archive B (b)") + require.Contains(t, out, "Archives are identical over the compared range.") +} + +// TestRenderCompareTextCompact covers the default rendering: only the columns that differ are shown. +func TestRenderCompareTextCompact(t *testing.T) { + var buf bytes.Buffer + result := compareResult{ + archiveA: "a", + archiveB: "b", + maxDiffs: -1, + diffs: []*hashlog.HashLogPair{ + { + HashesFromA: []*hashlog.HashLog{hl(7, "v1", map[string][]byte{ + "root": {0x01}, "memIAVL": {0x02}, "app": {0x03}, + })}, + HashesFromB: []*hashlog.HashLog{hl(7, "v1", map[string][]byte{ + "root": {0xff}, "memIAVL": {0x02}, "app": {0x03}, + })}, + }, + }, + } + require.NoError(t, renderCompare(&buf, result, false, false)) + + out := buf.String() + require.Contains(t, out, "block 7 differs (1 of 3 columns):") + require.Contains(t, out, "A: 01") + require.Contains(t, out, "B: ff") + // Unchanged columns must be omitted in compact mode. + require.NotContains(t, out, "memIAVL:") + require.NotContains(t, out, "app:") +} + +// TestRenderCompareTextCompactRollbackFallback covers the compact-mode fallback when record counts differ. +func TestRenderCompareTextCompactRollbackFallback(t *testing.T) { + var buf bytes.Buffer + result := compareResult{ + archiveA: "a", + archiveB: "b", + maxDiffs: -1, + diffs: []*hashlog.HashLogPair{ + { + HashesFromA: []*hashlog.HashLog{ + hl(4, "v1", map[string][]byte{"root": {0x04}}), + hl(4, "v1", map[string][]byte{"root": {0x44}}), + }, + HashesFromB: nil, + }, + }, + } + require.NoError(t, renderCompare(&buf, result, false, false)) + require.Contains(t, buf.String(), "block 4 differs: 2 record(s) in A vs 0 in B (use --full to see them)") +} + +func TestRenderCompareTextFull(t *testing.T) { + var buf bytes.Buffer + result := compareResult{ + archiveA: "a", + archiveB: "b", + ranged: true, + low: 1, + high: 10, + maxDiffs: 1, + diffs: []*hashlog.HashLogPair{ + { + // Side A has two records (rollback); side B has none for this block. + HashesFromA: []*hashlog.HashLog{ + hl(4, "v1", map[string][]byte{"root": {0x04}}), + hl(4, "v1", map[string][]byte{"root": {0x44}}), + }, + HashesFromB: nil, + }, + }, + } + require.NoError(t, renderCompare(&buf, result, false, true)) + + out := buf.String() + require.Contains(t, out, "Restricted to blocks [1, 10]") + require.Contains(t, out, "block 4 differs:") + require.Contains(t, out, "archive A:") + require.Contains(t, out, "record 1:") + require.Contains(t, out, "record 2:") + require.Contains(t, out, "archive B:") + require.Contains(t, out, "") + require.Contains(t, out, "1 differing block(s) reported.") + // len(diffs) == maxDiffs, so the truncation warning must fire. + require.Contains(t, out, "Output truncated at --max-diffs=1") +} + +func TestRenderCompareJSON(t *testing.T) { + var buf bytes.Buffer + result := compareResult{ + archiveA: "a", + archiveB: "b", + maxDiffs: -1, + diffs: []*hashlog.HashLogPair{ + { + HashesFromA: []*hashlog.HashLog{hl(2, "v1", map[string][]byte{"root": {0x02}})}, + HashesFromB: []*hashlog.HashLog{hl(2, "v1", map[string][]byte{"root": {0xff}})}, + }, + }, + } + require.NoError(t, renderCompare(&buf, result, true, false)) + + var got []hashLogPairJSON + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + require.Len(t, got, 1) + require.Equal(t, uint64(2), got[0].Block) + require.Equal(t, "02", *got[0].HashesFromA[0].Hashes["root"]) + require.Equal(t, "ff", *got[0].HashesFromB[0].Hashes["root"]) +} + +// TestRenderCompareJSONColumnFiltering checks that JSON honors compact-by-default (only differing columns) and +// emits every column under --full. +func TestRenderCompareJSONColumnFiltering(t *testing.T) { + result := compareResult{ + archiveA: "a", + archiveB: "b", + maxDiffs: -1, + diffs: []*hashlog.HashLogPair{ + { + HashesFromA: []*hashlog.HashLog{hl(7, "v1", map[string][]byte{ + "root": {0x01}, "memIAVL": {0x02}, "app": {0x03}, + })}, + HashesFromB: []*hashlog.HashLog{hl(7, "v1", map[string][]byte{ + "root": {0xff}, "memIAVL": {0x02}, "app": {0x03}, + })}, + }, + }, + } + + var compactBuf bytes.Buffer + require.NoError(t, renderCompare(&compactBuf, result, true, false)) + var compact []hashLogPairJSON + require.NoError(t, json.Unmarshal(compactBuf.Bytes(), &compact)) + require.Len(t, compact, 1) + // Only the differing column survives on each side. + require.Equal(t, []string{"root"}, keysOf(compact[0].HashesFromA[0].Hashes)) + require.Equal(t, []string{"root"}, keysOf(compact[0].HashesFromB[0].Hashes)) + + var fullBuf bytes.Buffer + require.NoError(t, renderCompare(&fullBuf, result, true, true)) + var full []hashLogPairJSON + require.NoError(t, json.Unmarshal(fullBuf.Bytes(), &full)) + require.Len(t, full, 1) + require.Len(t, full[0].HashesFromA[0].Hashes, 3) +} + +func keysOf(m map[string]*string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// TestHashLogReadEndToEnd builds a real archive through the public hashlogger writer and reads it back through +// the same reader utility the CLI calls, exercising the full path the get-block/compare commands rely on. +func TestHashLogReadEndToEnd(t *testing.T) { + dirA := filepath.Join(t.TempDir(), "a") + dirB := filepath.Join(t.TempDir(), "b") + + // Archive A: blocks 1 and 2. Archive B: same, but block 2 deviates. + writeHashArchive(t, dirA, "v1.2.3", map[uint64][]byte{1: {0x01}, 2: {0x02}}) + writeHashArchive(t, dirB, "v1.2.3", map[uint64][]byte{1: {0x01}, 2: {0xff}}) + + logs, err := hashlog.ReadHashForBlock(dirA, 2) + require.NoError(t, err) + require.Len(t, logs, 1) + + var buf bytes.Buffer + require.NoError(t, renderGetBlock(&buf, 2, logs, false)) + out := buf.String() + require.Contains(t, out, "block 2:") + require.Contains(t, out, "root: 02") + require.Contains(t, out, "version: v1.2.3") + + diffs, err := hashlog.CompareHashes(dirA, dirB, -1) + require.NoError(t, err) + require.Len(t, diffs, 1) + require.Equal(t, uint64(2), pairBlock(diffs[0])) +} + +// writeHashArchive writes a "root" hash per block into a fresh archive directory using the public logger API, +// then closes it to flush everything to disk. +func writeHashArchive(t *testing.T, dir string, version string, blocks map[uint64][]byte) { + t.Helper() + cfg := hashlog.DefaultHashLoggerConfig(dir, version) + cfg.HashTypes = []string{"root"} + cfg.DisableChangesetHashing = true + hashLogger, err := hashlog.NewHashLogger(cfg) + require.NoError(t, err) + ordered := make([]uint64, 0, len(blocks)) + for block := range blocks { + ordered = append(ordered, block) + } + sort.Slice(ordered, func(i int, j int) bool { return ordered[i] < ordered[j] }) + for _, block := range ordered { + require.NoError(t, hashLogger.ReportHash(block, "root", blocks[block])) + } + require.NoError(t, hashLogger.Close()) +}