From d1906af9622012d716392b0d4ca56c9ac740cdbb Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 2 Dec 2025 15:37:44 -0300 Subject: [PATCH 01/31] add cmd report; pernding performance improvements --- cmd/report/html.go | 586 +++++++++++++++++++++++++++++++++++++++ cmd/report/report.go | 492 ++++++++++++++++++++++++++++++++ cmd/report/template.html | 246 ++++++++++++++++ cmd/report/types.go | 95 +++++++ cmd/report/usage.md | 81 ++++++ cmd/root.go | 2 + 6 files changed, 1502 insertions(+) create mode 100644 cmd/report/html.go create mode 100644 cmd/report/report.go create mode 100644 cmd/report/template.html create mode 100644 cmd/report/types.go create mode 100644 cmd/report/usage.md diff --git a/cmd/report/html.go b/cmd/report/html.go new file mode 100644 index 000000000..23e7e501a --- /dev/null +++ b/cmd/report/html.go @@ -0,0 +1,586 @@ +package report + +import ( + _ "embed" + "fmt" + "strings" + "time" +) + +//go:embed template.html +var htmlTemplate string + +// generateHTML creates an HTML report from the BlockReport data +func generateHTML(report *BlockReport) string { + html := htmlTemplate + + // Replace metadata placeholders + html = strings.ReplaceAll(html, "{{CHAIN_ID}}", fmt.Sprintf("%d", report.ChainID)) + html = strings.ReplaceAll(html, "{{RPC_URL}}", report.RpcUrl) + html = strings.ReplaceAll(html, "{{BLOCK_RANGE}}", fmt.Sprintf("%d - %d", report.StartBlock, report.EndBlock)) + html = strings.ReplaceAll(html, "{{GENERATED_AT}}", report.GeneratedAt.Format(time.RFC3339)) + html = strings.ReplaceAll(html, "{{TOTAL_BLOCKS}}", formatNumber(report.Summary.TotalBlocks)) + + // Generate and replace stat cards + html = strings.ReplaceAll(html, "{{STAT_CARDS}}", generateStatCards(report)) + + // Generate and replace charts + html = strings.ReplaceAll(html, "{{TX_COUNT_CHART}}", generateTxCountChart(report)) + html = strings.ReplaceAll(html, "{{GAS_USAGE_CHART}}", generateGasUsageChart(report)) + + // Generate and replace top 10 sections + html = strings.ReplaceAll(html, "{{TOP_10_SECTIONS}}", generateTop10Sections(report)) + + return html +} + +// 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 > 0 { + cards = append(cards, struct { + title string + value string + }{"Avg Base Fee (Gwei)", fmt.Sprintf("%.2f", float64(report.Summary.AvgBaseFeePerGas)/1e9)}) + } + + for _, card := range cards { + sb.WriteString(fmt.Sprintf(` +
+

%s

+
%s
+
`, card.title, 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 + for i := 0; i < len(report.Blocks); i += step { + block := report.Blocks[i] + x := padding + (float64(numPoints) / float64((len(report.Blocks)-1)/step)) * 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)) + + 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 + for i := 0; i < len(report.Blocks); i += step { + block := report.Blocks[i] + x := padding + (float64(numPoints) / float64((len(report.Blocks)-1)/step)) * 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))) + + return sb.String() +} + +// generateBlocksTable creates a table with detailed block information +func generateBlocksTable(report *BlockReport) string { + if len(report.Blocks) == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString(` +

Block Details

+ + + + + + + + + `) + + // Check if any block has base fee + hasBaseFee := false + for _, block := range report.Blocks { + if block.BaseFeePerGas != nil { + hasBaseFee = true + break + } + } + + if hasBaseFee { + sb.WriteString(` + `) + } + + sb.WriteString(` + + + `) + + // Limit table rows if there are too many blocks + blocks := report.Blocks + showEllipsis := false + if len(blocks) > 1000 { + // Show first 500 and last 500 + blocks = append(report.Blocks[:500], report.Blocks[len(report.Blocks)-500:]...) + showEllipsis = true + } + + for i, block := range blocks { + // Insert ellipsis row after first 500 + if showEllipsis && i == 500 { + colSpan := 6 + if hasBaseFee { + colSpan = 7 + } + sb.WriteString(fmt.Sprintf(` + + + `, colSpan, len(report.Blocks))) + } + + timestamp := time.Unix(int64(block.Timestamp), 0).Format("2006-01-02 15:04:05") + gasUsedPercent := 0.0 + if block.GasLimit > 0 { + gasUsedPercent = (float64(block.GasUsed) / float64(block.GasLimit)) * 100 + } + + sb.WriteString(fmt.Sprintf(` + + + + + + + `, + block.Number, + timestamp, + formatNumber(block.TxCount), + formatNumber(block.GasUsed), + formatNumber(block.GasLimit), + gasUsedPercent)) + + if hasBaseFee { + baseFeeGwei := "-" + if block.BaseFeePerGas != nil { + baseFeeGwei = fmt.Sprintf("%.2f", float64(block.BaseFeePerGas.Uint64())/1e9) + } + sb.WriteString(fmt.Sprintf(` + `, baseFeeGwei)) + } + + sb.WriteString(` + `) + } + + sb.WriteString(` + +
Block NumberTimestampTransactionsGas UsedGas LimitGas Used %Base Fee (Gwei)
+ ... (showing first 500 and last 500 blocks of %d total) +
%d%s%s%s%s%.2f%%%s
`) + + 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, 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, 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
`) + } + + return sb.String() +} diff --git a/cmd/report/report.go b/cmd/report/report.go new file mode 100644 index 000000000..443313100 --- /dev/null +++ b/cmd/report/report.go @@ -0,0 +1,492 @@ +package report + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "os" + "strconv" + "time" + + _ "embed" + + "github.com/0xPolygon/polygon-cli/util" + ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +type ( + reportParams struct { + RpcUrl string + StartBlock uint64 + EndBlock uint64 + OutputFile string + Format string + } +) + +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() + + log.Info(). + Str("rpc-url", inputReport.RpcUrl). + Uint64("start-block", inputReport.StartBlock). + Uint64("end-block", inputReport.EndBlock). + Msg("Starting block analysis") + + // 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) + + // Initialize the report + report := &BlockReport{ + ChainID: chainID, + RpcUrl: inputReport.RpcUrl, + StartBlock: inputReport.StartBlock, + EndBlock: inputReport.EndBlock, + GeneratedAt: time.Now(), + Blocks: []BlockInfo{}, + } + + // Generate the report + err = generateReport(ctx, ec, report) + if err != nil { + return fmt.Errorf("failed to generate report: %w", err) + } + + // Output the report + if err := outputReport(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", 0, "starting block number for analysis") + f.Uint64Var(&inputReport.EndBlock, "end-block", 0, "ending block number for analysis") + f.StringVarP(&inputReport.OutputFile, "output", "o", "", "output file path (default: stdout for JSON, report.html for HTML)") + f.StringVarP(&inputReport.Format, "format", "f", "json", "output format [json, html]") +} + +func checkFlags() error { + // Validate RPC URL + if err := util.ValidateUrl(inputReport.RpcUrl); err != nil { + return err + } + + // Validate block range + if inputReport.EndBlock < inputReport.StartBlock { + return fmt.Errorf("end-block must be greater than or equal to start-block") + } + + // Validate format + if inputReport.Format != "json" && inputReport.Format != "html" { + return fmt.Errorf("format must be either 'json' or 'html'") + } + + // Set default output file for HTML if not specified + if inputReport.Format == "html" && inputReport.OutputFile == "" { + inputReport.OutputFile = "report.html" + } + + return nil +} + +// generateReport analyzes the block range and generates a report +func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport) error { + log.Info().Msg("Fetching and analyzing blocks") + + totalTxCount := uint64(0) + totalGasUsed := uint64(0) + totalBaseFee := big.NewInt(0) + blockCount := uint64(0) + uniqueSenders := make(map[string]bool) + uniqueRecipients := make(map[string]bool) + + // Fetch blocks in the range + for blockNum := report.StartBlock; blockNum <= report.EndBlock; blockNum++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + blockInfo, err := fetchBlockInfo(ctx, ec, blockNum) + if err != nil { + log.Warn().Err(err).Uint64("block", blockNum).Msg("Failed to fetch block, skipping") + continue + } + + report.Blocks = append(report.Blocks, *blockInfo) + totalTxCount += blockInfo.TxCount + totalGasUsed += blockInfo.GasUsed + if blockInfo.BaseFeePerGas != nil { + totalBaseFee.Add(totalBaseFee, blockInfo.BaseFeePerGas) + } + blockCount++ + + // Track unique addresses + for _, tx := range blockInfo.Transactions { + if tx.From != "" { + uniqueSenders[tx.From] = true + } + if tx.To != "" { + uniqueRecipients[tx.To] = true + } + } + + if blockNum%100 == 0 || blockNum == report.EndBlock { + log.Info().Uint64("block", blockNum).Uint64("progress", blockNum-report.StartBlock+1).Uint64("total", report.EndBlock-report.StartBlock+1).Msg("Progress") + } + } + + // 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) + if totalBaseFee.Cmp(big.NewInt(0)) > 0 { + avgBaseFee := new(big.Int).Div(totalBaseFee, big.NewInt(int64(blockCount))) + report.Summary.AvgBaseFeePerGas = avgBaseFee.Uint64() + } + } + + // 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) (*BlockInfo, error) { + 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 { + bf := new(big.Int) + bf.SetString(baseFee[2:], 16) // Remove "0x" prefix + blockInfo.BaseFeePerGas = bf + } + + // Process transactions + if txs, ok := result["transactions"].([]any); ok { + blockInfo.TxCount = uint64(len(txs)) + + // Fetch transaction receipts to get actual gas used + for _, txData := range txs { + txMap, ok := txData.(map[string]any) + if !ok { + continue + } + + txHash, _ := txMap["hash"].(string) + from, _ := txMap["from"].(string) + to, _ := txMap["to"].(string) + gasPrice := hexToUint64(txMap["gasPrice"]) + gasLimit := hexToUint64(txMap["gas"]) + + // Fetch transaction receipt for gas used + var receipt map[string]any + err := ec.CallContext(ctx, &receipt, "eth_getTransactionReceipt", txHash) + if err != nil || receipt == nil { + continue + } + + gasUsed := hexToUint64(receipt["gasUsed"]) + 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 + for i := 0; i < len(blocksByTxCount)-1; i++ { + for j := i + 1; j < len(blocksByTxCount); j++ { + if blocksByTxCount[j].TxCount > blocksByTxCount[i].TxCount { + blocksByTxCount[i], blocksByTxCount[j] = blocksByTxCount[j], blocksByTxCount[i] + } + } + } + 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 + for i := 0; i < len(blocksByGasUsed)-1; i++ { + for j := i + 1; j < len(blocksByGasUsed); j++ { + if blocksByGasUsed[j].GasUsed > blocksByGasUsed[i].GasUsed { + blocksByGasUsed[i], blocksByGasUsed[j] = blocksByGasUsed[j], blocksByGasUsed[i] + } + } + } + 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 + for i := 0; i < len(allTxsByGasUsed)-1; i++ { + for j := i + 1; j < len(allTxsByGasUsed); j++ { + if allTxsByGasUsed[j].GasUsed > allTxsByGasUsed[i].GasUsed { + allTxsByGasUsed[i], allTxsByGasUsed[j] = allTxsByGasUsed[j], allTxsByGasUsed[i] + } + } + } + if len(allTxsByGasUsed) > 10 { + top10.TransactionsByGas = allTxsByGasUsed[:10] + } else { + top10.TransactionsByGas = allTxsByGasUsed + } + + // Top 10 transactions by gas limit + // Sort transactions by gas limit descending + for i := 0; i < len(allTxsByGasLimit)-1; i++ { + for j := i + 1; j < len(allTxsByGasLimit); j++ { + if allTxsByGasLimit[j].GasLimit > allTxsByGasLimit[i].GasLimit { + allTxsByGasLimit[i], allTxsByGasLimit[j] = allTxsByGasLimit[j], allTxsByGasLimit[i] + } + } + } + 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 + for i := 0; i < len(gasPriceFreqs)-1; i++ { + for j := i + 1; j < len(gasPriceFreqs); j++ { + if gasPriceFreqs[j].Count > gasPriceFreqs[i].Count { + gasPriceFreqs[i], gasPriceFreqs[j] = gasPriceFreqs[j], gasPriceFreqs[i] + } + } + } + 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 + for i := 0; i < len(gasLimitFreqs)-1; i++ { + for j := i + 1; j < len(gasLimitFreqs); j++ { + if gasLimitFreqs[j].Count > gasLimitFreqs[i].Count { + gasLimitFreqs[i], gasLimitFreqs[j] = gasLimitFreqs[j], gasLimitFreqs[i] + } + } + } + if len(gasLimitFreqs) > 10 { + top10.MostUsedGasLimits = gasLimitFreqs[:10] + } else { + top10.MostUsedGasLimits = gasLimitFreqs + } + + return top10 +} + +// outputReport writes the report to the specified output +func outputReport(report *BlockReport, format, outputFile string) error { + switch format { + case "json": + return outputJSON(report, outputFile) + case "html": + return outputHTML(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/template.html b/cmd/report/template.html new file mode 100644 index 000000000..e0ac9cabf --- /dev/null +++ b/cmd/report/template.html @@ -0,0 +1,246 @@ + + + + + + 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 000000000..7484b7fbd --- /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 uint64 `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 000000000..1f9c9d12f --- /dev/null +++ b/cmd/report/usage.md @@ -0,0 +1,81 @@ +The `report` command analyzes a range of blocks from an Ethereum-compatible blockchain and generates a comprehensive report with statistics and visualizations. + +## Features + +- **Stateless Operation**: All data is queried from the blockchain via RPC, no local storage required +- **JSON Output**: Always generates a structured JSON report for programmatic analysis +- **HTML Visualization**: Optionally generates a visual HTML report with charts and tables +- **Block Range Analysis**: Analyze any range of blocks from start to end +- **Transaction Metrics**: Track transaction counts, gas usage, and other key metrics + +## Basic Usage + +Generate a JSON report for blocks 1000 to 2000: + +```bash +polycli report --rpc-url http://localhost:8545 --start-block 1000 --end-block 2000 +``` + +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' +``` + +## Notes + +- The command queries blocks sequentially to avoid overwhelming the RPC endpoint +- Progress is logged every 100 blocks +- Blocks that cannot be fetched are skipped with a warning +- 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 25fb19269..ebac90a6e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -35,6 +35,7 @@ import ( "github.com/0xPolygon/polygon-cli/cmd/monitor" "github.com/0xPolygon/polygon-cli/cmd/monitorv2" "github.com/0xPolygon/polygon-cli/cmd/nodekey" + "github.com/0xPolygon/polygon-cli/cmd/report" "github.com/0xPolygon/polygon-cli/cmd/rpcfuzz" "github.com/0xPolygon/polygon-cli/cmd/signer" "github.com/0xPolygon/polygon-cli/cmd/version" @@ -149,6 +150,7 @@ func NewPolycliCommand() *cobra.Command { nodekey.NodekeyCmd, p2p.P2pCmd, parseethwallet.ParseETHWalletCmd, + report.ReportCmd, retest.RetestCmd, rpcfuzz.RPCFuzzCmd, signer.SignerCmd, From f9b09bf636f2a47e84013b02fd490ae6f893bb23 Mon Sep 17 00:00:00 2001 From: tclemos Date: Wed, 3 Dec 2025 09:53:27 -0300 Subject: [PATCH 02/31] report: add some parallelism and concurrency to report generation --- cmd/report/report.go | 316 +++++++++++++++++++++++++++---------------- 1 file changed, 202 insertions(+), 114 deletions(-) diff --git a/cmd/report/report.go b/cmd/report/report.go index 443313100..e98ca02cd 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -6,7 +6,9 @@ import ( "fmt" "math/big" "os" + "sort" "strconv" + "sync" "time" _ "embed" @@ -15,15 +17,18 @@ import ( ethrpc "github.com/ethereum/go-ethereum/rpc" "github.com/rs/zerolog/log" "github.com/spf13/cobra" + "golang.org/x/time/rate" ) type ( reportParams struct { - RpcUrl string - StartBlock uint64 - EndBlock uint64 - OutputFile string - Format string + RpcUrl string + StartBlock uint64 + EndBlock uint64 + OutputFile string + Format string + Concurrency int + RateLimit float64 } ) @@ -77,7 +82,7 @@ var ReportCmd = &cobra.Command{ } // Generate the report - err = generateReport(ctx, ec, report) + err = generateReport(ctx, ec, report, inputReport.Concurrency, inputReport.RateLimit) if err != nil { return fmt.Errorf("failed to generate report: %w", err) } @@ -99,6 +104,8 @@ func init() { f.Uint64Var(&inputReport.EndBlock, "end-block", 0, "ending block number for analysis") f.StringVarP(&inputReport.OutputFile, "output", "o", "", "output file path (default: stdout for JSON, report.html for HTML)") f.StringVarP(&inputReport.Format, "format", "f", "json", "output format [json, html]") + 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 { @@ -126,53 +133,105 @@ func checkFlags() error { } // generateReport analyzes the block range and generates a report -func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport) error { +func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, concurrency int, rateLimit float64) error { log.Info().Msg("Fetching and analyzing blocks") + // Create rate limiter + rateLimiter := rate.NewLimiter(rate.Limit(rateLimit), 1) + + totalBlocks := report.EndBlock - report.StartBlock + 1 + blockChan := make(chan uint64, totalBlocks) + resultChan := make(chan *BlockInfo, concurrency) + errorChan := make(chan error, totalBlocks) + + // Fill the block channel with block numbers to fetch + for blockNum := report.StartBlock; blockNum <= report.EndBlock; blockNum++ { + blockChan <- blockNum + } + close(blockChan) + + // Start worker goroutines + var wg sync.WaitGroup + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for blockNum := range blockChan { + select { + case <-ctx.Done(): + errorChan <- ctx.Err() + return + default: + } + + blockInfo, err := fetchBlockInfo(ctx, ec, blockNum, rateLimiter) + if err != nil { + errorChan <- fmt.Errorf("failed to fetch block %d: %w", blockNum, err) + return + } + + resultChan <- blockInfo + } + }() + } + + // Close result channel when all workers are done + go func() { + wg.Wait() + close(resultChan) + close(errorChan) + }() + + // Collect results totalTxCount := uint64(0) totalGasUsed := uint64(0) totalBaseFee := big.NewInt(0) blockCount := uint64(0) uniqueSenders := make(map[string]bool) uniqueRecipients := make(map[string]bool) + processedBlocks := uint64(0) - // Fetch blocks in the range - for blockNum := report.StartBlock; blockNum <= report.EndBlock; blockNum++ { + // Process results and check for errors + for { select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - blockInfo, err := fetchBlockInfo(ctx, ec, blockNum) - if err != nil { - log.Warn().Err(err).Uint64("block", blockNum).Msg("Failed to fetch block, skipping") - continue - } - - report.Blocks = append(report.Blocks, *blockInfo) - totalTxCount += blockInfo.TxCount - totalGasUsed += blockInfo.GasUsed - if blockInfo.BaseFeePerGas != nil { - totalBaseFee.Add(totalBaseFee, blockInfo.BaseFeePerGas) - } - blockCount++ - - // Track unique addresses - for _, tx := range blockInfo.Transactions { - if tx.From != "" { - uniqueSenders[tx.From] = true + case blockInfo, ok := <-resultChan: + if !ok { + // Channel closed, all results processed + goto done } - if tx.To != "" { - uniqueRecipients[tx.To] = true + report.Blocks = append(report.Blocks, *blockInfo) + totalTxCount += blockInfo.TxCount + totalGasUsed += blockInfo.GasUsed + if blockInfo.BaseFeePerGas != nil { + totalBaseFee.Add(totalBaseFee, blockInfo.BaseFeePerGas) + } + blockCount++ + + // Track unique addresses + for _, tx := range blockInfo.Transactions { + if tx.From != "" { + uniqueSenders[tx.From] = true + } + if tx.To != "" { + uniqueRecipients[tx.To] = true + } } - } - if blockNum%100 == 0 || blockNum == report.EndBlock { - log.Info().Uint64("block", blockNum).Uint64("progress", blockNum-report.StartBlock+1).Uint64("total", report.EndBlock-report.StartBlock+1).Msg("Progress") + processedBlocks++ + if processedBlocks%100 == 0 || processedBlocks == totalBlocks { + log.Info().Uint64("progress", processedBlocks).Uint64("total", totalBlocks).Msg("Progress") + } + case err, ok := <-errorChan: + if !ok { + // Channel closed, no more errors + goto done + } + if err != nil { + return err + } } } - +done: // Calculate summary statistics report.Summary = SummaryStats{ TotalBlocks: blockCount, @@ -198,7 +257,12 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport) } // fetchBlockInfo retrieves information about a specific block and its transactions -func fetchBlockInfo(ctx context.Context, ec *ethrpc.Client, blockNum uint64) (*BlockInfo, error) { +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 { @@ -228,45 +292,93 @@ func fetchBlockInfo(ctx context.Context, ec *ethrpc.Client, blockNum uint64) (*B if txs, ok := result["transactions"].([]any); ok { blockInfo.TxCount = uint64(len(txs)) - // Fetch transaction receipts to get actual gas used + // Prepare channels for parallel receipt fetching + type txWithReceipt struct { + info TransactionInfo + err error + } + txChan := make(chan map[string]any, len(txs)) + receiptChan := make(chan txWithReceipt, len(txs)) + + // Fill transaction channel for _, txData := range txs { - txMap, ok := txData.(map[string]any) - if !ok { - continue + if txMap, ok := txData.(map[string]any); ok { + txChan <- txMap } + } + close(txChan) - txHash, _ := txMap["hash"].(string) - from, _ := txMap["from"].(string) - to, _ := txMap["to"].(string) - gasPrice := hexToUint64(txMap["gasPrice"]) - gasLimit := hexToUint64(txMap["gas"]) - - // Fetch transaction receipt for gas used - var receipt map[string]any - err := ec.CallContext(ctx, &receipt, "eth_getTransactionReceipt", txHash) - if err != nil || receipt == nil { - continue - } + // Fetch receipts in parallel (limit to 10 concurrent requests) + var wg sync.WaitGroup + workers := 10 + if len(txs) < workers { + workers = len(txs) + } - gasUsed := hexToUint64(receipt["gasUsed"]) - gasUsedPercent := 0.0 - if blockInfo.GasLimit > 0 { - gasUsedPercent = (float64(gasUsed) / float64(blockInfo.GasLimit)) * 100 - } + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for txMap := range txChan { + txHash, _ := txMap["hash"].(string) + from, _ := txMap["from"].(string) + to, _ := txMap["to"].(string) + gasPrice := hexToUint64(txMap["gasPrice"]) + gasLimit := hexToUint64(txMap["gas"]) + + // Wait for rate limiter before making RPC call + if err := rateLimiter.Wait(ctx); err != nil { + receiptChan <- txWithReceipt{err: fmt.Errorf("rate limiter error for tx %s: %w", txHash, err)} + continue + } + + // Fetch transaction receipt for gas used + var receipt map[string]any + err := ec.CallContext(ctx, &receipt, "eth_getTransactionReceipt", txHash) + if err != nil { + receiptChan <- txWithReceipt{err: fmt.Errorf("failed to fetch receipt for tx %s: %w", txHash, err)} + continue + } + if receipt == nil { + receiptChan <- txWithReceipt{err: fmt.Errorf("receipt not found for tx %s", txHash)} + continue + } + + gasUsed := hexToUint64(receipt["gasUsed"]) + 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, + } + + receiptChan <- txWithReceipt{info: txInfo} + } + }() + } - txInfo := TransactionInfo{ - Hash: txHash, - From: from, - To: to, - BlockNumber: blockNum, - GasUsed: gasUsed, - GasLimit: gasLimit, - GasPrice: gasPrice, - BlockGasLimit: blockInfo.GasLimit, - GasUsedPercent: gasUsedPercent, - } + // Close receipt channel when all workers are done + go func() { + wg.Wait() + close(receiptChan) + }() - blockInfo.Transactions = append(blockInfo.Transactions, txInfo) + // Collect transaction results + for result := range receiptChan { + if result.err != nil { + return nil, result.err + } + blockInfo.Transactions = append(blockInfo.Transactions, result.info) } } @@ -302,13 +414,9 @@ func calculateTop10Stats(blocks []BlockInfo) Top10Stats { } } // Sort by tx count descending - for i := 0; i < len(blocksByTxCount)-1; i++ { - for j := i + 1; j < len(blocksByTxCount); j++ { - if blocksByTxCount[j].TxCount > blocksByTxCount[i].TxCount { - blocksByTxCount[i], blocksByTxCount[j] = blocksByTxCount[j], blocksByTxCount[i] - } - } - } + sort.Slice(blocksByTxCount, func(i, j int) bool { + return blocksByTxCount[i].TxCount > blocksByTxCount[j].TxCount + }) if len(blocksByTxCount) > 10 { top10.BlocksByTxCount = blocksByTxCount[:10] } else { @@ -330,13 +438,9 @@ func calculateTop10Stats(blocks []BlockInfo) Top10Stats { } } // Sort by gas used descending - for i := 0; i < len(blocksByGasUsed)-1; i++ { - for j := i + 1; j < len(blocksByGasUsed); j++ { - if blocksByGasUsed[j].GasUsed > blocksByGasUsed[i].GasUsed { - blocksByGasUsed[i], blocksByGasUsed[j] = blocksByGasUsed[j], blocksByGasUsed[i] - } - } - } + sort.Slice(blocksByGasUsed, func(i, j int) bool { + return blocksByGasUsed[i].GasUsed > blocksByGasUsed[j].GasUsed + }) if len(blocksByGasUsed) > 10 { top10.BlocksByGasUsed = blocksByGasUsed[:10] } else { @@ -372,13 +476,9 @@ func calculateTop10Stats(blocks []BlockInfo) Top10Stats { // Top 10 transactions by gas used // Sort transactions by gas used descending - for i := 0; i < len(allTxsByGasUsed)-1; i++ { - for j := i + 1; j < len(allTxsByGasUsed); j++ { - if allTxsByGasUsed[j].GasUsed > allTxsByGasUsed[i].GasUsed { - allTxsByGasUsed[i], allTxsByGasUsed[j] = allTxsByGasUsed[j], allTxsByGasUsed[i] - } - } - } + sort.Slice(allTxsByGasUsed, func(i, j int) bool { + return allTxsByGasUsed[i].GasUsed > allTxsByGasUsed[j].GasUsed + }) if len(allTxsByGasUsed) > 10 { top10.TransactionsByGas = allTxsByGasUsed[:10] } else { @@ -387,13 +487,9 @@ func calculateTop10Stats(blocks []BlockInfo) Top10Stats { // Top 10 transactions by gas limit // Sort transactions by gas limit descending - for i := 0; i < len(allTxsByGasLimit)-1; i++ { - for j := i + 1; j < len(allTxsByGasLimit); j++ { - if allTxsByGasLimit[j].GasLimit > allTxsByGasLimit[i].GasLimit { - allTxsByGasLimit[i], allTxsByGasLimit[j] = allTxsByGasLimit[j], allTxsByGasLimit[i] - } - } - } + sort.Slice(allTxsByGasLimit, func(i, j int) bool { + return allTxsByGasLimit[i].GasLimit > allTxsByGasLimit[j].GasLimit + }) if len(allTxsByGasLimit) > 10 { top10.TransactionsByGasLimit = allTxsByGasLimit[:10] } else { @@ -409,13 +505,9 @@ func calculateTop10Stats(blocks []BlockInfo) Top10Stats { }) } // Sort by count descending - for i := 0; i < len(gasPriceFreqs)-1; i++ { - for j := i + 1; j < len(gasPriceFreqs); j++ { - if gasPriceFreqs[j].Count > gasPriceFreqs[i].Count { - gasPriceFreqs[i], gasPriceFreqs[j] = gasPriceFreqs[j], gasPriceFreqs[i] - } - } - } + sort.Slice(gasPriceFreqs, func(i, j int) bool { + return gasPriceFreqs[i].Count > gasPriceFreqs[j].Count + }) if len(gasPriceFreqs) > 10 { top10.MostUsedGasPrices = gasPriceFreqs[:10] } else { @@ -431,13 +523,9 @@ func calculateTop10Stats(blocks []BlockInfo) Top10Stats { }) } // Sort by count descending - for i := 0; i < len(gasLimitFreqs)-1; i++ { - for j := i + 1; j < len(gasLimitFreqs); j++ { - if gasLimitFreqs[j].Count > gasLimitFreqs[i].Count { - gasLimitFreqs[i], gasLimitFreqs[j] = gasLimitFreqs[j], gasLimitFreqs[i] - } - } - } + sort.Slice(gasLimitFreqs, func(i, j int) bool { + return gasLimitFreqs[i].Count > gasLimitFreqs[j].Count + }) if len(gasLimitFreqs) > 10 { top10.MostUsedGasLimits = gasLimitFreqs[:10] } else { From 53becda3b7f0d2279b8d44bc3a7baefaad118f6d Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 9 Dec 2025 10:47:27 -0300 Subject: [PATCH 03/31] report: load receipts by block instead of one by one --- cmd/report/report.go | 132 ++++++++++++++++--------------------------- 1 file changed, 48 insertions(+), 84 deletions(-) diff --git a/cmd/report/report.go b/cmd/report/report.go index e98ca02cd..46893e7ca 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -152,7 +152,7 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, // Start worker goroutines var wg sync.WaitGroup - for i := 0; i < concurrency; i++ { + for range concurrency { wg.Add(1) go func() { defer wg.Done() @@ -274,10 +274,10 @@ func fetchBlockInfo(ctx context.Context, ec *ethrpc.Client, blockNum uint64, rat } blockInfo := &BlockInfo{ - Number: blockNum, - Timestamp: hexToUint64(result["timestamp"]), - GasUsed: hexToUint64(result["gasUsed"]), - GasLimit: hexToUint64(result["gasLimit"]), + Number: blockNum, + Timestamp: hexToUint64(result["timestamp"]), + GasUsed: hexToUint64(result["gasUsed"]), + GasLimit: hexToUint64(result["gasLimit"]), Transactions: []TransactionInfo{}, } @@ -292,93 +292,57 @@ func fetchBlockInfo(ctx context.Context, ec *ethrpc.Client, blockNum uint64, rat if txs, ok := result["transactions"].([]any); ok { blockInfo.TxCount = uint64(len(txs)) - // Prepare channels for parallel receipt fetching - type txWithReceipt struct { - info TransactionInfo - err error - } - txChan := make(chan map[string]any, len(txs)) - receiptChan := make(chan txWithReceipt, len(txs)) + // Fetch all receipts for this block in a single call + 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) + } - // Fill transaction channel - for _, txData := range txs { - if txMap, ok := txData.(map[string]any); ok { - txChan <- txMap + 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) } - } - close(txChan) - // Fetch receipts in parallel (limit to 10 concurrent requests) - var wg sync.WaitGroup - workers := 10 - if len(txs) < workers { - workers = len(txs) - } + if len(receipts) != len(txs) { + return nil, fmt.Errorf("mismatch between transactions (%d) and receipts (%d)", len(txs), len(receipts)) + } - for i := 0; i < workers; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for txMap := range txChan { - txHash, _ := txMap["hash"].(string) - from, _ := txMap["from"].(string) - to, _ := txMap["to"].(string) - gasPrice := hexToUint64(txMap["gasPrice"]) - gasLimit := hexToUint64(txMap["gas"]) - - // Wait for rate limiter before making RPC call - if err := rateLimiter.Wait(ctx); err != nil { - receiptChan <- txWithReceipt{err: fmt.Errorf("rate limiter error for tx %s: %w", txHash, err)} - continue - } - - // Fetch transaction receipt for gas used - var receipt map[string]any - err := ec.CallContext(ctx, &receipt, "eth_getTransactionReceipt", txHash) - if err != nil { - receiptChan <- txWithReceipt{err: fmt.Errorf("failed to fetch receipt for tx %s: %w", txHash, err)} - continue - } - if receipt == nil { - receiptChan <- txWithReceipt{err: fmt.Errorf("receipt not found for tx %s", txHash)} - continue - } - - gasUsed := hexToUint64(receipt["gasUsed"]) - 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, - } - - receiptChan <- txWithReceipt{info: txInfo} + // Process each transaction with its corresponding receipt + for i, txData := range txs { + txMap, ok := txData.(map[string]any) + if !ok { + continue } - }() - } - // Close receipt channel when all workers are done - go func() { - wg.Wait() - close(receiptChan) - }() + txHash, _ := txMap["hash"].(string) + from, _ := txMap["from"].(string) + to, _ := txMap["to"].(string) + gasPrice := hexToUint64(txMap["gasPrice"]) + gasLimit := hexToUint64(txMap["gas"]) + + receipt := receipts[i] + gasUsed := hexToUint64(receipt["gasUsed"]) + 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, + } - // Collect transaction results - for result := range receiptChan { - if result.err != nil { - return nil, result.err + blockInfo.Transactions = append(blockInfo.Transactions, txInfo) } - blockInfo.Transactions = append(blockInfo.Transactions, result.info) } } From 8dd9f33c9bc2ee35c03e0d7394f11abd8f6490c6 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 9 Dec 2025 11:05:32 -0300 Subject: [PATCH 04/31] report: add support to pdf format --- cmd/report/html.go | 71 ++++++++++++++++++++++++------------ cmd/report/pdf.go | 79 ++++++++++++++++++++++++++++++++++++++++ cmd/report/report.go | 15 ++++++-- cmd/report/template.html | 73 +++++++++++++++++++++++++++++++++++++ go.mod | 7 ++++ go.sum | 14 +++++++ 6 files changed, 232 insertions(+), 27 deletions(-) create mode 100644 cmd/report/pdf.go diff --git a/cmd/report/html.go b/cmd/report/html.go index 23e7e501a..b4921012a 100644 --- a/cmd/report/html.go +++ b/cmd/report/html.go @@ -78,8 +78,9 @@ func generateTxCountChart(report *BlockReport) string { var sb strings.Builder sb.WriteString(` -

Transaction Count by Block

-
`) +
+

Transaction Count by Block

+
`) // Find max tx count for scaling maxTx := uint64(1) @@ -153,6 +154,9 @@ func generateTxCountChart(report *BlockReport) string { padding-35, height-padding+5, padding-35, padding+5, maxTx)) + sb.WriteString(` +
`) + return sb.String() } @@ -164,8 +168,9 @@ func generateGasUsageChart(report *BlockReport) string { var sb strings.Builder sb.WriteString(` -

Gas Usage by Block

-
`) +
+

Gas Usage by Block

+
`) // Find max gas for scaling maxGas := uint64(1) @@ -239,6 +244,9 @@ func generateGasUsageChart(report *BlockReport) string { padding-35, height-padding+5, padding-35, padding+5, formatNumber(maxGas))) + sb.WriteString(` +
`) + return sb.String() } @@ -400,13 +408,16 @@ func formatNumberWithUnits(n uint64) string { func generateTop10Sections(report *BlockReport) string { var sb strings.Builder - sb.WriteString(`

Top 10 Analysis

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

- +
+

Top 10 Blocks by Transaction Count

+
@@ -427,14 +438,16 @@ func generateTop10Sections(report *BlockReport) string { sb.WriteString(` -
Rank
`) + +
`) } // Top 10 blocks by gas used if len(report.Top10.BlocksByGasUsed) > 0 { sb.WriteString(` -

Top 10 Blocks by Gas Used

- +
+

Top 10 Blocks by Gas Used

+
@@ -459,14 +472,16 @@ func generateTop10Sections(report *BlockReport) string { sb.WriteString(` -
Rank
`) + +
`) } // Top 10 transactions by gas used if len(report.Top10.TransactionsByGas) > 0 { sb.WriteString(` -

Top 10 Transactions by Gas Used

- +
+

Top 10 Transactions by Gas Used

+
@@ -491,14 +506,16 @@ func generateTop10Sections(report *BlockReport) string { sb.WriteString(` -
Rank
`) + +
`) } // Top 10 transactions by gas limit if len(report.Top10.TransactionsByGasLimit) > 0 { sb.WriteString(` -

Top 10 Transactions by Gas Limit

- +
+

Top 10 Transactions by Gas Limit

+
@@ -523,14 +540,16 @@ func generateTop10Sections(report *BlockReport) string { sb.WriteString(` -
Rank
`) + +
`) } // Top 10 most used gas prices if len(report.Top10.MostUsedGasPrices) > 0 { sb.WriteString(` -

Top 10 Most Used Gas Prices

- +
+

Top 10 Most Used Gas Prices

+
@@ -551,14 +570,16 @@ func generateTop10Sections(report *BlockReport) string { sb.WriteString(` -
Rank
`) + +
`) } // Top 10 most used gas limits if len(report.Top10.MostUsedGasLimits) > 0 { sb.WriteString(` -

Top 10 Most Used Gas Limits

- +
+

Top 10 Most Used Gas Limits

+
@@ -579,8 +600,12 @@ func generateTop10Sections(report *BlockReport) string { sb.WriteString(` -
Rank
`) + + `) } + sb.WriteString(` + `) + return sb.String() } diff --git a/cmd/report/pdf.go b/cmd/report/pdf.go new file mode 100644 index 000000000..1d2146348 --- /dev/null +++ b/cmd/report/pdf.go @@ -0,0 +1,79 @@ +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(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 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Allocate a new browser context + ctx, cancel = chromedp.NewContext(ctx) + defer cancel() + + var buf []byte + err := chromedp.Run(ctx, + 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 + time.Sleep(500 * time.Millisecond) + return nil + }), + 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: %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 index 46893e7ca..aaf832457 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -102,8 +102,8 @@ func init() { f.StringVar(&inputReport.RpcUrl, "rpc-url", "http://localhost:8545", "RPC endpoint URL") f.Uint64Var(&inputReport.StartBlock, "start-block", 0, "starting block number for analysis") f.Uint64Var(&inputReport.EndBlock, "end-block", 0, "ending block number for analysis") - f.StringVarP(&inputReport.OutputFile, "output", "o", "", "output file path (default: stdout for JSON, report.html for HTML)") - f.StringVarP(&inputReport.Format, "format", "f", "json", "output format [json, html]") + 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") } @@ -120,8 +120,8 @@ func checkFlags() error { } // Validate format - if inputReport.Format != "json" && inputReport.Format != "html" { - return fmt.Errorf("format must be either 'json' or 'html'") + 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 @@ -129,6 +129,11 @@ func checkFlags() error { 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 } @@ -506,6 +511,8 @@ func outputReport(report *BlockReport, format, outputFile string) error { return outputJSON(report, outputFile) case "html": return outputHTML(report, outputFile) + case "pdf": + return outputPDF(report, outputFile) default: return fmt.Errorf("unsupported format: %s", format) } diff --git a/cmd/report/template.html b/cmd/report/template.html index e0ac9cabf..32d2d758f 100644 --- a/cmd/report/template.html +++ b/cmd/report/template.html @@ -175,6 +175,79 @@ .chart-tooltip.visible { opacity: 1; } + + /* PDF/Print-specific styles for page break control */ + @media print { + body { + background: white; + padding: 0; + } + + .container { + box-shadow: none; + padding: 0; + } + + /* Keep sections together and start them on new pages */ + .section { + break-before: page; + page-break-before: always; + } + + /* Don't break the first section (Transaction Count chart) */ + .section:first-of-type { + break-before: auto; + page-break-before: auto; + } + + /* Keep subsections together (h3 + table) */ + .subsection { + break-inside: avoid; + page-break-inside: avoid; + } + + /* Prevent page breaks inside these elements */ + .metadata, + .stat-card, + .chart-container, + table, + h3 { + break-inside: avoid; + page-break-inside: avoid; + } + + /* Keep summary statistics section on first page */ + h2:first-of-type { + break-before: auto; + page-break-before: auto; + } + + /* Keep h3 with the content that follows */ + h3 { + break-after: avoid; + page-break-after: avoid; + } + + /* Prevent orphaned table headers */ + thead { + display: table-header-group; + } + + tbody tr { + break-inside: avoid; + page-break-inside: avoid; + } + + /* Don't break between h2 and stats-grid */ + .stats-grid { + break-inside: avoid; + page-break-inside: avoid; + } + + .footer { + margin-top: 20px; + } + } diff --git a/go.mod b/go.mod index 55c6740a6..4532fe66f 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,9 @@ require ( require github.com/alecthomas/participle/v2 v2.1.4 require ( + github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect + github.com/chromedp/chromedp v0.14.2 // 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 @@ -50,7 +53,11 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.0 // 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.2.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect diff --git a/go.sum b/go.sum index 0c11fc1a4..9bb317cb8 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,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= @@ -203,6 +209,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= @@ -218,6 +226,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= From 6c65ff545d0c040ee781a9e1be46ddf4a5be7f27 Mon Sep 17 00:00:00 2001 From: tclemos Date: Mon, 29 Dec 2025 11:58:21 -0300 Subject: [PATCH 05/31] fix merge conflict --- go.mod | 12 ++++++++++++ go.sum | 44 ++++++++++++++++++++++++++++---------------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 3864cd71c..4856c34de 100644 --- a/go.mod +++ b/go.mod @@ -58,21 +58,32 @@ require ( 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/hashicorp/hcl v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect 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.2 // 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.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.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.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gotest.tools/v3 v3.5.2 // indirect ) @@ -176,6 +187,7 @@ require ( github.com/iden3/go-iden3-crypto v0.0.17 github.com/montanaflynn/stats v0.7.1 github.com/rivo/tview v0.42.0 + github.com/spf13/viper v1.19.0 ) require ( diff --git a/go.sum b/go.sum index 25a5229f8..015006a3b 100644 --- a/go.sum +++ b/go.sum @@ -229,8 +229,6 @@ github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiU github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 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= @@ -307,6 +305,8 @@ github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpx github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= @@ -352,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= @@ -360,6 +362,8 @@ github.com/linxGnu/grocksdb v1.8.14 h1:HTgyYalNwBSG/1qCQUIott44wU5b2Y9Kr3z7SK5Of github.com/linxGnu/grocksdb v1.8.14/go.mod h1:QYiYypR2d4v63Wj1adOOfzglnoII0gLj3PNh4fZkcFA= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -442,8 +446,10 @@ 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/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/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.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= @@ -499,8 +505,10 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6 github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= @@ -509,24 +517,25 @@ github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKl github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.1.5-0.20170601210322-f6abca593680/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -537,6 +546,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -598,10 +608,10 @@ go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20170613210332-850760c427c5/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -769,6 +779,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= From 7ab4029036e6f4d76b6bf418bd87bcbecdbee09d Mon Sep 17 00:00:00 2001 From: tclemos Date: Mon, 29 Dec 2025 19:16:50 -0300 Subject: [PATCH 06/31] make gen; linter fixes --- README.md | 2 + cmd/report/html.go | 121 +++---------------------------------- cmd/report/types.go | 54 ++++++++--------- doc/polycli.md | 2 + doc/polycli_report.md | 135 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 175 insertions(+), 139 deletions(-) create mode 100644 doc/polycli_report.md diff --git a/README.md b/README.md index 639e2f7c6..fb17c373f 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 index b4921012a..0d1c7f7ef 100644 --- a/cmd/report/html.go +++ b/cmd/report/html.go @@ -118,8 +118,8 @@ func generateTxCountChart(report *BlockReport) string { numPoints := 0 for i := 0; i < len(report.Blocks); i += step { block := report.Blocks[i] - x := padding + (float64(numPoints) / float64((len(report.Blocks)-1)/step)) * chartWidth - y := height - padding - (float64(block.TxCount) / float64(maxTx)) * chartHeight + x := padding + (float64(numPoints)/float64((len(report.Blocks)-1)/step))*chartWidth + y := height - padding - (float64(block.TxCount)/float64(maxTx))*chartHeight points = append(points, fmt.Sprintf("%.2f,%.2f", x, y)) circles.WriteString(fmt.Sprintf(` @@ -208,8 +208,8 @@ func generateGasUsageChart(report *BlockReport) string { numPoints := 0 for i := 0; i < len(report.Blocks); i += step { block := report.Blocks[i] - x := padding + (float64(numPoints) / float64((len(report.Blocks)-1)/step)) * chartWidth - y := height - padding - (float64(block.GasUsed) / float64(maxGas)) * chartHeight + x := padding + (float64(numPoints)/float64((len(report.Blocks)-1)/step))*chartWidth + y := height - padding - (float64(block.GasUsed)/float64(maxGas))*chartHeight points = append(points, fmt.Sprintf("%.2f,%.2f", x, y)) circles.WriteString(fmt.Sprintf(` @@ -250,109 +250,6 @@ func generateGasUsageChart(report *BlockReport) string { return sb.String() } -// generateBlocksTable creates a table with detailed block information -func generateBlocksTable(report *BlockReport) string { - if len(report.Blocks) == 0 { - return "" - } - - var sb strings.Builder - sb.WriteString(` -

Block Details

- - - - - - - - - `) - - // Check if any block has base fee - hasBaseFee := false - for _, block := range report.Blocks { - if block.BaseFeePerGas != nil { - hasBaseFee = true - break - } - } - - if hasBaseFee { - sb.WriteString(` - `) - } - - sb.WriteString(` - - - `) - - // Limit table rows if there are too many blocks - blocks := report.Blocks - showEllipsis := false - if len(blocks) > 1000 { - // Show first 500 and last 500 - blocks = append(report.Blocks[:500], report.Blocks[len(report.Blocks)-500:]...) - showEllipsis = true - } - - for i, block := range blocks { - // Insert ellipsis row after first 500 - if showEllipsis && i == 500 { - colSpan := 6 - if hasBaseFee { - colSpan = 7 - } - sb.WriteString(fmt.Sprintf(` - - - `, colSpan, len(report.Blocks))) - } - - timestamp := time.Unix(int64(block.Timestamp), 0).Format("2006-01-02 15:04:05") - gasUsedPercent := 0.0 - if block.GasLimit > 0 { - gasUsedPercent = (float64(block.GasUsed) / float64(block.GasLimit)) * 100 - } - - sb.WriteString(fmt.Sprintf(` - - - - - - - `, - block.Number, - timestamp, - formatNumber(block.TxCount), - formatNumber(block.GasUsed), - formatNumber(block.GasLimit), - gasUsedPercent)) - - if hasBaseFee { - baseFeeGwei := "-" - if block.BaseFeePerGas != nil { - baseFeeGwei = fmt.Sprintf("%.2f", float64(block.BaseFeePerGas.Uint64())/1e9) - } - sb.WriteString(fmt.Sprintf(` - `, baseFeeGwei)) - } - - sb.WriteString(` - `) - } - - sb.WriteString(` - -
Block NumberTimestampTransactionsGas UsedGas LimitGas Used %Base Fee (Gwei)
- ... (showing first 500 and last 500 blocks of %d total) -
%d%s%s%s%s%.2f%%%s
`) - - return sb.String() -} - // formatNumber adds thousand separators to numbers func formatNumber(n uint64) string { str := fmt.Sprintf("%d", n) @@ -380,11 +277,11 @@ func formatNumberWithUnits(n uint64) string { suffix string threshold uint64 }{ - {"Q", 1e15}, // Quadrillion - {"T", 1e12}, // Trillion - {"B", 1e9}, // Billion - {"M", 1e6}, // Million - {"K", 1e3}, // Thousand + {"Q", 1e15}, // Quadrillion + {"T", 1e12}, // Trillion + {"B", 1e9}, // Billion + {"M", 1e6}, // Million + {"K", 1e3}, // Thousand } for _, unit := range units { diff --git a/cmd/report/types.go b/cmd/report/types.go index 7484b7fbd..4585e964b 100644 --- a/cmd/report/types.go +++ b/cmd/report/types.go @@ -31,36 +31,36 @@ type SummaryStats struct { // 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"` + 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"` + 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"` + 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 @@ -74,12 +74,12 @@ type TopBlock struct { // 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"` + 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 diff --git a/doc/polycli.md b/doc/polycli.md index 01506c548..a9f4f7b44 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 000000000..e03ee707b --- /dev/null +++ b/doc/polycli_report.md @@ -0,0 +1,135 @@ +# `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. + +## Features + +- **Stateless Operation**: All data is queried from the blockchain via RPC, no local storage required +- **JSON Output**: Always generates a structured JSON report for programmatic analysis +- **HTML Visualization**: Optionally generates a visual HTML report with charts and tables +- **Block Range Analysis**: Analyze any range of blocks from start to end +- **Transaction Metrics**: Track transaction counts, gas usage, and other key metrics + +## Basic Usage + +Generate a JSON report for blocks 1000 to 2000: + +```bash +polycli report --rpc-url http://localhost:8545 --start-block 1000 --end-block 2000 +``` + +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' +``` + +## Notes + +- The command queries blocks sequentially to avoid overwhelming the RPC endpoint +- Progress is logged every 100 blocks +- Blocks that cannot be fetched are skipped with a warning +- 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 for analysis + -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 for analysis +``` + +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. From 5c1eac5b863a0113227ebdb78dd84333d608b724 Mon Sep 17 00:00:00 2001 From: tclemos Date: Mon, 29 Dec 2025 22:17:47 -0300 Subject: [PATCH 07/31] report: copilot fixes --- cmd/report/html.go | 59 ++++++++++++++++++++++----------- cmd/report/pdf.go | 14 +++++--- cmd/report/report.go | 79 +++++++++++++++++++++++++++++++------------- cmd/report/types.go | 2 +- cmd/report/usage.md | 18 +++++++++- 5 files changed, 122 insertions(+), 50 deletions(-) diff --git a/cmd/report/html.go b/cmd/report/html.go index 0d1c7f7ef..55d5451db 100644 --- a/cmd/report/html.go +++ b/cmd/report/html.go @@ -3,6 +3,8 @@ package report import ( _ "embed" "fmt" + "html" + "math/big" "strings" "time" ) @@ -12,26 +14,26 @@ var htmlTemplate string // generateHTML creates an HTML report from the BlockReport data func generateHTML(report *BlockReport) string { - html := htmlTemplate + output := htmlTemplate // Replace metadata placeholders - html = strings.ReplaceAll(html, "{{CHAIN_ID}}", fmt.Sprintf("%d", report.ChainID)) - html = strings.ReplaceAll(html, "{{RPC_URL}}", report.RpcUrl) - html = strings.ReplaceAll(html, "{{BLOCK_RANGE}}", fmt.Sprintf("%d - %d", report.StartBlock, report.EndBlock)) - html = strings.ReplaceAll(html, "{{GENERATED_AT}}", report.GeneratedAt.Format(time.RFC3339)) - html = strings.ReplaceAll(html, "{{TOTAL_BLOCKS}}", formatNumber(report.Summary.TotalBlocks)) + 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 - html = strings.ReplaceAll(html, "{{STAT_CARDS}}", generateStatCards(report)) + output = strings.ReplaceAll(output, "{{STAT_CARDS}}", generateStatCards(report)) // Generate and replace charts - html = strings.ReplaceAll(html, "{{TX_COUNT_CHART}}", generateTxCountChart(report)) - html = strings.ReplaceAll(html, "{{GAS_USAGE_CHART}}", generateGasUsageChart(report)) + output = strings.ReplaceAll(output, "{{TX_COUNT_CHART}}", generateTxCountChart(report)) + output = strings.ReplaceAll(output, "{{GAS_USAGE_CHART}}", generateGasUsageChart(report)) // Generate and replace top 10 sections - html = strings.ReplaceAll(html, "{{TOP_10_SECTIONS}}", generateTop10Sections(report)) + output = strings.ReplaceAll(output, "{{TOP_10_SECTIONS}}", generateTop10Sections(report)) - return html + return output } // generateStatCards creates the statistics cards HTML @@ -52,11 +54,18 @@ func generateStatCards(report *BlockReport) string { } // Add base fee card if available - if report.Summary.AvgBaseFeePerGas > 0 { - cards = append(cards, struct { - title string - value string - }{"Avg Base Fee (Gwei)", fmt.Sprintf("%.2f", float64(report.Summary.AvgBaseFeePerGas)/1e9)}) + 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 { @@ -116,9 +125,14 @@ func generateTxCountChart(report *BlockReport) string { 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((len(report.Blocks)-1)/step))*chartWidth + 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)) @@ -206,9 +220,14 @@ func generateGasUsageChart(report *BlockReport) string { 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((len(report.Blocks)-1)/step))*chartWidth + 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)) @@ -398,7 +417,7 @@ func generateTop10Sections(report *BlockReport) string { %s %s %s - `, i+1, tx.Hash, formatNumber(tx.BlockNumber), formatNumberWithUnits(tx.GasLimit), formatNumber(tx.GasUsed))) + `, i+1, html.EscapeString(tx.Hash), formatNumber(tx.BlockNumber), formatNumberWithUnits(tx.GasLimit), formatNumber(tx.GasUsed))) } sb.WriteString(` @@ -432,7 +451,7 @@ func generateTop10Sections(report *BlockReport) string { %s %s %s - `, i+1, tx.Hash, formatNumber(tx.BlockNumber), formatNumberWithUnits(tx.GasLimit), formatNumber(tx.GasUsed))) + `, i+1, html.EscapeString(tx.Hash), formatNumber(tx.BlockNumber), formatNumberWithUnits(tx.GasLimit), formatNumber(tx.GasUsed))) } sb.WriteString(` diff --git a/cmd/report/pdf.go b/cmd/report/pdf.go index 1d2146348..75a176ac2 100644 --- a/cmd/report/pdf.go +++ b/cmd/report/pdf.go @@ -23,8 +23,8 @@ func outputPDF(report *BlockReport, outputFile string) error { defer cancel() // Allocate a new browser context - ctx, cancel = chromedp.NewContext(ctx) - defer cancel() + ctx, cancelChrome := chromedp.NewContext(ctx) + defer cancelChrome() var buf []byte err := chromedp.Run(ctx, @@ -40,9 +40,13 @@ func outputPDF(report *BlockReport, outputFile string) error { 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 - time.Sleep(500 * time.Millisecond) - return nil + // Wait a bit for any dynamic content to settle, respecting context cancellation + select { + case <-time.After(500 * time.Millisecond): + return nil + case <-ctx.Done(): + return ctx.Err() + } }), chromedp.ActionFunc(func(ctx context.Context) error { // Print to PDF with appropriate settings diff --git a/cmd/report/report.go b/cmd/report/report.go index aaf832457..2d2e9323c 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -6,6 +6,7 @@ import ( "fmt" "math/big" "os" + "slices" "sort" "strconv" "sync" @@ -101,11 +102,16 @@ func init() { f := ReportCmd.Flags() f.StringVar(&inputReport.RpcUrl, "rpc-url", "http://localhost:8545", "RPC endpoint URL") f.Uint64Var(&inputReport.StartBlock, "start-block", 0, "starting block number for analysis") - f.Uint64Var(&inputReport.EndBlock, "end-block", 0, "ending block number for analysis") + f.Uint64Var(&inputReport.EndBlock, "end-block", 0, "ending block number for analysis (required)") 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") + + // Mark end-block as required to prevent confusion with default values + if err := ReportCmd.MarkFlagRequired("end-block"); err != nil { + panic(fmt.Sprintf("failed to mark end-block as required: %v", err)) + } } func checkFlags() error { @@ -141,13 +147,17 @@ func checkFlags() error { func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, concurrency int, rateLimit float64) error { log.Info().Msg("Fetching and analyzing blocks") + // 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) totalBlocks := report.EndBlock - report.StartBlock + 1 blockChan := make(chan uint64, totalBlocks) resultChan := make(chan *BlockInfo, concurrency) - errorChan := make(chan error, totalBlocks) + errorChan := make(chan error, 1) // Only need space for one error // Fill the block channel with block numbers to fetch for blockNum := report.StartBlock; blockNum <= report.EndBlock; blockNum++ { @@ -162,20 +172,27 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, go func() { defer wg.Done() for blockNum := range blockChan { - select { - case <-ctx.Done(): - errorChan <- ctx.Err() + // Check if worker context is canceled + if workerCtx.Err() != nil { return - default: } - blockInfo, err := fetchBlockInfo(ctx, ec, blockNum, rateLimiter) + blockInfo, err := fetchBlockInfo(workerCtx, ec, blockNum, rateLimiter) if err != nil { - errorChan <- fmt.Errorf("failed to fetch block %d: %w", blockNum, err) - return + // Check for context cancellation errors (user interrupt or internal cancellation) + if workerCtx.Err() != nil { + return + } + log.Warn().Err(err).Uint64("block", blockNum).Msg("Failed to fetch block, skipping") + continue } - resultChan <- blockInfo + // Send result with context check to avoid blocking + select { + case resultChan <- blockInfo: + case <-workerCtx.Done(): + return + } } }() } @@ -196,7 +213,7 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, uniqueRecipients := make(map[string]bool) processedBlocks := uint64(0) - // Process results and check for errors + // Process results and check for context cancellation for { select { case blockInfo, ok := <-resultChan: @@ -226,17 +243,28 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, if processedBlocks%100 == 0 || processedBlocks == totalBlocks { log.Info().Uint64("progress", processedBlocks).Uint64("total", totalBlocks).Msg("Progress") } - case err, ok := <-errorChan: - if !ok { - // Channel closed, no more errors - goto done - } - if err != nil { - return err - } + 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: + // 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 + }) + + // Warn if no blocks were successfully fetched + if len(report.Blocks) == 0 { + log.Warn().Msg("No blocks were successfully fetched. Report will be empty.") + } + // Calculate summary statistics report.Summary = SummaryStats{ TotalBlocks: blockCount, @@ -251,7 +279,7 @@ done: report.Summary.AvgGasPerBlock = float64(totalGasUsed) / float64(blockCount) if totalBaseFee.Cmp(big.NewInt(0)) > 0 { avgBaseFee := new(big.Int).Div(totalBaseFee, big.NewInt(int64(blockCount))) - report.Summary.AvgBaseFeePerGas = avgBaseFee.Uint64() + report.Summary.AvgBaseFeePerGas = avgBaseFee.String() } } @@ -287,10 +315,15 @@ func fetchBlockInfo(ctx context.Context, ec *ethrpc.Client, blockNum uint64, rat } // Parse base fee if present (EIP-1559) - if baseFee, ok := result["baseFeePerGas"].(string); ok { + if baseFee, ok := result["baseFeePerGas"].(string); ok && baseFee != "" { bf := new(big.Int) - bf.SetString(baseFee[2:], 16) // Remove "0x" prefix - blockInfo.BaseFeePerGas = bf + // 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 diff --git a/cmd/report/types.go b/cmd/report/types.go index 4585e964b..bd07811a7 100644 --- a/cmd/report/types.go +++ b/cmd/report/types.go @@ -24,7 +24,7 @@ type SummaryStats struct { TotalGasUsed uint64 `json:"total_gas_used"` AvgTxPerBlock float64 `json:"avg_tx_per_block"` AvgGasPerBlock float64 `json:"avg_gas_per_block"` - AvgBaseFeePerGas uint64 `json:"avg_base_fee_per_gas,omitempty"` + AvgBaseFeePerGas string `json:"avg_base_fee_per_gas,omitempty"` UniqueSenders uint64 `json:"unique_senders"` UniqueRecipients uint64 `json:"unique_recipients"` } diff --git a/cmd/report/usage.md b/cmd/report/usage.md index 1f9c9d12f..7e2782adb 100644 --- a/cmd/report/usage.md +++ b/cmd/report/usage.md @@ -72,9 +72,25 @@ Quick analysis to stdout: 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 +``` + ## Notes -- The command queries blocks sequentially to avoid overwhelming the RPC endpoint +- The `--end-block` flag is required; you must explicitly specify the block range to analyze +- The `--start-block` flag defaults to 0 (genesis block), which is a valid starting point +- 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 - Blocks that cannot be fetched are skipped with a warning - HTML reports include interactive hover tooltips on charts From d686bc3865b0d1be070d6a72b45c997989b6a0c1 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 12:48:59 -0300 Subject: [PATCH 08/31] report: review start-block and end-block flags behavior and defaults --- cmd/report/report.go | 84 +++++++++++++++++++++++++++++++++---------- cmd/report/usage.md | 42 ++++++++++++++++++++-- doc/polycli_report.md | 60 ++++++++++++++++++++++++++++--- 3 files changed, 161 insertions(+), 25 deletions(-) diff --git a/cmd/report/report.go b/cmd/report/report.go index 2d2e9323c..c4f9b151c 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math" "math/big" "os" "slices" @@ -21,6 +22,13 @@ import ( "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 @@ -58,12 +66,6 @@ var ReportCmd = &cobra.Command{ } defer ec.Close() - log.Info(). - Str("rpc-url", inputReport.RpcUrl). - Uint64("start-block", inputReport.StartBlock). - Uint64("end-block", inputReport.EndBlock). - Msg("Starting block analysis") - // Fetch chain ID var chainIDHex string err = ec.CallContext(ctx, &chainIDHex, "eth_chainId") @@ -72,12 +74,61 @@ var ReportCmd = &cobra.Command{ } 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: inputReport.StartBlock, - EndBlock: inputReport.EndBlock, + StartBlock: startBlock, + EndBlock: endBlock, GeneratedAt: time.Now(), Blocks: []BlockInfo{}, } @@ -101,17 +152,12 @@ var ReportCmd = &cobra.Command{ func init() { f := ReportCmd.Flags() f.StringVar(&inputReport.RpcUrl, "rpc-url", "http://localhost:8545", "RPC endpoint URL") - f.Uint64Var(&inputReport.StartBlock, "start-block", 0, "starting block number for analysis") - f.Uint64Var(&inputReport.EndBlock, "end-block", 0, "ending block number for analysis (required)") + 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") - - // Mark end-block as required to prevent confusion with default values - if err := ReportCmd.MarkFlagRequired("end-block"); err != nil { - panic(fmt.Sprintf("failed to mark end-block as required: %v", err)) - } } func checkFlags() error { @@ -120,9 +166,11 @@ func checkFlags() error { return err } - // Validate block range - if inputReport.EndBlock < inputReport.StartBlock { - return fmt.Errorf("end-block must be greater than or equal to start-block") + // 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 format diff --git a/cmd/report/usage.md b/cmd/report/usage.md index 7e2782adb..e78db4b1f 100644 --- a/cmd/report/usage.md +++ b/cmd/report/usage.md @@ -3,19 +3,44 @@ The `report` command analyzes a range of blocks from an Ethereum-compatible bloc ## 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 -- **Block Range Analysis**: Analyze any range of blocks from start to end +- **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 @@ -82,10 +107,21 @@ polycli report --rpc-url https://public-rpc.example.com \ --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`. + ## Notes -- The `--end-block` flag is required; you must explicitly specify the block range to analyze -- The `--start-block` flag defaults to 0 (genesis block), which is a valid starting point - 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) diff --git a/doc/polycli_report.md b/doc/polycli_report.md index e03ee707b..9fad36d43 100644 --- a/doc/polycli_report.md +++ b/doc/polycli_report.md @@ -24,19 +24,44 @@ The `report` command analyzes a range of blocks from an Ethereum-compatible bloc ## 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 -- **Block Range Analysis**: Analyze any range of blocks from start to end +- **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 @@ -93,9 +118,36 @@ Quick analysis to stdout: 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`. + ## Notes -- The command queries blocks sequentially to avoid overwhelming the RPC endpoint +- 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 - Blocks that cannot be fetched are skipped with a warning - HTML reports include interactive hover tooltips on charts @@ -105,13 +157,13 @@ polycli report --rpc-url http://localhost:8545 --start-block 1000 --end-block 11 ```bash --concurrency int number of concurrent RPC requests (default 10) - --end-block uint ending block number for analysis + --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 for analysis + --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. From ac9e5cc9ed11c45b1a4e5251e1762ffab93d2940 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 12:51:04 -0300 Subject: [PATCH 09/31] report: remove leftover errorChan --- cmd/report/report.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/report/report.go b/cmd/report/report.go index c4f9b151c..b75b7de83 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -205,7 +205,6 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, totalBlocks := report.EndBlock - report.StartBlock + 1 blockChan := make(chan uint64, totalBlocks) resultChan := make(chan *BlockInfo, concurrency) - errorChan := make(chan error, 1) // Only need space for one error // Fill the block channel with block numbers to fetch for blockNum := report.StartBlock; blockNum <= report.EndBlock; blockNum++ { @@ -249,7 +248,6 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, go func() { wg.Wait() close(resultChan) - close(errorChan) }() // Collect results From 22fbd7b2eea8caebef47ad8a64f40b905edce1f1 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 12:56:40 -0300 Subject: [PATCH 10/31] report: fix average base-fee computation --- cmd/report/report.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/report/report.go b/cmd/report/report.go index b75b7de83..988205cf3 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -255,6 +255,7 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, 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) @@ -272,6 +273,7 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, totalGasUsed += blockInfo.GasUsed if blockInfo.BaseFeePerGas != nil { totalBaseFee.Add(totalBaseFee, blockInfo.BaseFeePerGas) + blocksWithBaseFee++ } blockCount++ @@ -323,8 +325,9 @@ done: if blockCount > 0 { report.Summary.AvgTxPerBlock = float64(totalTxCount) / float64(blockCount) report.Summary.AvgGasPerBlock = float64(totalGasUsed) / float64(blockCount) - if totalBaseFee.Cmp(big.NewInt(0)) > 0 { - avgBaseFee := new(big.Int).Div(totalBaseFee, big.NewInt(int64(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() } } From 56885c32ba21bea3786071a40a58c399f236f351 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 13:02:42 -0300 Subject: [PATCH 11/31] report: add rpc requirements to docs --- cmd/report/report.go | 4 ++++ cmd/report/usage.md | 23 +++++++++++++++++++++++ doc/polycli_report.md | 23 +++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/cmd/report/report.go b/cmd/report/report.go index 988205cf3..6c29a46a5 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -380,6 +380,10 @@ func fetchBlockInfo(ctx context.Context, ec *ethrpc.Client, blockNum uint64, rat 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 { diff --git a/cmd/report/usage.md b/cmd/report/usage.md index e78db4b1f..0357c60bf 100644 --- a/cmd/report/usage.md +++ b/cmd/report/usage.md @@ -1,5 +1,7 @@ The `report` command analyzes a range of blocks from an Ethereum-compatible blockchain and generates a comprehensive report with statistics and visualizations. +**⚠️ Note**: Requires an RPC endpoint with `eth_getBlockReceipts` support (see [RPC Requirements](#rpc-requirements)). + ## Features - **Stateless Operation**: All data is queried from the blockchain via RPC, no local storage required @@ -120,6 +122,27 @@ The default range of 500 blocks can be modified by changing the `DefaultBlockRan **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. + ## Notes - To analyze a single block, set both start and end to the same block number (e.g., `--start-block 100 --end-block 100`) diff --git a/doc/polycli_report.md b/doc/polycli_report.md index 9fad36d43..e1da04032 100644 --- a/doc/polycli_report.md +++ b/doc/polycli_report.md @@ -21,6 +21,8 @@ polycli report [flags] The `report` command analyzes a range of blocks from an Ethereum-compatible blockchain and generates a comprehensive report with statistics and visualizations. +**⚠️ Note**: Requires an RPC endpoint with `eth_getBlockReceipts` support (see [RPC Requirements](#rpc-requirements)). + ## Features - **Stateless Operation**: All data is queried from the blockchain via RPC, no local storage required @@ -141,6 +143,27 @@ The default range of 500 blocks can be modified by changing the `DefaultBlockRan **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. + ## Notes - To analyze a single block, set both start and end to the same block number (e.g., `--start-block 100 --end-block 100`) From f7bfc0f4fca7ce7dbcfbca919232422d0b74fab2 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 13:26:00 -0300 Subject: [PATCH 12/31] report: review dependency versions --- go.mod | 19 ++++++++----------- go.sum | 40 ++++++++++++++++------------------------ 2 files changed, 24 insertions(+), 35 deletions(-) diff --git a/go.mod b/go.mod index 4856c34de..52b1f2e05 100644 --- a/go.mod +++ b/go.mod @@ -55,35 +55,32 @@ require ( 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/hashicorp/hcl v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect 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.2 // 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.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // 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.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gotest.tools/v3 v3.5.2 // indirect ) @@ -187,7 +184,7 @@ require ( github.com/iden3/go-iden3-crypto v0.0.17 github.com/montanaflynn/stats v0.7.1 github.com/rivo/tview v0.42.0 - github.com/spf13/viper v1.19.0 + github.com/spf13/viper v1.21.0 ) require ( diff --git a/go.sum b/go.sum index 015006a3b..d5fd08f0c 100644 --- a/go.sum +++ b/go.sum @@ -229,6 +229,8 @@ github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiU github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 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= @@ -305,8 +307,6 @@ github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpx github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= @@ -362,8 +362,6 @@ github.com/linxGnu/grocksdb v1.8.14 h1:HTgyYalNwBSG/1qCQUIott44wU5b2Y9Kr3z7SK5Of github.com/linxGnu/grocksdb v1.8.14/go.mod h1:QYiYypR2d4v63Wj1adOOfzglnoII0gLj3PNh4fZkcFA= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -448,8 +446,8 @@ github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsq 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.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +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= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= @@ -505,10 +503,8 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6 github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= @@ -517,25 +513,24 @@ github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKl github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.1.5-0.20170601210322-f6abca593680/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -546,7 +541,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -608,10 +602,10 @@ go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20170613210332-850760c427c5/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -779,8 +773,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= From 927deebdf2177930aefd1fa48e0f006511589313 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 13:31:31 -0300 Subject: [PATCH 13/31] report: add unit tests --- cmd/report/html_test.go | 454 ++++++++++++++++++++++++++++++++++++++ cmd/report/report_test.go | 336 ++++++++++++++++++++++++++++ 2 files changed, 790 insertions(+) create mode 100644 cmd/report/html_test.go create mode 100644 cmd/report/report_test.go diff --git a/cmd/report/html_test.go b/cmd/report/html_test.go new file mode 100644 index 000000000..f0fe593a7 --- /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/report_test.go b/cmd/report/report_test.go new file mode 100644 index 000000000..e43f45719 --- /dev/null +++ b/cmd/report/report_test.go @@ -0,0 +1,336 @@ +package report + +import ( + "math" + "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) + } +} From 93f2f68a4764246895c2dc369e216c61f2bcc2d8 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 13:36:30 -0300 Subject: [PATCH 14/31] report: improve flag validation --- cmd/report/report.go | 10 +++ cmd/report/report_test.go | 127 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/cmd/report/report.go b/cmd/report/report.go index 6c29a46a5..c6fca139c 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -173,6 +173,16 @@ func checkFlags() error { } } + // 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'") diff --git a/cmd/report/report_test.go b/cmd/report/report_test.go index e43f45719..9bf896abd 100644 --- a/cmd/report/report_test.go +++ b/cmd/report/report_test.go @@ -334,3 +334,130 @@ func TestDefaultBlockRangeConstant(t *testing.T) { 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 +} From ee06b6575025c5a38ee604b5a8fa81e35bb62824 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 13:51:28 -0300 Subject: [PATCH 15/31] report: improved chrome dependency validation to generate pdf reports --- cmd/report/pdf.go | 2 +- cmd/report/usage.md | 40 +++++++++++++++++++++++++++++++++++++++- doc/polycli_report.md | 40 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/cmd/report/pdf.go b/cmd/report/pdf.go index 75a176ac2..802ef10a5 100644 --- a/cmd/report/pdf.go +++ b/cmd/report/pdf.go @@ -70,7 +70,7 @@ func outputPDF(report *BlockReport, outputFile string) error { ) if err != nil { - return fmt.Errorf("failed to generate PDF: %w", err) + return fmt.Errorf("failed to generate PDF: %w\n\nPDF generation requires Google Chrome or Chromium to be installed on your system.\nPlease install Chrome/Chromium and try again. See documentation for installation instructions", err) } // Write PDF to file diff --git a/cmd/report/usage.md b/cmd/report/usage.md index 0357c60bf..9d25873dc 100644 --- a/cmd/report/usage.md +++ b/cmd/report/usage.md @@ -1,6 +1,8 @@ The `report` command analyzes a range of blocks from an Ethereum-compatible blockchain and generates a comprehensive report with statistics and visualizations. -**⚠️ Note**: Requires an RPC endpoint with `eth_getBlockReceipts` support (see [RPC Requirements](#rpc-requirements)). +**⚠️ 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 @@ -8,6 +10,7 @@ The `report` command analyzes a range of blocks from an Ethereum-compatible bloc - **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 @@ -143,6 +146,41 @@ failed to fetch block receipts: method eth_getBlockReceipts does not exist/is no **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`) diff --git a/doc/polycli_report.md b/doc/polycli_report.md index e1da04032..27e44906a 100644 --- a/doc/polycli_report.md +++ b/doc/polycli_report.md @@ -21,7 +21,9 @@ polycli report [flags] The `report` command analyzes a range of blocks from an Ethereum-compatible blockchain and generates a comprehensive report with statistics and visualizations. -**⚠️ Note**: Requires an RPC endpoint with `eth_getBlockReceipts` support (see [RPC Requirements](#rpc-requirements)). +**⚠️ 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 @@ -29,6 +31,7 @@ The `report` command analyzes a range of blocks from an Ethereum-compatible bloc - **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 @@ -164,6 +167,41 @@ failed to fetch block receipts: method eth_getBlockReceipts does not exist/is no **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`) From 097e7df52888c49ef249059ea312bb00af2042ce Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 14:13:02 -0300 Subject: [PATCH 16/31] report: fix timer goroutine leak in pdf generation Replace time.After() with time.NewTimer() in the PDF generation sleep logic to prevent goroutine leaks when the context is cancelled before the timer expires. The timer is now properly stopped via defer. --- cmd/report/pdf.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/report/pdf.go b/cmd/report/pdf.go index 802ef10a5..2e078feb6 100644 --- a/cmd/report/pdf.go +++ b/cmd/report/pdf.go @@ -41,8 +41,10 @@ func outputPDF(report *BlockReport, outputFile string) error { }), 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 <-time.After(500 * time.Millisecond): + case <-timer.C: return nil case <-ctx.Done(): return ctx.Err() From 8edcaf05326035746884d399d240d8d4e6f1ac2f Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 14:15:31 -0300 Subject: [PATCH 17/31] report: fix error message formatting in pdf generation Restructure the error message to put context before the wrapped error (%w) instead of after it. The previous format with newlines after %w would produce malformed output. The new single-line format properly wraps the error while providing clear context about Chrome/Chromium requirements. --- cmd/report/pdf.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/report/pdf.go b/cmd/report/pdf.go index 2e078feb6..4fb93299a 100644 --- a/cmd/report/pdf.go +++ b/cmd/report/pdf.go @@ -72,7 +72,7 @@ func outputPDF(report *BlockReport, outputFile string) error { ) if err != nil { - return fmt.Errorf("failed to generate PDF: %w\n\nPDF generation requires Google Chrome or Chromium to be installed on your system.\nPlease install Chrome/Chromium and try again. See documentation for installation instructions", err) + 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 From b5856d87ee2deb0572f282de29e475686a20b375 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 14:22:50 -0300 Subject: [PATCH 18/31] fix: restore viper to main require block in go.mod Move github.com/spf13/viper from a separate require block back to the main require block where it belongs. This restores consistency with the go.mod structure in the main branch. The viper dependency is a direct dependency used for configuration management and should be grouped with other direct dependencies like cobra and pflag. --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 52b1f2e05..de7da364a 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/schollz/progressbar/v3 v3.19.0 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d github.com/tyler-smith/go-bip32 v1.0.0 @@ -184,7 +185,6 @@ require ( github.com/iden3/go-iden3-crypto v0.0.17 github.com/montanaflynn/stats v0.7.1 github.com/rivo/tview v0.42.0 - github.com/spf13/viper v1.21.0 ) require ( From b99f2673893424d5f126e5ad231fc8ee53d3d75c Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 15:56:27 -0300 Subject: [PATCH 19/31] report: add defensive validation to prevent infinite loop Add validation in generateReport to ensure start and end blocks are not BlockNotSet (math.MaxUint64) before entering the block fetching loop. If endBlock is math.MaxUint64, the loop condition (blockNum <= report.EndBlock) would cause an overflow when blockNum increments past MaxUint64, resulting in an infinite loop. This is defensive programming since the RunE function already resolves BlockNotSet values to actual block numbers before calling generateReport. However, this validation prevents potential bugs if generateReport is called directly or if the smart defaults logic is bypassed. Added comprehensive tests to verify the validation catches all invalid block range scenarios. --- cmd/report/report.go | 11 +++++++ cmd/report/report_test.go | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/cmd/report/report.go b/cmd/report/report.go index c6fca139c..4ba7d1958 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -205,6 +205,17 @@ func checkFlags() error { 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 diff --git a/cmd/report/report_test.go b/cmd/report/report_test.go index 9bf896abd..6b10de2c0 100644 --- a/cmd/report/report_test.go +++ b/cmd/report/report_test.go @@ -1,7 +1,9 @@ package report import ( + "context" "math" + "strings" "testing" ) @@ -461,3 +463,68 @@ func TestCheckFlagsValidation(t *testing.T) { // 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) + } + }) + } +} From 4f2f8d92a0007c8b267bc7e5d650b3e13291ca01 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 16:00:37 -0300 Subject: [PATCH 20/31] report: fix context shadowing in pdf generation --- cmd/report/pdf.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/report/pdf.go b/cmd/report/pdf.go index 4fb93299a..470a5d1ca 100644 --- a/cmd/report/pdf.go +++ b/cmd/report/pdf.go @@ -19,15 +19,15 @@ func outputPDF(report *BlockReport, outputFile string) error { html := generateHTML(report) // Create chromedp context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + timeoutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Allocate a new browser context - ctx, cancelChrome := chromedp.NewContext(ctx) + chromeCtx, cancelChrome := chromedp.NewContext(timeoutCtx) defer cancelChrome() var buf []byte - err := chromedp.Run(ctx, + err := chromedp.Run(chromeCtx, chromedp.Navigate("about:blank"), chromedp.ActionFunc(func(ctx context.Context) error { // Get the frame tree to set document content From d8922c7e790a3c2cbd45a38080c1f7cf5324f75d Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 16:54:20 -0300 Subject: [PATCH 21/31] report: reduce channel buffer to avoid excessive memory allocation --- cmd/report/report.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/cmd/report/report.go b/cmd/report/report.go index 4ba7d1958..73a4843a9 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -224,14 +224,17 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, rateLimiter := rate.NewLimiter(rate.Limit(rateLimit), 1) totalBlocks := report.EndBlock - report.StartBlock + 1 - blockChan := make(chan uint64, totalBlocks) + // Use a small fixed buffer size to avoid excessive memory allocation for large block ranges + blockChan := make(chan uint64, concurrency*2) resultChan := make(chan *BlockInfo, concurrency) - // Fill the block channel with block numbers to fetch - for blockNum := report.StartBlock; blockNum <= report.EndBlock; blockNum++ { - blockChan <- blockNum - } - close(blockChan) + // Fill the block channel with block numbers to fetch (in a goroutine to avoid blocking) + go func() { + for blockNum := report.StartBlock; blockNum <= report.EndBlock; blockNum++ { + blockChan <- blockNum + } + close(blockChan) + }() // Start worker goroutines var wg sync.WaitGroup From 8ddce4fe33a5a908a96dddd400b0c2578a0834a4 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 17:08:29 -0300 Subject: [PATCH 22/31] report: propagate context to pdf generation for immediate cancellation --- cmd/report/pdf.go | 4 ++-- cmd/report/report.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/report/pdf.go b/cmd/report/pdf.go index 470a5d1ca..6a09876e7 100644 --- a/cmd/report/pdf.go +++ b/cmd/report/pdf.go @@ -12,14 +12,14 @@ import ( ) // outputPDF generates a PDF report from the BlockReport data -func outputPDF(report *BlockReport, outputFile string) error { +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(context.Background(), 30*time.Second) + timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() // Allocate a new browser context diff --git a/cmd/report/report.go b/cmd/report/report.go index 73a4843a9..d4e2502cb 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -140,7 +140,7 @@ var ReportCmd = &cobra.Command{ } // Output the report - if err := outputReport(report, inputReport.Format, inputReport.OutputFile); err != nil { + if err := outputReport(ctx, report, inputReport.Format, inputReport.OutputFile); err != nil { return fmt.Errorf("failed to output report: %w", err) } @@ -615,14 +615,14 @@ func calculateTop10Stats(blocks []BlockInfo) Top10Stats { } // outputReport writes the report to the specified output -func outputReport(report *BlockReport, format, outputFile string) error { +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(report, outputFile) + return outputPDF(ctx, report, outputFile) default: return fmt.Errorf("unsupported format: %s", format) } From 9a476a4f6fcb7f0f07bf234d7f20a992142414bc Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 17:10:57 -0300 Subject: [PATCH 23/31] report: add html escaping to stat card titles and values --- cmd/report/html.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/report/html.go b/cmd/report/html.go index 55d5451db..bbda92718 100644 --- a/cmd/report/html.go +++ b/cmd/report/html.go @@ -73,7 +73,7 @@ func generateStatCards(report *BlockReport) string {

%s

%s
-
`, card.title, card.value)) + `, html.EscapeString(card.title), html.EscapeString(card.value))) } return sb.String() From 4e6e7e4d301addc94d4b2b5e9f7a8976c7878ba3 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 17:28:19 -0300 Subject: [PATCH 24/31] report: fix goroutine leak in block channel producer --- cmd/report/report.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/report/report.go b/cmd/report/report.go index d4e2502cb..0eeaf11d6 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -230,10 +230,14 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, // Fill the block channel with block numbers to fetch (in a goroutine to avoid blocking) go func() { + defer close(blockChan) for blockNum := report.StartBlock; blockNum <= report.EndBlock; blockNum++ { - blockChan <- blockNum + select { + case blockChan <- blockNum: + case <-workerCtx.Done(): + return + } } - close(blockChan) }() // Start worker goroutines From c26d5f71261723fc48f896abf2c124ee98725372 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 17:42:45 -0300 Subject: [PATCH 25/31] report: add retry mechanism to ensure deterministic reports --- cmd/report/report.go | 107 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 14 deletions(-) diff --git a/cmd/report/report.go b/cmd/report/report.go index 0eeaf11d6..0e0925a84 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -11,6 +11,7 @@ import ( "sort" "strconv" "sync" + "sync/atomic" "time" _ "embed" @@ -223,17 +224,54 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, // 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 uint64, concurrency*2) + 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) - // Fill the block channel with block numbers to fetch (in a goroutine to avoid blocking) + // 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() { - defer close(blockChan) for blockNum := report.StartBlock; blockNum <= report.EndBlock; blockNum++ { select { - case blockChan <- blockNum: + 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 } @@ -246,36 +284,68 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, wg.Add(1) go func() { defer wg.Done() - for blockNum := range blockChan { + for req := range blockChan { // Check if worker context is canceled if workerCtx.Err() != nil { return } - blockInfo, err := fetchBlockInfo(workerCtx, ec, blockNum, rateLimiter) + 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 } - log.Warn().Err(err).Uint64("block", blockNum).Msg("Failed to fetch block, skipping") + + 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 } - // Send result with context check to avoid blocking + // 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 + } } }() } - // Close result channel when all workers are done + // 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 @@ -287,6 +357,7 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, uniqueSenders := make(map[string]bool) uniqueRecipients := make(map[string]bool) processedBlocks := uint64(0) + var failedBlocks []uint64 // Process results and check for context cancellation for { @@ -319,6 +390,8 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, if processedBlocks%100 == 0 || processedBlocks == totalBlocks { log.Info().Uint64("progress", processedBlocks).Uint64("total", totalBlocks).Msg("Progress") } + case failedBlock := <-failedChan: + failedBlocks = append(failedBlocks, failedBlock) case <-ctx.Done(): // Parent context canceled (e.g., user pressed Ctrl+C) // cancelWorkers() will be called by defer to stop all workers @@ -326,6 +399,17 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, } } done: + // 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 { @@ -336,11 +420,6 @@ done: return 0 }) - // Warn if no blocks were successfully fetched - if len(report.Blocks) == 0 { - log.Warn().Msg("No blocks were successfully fetched. Report will be empty.") - } - // Calculate summary statistics report.Summary = SummaryStats{ TotalBlocks: blockCount, From eb69a61d6ee4837f26dead67cb8af6021aa105a4 Mon Sep 17 00:00:00 2001 From: tclemos Date: Tue, 30 Dec 2025 17:54:09 -0300 Subject: [PATCH 26/31] docs: add code quality checklist to prevent common mistakes --- CLAUDE.md | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0f779e847..9ee284361 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,135 @@ 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: + +### 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 +- **Question to ask**: "If I run this twice with the same parameters, will I get identical results? What makes this non-deterministic?" + +### 5. Error Handling +- **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) +- **Question to ask**: "What can fail? How is each failure mode handled? Are errors properly wrapped?" + +### 6. Concurrency Patterns +- **Channel Closing**: Close channels in the correct goroutine (usually the sender); use atomic counters to coordinate +- **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?" + +### 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 +- **Question to ask**: "What edge cases exist? How do I test failure modes?" + +### 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 From 69cab77796dcc5d37569af988221e70563ad1a91 Mon Sep 17 00:00:00 2001 From: tclemos Date: Wed, 31 Dec 2025 11:12:39 -0300 Subject: [PATCH 27/31] docs: update retry behavior to match implementation --- cmd/report/usage.md | 2 +- doc/polycli_report.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/report/usage.md b/cmd/report/usage.md index 9d25873dc..bcfe5be4f 100644 --- a/cmd/report/usage.md +++ b/cmd/report/usage.md @@ -189,6 +189,6 @@ If Chrome/Chromium is not installed, PDF generation will fail with an error mess - `--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 -- Blocks that cannot be fetched are skipped with a warning +- **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/doc/polycli_report.md b/doc/polycli_report.md index 27e44906a..2978e81dc 100644 --- a/doc/polycli_report.md +++ b/doc/polycli_report.md @@ -210,7 +210,7 @@ If Chrome/Chromium is not installed, PDF generation will fail with an error mess - `--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 -- Blocks that cannot be fetched are skipped with a warning +- **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 From 18e710314c69171c45b3b137bb8c6a45ecd7f3e7 Mon Sep 17 00:00:00 2001 From: tclemos Date: Wed, 31 Dec 2025 11:15:02 -0300 Subject: [PATCH 28/31] report: fix race condition by draining failedChan after main loop --- cmd/report/report.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/report/report.go b/cmd/report/report.go index 0e0925a84..277dd2007 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -399,6 +399,11 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, } } 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) From de06605ae59822c265c6b5a04dec33f35a927367 Mon Sep 17 00:00:00 2001 From: tclemos Date: Wed, 31 Dec 2025 11:20:49 -0300 Subject: [PATCH 29/31] report: improve progress logging to show successful and failed blocks --- cmd/report/report.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmd/report/report.go b/cmd/report/report.go index 277dd2007..4a874e76f 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -388,10 +388,17 @@ func generateReport(ctx context.Context, ec *ethrpc.Client, report *BlockReport, processedBlocks++ if processedBlocks%100 == 0 || processedBlocks == totalBlocks { - log.Info().Uint64("progress", processedBlocks).Uint64("total", totalBlocks).Msg("Progress") + 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 From eeaac154624afe04972368c8e9013f61b1aba51b Mon Sep 17 00:00:00 2001 From: tclemos Date: Wed, 31 Dec 2025 11:28:39 -0300 Subject: [PATCH 30/31] report: use effectiveGasPrice from receipt for EIP-1559 compatibility --- cmd/report/report.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/report/report.go b/cmd/report/report.go index 4a874e76f..5865e44c0 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -529,11 +529,14 @@ func fetchBlockInfo(ctx context.Context, ec *ethrpc.Client, blockNum uint64, rat txHash, _ := txMap["hash"].(string) from, _ := txMap["from"].(string) to, _ := txMap["to"].(string) - gasPrice := hexToUint64(txMap["gasPrice"]) 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 From fc4bbeedd65c755e3a403a283b07e5121d5bfc9c Mon Sep 17 00:00:00 2001 From: tclemos Date: Wed, 31 Dec 2025 11:31:52 -0300 Subject: [PATCH 31/31] docs: update code quality checklist with critical thinking and new lessons --- CLAUDE.md | 50 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ee284361..5cbe5c515 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,42 @@ The tool supports configuration via: **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) @@ -154,28 +190,32 @@ The tool supports configuration via: - **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 -- **Question to ask**: "If I run this twice with the same parameters, will I get identical results? What makes this non-deterministic?" +- **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 +### 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) -- **Question to ask**: "What can fail? How is each failure mode handled? Are errors properly wrapped?" +- **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?" +- **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 -- **Question to ask**: "What edge cases exist? How do I test failure modes?" +- **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