- Introduced a new Diagnostics struct to capture transaction visibility state and activity state. - Updated BuildSnapshot function to return diagnostics alongside snapshot, completeness, and sampling. - Enhanced test cases to validate the new diagnostics data. - Updated frontend components to utilize the new diagnostics information for improved user feedback on freshness context. This change improves the observability of transaction activity and enhances the user experience by providing clearer insights into the freshness of data.
404 lines
11 KiB
Go
404 lines
11 KiB
Go
package track1
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ethereum/go-ethereum/common"
|
|
"github.com/explorer/backend/api/freshness"
|
|
"github.com/explorer/backend/libs/go-rpc-gateway"
|
|
)
|
|
|
|
var track1HashPattern = regexp.MustCompile(`^0x[a-fA-F0-9]{64}$`)
|
|
|
|
// Server handles Track 1 endpoints (uses RPC gateway from lib)
|
|
type Server struct {
|
|
rpcGateway *gateway.RPCGateway
|
|
freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error)
|
|
}
|
|
|
|
// NewServer creates a new Track 1 server
|
|
func NewServer(
|
|
rpcGateway *gateway.RPCGateway,
|
|
freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error),
|
|
) *Server {
|
|
return &Server{
|
|
rpcGateway: rpcGateway,
|
|
freshnessLoader: freshnessLoader,
|
|
}
|
|
}
|
|
|
|
// HandleLatestBlocks handles GET /api/v1/track1/blocks/latest
|
|
func (s *Server) HandleLatestBlocks(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
|
return
|
|
}
|
|
|
|
limit := 10
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
// Get latest block number
|
|
blockNumResp, err := s.rpcGateway.GetBlockNumber(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "rpc_error", err.Error())
|
|
return
|
|
}
|
|
|
|
blockNumHex, ok := blockNumResp.Result.(string)
|
|
if !ok {
|
|
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid block number response")
|
|
return
|
|
}
|
|
|
|
// Parse block number
|
|
blockNum, err := hexToInt(blockNumHex)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "parse_error", err.Error())
|
|
return
|
|
}
|
|
|
|
// Fetch blocks
|
|
blocks := []map[string]interface{}{}
|
|
for i := 0; i < limit && blockNum-int64(i) >= 0; i++ {
|
|
blockNumStr := fmt.Sprintf("0x%x", blockNum-int64(i))
|
|
blockResp, err := s.rpcGateway.GetBlockByNumber(r.Context(), blockNumStr, false)
|
|
if err != nil {
|
|
continue // Skip failed blocks
|
|
}
|
|
|
|
blockData, ok := blockResp.Result.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Transform to our format
|
|
block := transformBlock(blockData)
|
|
blocks = append(blocks, block)
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": blocks,
|
|
"pagination": map[string]interface{}{
|
|
"page": 1,
|
|
"limit": limit,
|
|
},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleLatestTransactions handles GET /api/v1/track1/txs/latest
|
|
func (s *Server) HandleLatestTransactions(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
|
return
|
|
}
|
|
|
|
limit := 10
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
// Get latest block number
|
|
blockNumResp, err := s.rpcGateway.GetBlockNumber(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "rpc_error", err.Error())
|
|
return
|
|
}
|
|
|
|
blockNumHex, ok := blockNumResp.Result.(string)
|
|
if !ok {
|
|
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid block number response")
|
|
return
|
|
}
|
|
|
|
blockNum, err := hexToInt(blockNumHex)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "parse_error", err.Error())
|
|
return
|
|
}
|
|
|
|
// Fetch transactions from recent blocks
|
|
transactions := []map[string]interface{}{}
|
|
for i := 0; i < 20 && len(transactions) < limit && blockNum-int64(i) >= 0; i++ {
|
|
blockNumStr := fmt.Sprintf("0x%x", blockNum-int64(i))
|
|
blockResp, err := s.rpcGateway.GetBlockByNumber(r.Context(), blockNumStr, true)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
blockData, ok := blockResp.Result.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
txs, ok := blockData["transactions"].([]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
for _, tx := range txs {
|
|
if len(transactions) >= limit {
|
|
break
|
|
}
|
|
txData, ok := tx.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
transactions = append(transactions, transformTransaction(txData))
|
|
}
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": transactions,
|
|
"pagination": map[string]interface{}{
|
|
"page": 1,
|
|
"limit": limit,
|
|
},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleBlockDetail handles GET /api/v1/track1/block/:number
|
|
func (s *Server) HandleBlockDetail(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track1/block/")
|
|
blockNumber, err := strconv.ParseInt(strings.TrimSpace(path), 10, 64)
|
|
if err != nil || blockNumber < 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid block number")
|
|
return
|
|
}
|
|
blockNumStr := fmt.Sprintf("0x%x", blockNumber)
|
|
|
|
blockResp, err := s.rpcGateway.GetBlockByNumber(r.Context(), blockNumStr, false)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Block not found")
|
|
return
|
|
}
|
|
|
|
blockData, ok := blockResp.Result.(map[string]interface{})
|
|
if !ok {
|
|
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid block response")
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": transformBlock(blockData),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleTransactionDetail handles GET /api/v1/track1/tx/:hash
|
|
func (s *Server) HandleTransactionDetail(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track1/tx/")
|
|
txHash := strings.TrimSpace(path)
|
|
if !track1HashPattern.MatchString(txHash) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid transaction hash")
|
|
return
|
|
}
|
|
|
|
txResp, err := s.rpcGateway.GetTransactionByHash(r.Context(), txHash)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Transaction not found")
|
|
return
|
|
}
|
|
|
|
txData, ok := txResp.Result.(map[string]interface{})
|
|
if !ok {
|
|
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid transaction response")
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": transformTransaction(txData),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleAddressBalance handles GET /api/v1/track1/address/:addr/balance
|
|
func (s *Server) HandleAddressBalance(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track1/address/")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) < 2 || parts[1] != "balance" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid path")
|
|
return
|
|
}
|
|
|
|
address := strings.TrimSpace(parts[0])
|
|
if !common.IsHexAddress(address) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid address")
|
|
return
|
|
}
|
|
balanceResp, err := s.rpcGateway.GetBalance(r.Context(), address, "latest")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "rpc_error", err.Error())
|
|
return
|
|
}
|
|
|
|
balanceHex, ok := balanceResp.Result.(string)
|
|
if !ok {
|
|
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid balance response")
|
|
return
|
|
}
|
|
|
|
balance, err := hexToBigInt(balanceHex)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "parse_error", err.Error())
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"address": address,
|
|
"balance": balance.String(),
|
|
"balance_wei": balance.String(),
|
|
"balance_ether": weiToEther(balance),
|
|
},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleBridgeStatus handles GET /api/v1/track1/bridge/status
|
|
func (s *Server) HandleBridgeStatus(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
|
defer cancel()
|
|
|
|
data := s.BuildBridgeStatusData(ctx)
|
|
response := map[string]interface{}{
|
|
"data": data,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func chainStatusFromProbe(p RPCProbeResult) string {
|
|
if p.OK {
|
|
return "operational"
|
|
}
|
|
return "unreachable"
|
|
}
|
|
|
|
// Helper functions
|
|
func writeError(w http.ResponseWriter, statusCode int, code, message string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(statusCode)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"error": map[string]interface{}{
|
|
"code": code,
|
|
"message": message,
|
|
},
|
|
})
|
|
}
|
|
|
|
func hexToInt(hex string) (int64, error) {
|
|
hex = strings.TrimPrefix(hex, "0x")
|
|
return strconv.ParseInt(hex, 16, 64)
|
|
}
|
|
|
|
func transformBlock(blockData map[string]interface{}) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"number": parseHexField(blockData["number"]),
|
|
"hash": blockData["hash"],
|
|
"parent_hash": blockData["parentHash"],
|
|
"timestamp": parseHexTimestamp(blockData["timestamp"]),
|
|
"transaction_count": countTransactions(blockData["transactions"]),
|
|
"gas_used": parseHexField(blockData["gasUsed"]),
|
|
"gas_limit": parseHexField(blockData["gasLimit"]),
|
|
"miner": blockData["miner"],
|
|
}
|
|
}
|
|
|
|
func transformTransaction(txData map[string]interface{}) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"hash": txData["hash"],
|
|
"from": txData["from"],
|
|
"to": txData["to"],
|
|
"value": txData["value"],
|
|
"block_number": parseHexField(txData["blockNumber"]),
|
|
"timestamp": parseHexTimestamp(txData["timestamp"]),
|
|
}
|
|
}
|
|
|
|
func parseHexField(field interface{}) interface{} {
|
|
if str, ok := field.(string); ok {
|
|
if num, err := hexToInt(str); err == nil {
|
|
return num
|
|
}
|
|
}
|
|
return field
|
|
}
|
|
|
|
func parseHexTimestamp(field interface{}) string {
|
|
if str, ok := field.(string); ok {
|
|
if num, err := hexToInt(str); err == nil {
|
|
return time.Unix(num, 0).Format(time.RFC3339)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func countTransactions(txs interface{}) int {
|
|
if txsList, ok := txs.([]interface{}); ok {
|
|
return len(txsList)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func hexToBigInt(hex string) (*big.Int, error) {
|
|
hex = strings.TrimPrefix(hex, "0x")
|
|
bigInt := new(big.Int)
|
|
bigInt, ok := bigInt.SetString(hex, 16)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid hex number")
|
|
}
|
|
return bigInt, nil
|
|
}
|
|
|
|
func weiToEther(wei *big.Int) string {
|
|
ether := new(big.Float).Quo(new(big.Float).SetInt(wei), big.NewFloat(1e18))
|
|
return ether.Text('f', 18)
|
|
}
|