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(` +
+

%s

+
%s
+
`, 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

+ + + + + + + + + `) + + for i, block := range report.Top10.BlocksByTxCount { + sb.WriteString(fmt.Sprintf(` + + + + + `, i+1, formatNumber(block.Number), formatNumber(block.TxCount))) + } + + sb.WriteString(` + +
RankBlock NumberTransaction Count
%d%s%s
+
`) + } + + // Top 10 blocks by gas used + if len(report.Top10.BlocksByGasUsed) > 0 { + sb.WriteString(` +
+

Top 10 Blocks by Gas Used

+ + + + + + + + + + + `) + + for i, block := range report.Top10.BlocksByGasUsed { + sb.WriteString(fmt.Sprintf(` + + + + + + + `, i+1, formatNumber(block.Number), formatNumber(block.GasUsed), formatNumberWithUnits(block.GasLimit), block.GasUsedPercent)) + } + + sb.WriteString(` + +
RankBlock NumberGas Used (Wei)Gas LimitGas Used %
%d%s%s%s%.2f%%
+
`) + } + + // Top 10 transactions by gas used + if len(report.Top10.TransactionsByGas) > 0 { + sb.WriteString(` +
+

Top 10 Transactions by Gas Used

+ + + + + + + + + + + `) + + for i, tx := range report.Top10.TransactionsByGas { + sb.WriteString(fmt.Sprintf(` + + + + + + + `, i+1, html.EscapeString(tx.Hash), formatNumber(tx.BlockNumber), formatNumberWithUnits(tx.GasLimit), formatNumber(tx.GasUsed))) + } + + sb.WriteString(` + +
RankTransaction HashBlock NumberGas LimitGas Used (Wei)
%d%s%s%s%s
+
`) + } + + // Top 10 transactions by gas limit + if len(report.Top10.TransactionsByGasLimit) > 0 { + sb.WriteString(` +
+

Top 10 Transactions by Gas Limit

+ + + + + + + + + + + `) + + for i, tx := range report.Top10.TransactionsByGasLimit { + sb.WriteString(fmt.Sprintf(` + + + + + + + `, i+1, html.EscapeString(tx.Hash), formatNumber(tx.BlockNumber), formatNumberWithUnits(tx.GasLimit), formatNumber(tx.GasUsed))) + } + + sb.WriteString(` + +
RankTransaction HashBlock NumberGas LimitGas Used (Wei)
%d%s%s%s%s
+
`) + } + + // Top 10 most used gas prices + if len(report.Top10.MostUsedGasPrices) > 0 { + sb.WriteString(` +
+

Top 10 Most Used Gas Prices

+ + + + + + + + + `) + + for i, gp := range report.Top10.MostUsedGasPrices { + sb.WriteString(fmt.Sprintf(` + + + + + `, i+1, formatNumber(gp.GasPrice), formatNumber(gp.Count))) + } + + sb.WriteString(` + +
RankGas Price (Wei)Transaction Count
%d%s%s
+
`) + } + + // Top 10 most used gas limits + if len(report.Top10.MostUsedGasLimits) > 0 { + sb.WriteString(` +
+

Top 10 Most Used Gas Limits

+ + + + + + + + + `) + + for i, gl := range report.Top10.MostUsedGasLimits { + sb.WriteString(fmt.Sprintf(` + + + + + `, i+1, formatNumberWithUnits(gl.GasLimit), formatNumber(gl.Count))) + } + + sb.WriteString(` + +
RankGas LimitTransaction Count
%d%s%s
+
`) + } + + 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=