diff --git a/CLAUDE.md b/CLAUDE.md
index 0f779e84..5cbe5c51 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -125,6 +125,175 @@ The tool supports configuration via:
## Development Memories
- Use `make build` to build polycli
+## Code Quality Checklist
+
+**CRITICAL**: Before writing any code, systematically check these categories to avoid rework:
+
+### 0. Critical Thinking & Code Analysis (Meta-Level)
+**MOST IMPORTANT**: Think critically before implementing any suggestion or requirement.
+
+- **Analyze Existing Code First**: Before adding validation, checks, or features, thoroughly examine what already exists
+- **Question Redundancy**: If validation already exists earlier in the call chain, don't add duplicate checks
+- **Provide Feedback Before Implementation**: When a suggestion seems unnecessary or redundant, explain WHY rather than blindly implementing it
+- **Consider Token Efficiency**: Rework wastes time and tokens. Get it right the first time by applying this entire checklist systematically
+- **Defense-in-Depth vs Redundancy**:
+ - Defense-in-depth = validating at system boundaries (user input, external APIs, different layers)
+ - Redundancy = checking the same condition twice in the same function after it was already validated
+ - Apply defense-in-depth, avoid redundancy
+- **Evaluate Necessity**: Just because something CAN be added doesn't mean it SHOULD be. Ask: "Does this add value or just clutter?"
+
+**Questions to ask before writing ANY code:**
+1. "What validation/checks already exist in this call chain?"
+2. "Is this truly adding safety, or is it redundant?"
+3. "What value does this code provide?"
+4. "Am I implementing this because it was suggested, or because it's actually needed?"
+
+**Example of what NOT to do:**
+```go
+// Earlier in function (line 211)
+if report.EndBlock == math.MaxUint64 {
+ return fmt.Errorf("end block must be specified")
+}
+
+// ... code ...
+
+// Later in same function (line 231) - REDUNDANT
+if report.EndBlock == math.MaxUint64 {
+ return fmt.Errorf("internal error: end block cannot be math.MaxUint64")
+}
+totalBlocks := report.EndBlock - report.StartBlock + 1
+```
+This is pure redundancy - if the value can't be MaxUint64 (validated at line 211), checking again at line 231 adds zero value.
+
+### 1. Security
+- **HTML/Template Injection**: Always use `html.EscapeString()` for any data interpolated into HTML, even if currently from trusted sources
+- **Input Validation**: Validate all user inputs at boundaries (flags, API inputs)
+- **SQL Injection**: Use parameterized queries, never string concatenation
+- **Command Injection**: Never pass user input directly to shell commands
+- **Question to ask**: "What data is untrusted? Where does it flow? Is it escaped/validated at every output point?"
+
+### 2. Resource Management & Performance
+- **Goroutine Lifecycle**: Every goroutine must have a clear termination condition via context cancellation
+- **Timer Cleanup**: Use `time.NewTimer()` + `defer timer.Stop()`, never `time.After()` in select statements (causes goroutine leaks)
+- **Channel Buffers**: Use small fixed buffers (e.g., `concurrency*2`), never proportional to total dataset size
+- **Memory Allocation**: Consider behavior with 10x, 100x, 1000x expected input
+- **Question to ask**: "How does every goroutine, timer, and channel clean up on cancellation? What's the memory footprint at scale?"
+
+### 3. Context Propagation
+- **Never create root contexts**: Always thread `context.Context` through call chains; never use `context.Background()` in the middle of operations
+- **Cancellation Flow**: Context should flow through every I/O operation, long-running task, and goroutine
+- **Timeout Management**: Create child contexts with `context.WithTimeout(parentCtx, duration)`, not `context.WithTimeout(context.Background(), duration)`
+- **Question to ask**: "Does context flow through all long-running operations? Will Ctrl+C immediately stop everything?"
+
+### 4. Data Integrity & Determinism
+- **Completeness**: Data collection operations must fetch ALL requested data or fail entirely - never produce partial results
+- **Retry Logic**: Failed operations should retry (with backoff) before failing
+- **Idempotency**: Same input parameters should produce identical output every time
+- **Validation**: Verify expected vs actual data counts; fail loudly if mismatched
+- **Use Correct Data Source**: For blockchain data, prefer receipt fields over transaction fields (e.g., `effectiveGasPrice` from receipt works for both legacy and EIP-1559 txs, while `gasPrice` from transaction is missing in EIP-1559)
+- **Question to ask**: "If I run this twice with the same parameters, will I get identical results? What makes this non-deterministic? Am I reading from the authoritative data source?"
+
+### 5. Error Handling & Logging
+- **Error Wrapping**: Use `fmt.Errorf("context: %w", err)` to wrap errors with context
+- **Single-line Messages**: Put context before `%w` in single line: `fmt.Errorf("failed after %d attempts: %w", n, err)`
+- **Failure Modes**: Consider and handle all failure paths explicitly
+- **Logging Levels**: Use appropriate levels (Error for failures, Warn for retries, Info for progress)
+- **Progress Accuracy**: Progress counters must reflect ALL completed work (successes + final failures), not just successes, or progress will appear stuck during retries
+- **Question to ask**: "What can fail? How is each failure mode handled? Are errors properly wrapped? Is progress logging accurate during retries/failures?"
+
+### 6. Concurrency Patterns
+- **Channel Closing**: Close channels in the correct goroutine (usually the sender); use atomic counters to coordinate
+- **Channel Draining**: When using select with multiple channels and one closes, drain remaining channels to avoid missing messages
+- **Worker Pools**: Use `sync.WaitGroup` to wait for workers; protect shared state with mutexes or channels
+- **Race Conditions**: Run with `-race` flag during testing
+- **Goroutine Leaks**: Ensure every goroutine can exit on context cancellation
+- **Question to ask**: "Who closes each channel? Can any goroutine block forever? Does this have race conditions? Are all channel messages guaranteed to be read?"
+
+### 7. Testing & Validation
+- **Test Coverage**: Write tests for edge cases, not just happy paths
+- **Error Injection**: Test retry logic, failure modes, and error paths
+- **Resource Limits**: Test with large inputs to verify scalability
+- **Cancellation**: Test that context cancellation stops operations immediately
+- **Documentation Consistency**: Ensure documentation accurately describes implementation behavior (e.g., "blocks are skipped" vs "command fails on errors")
+- **Question to ask**: "What edge cases exist? How do I test failure modes? Does the documentation match what the code actually does?"
+
+### Common Patterns to Apply by Default
+
+```go
+// ✅ DO: Timer cleanup
+timer := time.NewTimer(500 * time.Millisecond)
+defer timer.Stop()
+select {
+case <-timer.C:
+case <-ctx.Done():
+ return ctx.Err()
+}
+
+// ❌ DON'T: Timer leak
+select {
+case <-time.After(500 * time.Millisecond): // Leaks if ctx cancels first
+case <-ctx.Done():
+}
+
+// ✅ DO: HTML escaping
+html := fmt.Sprintf(`
%s
`, html.EscapeString(userInput))
+
+// ❌ DON'T: HTML injection risk
+html := fmt.Sprintf(`%s
`, userInput)
+
+// ✅ DO: Context propagation
+func outputPDF(ctx context.Context, ...) error {
+ timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ ...
+}
+
+// ❌ DON'T: Context.Background in call chain
+func outputPDF(...) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ ...
+}
+
+// ✅ DO: Deterministic data collection with retries
+for attempt := 1; attempt <= maxRetries; attempt++ {
+ if data, err := fetch(); err == nil {
+ return data
+ }
+}
+return fmt.Errorf("failed after %d attempts", maxRetries)
+
+// ❌ DON'T: Skip failures (non-deterministic)
+if data, err := fetch(); err != nil {
+ log.Warn("skipping failed item")
+ continue
+}
+
+// ✅ DO: Fixed channel buffer
+ch := make(chan T, concurrency*2)
+
+// ❌ DON'T: Buffer proportional to input size
+ch := make(chan T, totalItems) // Can allocate GB of memory
+
+// ✅ DO: Goroutine with cancellation
+go func() {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case item := <-inputChan:
+ process(item)
+ }
+ }
+}()
+
+// ❌ DON'T: Goroutine without cancellation path
+go func() {
+ for item := range inputChan { // Blocks forever if ctx cancels
+ process(item)
+ }
+}()
+```
+
## Code Style
### Cobra Flags
diff --git a/README.md b/README.md
index 639e2f7c..fb17c373 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,8 @@ Note: Do not modify this section! It is auto-generated by `cobra` using `make ge
- [polycli publish](doc/polycli_publish.md) - Publish transactions to the network with high-throughput.
+- [polycli report](doc/polycli_report.md) - Generate a report analyzing a range of blocks from an Ethereum-compatible blockchain.
+
- [polycli retest](doc/polycli_retest.md) - Convert the standard ETH test fillers into something to be replayed against an RPC.
- [polycli rpcfuzz](doc/polycli_rpcfuzz.md) - Continually run a variety of RPC calls and fuzzers.
diff --git a/cmd/report/html.go b/cmd/report/html.go
new file mode 100644
index 00000000..bbda9271
--- /dev/null
+++ b/cmd/report/html.go
@@ -0,0 +1,527 @@
+package report
+
+import (
+ _ "embed"
+ "fmt"
+ "html"
+ "math/big"
+ "strings"
+ "time"
+)
+
+//go:embed template.html
+var htmlTemplate string
+
+// generateHTML creates an HTML report from the BlockReport data
+func generateHTML(report *BlockReport) string {
+ output := htmlTemplate
+
+ // Replace metadata placeholders
+ output = strings.ReplaceAll(output, "{{CHAIN_ID}}", fmt.Sprintf("%d", report.ChainID))
+ output = strings.ReplaceAll(output, "{{RPC_URL}}", html.EscapeString(report.RpcUrl))
+ output = strings.ReplaceAll(output, "{{BLOCK_RANGE}}", fmt.Sprintf("%d - %d", report.StartBlock, report.EndBlock))
+ output = strings.ReplaceAll(output, "{{GENERATED_AT}}", report.GeneratedAt.Format(time.RFC3339))
+ output = strings.ReplaceAll(output, "{{TOTAL_BLOCKS}}", formatNumber(report.Summary.TotalBlocks))
+
+ // Generate and replace stat cards
+ output = strings.ReplaceAll(output, "{{STAT_CARDS}}", generateStatCards(report))
+
+ // Generate and replace charts
+ output = strings.ReplaceAll(output, "{{TX_COUNT_CHART}}", generateTxCountChart(report))
+ output = strings.ReplaceAll(output, "{{GAS_USAGE_CHART}}", generateGasUsageChart(report))
+
+ // Generate and replace top 10 sections
+ output = strings.ReplaceAll(output, "{{TOP_10_SECTIONS}}", generateTop10Sections(report))
+
+ return output
+}
+
+// generateStatCards creates the statistics cards HTML
+func generateStatCards(report *BlockReport) string {
+ var sb strings.Builder
+
+ cards := []struct {
+ title string
+ value string
+ }{
+ {"Total Blocks", formatNumber(report.Summary.TotalBlocks)},
+ {"Total Transactions", formatNumber(report.Summary.TotalTransactions)},
+ {"Unique Senders", formatNumber(report.Summary.UniqueSenders)},
+ {"Unique Recipients", formatNumber(report.Summary.UniqueRecipients)},
+ {"Average Tx/Block", fmt.Sprintf("%.2f", report.Summary.AvgTxPerBlock)},
+ {"Total Gas Used", formatNumber(report.Summary.TotalGasUsed)},
+ {"Average Gas/Block", formatNumber(uint64(report.Summary.AvgGasPerBlock))},
+ }
+
+ // Add base fee card if available
+ if report.Summary.AvgBaseFeePerGas != "" {
+ // Parse the base fee string as big.Int and convert to Gwei
+ avgBaseFee := new(big.Int)
+ if _, ok := avgBaseFee.SetString(report.Summary.AvgBaseFeePerGas, 10); ok {
+ avgBaseFeeFloat := new(big.Float).SetInt(avgBaseFee)
+ gwei := new(big.Float).Quo(avgBaseFeeFloat, big.NewFloat(1e9))
+ gweiFloat, _ := gwei.Float64()
+ cards = append(cards, struct {
+ title string
+ value string
+ }{"Avg Base Fee (Gwei)", fmt.Sprintf("%.2f", gweiFloat)})
+ }
+ }
+
+ for _, card := range cards {
+ sb.WriteString(fmt.Sprintf(`
+ `, html.EscapeString(card.title), html.EscapeString(card.value)))
+ }
+
+ return sb.String()
+}
+
+// generateTxCountChart creates a line chart for transaction counts
+func generateTxCountChart(report *BlockReport) string {
+ if len(report.Blocks) == 0 {
+ return ""
+ }
+
+ var sb strings.Builder
+ sb.WriteString(`
+
+
Transaction Count by Block
+
`)
+
+ // Find max tx count for scaling
+ maxTx := uint64(1)
+ for _, block := range report.Blocks {
+ if block.TxCount > maxTx {
+ maxTx = block.TxCount
+ }
+ }
+
+ // Limit the number of points to avoid overcrowding
+ step := 1
+ if len(report.Blocks) > 500 {
+ step = len(report.Blocks) / 500
+ }
+
+ // Generate SVG line chart
+ width := 1200.0
+ height := 300.0
+ padding := 40.0
+ chartWidth := width - 2*padding
+ chartHeight := height - 2*padding
+
+ sb.WriteString(fmt.Sprintf(`
+
+
+
+ `,
+ height, width, height,
+ padding, padding, padding, height-padding,
+ padding, height-padding, width-padding, height-padding))
+
+ // Build points for the line
+ var points []string
+ var circles strings.Builder
+ numPoints := 0
+ // Calculate number of data points to avoid division by zero
+ totalPoints := (len(report.Blocks) - 1) / step
+ if totalPoints < 1 {
+ totalPoints = 1
+ }
+ for i := 0; i < len(report.Blocks); i += step {
+ block := report.Blocks[i]
+ x := padding + (float64(numPoints)/float64(totalPoints))*chartWidth
+ y := height - padding - (float64(block.TxCount)/float64(maxTx))*chartHeight
+
+ points = append(points, fmt.Sprintf("%.2f,%.2f", x, y))
+ circles.WriteString(fmt.Sprintf(`
+
+ Block %d: %d transactions
+ `, x, y, block.Number, block.TxCount))
+ numPoints++
+ }
+
+ // Draw the line
+ sb.WriteString(fmt.Sprintf(`
+ `,
+ strings.Join(points, " ")))
+
+ // Draw circles (points)
+ sb.WriteString(circles.String())
+
+ // Add axis labels
+ sb.WriteString(fmt.Sprintf(`
+ Block Number
+ Transactions
+ %d
+ %d
+ 0
+ %d
+
+
`,
+ width/2, height-10,
+ height/2, height/2,
+ padding, height-padding+15, report.Blocks[0].Number,
+ width-padding, height-padding+15, report.Blocks[len(report.Blocks)-1].Number,
+ padding-35, height-padding+5,
+ padding-35, padding+5, maxTx))
+
+ sb.WriteString(`
+
`)
+
+ return sb.String()
+}
+
+// generateGasUsageChart creates a line chart for gas usage
+func generateGasUsageChart(report *BlockReport) string {
+ if len(report.Blocks) == 0 {
+ return ""
+ }
+
+ var sb strings.Builder
+ sb.WriteString(`
+
+
Gas Usage by Block
+
`)
+
+ // Find max gas for scaling
+ maxGas := uint64(1)
+ for _, block := range report.Blocks {
+ if block.GasUsed > maxGas {
+ maxGas = block.GasUsed
+ }
+ }
+
+ // Limit the number of points to avoid overcrowding
+ step := 1
+ if len(report.Blocks) > 500 {
+ step = len(report.Blocks) / 500
+ }
+
+ // Generate SVG line chart
+ width := 1200.0
+ height := 300.0
+ padding := 40.0
+ chartWidth := width - 2*padding
+ chartHeight := height - 2*padding
+
+ sb.WriteString(fmt.Sprintf(`
+
+
+
+ `,
+ height, width, height,
+ padding, padding, padding, height-padding,
+ padding, height-padding, width-padding, height-padding))
+
+ // Build points for the line
+ var points []string
+ var circles strings.Builder
+ numPoints := 0
+ // Calculate number of data points to avoid division by zero
+ totalPoints := (len(report.Blocks) - 1) / step
+ if totalPoints < 1 {
+ totalPoints = 1
+ }
+ for i := 0; i < len(report.Blocks); i += step {
+ block := report.Blocks[i]
+ x := padding + (float64(numPoints)/float64(totalPoints))*chartWidth
+ y := height - padding - (float64(block.GasUsed)/float64(maxGas))*chartHeight
+
+ points = append(points, fmt.Sprintf("%.2f,%.2f", x, y))
+ circles.WriteString(fmt.Sprintf(`
+
+ Block %d: %s gas
+ `, x, y, block.Number, formatNumber(block.GasUsed)))
+ numPoints++
+ }
+
+ // Draw the line
+ sb.WriteString(fmt.Sprintf(`
+ `,
+ strings.Join(points, " ")))
+
+ // Draw circles (points)
+ sb.WriteString(circles.String())
+
+ // Add axis labels
+ sb.WriteString(fmt.Sprintf(`
+ Block Number
+ Gas Used
+ %d
+ %d
+ 0
+ %s
+
+
`,
+ width/2, height-10,
+ height/2, height/2,
+ padding, height-padding+15, report.Blocks[0].Number,
+ width-padding, height-padding+15, report.Blocks[len(report.Blocks)-1].Number,
+ padding-35, height-padding+5,
+ padding-35, padding+5, formatNumber(maxGas)))
+
+ sb.WriteString(`
+
`)
+
+ return sb.String()
+}
+
+// formatNumber adds thousand separators to numbers
+func formatNumber(n uint64) string {
+ str := fmt.Sprintf("%d", n)
+ if len(str) <= 3 {
+ return str
+ }
+
+ var result strings.Builder
+ for i, digit := range str {
+ if i > 0 && (len(str)-i)%3 == 0 {
+ result.WriteRune(',')
+ }
+ result.WriteRune(digit)
+ }
+ return result.String()
+}
+
+// formatNumberWithUnits formats large numbers with units (K, M, B, T, Q)
+func formatNumberWithUnits(n uint64) string {
+ if n == 0 {
+ return "0"
+ }
+
+ units := []struct {
+ suffix string
+ threshold uint64
+ }{
+ {"Q", 1e15}, // Quadrillion
+ {"T", 1e12}, // Trillion
+ {"B", 1e9}, // Billion
+ {"M", 1e6}, // Million
+ {"K", 1e3}, // Thousand
+ }
+
+ for _, unit := range units {
+ if n >= unit.threshold {
+ value := float64(n) / float64(unit.threshold)
+ // Format with appropriate precision
+ if value >= 100 {
+ return fmt.Sprintf("%.0f%s", value, unit.suffix)
+ } else if value >= 10 {
+ return fmt.Sprintf("%.1f%s", value, unit.suffix)
+ } else {
+ return fmt.Sprintf("%.2f%s", value, unit.suffix)
+ }
+ }
+ }
+
+ return formatNumber(n)
+}
+
+// generateTop10Sections creates all top 10 sections HTML
+func generateTop10Sections(report *BlockReport) string {
+ var sb strings.Builder
+
+ sb.WriteString(`
+
+
Top 10 Analysis `)
+
+ // Top 10 blocks by transaction count
+ if len(report.Top10.BlocksByTxCount) > 0 {
+ sb.WriteString(`
+
+
Top 10 Blocks by Transaction Count
+
+
+
+ Rank
+ Block Number
+ Transaction Count
+
+
+ `)
+
+ for i, block := range report.Top10.BlocksByTxCount {
+ sb.WriteString(fmt.Sprintf(`
+
+ %d
+ %s
+ %s
+ `, i+1, formatNumber(block.Number), formatNumber(block.TxCount)))
+ }
+
+ sb.WriteString(`
+
+
+
`)
+ }
+
+ // Top 10 blocks by gas used
+ if len(report.Top10.BlocksByGasUsed) > 0 {
+ sb.WriteString(`
+
+
Top 10 Blocks by Gas Used
+
+
+
+ Rank
+ Block Number
+ Gas Used (Wei)
+ Gas Limit
+ Gas Used %
+
+
+ `)
+
+ for i, block := range report.Top10.BlocksByGasUsed {
+ sb.WriteString(fmt.Sprintf(`
+
+ %d
+ %s
+ %s
+ %s
+ %.2f%%
+ `, i+1, formatNumber(block.Number), formatNumber(block.GasUsed), formatNumberWithUnits(block.GasLimit), block.GasUsedPercent))
+ }
+
+ sb.WriteString(`
+
+
+
`)
+ }
+
+ // Top 10 transactions by gas used
+ if len(report.Top10.TransactionsByGas) > 0 {
+ sb.WriteString(`
+
+
Top 10 Transactions by Gas Used
+
+
+
+ Rank
+ Transaction Hash
+ Block Number
+ Gas Limit
+ Gas Used (Wei)
+
+
+ `)
+
+ for i, tx := range report.Top10.TransactionsByGas {
+ sb.WriteString(fmt.Sprintf(`
+
+ %d
+ %s
+ %s
+ %s
+ %s
+ `, i+1, html.EscapeString(tx.Hash), formatNumber(tx.BlockNumber), formatNumberWithUnits(tx.GasLimit), formatNumber(tx.GasUsed)))
+ }
+
+ sb.WriteString(`
+
+
+
`)
+ }
+
+ // Top 10 transactions by gas limit
+ if len(report.Top10.TransactionsByGasLimit) > 0 {
+ sb.WriteString(`
+
+
Top 10 Transactions by Gas Limit
+
+
+
+ Rank
+ Transaction Hash
+ Block Number
+ Gas Limit
+ Gas Used (Wei)
+
+
+ `)
+
+ for i, tx := range report.Top10.TransactionsByGasLimit {
+ sb.WriteString(fmt.Sprintf(`
+
+ %d
+ %s
+ %s
+ %s
+ %s
+ `, i+1, html.EscapeString(tx.Hash), formatNumber(tx.BlockNumber), formatNumberWithUnits(tx.GasLimit), formatNumber(tx.GasUsed)))
+ }
+
+ sb.WriteString(`
+
+
+
`)
+ }
+
+ // Top 10 most used gas prices
+ if len(report.Top10.MostUsedGasPrices) > 0 {
+ sb.WriteString(`
+
+
Top 10 Most Used Gas Prices
+
+
+
+ Rank
+ Gas Price (Wei)
+ Transaction Count
+
+
+ `)
+
+ for i, gp := range report.Top10.MostUsedGasPrices {
+ sb.WriteString(fmt.Sprintf(`
+
+ %d
+ %s
+ %s
+ `, i+1, formatNumber(gp.GasPrice), formatNumber(gp.Count)))
+ }
+
+ sb.WriteString(`
+
+
+
`)
+ }
+
+ // Top 10 most used gas limits
+ if len(report.Top10.MostUsedGasLimits) > 0 {
+ sb.WriteString(`
+
+
Top 10 Most Used Gas Limits
+
+
+
+ Rank
+ Gas Limit
+ Transaction Count
+
+
+ `)
+
+ for i, gl := range report.Top10.MostUsedGasLimits {
+ sb.WriteString(fmt.Sprintf(`
+
+ %d
+ %s
+ %s
+ `, i+1, formatNumberWithUnits(gl.GasLimit), formatNumber(gl.Count)))
+ }
+
+ sb.WriteString(`
+
+
+
`)
+ }
+
+ sb.WriteString(`
+
`)
+
+ return sb.String()
+}
diff --git a/cmd/report/html_test.go b/cmd/report/html_test.go
new file mode 100644
index 00000000..f0fe593a
--- /dev/null
+++ b/cmd/report/html_test.go
@@ -0,0 +1,454 @@
+package report
+
+import (
+ "math/big"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestFormatNumber tests the formatNumber function for comma separation
+func TestFormatNumber(t *testing.T) {
+ tests := []struct {
+ name string
+ input uint64
+ expected string
+ }{
+ {
+ name: "zero",
+ input: 0,
+ expected: "0",
+ },
+ {
+ name: "single digit",
+ input: 5,
+ expected: "5",
+ },
+ {
+ name: "three digits",
+ input: 999,
+ expected: "999",
+ },
+ {
+ name: "four digits",
+ input: 1000,
+ expected: "1,000",
+ },
+ {
+ name: "five digits",
+ input: 12345,
+ expected: "12,345",
+ },
+ {
+ name: "six digits",
+ input: 123456,
+ expected: "123,456",
+ },
+ {
+ name: "seven digits",
+ input: 1234567,
+ expected: "1,234,567",
+ },
+ {
+ name: "million",
+ input: 1000000,
+ expected: "1,000,000",
+ },
+ {
+ name: "large number",
+ input: 1234567890,
+ expected: "1,234,567,890",
+ },
+ {
+ name: "max uint64",
+ input: 18446744073709551615,
+ expected: "18,446,744,073,709,551,615",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := formatNumber(tt.input)
+ if result != tt.expected {
+ t.Errorf("formatNumber(%d) = %s, want %s", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
+// TestFormatNumberWithUnits tests the formatNumberWithUnits function
+func TestFormatNumberWithUnits(t *testing.T) {
+ tests := []struct {
+ name string
+ input uint64
+ expected string
+ }{
+ {
+ name: "zero",
+ input: 0,
+ expected: "0",
+ },
+ {
+ name: "hundred",
+ input: 100,
+ expected: "100",
+ },
+ {
+ name: "thousand - uses 2 decimals for values < 10",
+ input: 1500,
+ expected: "1.50K",
+ },
+ {
+ name: "million - uses 2 decimals for values < 10",
+ input: 2500000,
+ expected: "2.50M",
+ },
+ {
+ name: "billion - uses 2 decimals for values < 10",
+ input: 3500000000,
+ expected: "3.50B",
+ },
+ {
+ name: "trillion - uses 2 decimals for values < 10",
+ input: 4500000000000,
+ expected: "4.50T",
+ },
+ {
+ name: "large value - uses 1 decimal for values >= 10",
+ input: 15000000,
+ expected: "15.0M",
+ },
+ {
+ name: "very large value - uses 0 decimals for values >= 100",
+ input: 150000000,
+ expected: "150M",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := formatNumberWithUnits(tt.input)
+ if result != tt.expected {
+ t.Errorf("formatNumberWithUnits(%d) = %s, want %s", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
+// TestGenerateStatCards tests the stat cards generation
+func TestGenerateStatCards(t *testing.T) {
+ report := &BlockReport{
+ Summary: SummaryStats{
+ TotalBlocks: 100,
+ TotalTransactions: 5000,
+ UniqueSenders: 250,
+ UniqueRecipients: 300,
+ AvgTxPerBlock: 50.0,
+ TotalGasUsed: 10000000,
+ AvgGasPerBlock: 100000.0,
+ AvgBaseFeePerGas: "20000000000", // 20 Gwei
+ },
+ }
+
+ result := generateStatCards(report)
+
+ // Check that result contains expected elements
+ if !strings.Contains(result, "Total Blocks") {
+ t.Error("expected stat cards to contain 'Total Blocks'")
+ }
+ if !strings.Contains(result, "100") {
+ t.Error("expected stat cards to contain '100' for total blocks")
+ }
+ if !strings.Contains(result, "5,000") {
+ t.Error("expected stat cards to contain '5,000' for total transactions")
+ }
+ if !strings.Contains(result, "Avg Base Fee (Gwei)") {
+ t.Error("expected stat cards to contain 'Avg Base Fee (Gwei)'")
+ }
+ if !strings.Contains(result, "20.00") {
+ t.Error("expected stat cards to contain '20.00' for avg base fee in Gwei")
+ }
+}
+
+// TestGenerateStatCardsWithoutBaseFee tests stat cards when base fee is not available
+func TestGenerateStatCardsWithoutBaseFee(t *testing.T) {
+ report := &BlockReport{
+ Summary: SummaryStats{
+ TotalBlocks: 100,
+ TotalTransactions: 5000,
+ AvgBaseFeePerGas: "", // No base fee
+ },
+ }
+
+ result := generateStatCards(report)
+
+ // Check that base fee card is not included
+ if strings.Contains(result, "Avg Base Fee") {
+ t.Error("expected stat cards not to contain 'Avg Base Fee' when not available")
+ }
+}
+
+// TestGenerateTxCountChart tests transaction count chart generation
+func TestGenerateTxCountChart(t *testing.T) {
+ t.Run("empty blocks", func(t *testing.T) {
+ report := &BlockReport{
+ Blocks: []BlockInfo{},
+ }
+
+ result := generateTxCountChart(report)
+
+ if result != "" {
+ t.Error("expected empty string for empty blocks")
+ }
+ })
+
+ t.Run("single block", func(t *testing.T) {
+ report := &BlockReport{
+ Blocks: []BlockInfo{
+ {Number: 100, TxCount: 50},
+ },
+ }
+
+ result := generateTxCountChart(report)
+
+ // Should handle single block without division by zero
+ if !strings.Contains(result, "Transaction Count by Block") {
+ t.Error("expected chart to contain title")
+ }
+ if !strings.Contains(result, "alert('xss')",
+ StartBlock: 0,
+ EndBlock: 100,
+ GeneratedAt: time.Now(),
+ Summary: SummaryStats{
+ TotalBlocks: 100,
+ },
+ Blocks: []BlockInfo{
+ {
+ Number: 100,
+ TxCount: 1,
+ Transactions: []TransactionInfo{
+ {
+ Hash: "",
+ BlockNumber: 100,
+ GasUsed: 21000,
+ },
+ },
+ },
+ },
+ Top10: Top10Stats{
+ TransactionsByGas: []TopTransaction{
+ {
+ Hash: "",
+ BlockNumber: 100,
+ GasUsed: 21000,
+ },
+ },
+ TransactionsByGasLimit: []TopTransaction{
+ {
+ Hash: "",
+ BlockNumber: 100,
+ GasLimit: 21000,
+ },
+ },
+ },
+ }
+
+ result := generateHTML(report)
+
+ // Check that malicious script tags are escaped
+ if strings.Contains(result, "") {
+ t.Error("expected HTML to escape script tags in RPC URL or transaction hashes")
+ }
+
+ // Check that escaped version is present
+ if !strings.Contains(result, "<script>") {
+ t.Error("expected HTML to contain escaped script tags")
+ }
+}
+
+// TestGenerateHTMLWithBaseFee tests HTML generation with base fee data
+func TestGenerateHTMLWithBaseFee(t *testing.T) {
+ baseFee := new(big.Int)
+ baseFee.SetString("20000000000", 10) // 20 Gwei
+
+ report := &BlockReport{
+ ChainID: 1,
+ RpcUrl: "http://localhost:8545",
+ StartBlock: 0,
+ EndBlock: 100,
+ GeneratedAt: time.Now(),
+ Summary: SummaryStats{
+ TotalBlocks: 100,
+ AvgBaseFeePerGas: "20000000000",
+ },
+ Blocks: []BlockInfo{
+ {
+ Number: 100,
+ TxCount: 1,
+ GasUsed: 21000,
+ GasLimit: 30000000,
+ BaseFeePerGas: baseFee,
+ },
+ },
+ }
+
+ result := generateHTML(report)
+
+ // Check that base fee is included
+ if !strings.Contains(result, "20.00") {
+ t.Error("expected HTML to contain base fee in Gwei (20.00)")
+ }
+}
+
+// TestGenerateHTMLWithoutBaseFee tests HTML generation without base fee data (pre-EIP-1559)
+func TestGenerateHTMLWithoutBaseFee(t *testing.T) {
+ report := &BlockReport{
+ ChainID: 1,
+ RpcUrl: "http://localhost:8545",
+ StartBlock: 0,
+ EndBlock: 100,
+ GeneratedAt: time.Now(),
+ Summary: SummaryStats{
+ TotalBlocks: 100,
+ AvgBaseFeePerGas: "", // No base fee
+ },
+ Blocks: []BlockInfo{
+ {
+ Number: 100,
+ TxCount: 1,
+ GasUsed: 21000,
+ GasLimit: 30000000,
+ BaseFeePerGas: nil,
+ },
+ },
+ }
+
+ result := generateHTML(report)
+
+ // Check that base fee section is not included
+ if strings.Contains(result, "Avg Base Fee (Gwei)") {
+ t.Error("expected HTML not to contain base fee when not available")
+ }
+}
diff --git a/cmd/report/pdf.go b/cmd/report/pdf.go
new file mode 100644
index 00000000..6a09876e
--- /dev/null
+++ b/cmd/report/pdf.go
@@ -0,0 +1,85 @@
+package report
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/chromedp/cdproto/page"
+ "github.com/chromedp/chromedp"
+ "github.com/rs/zerolog/log"
+)
+
+// outputPDF generates a PDF report from the BlockReport data
+func outputPDF(ctx context.Context, report *BlockReport, outputFile string) error {
+ log.Info().Msg("Generating PDF report from HTML")
+
+ // Generate HTML from the existing template
+ html := generateHTML(report)
+
+ // Create chromedp context with timeout
+ timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+
+ // Allocate a new browser context
+ chromeCtx, cancelChrome := chromedp.NewContext(timeoutCtx)
+ defer cancelChrome()
+
+ var buf []byte
+ err := chromedp.Run(chromeCtx,
+ chromedp.Navigate("about:blank"),
+ chromedp.ActionFunc(func(ctx context.Context) error {
+ // Get the frame tree to set document content
+ frameTree, err := page.GetFrameTree().Do(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get frame tree: %w", err)
+ }
+
+ // Set the HTML content
+ return page.SetDocumentContent(frameTree.Frame.ID, html).Do(ctx)
+ }),
+ chromedp.ActionFunc(func(ctx context.Context) error {
+ // Wait a bit for any dynamic content to settle, respecting context cancellation
+ timer := time.NewTimer(500 * time.Millisecond)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }),
+ chromedp.ActionFunc(func(ctx context.Context) error {
+ // Print to PDF with appropriate settings
+ var err error
+ buf, _, err = page.PrintToPDF().
+ WithPrintBackground(true).
+ WithScale(0.8).
+ WithPreferCSSPageSize(false).
+ WithPaperWidth(8.5).
+ WithPaperHeight(11).
+ WithMarginTop(0.4).
+ WithMarginBottom(0.4).
+ WithMarginLeft(0.4).
+ WithMarginRight(0.4).
+ Do(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to print to PDF: %w", err)
+ }
+ return nil
+ }),
+ )
+
+ if err != nil {
+ return fmt.Errorf("failed to generate PDF (requires Chrome or Chromium to be installed - see documentation for installation instructions): %w", err)
+ }
+
+ // Write PDF to file
+ if err := os.WriteFile(outputFile, buf, 0644); err != nil {
+ return fmt.Errorf("failed to write PDF file: %w", err)
+ }
+
+ log.Info().Str("file", outputFile).Msg("PDF report written")
+ return nil
+}
diff --git a/cmd/report/report.go b/cmd/report/report.go
new file mode 100644
index 00000000..5865e44c
--- /dev/null
+++ b/cmd/report/report.go
@@ -0,0 +1,759 @@
+package report
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math"
+ "math/big"
+ "os"
+ "slices"
+ "sort"
+ "strconv"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ _ "embed"
+
+ "github.com/0xPolygon/polygon-cli/util"
+ ethrpc "github.com/ethereum/go-ethereum/rpc"
+ "github.com/rs/zerolog/log"
+ "github.com/spf13/cobra"
+ "golang.org/x/time/rate"
+)
+
+const (
+ // DefaultBlockRange is the default number of blocks to analyze when start/end blocks are not specified
+ DefaultBlockRange = 500
+ // BlockNotSet is a sentinel value to indicate a block number flag was not set by the user
+ BlockNotSet = math.MaxUint64
+)
+
+type (
+ reportParams struct {
+ RpcUrl string
+ StartBlock uint64
+ EndBlock uint64
+ OutputFile string
+ Format string
+ Concurrency int
+ RateLimit float64
+ }
+)
+
+var (
+ //go:embed usage.md
+ usage string
+ inputReport reportParams = reportParams{}
+)
+
+// ReportCmd represents the report command
+var ReportCmd = &cobra.Command{
+ Use: "report",
+ Short: "Generate a report analyzing a range of blocks from an Ethereum-compatible blockchain.",
+ Long: usage,
+ Args: cobra.NoArgs,
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ return checkFlags()
+ },
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+
+ // Connect to RPC
+ ec, err := ethrpc.DialContext(ctx, inputReport.RpcUrl)
+ if err != nil {
+ return fmt.Errorf("failed to connect to RPC: %w", err)
+ }
+ defer ec.Close()
+
+ // Fetch chain ID
+ var chainIDHex string
+ err = ec.CallContext(ctx, &chainIDHex, "eth_chainId")
+ if err != nil {
+ return fmt.Errorf("failed to fetch chain ID: %w", err)
+ }
+ chainID := hexToUint64(chainIDHex)
+
+ // Determine block range with smart defaults
+ startBlock := inputReport.StartBlock
+ endBlock := inputReport.EndBlock
+
+ // Fetch latest block if needed for auto-detection
+ var latestBlock uint64
+ needsLatest := startBlock == BlockNotSet || endBlock == BlockNotSet
+ if needsLatest {
+ var latestBlockHex string
+ err = ec.CallContext(ctx, &latestBlockHex, "eth_blockNumber")
+ if err != nil {
+ return fmt.Errorf("failed to fetch latest block number: %w", err)
+ }
+ latestBlock = hexToUint64(latestBlockHex)
+ log.Info().Uint64("latest-block", latestBlock).Msg("Auto-detected latest block")
+ }
+
+ // Apply smart defaults based on which flags were set
+ if startBlock == BlockNotSet && endBlock == BlockNotSet {
+ // Both unspecified: analyze latest DefaultBlockRange blocks
+ endBlock = latestBlock
+ if latestBlock >= DefaultBlockRange-1 {
+ startBlock = latestBlock - (DefaultBlockRange - 1)
+ } else {
+ startBlock = 0
+ }
+ } else if startBlock == BlockNotSet {
+ // Only start-block unspecified: analyze previous DefaultBlockRange blocks from end-block
+ if endBlock >= DefaultBlockRange-1 {
+ startBlock = endBlock - (DefaultBlockRange - 1)
+ } else {
+ startBlock = 0
+ }
+ } else if endBlock == BlockNotSet {
+ // Only end-block unspecified: analyze next DefaultBlockRange blocks from start-block
+ // But don't exceed the latest block
+ endBlock = startBlock + (DefaultBlockRange - 1)
+ if endBlock > latestBlock {
+ endBlock = latestBlock
+ }
+ }
+ // If both are set by user (including 0,0), use them as-is
+
+ log.Info().
+ Str("rpc-url", inputReport.RpcUrl).
+ Uint64("start-block", startBlock).
+ Uint64("end-block", endBlock).
+ Msg("Starting block analysis")
+
+ // Initialize the report
+ report := &BlockReport{
+ ChainID: chainID,
+ RpcUrl: inputReport.RpcUrl,
+ StartBlock: startBlock,
+ EndBlock: endBlock,
+ GeneratedAt: time.Now(),
+ Blocks: []BlockInfo{},
+ }
+
+ // Generate the report
+ err = generateReport(ctx, ec, report, inputReport.Concurrency, inputReport.RateLimit)
+ if err != nil {
+ return fmt.Errorf("failed to generate report: %w", err)
+ }
+
+ // Output the report
+ if err := outputReport(ctx, report, inputReport.Format, inputReport.OutputFile); err != nil {
+ return fmt.Errorf("failed to output report: %w", err)
+ }
+
+ log.Info().Msg("Report generation completed")
+ return nil
+ },
+}
+
+func init() {
+ f := ReportCmd.Flags()
+ f.StringVar(&inputReport.RpcUrl, "rpc-url", "http://localhost:8545", "RPC endpoint URL")
+ f.Uint64Var(&inputReport.StartBlock, "start-block", BlockNotSet, "starting block number (default: auto-detect based on end-block or latest)")
+ f.Uint64Var(&inputReport.EndBlock, "end-block", BlockNotSet, "ending block number (default: auto-detect based on start-block or latest)")
+ f.StringVarP(&inputReport.OutputFile, "output", "o", "", "output file path (default: stdout for JSON, report.html for HTML, report.pdf for PDF)")
+ f.StringVarP(&inputReport.Format, "format", "f", "json", "output format [json, html, pdf]")
+ f.IntVar(&inputReport.Concurrency, "concurrency", 10, "number of concurrent RPC requests")
+ f.Float64Var(&inputReport.RateLimit, "rate-limit", 4, "requests per second limit")
+}
+
+func checkFlags() error {
+ // Validate RPC URL
+ if err := util.ValidateUrl(inputReport.RpcUrl); err != nil {
+ return err
+ }
+
+ // Validate block range only if both are explicitly specified by the user
+ if inputReport.StartBlock != BlockNotSet && inputReport.EndBlock != BlockNotSet {
+ if inputReport.EndBlock < inputReport.StartBlock {
+ return fmt.Errorf("end-block must be greater than or equal to start-block")
+ }
+ }
+
+ // Validate concurrency
+ if inputReport.Concurrency < 1 {
+ return fmt.Errorf("concurrency must be at least 1")
+ }
+
+ // Validate rate limit
+ if inputReport.RateLimit <= 0 {
+ return fmt.Errorf("rate-limit must be greater than 0")
+ }
+
+ // Validate format
+ if inputReport.Format != "json" && inputReport.Format != "html" && inputReport.Format != "pdf" {
+ return fmt.Errorf("format must be either 'json', 'html', or 'pdf'")
+ }
+
+ // Set default output file for HTML if not specified
+ if inputReport.Format == "html" && inputReport.OutputFile == "" {
+ inputReport.OutputFile = "report.html"
+ }
+
+ // Set default output file for PDF if not specified
+ if inputReport.Format == "pdf" && inputReport.OutputFile == "" {
+ inputReport.OutputFile = "report.pdf"
+ }
+
+ return nil
+}
+
+// generateReport analyzes the block range and generates a report
+func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, concurrency int, rateLimit float64) error {
+ log.Info().Msg("Fetching and analyzing blocks")
+
+ // Validate block range to prevent infinite loop
+ if report.StartBlock == BlockNotSet {
+ return fmt.Errorf("start block must be specified")
+ }
+ if report.EndBlock == BlockNotSet {
+ return fmt.Errorf("end block must be specified")
+ }
+ if report.EndBlock < report.StartBlock {
+ return fmt.Errorf("end block (%d) must be greater than or equal to start block (%d)", report.EndBlock, report.StartBlock)
+ }
+
+ // Create a cancellable context for workers
+ workerCtx, cancelWorkers := context.WithCancel(ctx)
+ defer cancelWorkers() // Ensure workers are stopped when function returns
+
+ // Create rate limiter
+ rateLimiter := rate.NewLimiter(rate.Limit(rateLimit), 1)
+
+ const maxRetries = 3
+ totalBlocks := report.EndBlock - report.StartBlock + 1
+
+ // blockRequest tracks a block fetch attempt
+ type blockRequest struct {
+ blockNum uint64
+ attempt int
+ }
+
+ // Use a small fixed buffer size to avoid excessive memory allocation for large block ranges
+ blockChan := make(chan blockRequest, concurrency*2)
+ resultChan := make(chan *BlockInfo, concurrency)
+ // Channel for blocks that need to be retried
+ retryChan := make(chan blockRequest, concurrency*2)
+ // Channel for blocks that failed all retry attempts
+ failedChan := make(chan uint64, totalBlocks)
+
+ // Track pending work to know when to close channels
+ var pendingWork atomic.Int64
+ pendingWork.Store(int64(totalBlocks))
+
+ // Fill the block channel with initial block requests (in a goroutine to avoid blocking)
+ go func() {
+ for blockNum := report.StartBlock; blockNum <= report.EndBlock; blockNum++ {
+ select {
+ case blockChan <- blockRequest{blockNum: blockNum, attempt: 1}:
+ case <-workerCtx.Done():
+ return
+ }
+ }
+ }()
+
+ // Goroutine to forward retries from retryChan back to blockChan
+ retryForwarderDone := make(chan struct{})
+ go func() {
+ defer close(retryForwarderDone)
+ for {
+ select {
+ case req, ok := <-retryChan:
+ if !ok {
+ // retryChan closed, exit
+ return
+ }
+ select {
+ case blockChan <- req:
+ case <-workerCtx.Done():
+ return
+ }
+ case <-workerCtx.Done():
+ return
+ }
+ }
+ }()
+
+ // Start worker goroutines
+ var wg sync.WaitGroup
+ for range concurrency {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for req := range blockChan {
+ // Check if worker context is canceled
+ if workerCtx.Err() != nil {
+ return
+ }
+
+ blockInfo, err := fetchBlockInfo(workerCtx, ec, req.blockNum, rateLimiter)
+ if err != nil {
+ // Check for context cancellation errors (user interrupt or internal cancellation)
+ if workerCtx.Err() != nil {
+ return
+ }
+
+ if req.attempt < maxRetries {
+ // Retry the block (don't decrement pendingWork yet)
+ log.Warn().Err(err).Uint64("block", req.blockNum).Int("attempt", req.attempt).Msg("Failed to fetch block, retrying")
+ select {
+ case retryChan <- blockRequest{blockNum: req.blockNum, attempt: req.attempt + 1}:
+ case <-workerCtx.Done():
+ return
+ }
+ continue
+ }
+
+ // All retry attempts exhausted - decrement pending work
+ log.Error().Err(err).Uint64("block", req.blockNum).Int("attempts", req.attempt).Msg("Failed to fetch block after all retry attempts")
+ select {
+ case failedChan <- req.blockNum:
+ case <-workerCtx.Done():
+ return
+ }
+ if pendingWork.Add(-1) == 0 {
+ close(retryChan) // No more retries possible
+ }
+ continue
+ }
+
+ // Block fetched successfully - send result and decrement pending work
+ select {
+ case resultChan <- blockInfo:
+ case <-workerCtx.Done():
+ return
+ }
+
+ if pendingWork.Add(-1) == 0 {
+ close(retryChan) // No more retries needed
+ }
+ }
+ }()
+ }
+
+ // Monitor goroutine to close blockChan when all work is done
+ go func() {
+ <-retryForwarderDone // Wait for retry forwarder to finish
+ close(blockChan) // Signal workers to exit
+ }()
+
+ // Close remaining channels when workers are done
+ go func() {
+ wg.Wait()
+ close(resultChan)
+ close(failedChan)
+ }()
+
+ // Collect results
+ totalTxCount := uint64(0)
+ totalGasUsed := uint64(0)
+ totalBaseFee := big.NewInt(0)
+ blockCount := uint64(0)
+ blocksWithBaseFee := uint64(0)
+ uniqueSenders := make(map[string]bool)
+ uniqueRecipients := make(map[string]bool)
+ processedBlocks := uint64(0)
+ var failedBlocks []uint64
+
+ // Process results and check for context cancellation
+ for {
+ select {
+ case blockInfo, ok := <-resultChan:
+ if !ok {
+ // Channel closed, all results processed
+ goto done
+ }
+ report.Blocks = append(report.Blocks, *blockInfo)
+ totalTxCount += blockInfo.TxCount
+ totalGasUsed += blockInfo.GasUsed
+ if blockInfo.BaseFeePerGas != nil {
+ totalBaseFee.Add(totalBaseFee, blockInfo.BaseFeePerGas)
+ blocksWithBaseFee++
+ }
+ blockCount++
+
+ // Track unique addresses
+ for _, tx := range blockInfo.Transactions {
+ if tx.From != "" {
+ uniqueSenders[tx.From] = true
+ }
+ if tx.To != "" {
+ uniqueRecipients[tx.To] = true
+ }
+ }
+
+ processedBlocks++
+ if processedBlocks%100 == 0 || processedBlocks == totalBlocks {
+ successfulBlocks := processedBlocks - uint64(len(failedBlocks))
+ log.Info().Uint64("processed", processedBlocks).Uint64("successful", successfulBlocks).Uint64("failed", uint64(len(failedBlocks))).Uint64("total", totalBlocks).Msg("Progress")
+ }
+ case failedBlock := <-failedChan:
+ failedBlocks = append(failedBlocks, failedBlock)
+ processedBlocks++
+ // Log progress for failed blocks too
+ if processedBlocks%100 == 0 || processedBlocks == totalBlocks {
+ successfulBlocks := processedBlocks - uint64(len(failedBlocks))
+ log.Info().Uint64("processed", processedBlocks).Uint64("successful", successfulBlocks).Uint64("failed", uint64(len(failedBlocks))).Uint64("total", totalBlocks).Msg("Progress")
+ }
+ case <-ctx.Done():
+ // Parent context canceled (e.g., user pressed Ctrl+C)
+ // cancelWorkers() will be called by defer to stop all workers
+ return ctx.Err()
+ }
+ }
+done:
+ // Drain any remaining failed blocks from failedChan to avoid missing failures
+ for failedBlock := range failedChan {
+ failedBlocks = append(failedBlocks, failedBlock)
+ }
+
+ // Check if any blocks failed after all retry attempts
+ if len(failedBlocks) > 0 {
+ slices.Sort(failedBlocks)
+ return fmt.Errorf("failed to fetch %d block(s) after %d retry attempts: %v", len(failedBlocks), maxRetries, failedBlocks)
+ }
+
+ // Verify we got all expected blocks
+ if uint64(len(report.Blocks)) != totalBlocks {
+ return fmt.Errorf("expected to fetch %d blocks but only got %d", totalBlocks, len(report.Blocks))
+ }
+
+ // Sort blocks by block number to ensure correct ordering for charts and analysis
+ slices.SortFunc(report.Blocks, func(a, b BlockInfo) int {
+ if a.Number < b.Number {
+ return -1
+ } else if a.Number > b.Number {
+ return 1
+ }
+ return 0
+ })
+
+ // Calculate summary statistics
+ report.Summary = SummaryStats{
+ TotalBlocks: blockCount,
+ TotalTransactions: totalTxCount,
+ TotalGasUsed: totalGasUsed,
+ UniqueSenders: uint64(len(uniqueSenders)),
+ UniqueRecipients: uint64(len(uniqueRecipients)),
+ }
+
+ if blockCount > 0 {
+ report.Summary.AvgTxPerBlock = float64(totalTxCount) / float64(blockCount)
+ report.Summary.AvgGasPerBlock = float64(totalGasUsed) / float64(blockCount)
+ // Only calculate average base fee if there are blocks with base fee (post-EIP-1559)
+ if blocksWithBaseFee > 0 {
+ avgBaseFee := new(big.Int).Div(totalBaseFee, big.NewInt(int64(blocksWithBaseFee)))
+ report.Summary.AvgBaseFeePerGas = avgBaseFee.String()
+ }
+ }
+
+ // Calculate top 10 statistics
+ report.Top10 = calculateTop10Stats(report.Blocks)
+
+ return nil
+}
+
+// fetchBlockInfo retrieves information about a specific block and its transactions
+func fetchBlockInfo(ctx context.Context, ec *ethrpc.Client, blockNum uint64, rateLimiter *rate.Limiter) (*BlockInfo, error) {
+ // Wait for rate limiter before making RPC call
+ if err := rateLimiter.Wait(ctx); err != nil {
+ return nil, fmt.Errorf("rate limiter error: %w", err)
+ }
+
+ var result map[string]any
+ err := ec.CallContext(ctx, &result, "eth_getBlockByNumber", fmt.Sprintf("0x%x", blockNum), true)
+ if err != nil {
+ return nil, err
+ }
+
+ if result == nil {
+ return nil, fmt.Errorf("block not found")
+ }
+
+ blockInfo := &BlockInfo{
+ Number: blockNum,
+ Timestamp: hexToUint64(result["timestamp"]),
+ GasUsed: hexToUint64(result["gasUsed"]),
+ GasLimit: hexToUint64(result["gasLimit"]),
+ Transactions: []TransactionInfo{},
+ }
+
+ // Parse base fee if present (EIP-1559)
+ if baseFee, ok := result["baseFeePerGas"].(string); ok && baseFee != "" {
+ bf := new(big.Int)
+ // Remove "0x" prefix if present
+ if len(baseFee) > 2 && baseFee[:2] == "0x" {
+ baseFee = baseFee[2:]
+ }
+ if _, success := bf.SetString(baseFee, 16); success {
+ blockInfo.BaseFeePerGas = bf
+ }
+ }
+
+ // Process transactions
+ if txs, ok := result["transactions"].([]any); ok {
+ blockInfo.TxCount = uint64(len(txs))
+
+ // Fetch all receipts for this block in a single call
+ // NOTE: eth_getBlockReceipts is a non-standard but widely supported RPC method.
+ // It's supported by Geth, Erigon, Polygon nodes, Alchemy, QuickNode, and most self-hosted nodes.
+ // It is NOT supported by Infura and some public RPC endpoints.
+ // If this method is not available, the command will fail.
+ if len(txs) > 0 {
+ // Wait for rate limiter before making RPC call
+ if err := rateLimiter.Wait(ctx); err != nil {
+ return nil, fmt.Errorf("rate limiter error: %w", err)
+ }
+
+ var receipts []map[string]any
+ err := ec.CallContext(ctx, &receipts, "eth_getBlockReceipts", fmt.Sprintf("0x%x", blockNum))
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch block receipts: %w", err)
+ }
+
+ if len(receipts) != len(txs) {
+ return nil, fmt.Errorf("mismatch between transactions (%d) and receipts (%d)", len(txs), len(receipts))
+ }
+
+ // Process each transaction with its corresponding receipt
+ for i, txData := range txs {
+ txMap, ok := txData.(map[string]any)
+ if !ok {
+ continue
+ }
+
+ txHash, _ := txMap["hash"].(string)
+ from, _ := txMap["from"].(string)
+ to, _ := txMap["to"].(string)
+ gasLimit := hexToUint64(txMap["gas"])
+
+ receipt := receipts[i]
+ gasUsed := hexToUint64(receipt["gasUsed"])
+ // Use effectiveGasPrice from receipt which works for both legacy and EIP-1559 transactions
+ // For legacy txs: effectiveGasPrice = gasPrice
+ // For EIP-1559 txs: effectiveGasPrice = baseFee + min(maxPriorityFeePerGas, maxFeePerGas - baseFee)
+ gasPrice := hexToUint64(receipt["effectiveGasPrice"])
+ gasUsedPercent := 0.0
+ if blockInfo.GasLimit > 0 {
+ gasUsedPercent = (float64(gasUsed) / float64(blockInfo.GasLimit)) * 100
+ }
+
+ txInfo := TransactionInfo{
+ Hash: txHash,
+ From: from,
+ To: to,
+ BlockNumber: blockNum,
+ GasUsed: gasUsed,
+ GasLimit: gasLimit,
+ GasPrice: gasPrice,
+ BlockGasLimit: blockInfo.GasLimit,
+ GasUsedPercent: gasUsedPercent,
+ }
+
+ blockInfo.Transactions = append(blockInfo.Transactions, txInfo)
+ }
+ }
+ }
+
+ return blockInfo, nil
+}
+
+// hexToUint64 converts a hex string to uint64
+func hexToUint64(v any) uint64 {
+ if v == nil {
+ return 0
+ }
+ str, ok := v.(string)
+ if !ok {
+ return 0
+ }
+ if len(str) > 2 && str[:2] == "0x" {
+ str = str[2:]
+ }
+ val, _ := strconv.ParseUint(str, 16, 64)
+ return val
+}
+
+// calculateTop10Stats calculates various top 10 lists from the collected blocks
+func calculateTop10Stats(blocks []BlockInfo) Top10Stats {
+ top10 := Top10Stats{}
+
+ // Top 10 blocks by transaction count
+ blocksByTxCount := make([]TopBlock, len(blocks))
+ for i, block := range blocks {
+ blocksByTxCount[i] = TopBlock{
+ Number: block.Number,
+ TxCount: block.TxCount,
+ }
+ }
+ // Sort by tx count descending
+ sort.Slice(blocksByTxCount, func(i, j int) bool {
+ return blocksByTxCount[i].TxCount > blocksByTxCount[j].TxCount
+ })
+ if len(blocksByTxCount) > 10 {
+ top10.BlocksByTxCount = blocksByTxCount[:10]
+ } else {
+ top10.BlocksByTxCount = blocksByTxCount
+ }
+
+ // Top 10 blocks by gas used
+ blocksByGasUsed := make([]TopBlock, len(blocks))
+ for i, block := range blocks {
+ gasUsedPercent := 0.0
+ if block.GasLimit > 0 {
+ gasUsedPercent = (float64(block.GasUsed) / float64(block.GasLimit)) * 100
+ }
+ blocksByGasUsed[i] = TopBlock{
+ Number: block.Number,
+ GasUsed: block.GasUsed,
+ GasLimit: block.GasLimit,
+ GasUsedPercent: gasUsedPercent,
+ }
+ }
+ // Sort by gas used descending
+ sort.Slice(blocksByGasUsed, func(i, j int) bool {
+ return blocksByGasUsed[i].GasUsed > blocksByGasUsed[j].GasUsed
+ })
+ if len(blocksByGasUsed) > 10 {
+ top10.BlocksByGasUsed = blocksByGasUsed[:10]
+ } else {
+ top10.BlocksByGasUsed = blocksByGasUsed
+ }
+
+ // Collect all transactions and track gas prices and gas limits
+ var allTxsByGasUsed []TopTransaction
+ var allTxsByGasLimit []TopTransaction
+ gasPriceMap := make(map[uint64]uint64)
+ gasLimitMap := make(map[uint64]uint64)
+
+ for _, block := range blocks {
+ for _, tx := range block.Transactions {
+ allTxsByGasUsed = append(allTxsByGasUsed, TopTransaction{
+ Hash: tx.Hash,
+ BlockNumber: tx.BlockNumber,
+ GasLimit: tx.GasLimit,
+ GasUsed: tx.GasUsed,
+ BlockGasLimit: tx.BlockGasLimit,
+ GasUsedPercent: tx.GasUsedPercent,
+ })
+ allTxsByGasLimit = append(allTxsByGasLimit, TopTransaction{
+ Hash: tx.Hash,
+ BlockNumber: tx.BlockNumber,
+ GasLimit: tx.GasLimit,
+ GasUsed: tx.GasUsed,
+ })
+ gasPriceMap[tx.GasPrice]++
+ gasLimitMap[tx.GasLimit]++
+ }
+ }
+
+ // Top 10 transactions by gas used
+ // Sort transactions by gas used descending
+ sort.Slice(allTxsByGasUsed, func(i, j int) bool {
+ return allTxsByGasUsed[i].GasUsed > allTxsByGasUsed[j].GasUsed
+ })
+ if len(allTxsByGasUsed) > 10 {
+ top10.TransactionsByGas = allTxsByGasUsed[:10]
+ } else {
+ top10.TransactionsByGas = allTxsByGasUsed
+ }
+
+ // Top 10 transactions by gas limit
+ // Sort transactions by gas limit descending
+ sort.Slice(allTxsByGasLimit, func(i, j int) bool {
+ return allTxsByGasLimit[i].GasLimit > allTxsByGasLimit[j].GasLimit
+ })
+ if len(allTxsByGasLimit) > 10 {
+ top10.TransactionsByGasLimit = allTxsByGasLimit[:10]
+ } else {
+ top10.TransactionsByGasLimit = allTxsByGasLimit
+ }
+
+ // Top 10 most used gas prices
+ gasPriceFreqs := make([]GasPriceFreq, 0, len(gasPriceMap))
+ for price, count := range gasPriceMap {
+ gasPriceFreqs = append(gasPriceFreqs, GasPriceFreq{
+ GasPrice: price,
+ Count: count,
+ })
+ }
+ // Sort by count descending
+ sort.Slice(gasPriceFreqs, func(i, j int) bool {
+ return gasPriceFreqs[i].Count > gasPriceFreqs[j].Count
+ })
+ if len(gasPriceFreqs) > 10 {
+ top10.MostUsedGasPrices = gasPriceFreqs[:10]
+ } else {
+ top10.MostUsedGasPrices = gasPriceFreqs
+ }
+
+ // Top 10 most used gas limits
+ gasLimitFreqs := make([]GasLimitFreq, 0, len(gasLimitMap))
+ for limit, count := range gasLimitMap {
+ gasLimitFreqs = append(gasLimitFreqs, GasLimitFreq{
+ GasLimit: limit,
+ Count: count,
+ })
+ }
+ // Sort by count descending
+ sort.Slice(gasLimitFreqs, func(i, j int) bool {
+ return gasLimitFreqs[i].Count > gasLimitFreqs[j].Count
+ })
+ if len(gasLimitFreqs) > 10 {
+ top10.MostUsedGasLimits = gasLimitFreqs[:10]
+ } else {
+ top10.MostUsedGasLimits = gasLimitFreqs
+ }
+
+ return top10
+}
+
+// outputReport writes the report to the specified output
+func outputReport(ctx context.Context, report *BlockReport, format, outputFile string) error {
+ switch format {
+ case "json":
+ return outputJSON(report, outputFile)
+ case "html":
+ return outputHTML(report, outputFile)
+ case "pdf":
+ return outputPDF(ctx, report, outputFile)
+ default:
+ return fmt.Errorf("unsupported format: %s", format)
+ }
+}
+
+// outputJSON writes the report as JSON
+func outputJSON(report *BlockReport, outputFile string) error {
+ jsonData, err := json.MarshalIndent(report, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal report to JSON: %w", err)
+ }
+
+ if outputFile == "" {
+ fmt.Println(string(jsonData))
+ return nil
+ }
+
+ if err := os.WriteFile(outputFile, jsonData, 0644); err != nil {
+ return fmt.Errorf("failed to write JSON file: %w", err)
+ }
+
+ log.Info().Str("file", outputFile).Msg("JSON report written")
+ return nil
+}
+
+// outputHTML generates an HTML report from the JSON data
+func outputHTML(report *BlockReport, outputFile string) error {
+ html := generateHTML(report)
+
+ if err := os.WriteFile(outputFile, []byte(html), 0644); err != nil {
+ return fmt.Errorf("failed to write HTML file: %w", err)
+ }
+
+ log.Info().Str("file", outputFile).Msg("HTML report written")
+ return nil
+}
diff --git a/cmd/report/report_test.go b/cmd/report/report_test.go
new file mode 100644
index 00000000..6b10de2c
--- /dev/null
+++ b/cmd/report/report_test.go
@@ -0,0 +1,530 @@
+package report
+
+import (
+ "context"
+ "math"
+ "strings"
+ "testing"
+)
+
+// TestHexToUint64 tests the hexToUint64 conversion function
+func TestHexToUint64(t *testing.T) {
+ tests := []struct {
+ name string
+ input any
+ expected uint64
+ }{
+ {
+ name: "nil input",
+ input: nil,
+ expected: 0,
+ },
+ {
+ name: "empty string",
+ input: "",
+ expected: 0,
+ },
+ {
+ name: "non-string input",
+ input: 123,
+ expected: 0,
+ },
+ {
+ name: "hex with 0x prefix",
+ input: "0x10",
+ expected: 16,
+ },
+ {
+ name: "hex without prefix",
+ input: "10",
+ expected: 16,
+ },
+ {
+ name: "zero",
+ input: "0x0",
+ expected: 0,
+ },
+ {
+ name: "large hex value",
+ input: "0xffffffffffffffff",
+ expected: math.MaxUint64,
+ },
+ {
+ name: "typical block number",
+ input: "0x1234567",
+ expected: 19088743,
+ },
+ {
+ name: "invalid hex",
+ input: "0xZZZ",
+ expected: 0,
+ },
+ {
+ name: "short hex",
+ input: "0x",
+ expected: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := hexToUint64(tt.input)
+ if result != tt.expected {
+ t.Errorf("hexToUint64(%v) = %d, want %d", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
+// TestCalculateTop10Stats tests the top 10 statistics calculation
+func TestCalculateTop10Stats(t *testing.T) {
+ t.Run("empty blocks", func(t *testing.T) {
+ blocks := []BlockInfo{}
+ result := calculateTop10Stats(blocks)
+
+ if len(result.BlocksByTxCount) != 0 {
+ t.Errorf("expected 0 blocks by tx count, got %d", len(result.BlocksByTxCount))
+ }
+ if len(result.BlocksByGasUsed) != 0 {
+ t.Errorf("expected 0 blocks by gas used, got %d", len(result.BlocksByGasUsed))
+ }
+ if len(result.TransactionsByGas) != 0 {
+ t.Errorf("expected 0 transactions by gas, got %d", len(result.TransactionsByGas))
+ }
+ })
+
+ t.Run("single block with transactions", func(t *testing.T) {
+ blocks := []BlockInfo{
+ {
+ Number: 100,
+ TxCount: 3,
+ GasUsed: 100000,
+ GasLimit: 200000,
+ Transactions: []TransactionInfo{
+ {Hash: "0x1", GasUsed: 50000, GasLimit: 60000, GasPrice: 20000000000, BlockNumber: 100, BlockGasLimit: 200000, GasUsedPercent: 25.0},
+ {Hash: "0x2", GasUsed: 30000, GasLimit: 40000, GasPrice: 20000000000, BlockNumber: 100, BlockGasLimit: 200000, GasUsedPercent: 15.0},
+ {Hash: "0x3", GasUsed: 20000, GasLimit: 30000, GasPrice: 30000000000, BlockNumber: 100, BlockGasLimit: 200000, GasUsedPercent: 10.0},
+ },
+ },
+ }
+
+ result := calculateTop10Stats(blocks)
+
+ // Check blocks by tx count
+ if len(result.BlocksByTxCount) != 1 {
+ t.Errorf("expected 1 block by tx count, got %d", len(result.BlocksByTxCount))
+ }
+ if result.BlocksByTxCount[0].Number != 100 {
+ t.Errorf("expected block 100, got %d", result.BlocksByTxCount[0].Number)
+ }
+ if result.BlocksByTxCount[0].TxCount != 3 {
+ t.Errorf("expected 3 transactions, got %d", result.BlocksByTxCount[0].TxCount)
+ }
+
+ // Check transactions by gas used
+ if len(result.TransactionsByGas) != 3 {
+ t.Errorf("expected 3 transactions by gas, got %d", len(result.TransactionsByGas))
+ }
+ // Should be sorted by gas used descending
+ if result.TransactionsByGas[0].GasUsed != 50000 {
+ t.Errorf("expected highest gas used to be 50000, got %d", result.TransactionsByGas[0].GasUsed)
+ }
+ if result.TransactionsByGas[2].GasUsed != 20000 {
+ t.Errorf("expected lowest gas used to be 20000, got %d", result.TransactionsByGas[2].GasUsed)
+ }
+
+ // Check most used gas prices
+ if len(result.MostUsedGasPrices) != 2 {
+ t.Errorf("expected 2 unique gas prices, got %d", len(result.MostUsedGasPrices))
+ }
+ // The most frequent gas price (20000000000) should be first
+ if result.MostUsedGasPrices[0].GasPrice != 20000000000 {
+ t.Errorf("expected most used gas price to be 20000000000, got %d", result.MostUsedGasPrices[0].GasPrice)
+ }
+ if result.MostUsedGasPrices[0].Count != 2 {
+ t.Errorf("expected count of 2, got %d", result.MostUsedGasPrices[0].Count)
+ }
+ })
+
+ t.Run("multiple blocks - top 10 limit", func(t *testing.T) {
+ // Create 15 blocks to test the top 10 limit
+ blocks := make([]BlockInfo, 15)
+ for i := range blocks {
+ blocks[i] = BlockInfo{
+ Number: uint64(i),
+ TxCount: uint64(i * 10), // Increasing tx count
+ GasUsed: uint64(i * 100000),
+ GasLimit: 1000000,
+ }
+ }
+
+ result := calculateTop10Stats(blocks)
+
+ // Should only return top 10
+ if len(result.BlocksByTxCount) != 10 {
+ t.Errorf("expected 10 blocks by tx count, got %d", len(result.BlocksByTxCount))
+ }
+
+ // Should be sorted descending, so highest should be first
+ if result.BlocksByTxCount[0].TxCount != 140 {
+ t.Errorf("expected highest tx count to be 140, got %d", result.BlocksByTxCount[0].TxCount)
+ }
+ if result.BlocksByTxCount[9].TxCount != 50 {
+ t.Errorf("expected 10th highest tx count to be 50, got %d", result.BlocksByTxCount[9].TxCount)
+ }
+ })
+
+ t.Run("gas used percentage calculation", func(t *testing.T) {
+ blocks := []BlockInfo{
+ {
+ Number: 100,
+ TxCount: 1,
+ GasUsed: 75000,
+ GasLimit: 100000,
+ },
+ }
+
+ result := calculateTop10Stats(blocks)
+
+ if len(result.BlocksByGasUsed) != 1 {
+ t.Fatalf("expected 1 block by gas used, got %d", len(result.BlocksByGasUsed))
+ }
+
+ expectedPercent := 75.0
+ if result.BlocksByGasUsed[0].GasUsedPercent != expectedPercent {
+ t.Errorf("expected gas used percent to be %.2f, got %.2f", expectedPercent, result.BlocksByGasUsed[0].GasUsedPercent)
+ }
+ })
+}
+
+// TestBlockRangeLogic tests the smart default logic for block ranges
+func TestBlockRangeLogic(t *testing.T) {
+ tests := []struct {
+ name string
+ startInput uint64
+ endInput uint64
+ latestBlock uint64
+ wantStart uint64
+ wantEnd uint64
+ }{
+ {
+ name: "no flags specified - latest 500 blocks",
+ startInput: BlockNotSet,
+ endInput: BlockNotSet,
+ latestBlock: 1000,
+ wantStart: 501,
+ wantEnd: 1000,
+ },
+ {
+ name: "no flags - small chain (< 500 blocks)",
+ startInput: BlockNotSet,
+ endInput: BlockNotSet,
+ latestBlock: 100,
+ wantStart: 0,
+ wantEnd: 100,
+ },
+ {
+ name: "only start specified - next 500 blocks",
+ startInput: 100,
+ endInput: BlockNotSet,
+ latestBlock: 1000,
+ wantStart: 100,
+ wantEnd: 599,
+ },
+ {
+ name: "only start specified - capped at latest",
+ startInput: 900,
+ endInput: BlockNotSet,
+ latestBlock: 1000,
+ wantStart: 900,
+ wantEnd: 1000,
+ },
+ {
+ name: "only end specified - previous 500 blocks",
+ startInput: BlockNotSet,
+ endInput: 600,
+ latestBlock: 1000,
+ wantStart: 101,
+ wantEnd: 600,
+ },
+ {
+ name: "only end specified - end < 500",
+ startInput: BlockNotSet,
+ endInput: 100,
+ latestBlock: 1000,
+ wantStart: 0,
+ wantEnd: 100,
+ },
+ {
+ name: "both specified - genesis block only",
+ startInput: 0,
+ endInput: 0,
+ latestBlock: 1000,
+ wantStart: 0,
+ wantEnd: 0,
+ },
+ {
+ name: "both specified - custom range",
+ startInput: 1000,
+ endInput: 2000,
+ latestBlock: 5000,
+ wantStart: 1000,
+ wantEnd: 2000,
+ },
+ {
+ name: "start at zero with end unspecified",
+ startInput: 0,
+ endInput: BlockNotSet,
+ latestBlock: 1000,
+ wantStart: 0,
+ wantEnd: 499,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ start := tt.startInput
+ end := tt.endInput
+
+ // Apply the same logic as in report.go RunE function
+ if start == BlockNotSet && end == BlockNotSet {
+ // Both unspecified
+ end = tt.latestBlock
+ if tt.latestBlock >= DefaultBlockRange-1 {
+ start = tt.latestBlock - (DefaultBlockRange - 1)
+ } else {
+ start = 0
+ }
+ } else if start == BlockNotSet {
+ // Only start unspecified
+ if end >= DefaultBlockRange-1 {
+ start = end - (DefaultBlockRange - 1)
+ } else {
+ start = 0
+ }
+ } else if end == BlockNotSet {
+ // Only end unspecified
+ end = start + (DefaultBlockRange - 1)
+ if end > tt.latestBlock {
+ end = tt.latestBlock
+ }
+ }
+ // Both set: use as-is
+
+ if start != tt.wantStart {
+ t.Errorf("start = %d, want %d", start, tt.wantStart)
+ }
+ if end != tt.wantEnd {
+ t.Errorf("end = %d, want %d", end, tt.wantEnd)
+ }
+ })
+ }
+}
+
+// TestBlockNotSetConstant verifies the sentinel value is set correctly
+func TestBlockNotSetConstant(t *testing.T) {
+ expected := uint64(math.MaxUint64)
+ actual := uint64(BlockNotSet)
+ if actual != expected {
+ t.Errorf("BlockNotSet = %d, want %d", actual, expected)
+ }
+}
+
+// TestDefaultBlockRangeConstant verifies the default block range
+func TestDefaultBlockRangeConstant(t *testing.T) {
+ if DefaultBlockRange != 500 {
+ t.Errorf("DefaultBlockRange = %d, want 500", DefaultBlockRange)
+ }
+}
+
+// TestCheckFlagsValidation tests the validation in checkFlags function
+func TestCheckFlagsValidation(t *testing.T) {
+ // Save original values to restore after tests
+ originalParams := inputReport
+
+ // Helper to reset to valid defaults
+ resetToValidDefaults := func() {
+ inputReport = reportParams{
+ RpcUrl: "http://localhost:8545",
+ StartBlock: 0,
+ EndBlock: 100,
+ OutputFile: "",
+ Format: "json",
+ Concurrency: 10,
+ RateLimit: 4.0,
+ }
+ }
+
+ tests := []struct {
+ name string
+ setupParams func()
+ wantError bool
+ errorMsg string
+ }{
+ {
+ name: "valid parameters",
+ setupParams: func() {
+ resetToValidDefaults()
+ },
+ wantError: false,
+ },
+ {
+ name: "concurrency zero",
+ setupParams: func() {
+ resetToValidDefaults()
+ inputReport.Concurrency = 0
+ },
+ wantError: true,
+ errorMsg: "concurrency must be at least 1",
+ },
+ {
+ name: "concurrency negative",
+ setupParams: func() {
+ resetToValidDefaults()
+ inputReport.Concurrency = -5
+ },
+ wantError: true,
+ errorMsg: "concurrency must be at least 1",
+ },
+ {
+ name: "rate-limit zero",
+ setupParams: func() {
+ resetToValidDefaults()
+ inputReport.RateLimit = 0
+ },
+ wantError: true,
+ errorMsg: "rate-limit must be greater than 0",
+ },
+ {
+ name: "rate-limit negative",
+ setupParams: func() {
+ resetToValidDefaults()
+ inputReport.RateLimit = -2.5
+ },
+ wantError: true,
+ errorMsg: "rate-limit must be greater than 0",
+ },
+ {
+ name: "invalid format",
+ setupParams: func() {
+ resetToValidDefaults()
+ inputReport.Format = "xml"
+ },
+ wantError: true,
+ errorMsg: "format must be either 'json', 'html', or 'pdf'",
+ },
+ {
+ name: "end-block less than start-block",
+ setupParams: func() {
+ resetToValidDefaults()
+ inputReport.StartBlock = 100
+ inputReport.EndBlock = 50
+ },
+ wantError: true,
+ errorMsg: "end-block must be greater than or equal to start-block",
+ },
+ {
+ name: "minimum valid concurrency",
+ setupParams: func() {
+ resetToValidDefaults()
+ inputReport.Concurrency = 1
+ },
+ wantError: false,
+ },
+ {
+ name: "minimum valid rate-limit",
+ setupParams: func() {
+ resetToValidDefaults()
+ inputReport.RateLimit = 0.1
+ },
+ wantError: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tt.setupParams()
+ err := checkFlags()
+
+ if tt.wantError {
+ if err == nil {
+ t.Errorf("checkFlags() expected error containing %q, got nil", tt.errorMsg)
+ } else if err.Error() != tt.errorMsg {
+ t.Errorf("checkFlags() error = %q, want %q", err.Error(), tt.errorMsg)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("checkFlags() unexpected error: %v", err)
+ }
+ }
+ })
+ }
+
+ // Restore original values
+ inputReport = originalParams
+}
+
+// TestGenerateReportBlockRangeValidation tests that generateReport validates block ranges
+func TestGenerateReportBlockRangeValidation(t *testing.T) {
+ ctx := context.Background()
+
+ tests := []struct {
+ name string
+ startBlock uint64
+ endBlock uint64
+ wantError bool
+ errorSubstr string
+ }{
+ {
+ name: "start block is BlockNotSet",
+ startBlock: BlockNotSet,
+ endBlock: 1000,
+ wantError: true,
+ errorSubstr: "start block must be specified",
+ },
+ {
+ name: "end block is BlockNotSet",
+ startBlock: 0,
+ endBlock: BlockNotSet,
+ wantError: true,
+ errorSubstr: "end block must be specified",
+ },
+ {
+ name: "both blocks are BlockNotSet",
+ startBlock: BlockNotSet,
+ endBlock: BlockNotSet,
+ wantError: true,
+ errorSubstr: "start block must be specified",
+ },
+ {
+ name: "end block less than start block",
+ startBlock: 1000,
+ endBlock: 500,
+ wantError: true,
+ errorSubstr: "end block (500) must be greater than or equal to start block (1000)",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ report := &BlockReport{
+ StartBlock: tt.startBlock,
+ EndBlock: tt.endBlock,
+ }
+
+ // We can't actually call generateReport without a real RPC client,
+ // so we only test the cases that should fail validation
+ if !tt.wantError {
+ t.Skip("skipping valid range test as it requires real RPC client")
+ }
+
+ err := generateReport(ctx, nil, report, 1, 1.0)
+
+ if err == nil {
+ t.Errorf("generateReport() expected error containing %q, got nil", tt.errorSubstr)
+ } else if !strings.Contains(err.Error(), tt.errorSubstr) {
+ t.Errorf("generateReport() error = %q, want error containing %q", err.Error(), tt.errorSubstr)
+ }
+ })
+ }
+}
diff --git a/cmd/report/template.html b/cmd/report/template.html
new file mode 100644
index 00000000..32d2d758
--- /dev/null
+++ b/cmd/report/template.html
@@ -0,0 +1,319 @@
+
+
+
+
+
+ Blockchain Block Analysis Report
+
+
+
+
+
Blockchain Block Analysis Report
+
+
+
+
Summary Statistics
+
+ {{STAT_CARDS}}
+
+
+ {{TX_COUNT_CHART}}
+
+ {{GAS_USAGE_CHART}}
+
+ {{TOP_10_SECTIONS}}
+
+
+
+
+
+
+
diff --git a/cmd/report/types.go b/cmd/report/types.go
new file mode 100644
index 00000000..bd07811a
--- /dev/null
+++ b/cmd/report/types.go
@@ -0,0 +1,95 @@
+package report
+
+import (
+ "math/big"
+ "time"
+)
+
+// BlockReport represents the complete report for a range of blocks
+type BlockReport struct {
+ ChainID uint64 `json:"chain_id"`
+ RpcUrl string `json:"rpc_url"`
+ StartBlock uint64 `json:"start_block"`
+ EndBlock uint64 `json:"end_block"`
+ GeneratedAt time.Time `json:"generated_at"`
+ Summary SummaryStats `json:"summary"`
+ Top10 Top10Stats `json:"top_10"`
+ Blocks []BlockInfo `json:"-"` // Internal use only, not exported
+}
+
+// SummaryStats contains aggregate statistics for the block range
+type SummaryStats struct {
+ TotalBlocks uint64 `json:"total_blocks"`
+ TotalTransactions uint64 `json:"total_transactions"`
+ TotalGasUsed uint64 `json:"total_gas_used"`
+ AvgTxPerBlock float64 `json:"avg_tx_per_block"`
+ AvgGasPerBlock float64 `json:"avg_gas_per_block"`
+ AvgBaseFeePerGas string `json:"avg_base_fee_per_gas,omitempty"`
+ UniqueSenders uint64 `json:"unique_senders"`
+ UniqueRecipients uint64 `json:"unique_recipients"`
+}
+
+// BlockInfo contains information about a single block
+type BlockInfo struct {
+ Number uint64 `json:"number"`
+ Timestamp uint64 `json:"timestamp"`
+ TxCount uint64 `json:"tx_count"`
+ GasUsed uint64 `json:"gas_used"`
+ GasLimit uint64 `json:"gas_limit"`
+ BaseFeePerGas *big.Int `json:"base_fee_per_gas,omitempty"`
+ Transactions []TransactionInfo `json:"-"` // Internal use only
+}
+
+// TransactionInfo contains information about a single transaction
+type TransactionInfo struct {
+ Hash string `json:"hash"`
+ From string `json:"from"`
+ To string `json:"to"`
+ BlockNumber uint64 `json:"block_number"`
+ GasUsed uint64 `json:"gas_used"`
+ GasLimit uint64 `json:"gas_limit"`
+ GasPrice uint64 `json:"gas_price"`
+ BlockGasLimit uint64 `json:"block_gas_limit"`
+ GasUsedPercent float64 `json:"gas_used_percent"`
+}
+
+// Top10Stats contains top 10 lists for various metrics
+type Top10Stats struct {
+ BlocksByTxCount []TopBlock `json:"blocks_by_tx_count"`
+ BlocksByGasUsed []TopBlock `json:"blocks_by_gas_used"`
+ TransactionsByGas []TopTransaction `json:"transactions_by_gas"`
+ TransactionsByGasLimit []TopTransaction `json:"transactions_by_gas_limit"`
+ MostUsedGasPrices []GasPriceFreq `json:"most_used_gas_prices"`
+ MostUsedGasLimits []GasLimitFreq `json:"most_used_gas_limits"`
+}
+
+// TopBlock represents a block in a top 10 list
+type TopBlock struct {
+ Number uint64 `json:"number"`
+ TxCount uint64 `json:"tx_count,omitempty"`
+ GasUsed uint64 `json:"gas_used,omitempty"`
+ GasLimit uint64 `json:"gas_limit,omitempty"`
+ GasUsedPercent float64 `json:"gas_used_percent,omitempty"`
+}
+
+// TopTransaction represents a transaction in a top 10 list
+type TopTransaction struct {
+ Hash string `json:"hash"`
+ BlockNumber uint64 `json:"block_number"`
+ GasUsed uint64 `json:"gas_used,omitempty"`
+ GasLimit uint64 `json:"gas_limit,omitempty"`
+ BlockGasLimit uint64 `json:"block_gas_limit,omitempty"`
+ GasUsedPercent float64 `json:"gas_used_percent,omitempty"`
+}
+
+// GasPriceFreq represents the frequency of a specific gas price
+type GasPriceFreq struct {
+ GasPrice uint64 `json:"gas_price"`
+ Count uint64 `json:"count"`
+}
+
+// GasLimitFreq represents the frequency of a specific gas limit
+type GasLimitFreq struct {
+ GasLimit uint64 `json:"gas_limit"`
+ Count uint64 `json:"count"`
+}
diff --git a/cmd/report/usage.md b/cmd/report/usage.md
new file mode 100644
index 00000000..bcfe5be4
--- /dev/null
+++ b/cmd/report/usage.md
@@ -0,0 +1,194 @@
+The `report` command analyzes a range of blocks from an Ethereum-compatible blockchain and generates a comprehensive report with statistics and visualizations.
+
+**⚠️ Important Requirements**:
+- RPC endpoint with `eth_getBlockReceipts` support (see [RPC Requirements](#rpc-requirements))
+- Chrome or Chromium for PDF generation (see [System Requirements](#system-requirements))
+
+## Features
+
+- **Stateless Operation**: All data is queried from the blockchain via RPC, no local storage required
+- **Smart Defaults**: Automatically analyzes the latest 500 blocks if no range is specified
+- **JSON Output**: Always generates a structured JSON report for programmatic analysis
+- **HTML Visualization**: Optionally generates a visual HTML report with charts and tables
+- **PDF Export**: Generate PDF reports (requires Chrome/Chromium installed)
+- **Flexible Block Range**: Analyze any range of blocks with automatic range completion
+- **Transaction Metrics**: Track transaction counts, gas usage, and other key metrics
+
+## Basic Usage
+
+Analyze the latest 500 blocks (no range specified):
+
+```bash
+polycli report --rpc-url http://localhost:8545
+```
+
+Generate a JSON report for blocks 1000 to 2000:
+
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 1000 --end-block 2000
+```
+
+Analyze 500 blocks starting from block 1000:
+
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 1000
+```
+
+Analyze the previous 500 blocks ending at block 2000:
+
+```bash
+polycli report --rpc-url http://localhost:8545 --end-block 2000
+```
+
+Analyze only the genesis block (block 0):
+
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 0 --end-block 0
+```
+
+Generate an HTML report:
+
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 1000 --end-block 2000 --format html
+```
+
+Save JSON output to a file:
+
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 1000 --end-block 2000 --output report.json
+```
+
+## Report Contents
+
+The report includes:
+
+### Summary Statistics
+- Total number of blocks analyzed
+- Total transaction count
+- Average transactions per block
+- Total gas used across all blocks
+- Average gas used per block
+- Average base fee per gas (for EIP-1559 compatible chains)
+
+### Block Details
+For each block in the range:
+- Block number and timestamp
+- Transaction count
+- Gas used and gas limit
+- Base fee per gas (if available)
+
+### Visualizations (HTML format only)
+- Transaction count chart showing distribution across blocks
+- Gas usage chart showing gas consumption patterns
+- Detailed table with all block information
+
+## Examples
+
+Analyze recent blocks:
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 19000000 --end-block 19000100 --format html -o analysis.html
+```
+
+Generate JSON for automated processing:
+```bash
+polycli report --rpc-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY \
+ --start-block 18000000 \
+ --end-block 18001000 \
+ --output mainnet-analysis.json
+```
+
+Quick analysis to stdout:
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 1000 --end-block 1100 | jq '.summary'
+```
+
+Adjust concurrency for rate-limited endpoints:
+```bash
+polycli report --rpc-url https://public-rpc.example.com \
+ --start-block 1000000 \
+ --end-block 1001000 \
+ --concurrency 5 \
+ --rate-limit 2 \
+ --format html
+```
+
+## Block Range Behavior
+
+The command uses smart defaults for block ranges:
+
+- **No flags specified**: Analyzes the latest 500 blocks on the chain
+- **Only `--start-block` specified**: Analyzes 500 blocks starting from the specified block, or up to the latest block if fewer than 500 blocks remain
+- **Only `--end-block` specified**: Analyzes 500 blocks ending at the specified block (or from block 0 if the chain has fewer than 500 blocks)
+- **Both flags specified**: Analyzes the exact range specified (e.g., `--start-block 0 --end-block 0` analyzes only the genesis block)
+
+The default range of 500 blocks can be modified by changing the `DefaultBlockRange` constant in the code.
+
+**Note**: Block 0 (genesis) can be explicitly specified. To analyze only the genesis block, use `--start-block 0 --end-block 0`.
+
+## RPC Requirements
+
+**IMPORTANT**: This command requires an RPC endpoint that supports the `eth_getBlockReceipts` method. This is a non-standard but widely implemented extension to the JSON-RPC API.
+
+### Supported RPC Providers
+- ✅ Geth (full nodes)
+- ✅ Erigon
+- ✅ Polygon nodes
+- ✅ Most self-hosted nodes
+- ✅ Alchemy (premium endpoints)
+- ✅ QuickNode
+- ❌ Many public/free RPC endpoints (may not support `eth_getBlockReceipts`)
+- ❌ Infura (does not support `eth_getBlockReceipts`)
+
+If your RPC endpoint does not support `eth_getBlockReceipts`, the command will fail with an error like:
+```
+failed to fetch block receipts: method eth_getBlockReceipts does not exist/is not available
+```
+
+**Recommendation**: Use a self-hosted node or a premium RPC provider that supports this method.
+
+## System Requirements
+
+### PDF Generation
+
+**IMPORTANT**: PDF report generation requires Google Chrome or Chromium to be installed on your system. The command uses Chrome's headless mode to render the HTML report as a PDF.
+
+**Installing Chrome/Chromium:**
+
+- **macOS**:
+ ```bash
+ brew install --cask google-chrome
+ # or
+ brew install chromium
+ ```
+
+- **Ubuntu/Debian**:
+ ```bash
+ sudo apt-get update
+ sudo apt-get install chromium-browser
+ # or
+ sudo apt-get install google-chrome-stable
+ ```
+
+- **RHEL/CentOS/Fedora**:
+ ```bash
+ sudo dnf install chromium
+ # or install Chrome from official RPM
+ ```
+
+- **Windows**: Download and install from [google.com/chrome](https://www.google.com/chrome/)
+
+If Chrome/Chromium is not installed, PDF generation will fail with an error message indicating that Chrome could not be found.
+
+**Alternative**: If you need PDF reports but cannot install Chrome, you can generate an HTML report and convert it to PDF using another tool.
+
+## Notes
+
+- To analyze a single block, set both start and end to the same block number (e.g., `--start-block 100 --end-block 100`)
+- The command queries blocks concurrently with rate limiting to avoid overwhelming the RPC endpoint:
+ - `--concurrency` controls the number of concurrent RPC requests (default: 10)
+ - `--rate-limit` controls the maximum requests per second (default: 4)
+ - Adjust these values based on your RPC endpoint's capacity
+- Progress is logged every 100 blocks
+- **Data Integrity**: The command automatically retries failed block fetches up to 3 times. If any blocks cannot be fetched after all retry attempts, the command fails with an error listing the failed blocks. This ensures reports are deterministic and complete - the same parameters always produce the same report.
+- HTML reports include interactive hover tooltips on charts
+- For large block ranges, consider running the command with a dedicated RPC endpoint
diff --git a/cmd/root.go b/cmd/root.go
index 60646ded..9915c2a1 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -27,6 +27,7 @@ import (
"github.com/0xPolygon/polygon-cli/cmd/parsebatchl2data"
"github.com/0xPolygon/polygon-cli/cmd/parseethwallet"
"github.com/0xPolygon/polygon-cli/cmd/publish"
+ "github.com/0xPolygon/polygon-cli/cmd/report"
"github.com/0xPolygon/polygon-cli/cmd/retest"
"github.com/0xPolygon/polygon-cli/cmd/rpcfuzz"
"github.com/0xPolygon/polygon-cli/cmd/signer"
@@ -146,6 +147,7 @@ func NewPolycliCommand() *cobra.Command {
nodekey.NodekeyCmd,
p2p.P2pCmd,
parseethwallet.ParseETHWalletCmd,
+ report.ReportCmd,
retest.RetestCmd,
rpcfuzz.RPCFuzzCmd,
signer.SignerCmd,
diff --git a/doc/polycli.md b/doc/polycli.md
index 01506c54..a9f4f7b4 100644
--- a/doc/polycli.md
+++ b/doc/polycli.md
@@ -82,6 +82,8 @@ Polycli is a collection of tools that are meant to be useful while building, tes
- [polycli publish](polycli_publish.md) - Publish transactions to the network with high-throughput.
+- [polycli report](polycli_report.md) - Generate a report analyzing a range of blocks from an Ethereum-compatible blockchain.
+
- [polycli retest](polycli_retest.md) - Convert the standard ETH test fillers into something to be replayed against an RPC.
- [polycli rpcfuzz](polycli_rpcfuzz.md) - Continually run a variety of RPC calls and fuzzers.
diff --git a/doc/polycli_report.md b/doc/polycli_report.md
new file mode 100644
index 00000000..2978e81d
--- /dev/null
+++ b/doc/polycli_report.md
@@ -0,0 +1,248 @@
+# `polycli report`
+
+> Auto-generated documentation.
+
+## Table of Contents
+
+- [Description](#description)
+- [Usage](#usage)
+- [Flags](#flags)
+- [See Also](#see-also)
+
+## Description
+
+Generate a report analyzing a range of blocks from an Ethereum-compatible blockchain.
+
+```bash
+polycli report [flags]
+```
+
+## Usage
+
+The `report` command analyzes a range of blocks from an Ethereum-compatible blockchain and generates a comprehensive report with statistics and visualizations.
+
+**⚠️ Important Requirements**:
+- RPC endpoint with `eth_getBlockReceipts` support (see [RPC Requirements](#rpc-requirements))
+- Chrome or Chromium for PDF generation (see [System Requirements](#system-requirements))
+
+## Features
+
+- **Stateless Operation**: All data is queried from the blockchain via RPC, no local storage required
+- **Smart Defaults**: Automatically analyzes the latest 500 blocks if no range is specified
+- **JSON Output**: Always generates a structured JSON report for programmatic analysis
+- **HTML Visualization**: Optionally generates a visual HTML report with charts and tables
+- **PDF Export**: Generate PDF reports (requires Chrome/Chromium installed)
+- **Flexible Block Range**: Analyze any range of blocks with automatic range completion
+- **Transaction Metrics**: Track transaction counts, gas usage, and other key metrics
+
+## Basic Usage
+
+Analyze the latest 500 blocks (no range specified):
+
+```bash
+polycli report --rpc-url http://localhost:8545
+```
+
+Generate a JSON report for blocks 1000 to 2000:
+
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 1000 --end-block 2000
+```
+
+Analyze 500 blocks starting from block 1000:
+
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 1000
+```
+
+Analyze the previous 500 blocks ending at block 2000:
+
+```bash
+polycli report --rpc-url http://localhost:8545 --end-block 2000
+```
+
+Analyze only the genesis block (block 0):
+
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 0 --end-block 0
+```
+
+Generate an HTML report:
+
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 1000 --end-block 2000 --format html
+```
+
+Save JSON output to a file:
+
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 1000 --end-block 2000 --output report.json
+```
+
+## Report Contents
+
+The report includes:
+
+### Summary Statistics
+- Total number of blocks analyzed
+- Total transaction count
+- Average transactions per block
+- Total gas used across all blocks
+- Average gas used per block
+- Average base fee per gas (for EIP-1559 compatible chains)
+
+### Block Details
+For each block in the range:
+- Block number and timestamp
+- Transaction count
+- Gas used and gas limit
+- Base fee per gas (if available)
+
+### Visualizations (HTML format only)
+- Transaction count chart showing distribution across blocks
+- Gas usage chart showing gas consumption patterns
+- Detailed table with all block information
+
+## Examples
+
+Analyze recent blocks:
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 19000000 --end-block 19000100 --format html -o analysis.html
+```
+
+Generate JSON for automated processing:
+```bash
+polycli report --rpc-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY \
+ --start-block 18000000 \
+ --end-block 18001000 \
+ --output mainnet-analysis.json
+```
+
+Quick analysis to stdout:
+```bash
+polycli report --rpc-url http://localhost:8545 --start-block 1000 --end-block 1100 | jq '.summary'
+```
+
+Adjust concurrency for rate-limited endpoints:
+```bash
+polycli report --rpc-url https://public-rpc.example.com \
+ --start-block 1000000 \
+ --end-block 1001000 \
+ --concurrency 5 \
+ --rate-limit 2 \
+ --format html
+```
+
+## Block Range Behavior
+
+The command uses smart defaults for block ranges:
+
+- **No flags specified**: Analyzes the latest 500 blocks on the chain
+- **Only `--start-block` specified**: Analyzes 500 blocks starting from the specified block, or up to the latest block if fewer than 500 blocks remain
+- **Only `--end-block` specified**: Analyzes 500 blocks ending at the specified block (or from block 0 if the chain has fewer than 500 blocks)
+- **Both flags specified**: Analyzes the exact range specified (e.g., `--start-block 0 --end-block 0` analyzes only the genesis block)
+
+The default range of 500 blocks can be modified by changing the `DefaultBlockRange` constant in the code.
+
+**Note**: Block 0 (genesis) can be explicitly specified. To analyze only the genesis block, use `--start-block 0 --end-block 0`.
+
+## RPC Requirements
+
+**IMPORTANT**: This command requires an RPC endpoint that supports the `eth_getBlockReceipts` method. This is a non-standard but widely implemented extension to the JSON-RPC API.
+
+### Supported RPC Providers
+- ✅ Geth (full nodes)
+- ✅ Erigon
+- ✅ Polygon nodes
+- ✅ Most self-hosted nodes
+- ✅ Alchemy (premium endpoints)
+- ✅ QuickNode
+- ❌ Many public/free RPC endpoints (may not support `eth_getBlockReceipts`)
+- ❌ Infura (does not support `eth_getBlockReceipts`)
+
+If your RPC endpoint does not support `eth_getBlockReceipts`, the command will fail with an error like:
+```
+failed to fetch block receipts: method eth_getBlockReceipts does not exist/is not available
+```
+
+**Recommendation**: Use a self-hosted node or a premium RPC provider that supports this method.
+
+## System Requirements
+
+### PDF Generation
+
+**IMPORTANT**: PDF report generation requires Google Chrome or Chromium to be installed on your system. The command uses Chrome's headless mode to render the HTML report as a PDF.
+
+**Installing Chrome/Chromium:**
+
+- **macOS**:
+ ```bash
+ brew install --cask google-chrome
+ # or
+ brew install chromium
+ ```
+
+- **Ubuntu/Debian**:
+ ```bash
+ sudo apt-get update
+ sudo apt-get install chromium-browser
+ # or
+ sudo apt-get install google-chrome-stable
+ ```
+
+- **RHEL/CentOS/Fedora**:
+ ```bash
+ sudo dnf install chromium
+ # or install Chrome from official RPM
+ ```
+
+- **Windows**: Download and install from [google.com/chrome](https://www.google.com/chrome/)
+
+If Chrome/Chromium is not installed, PDF generation will fail with an error message indicating that Chrome could not be found.
+
+**Alternative**: If you need PDF reports but cannot install Chrome, you can generate an HTML report and convert it to PDF using another tool.
+
+## Notes
+
+- To analyze a single block, set both start and end to the same block number (e.g., `--start-block 100 --end-block 100`)
+- The command queries blocks concurrently with rate limiting to avoid overwhelming the RPC endpoint:
+ - `--concurrency` controls the number of concurrent RPC requests (default: 10)
+ - `--rate-limit` controls the maximum requests per second (default: 4)
+ - Adjust these values based on your RPC endpoint's capacity
+- Progress is logged every 100 blocks
+- **Data Integrity**: The command automatically retries failed block fetches up to 3 times. If any blocks cannot be fetched after all retry attempts, the command fails with an error listing the failed blocks. This ensures reports are deterministic and complete - the same parameters always produce the same report.
+- HTML reports include interactive hover tooltips on charts
+- For large block ranges, consider running the command with a dedicated RPC endpoint
+
+## Flags
+
+```bash
+ --concurrency int number of concurrent RPC requests (default 10)
+ --end-block uint ending block number (default: auto-detect based on start-block or latest) (default 18446744073709551615)
+ -f, --format string output format [json, html, pdf] (default "json")
+ -h, --help help for report
+ -o, --output string output file path (default: stdout for JSON, report.html for HTML, report.pdf for PDF)
+ --rate-limit float requests per second limit (default 4)
+ --rpc-url string RPC endpoint URL (default "http://localhost:8545")
+ --start-block uint starting block number (default: auto-detect based on end-block or latest) (default 18446744073709551615)
+```
+
+The command also inherits flags from parent commands.
+
+```bash
+ --config string config file (default is $HOME/.polygon-cli.yaml)
+ --pretty-logs output logs in pretty format instead of JSON (default true)
+ -v, --verbosity string log level (string or int):
+ 0 - silent
+ 100 - panic
+ 200 - fatal
+ 300 - error
+ 400 - warn
+ 500 - info (default)
+ 600 - debug
+ 700 - trace (default "info")
+```
+
+## See also
+
+- [polycli](polycli.md) - A Swiss Army knife of blockchain tools.
diff --git a/go.mod b/go.mod
index 319113d9..de7da364 100644
--- a/go.mod
+++ b/go.mod
@@ -41,6 +41,7 @@ require github.com/alecthomas/participle/v2 v2.1.4
require (
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 // indirect
+ github.com/chromedp/sysutil v1.1.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
@@ -54,7 +55,11 @@ require (
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect
github.com/ferranbt/fastssz v0.1.4 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
+ github.com/gobwas/ws v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
@@ -62,11 +67,17 @@ require (
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pion/dtls/v2 v2.2.12 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/stun/v2 v2.0.0 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pion/transport/v3 v3.0.1 // indirect
+ github.com/sagikazarmark/locafero v0.11.0 // indirect
+ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
@@ -132,20 +143,14 @@ require (
github.com/multiformats/go-varint v0.0.7 // indirect
github.com/nsf/termbox-go v1.1.1 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
- github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
- github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
- github.com/spf13/afero v1.15.0 // indirect
- github.com/spf13/cast v1.10.0 // indirect
- github.com/subosito/gotenv v1.6.0 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.9.0 // indirect
@@ -168,6 +173,8 @@ require (
require (
cloud.google.com/go/kms v1.23.2
github.com/0xPolygon/cdk-contracts-tooling v0.0.1
+ github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
+ github.com/chromedp/chromedp v0.14.2
github.com/cometbft/cometbft v0.38.19
github.com/docker/docker v28.5.2+incompatible
github.com/fatih/color v1.18.0
diff --git a/go.sum b/go.sum
index b90ca8df..d5fd08f0 100644
--- a/go.sum
+++ b/go.sum
@@ -76,6 +76,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
+github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
@@ -208,6 +214,8 @@ github.com/gizak/termui/v3 v3.1.1-0.20231111080052-b3569a6cd52d h1:oAvxuiOB52vYA
github.com/gizak/termui/v3 v3.1.1-0.20231111080052-b3569a6cd52d/go.mod h1:G7SWm+OY7CWC3dxqXjsPO4taVBtDDEO0otCjaLSlf/0=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -223,6 +231,12 @@ github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
@@ -338,6 +352,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg=
github.com/libp2p/go-libp2p v0.36.5 h1:DoABsaHO0VXwH6pwCs2F6XKAXWYjFMO4HFBoVxTnF9g=
@@ -428,6 +444,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM=