package rest import ( "context" "database/sql" "encoding/json" "fmt" "net/http" "os" "strings" "time" "github.com/explorer/backend/api/freshness" ) type explorerStats struct { TotalBlocks int64 `json:"total_blocks"` TotalTransactions int64 `json:"total_transactions"` TotalAddresses int64 `json:"total_addresses"` LatestBlock int64 `json:"latest_block"` AverageBlockTime *float64 `json:"average_block_time,omitempty"` GasPrices *explorerGasPrices `json:"gas_prices,omitempty"` NetworkUtilizationPercentage *float64 `json:"network_utilization_percentage,omitempty"` TransactionsToday *int64 `json:"transactions_today,omitempty"` Freshness freshness.Snapshot `json:"freshness"` Completeness freshness.SummaryCompleteness `json:"completeness"` Sampling freshness.Sampling `json:"sampling"` Diagnostics freshness.Diagnostics `json:"diagnostics"` } type explorerGasPrices struct { Average *float64 `json:"average,omitempty"` } type statsQueryFunc = freshness.QueryRowFunc func queryNullableFloat64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*float64, error) { var value sql.NullFloat64 if err := queryRow(ctx, query, args...).Scan(&value); err != nil { return nil, err } if !value.Valid { return nil, nil } return &value.Float64, nil } func queryNullableInt64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*int64, error) { var value sql.NullInt64 if err := queryRow(ctx, query, args...).Scan(&value); err != nil { return nil, err } if !value.Valid { return nil, nil } return &value.Int64, nil } func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc) (explorerStats, error) { var stats explorerStats _ = chainID if err := queryRow(ctx, `SELECT COUNT(*) FROM blocks`, ).Scan(&stats.TotalBlocks); err != nil { return explorerStats{}, fmt.Errorf("query total blocks: %w", err) } if err := queryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE block_hash IS NOT NULL`, ).Scan(&stats.TotalTransactions); err != nil { return explorerStats{}, fmt.Errorf("query total transactions: %w", err) } if err := queryRow(ctx, `SELECT COUNT(*) FROM ( SELECT from_address_hash AS address FROM transactions WHERE from_address_hash IS NOT NULL UNION SELECT to_address_hash AS address FROM transactions WHERE to_address_hash IS NOT NULL ) unique_addresses`, ).Scan(&stats.TotalAddresses); err != nil { return explorerStats{}, fmt.Errorf("query total addresses: %w", err) } if err := queryRow(ctx, `SELECT COALESCE(MAX(number), 0) FROM blocks`, ).Scan(&stats.LatestBlock); err != nil { return explorerStats{}, fmt.Errorf("query latest block: %w", err) } statsIssues := map[string]string{} averageBlockTime, err := queryNullableFloat64(ctx, queryRow, `SELECT CASE WHEN COUNT(*) >= 2 THEN (EXTRACT(EPOCH FROM (MAX(timestamp) - MIN(timestamp))) * 1000.0) / NULLIF(COUNT(*) - 1, 0) ELSE NULL END FROM ( SELECT timestamp FROM blocks ORDER BY number DESC LIMIT 100 ) recent_blocks`, ) if err != nil { statsIssues["average_block_time"] = err.Error() } else { stats.AverageBlockTime = averageBlockTime } averageGasPrice, err := queryNullableFloat64(ctx, queryRow, `SELECT AVG(gas_price_wei)::double precision / 1000000000.0 FROM ( SELECT gas_price AS gas_price_wei FROM transactions WHERE block_hash IS NOT NULL AND gas_price IS NOT NULL ORDER BY block_number DESC, "index" DESC LIMIT 1000 ) recent_transactions`, ) if err != nil { statsIssues["average_gas_price"] = err.Error() } else if averageGasPrice != nil { stats.GasPrices = &explorerGasPrices{Average: averageGasPrice} } networkUtilization, err := queryNullableFloat64(ctx, queryRow, `SELECT AVG((gas_used::double precision / NULLIF(gas_limit, 0)) * 100.0) FROM ( SELECT gas_used, gas_limit FROM blocks WHERE gas_limit IS NOT NULL AND gas_limit > 0 ORDER BY number DESC LIMIT 100 ) recent_blocks`, ) if err != nil { statsIssues["network_utilization_percentage"] = err.Error() } else { stats.NetworkUtilizationPercentage = networkUtilization } transactionsToday, err := queryNullableInt64(ctx, queryRow, `SELECT COUNT(*)::bigint FROM transactions t JOIN blocks b ON b.number = t.block_number WHERE b.timestamp >= NOW() - INTERVAL '24 hours'`, ) if err != nil { statsIssues["transactions_today"] = err.Error() } else { stats.TransactionsToday = transactionsToday } rpcURL := strings.TrimSpace(os.Getenv("RPC_URL")) snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot( ctx, chainID, queryRow, func(ctx context.Context) (*freshness.Reference, error) { return freshness.ProbeChainHead(ctx, rpcURL) }, time.Now().UTC(), averageGasPrice, networkUtilization, ) if err != nil { return explorerStats{}, fmt.Errorf("build freshness snapshot: %w", err) } if len(statsIssues) > 0 { if sampling.Issues == nil { sampling.Issues = map[string]string{} } for key, value := range statsIssues { sampling.Issues[key] = value } } stats.Freshness = snapshot stats.Completeness = completeness stats.Sampling = sampling stats.Diagnostics = diagnostics return stats, nil } // handleStats handles GET /api/v2/stats func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w) return } if !s.requireDB(w) { return } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() stats, err := loadExplorerStats(ctx, s.chainID, s.db.QueryRow) if err != nil { writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer stats are temporarily unavailable") return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stats) }