package track2 import ( "encoding/hex" "encoding/json" "fmt" "net/http" "regexp" "strconv" "strings" "github.com/ethereum/go-ethereum/common" "github.com/jackc/pgx/v5/pgxpool" ) var track2HashPattern = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`) // Server handles Track 2 endpoints type Server struct { db *pgxpool.Pool chainID int } // NewServer creates a new Track 2 server func NewServer(db *pgxpool.Pool, chainID int) *Server { return &Server{ db: db, chainID: chainID, } } // HandleAddressTransactions handles GET /api/v1/track2/address/:addr/txs func (s *Server) HandleAddressTransactions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") return } if !s.requireDB(w) { return } path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/address/") parts := strings.Split(path, "/") if len(parts) < 2 || parts[1] != "txs" { writeError(w, http.StatusBadRequest, "bad_request", "Invalid path") return } address, err := normalizeTrack2Address(parts[0]) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { page = 1 } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit < 1 || limit > 100 { limit = 20 } offset := (page - 1) * limit query := ` SELECT hash, from_address, to_address, value, block_number, timestamp, status FROM transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2) ORDER BY block_number DESC, timestamp DESC LIMIT $3 OFFSET $4 ` rows, err := s.db.Query(r.Context(), query, s.chainID, address, limit, offset) if err != nil { writeError(w, http.StatusInternalServerError, "database_error", err.Error()) return } defer rows.Close() transactions := []map[string]interface{}{} for rows.Next() { var hash, from, to, value, status string var blockNumber int64 var timestamp interface{} if err := rows.Scan(&hash, &from, &to, &value, &blockNumber, ×tamp, &status); err != nil { continue } direction := "received" if strings.ToLower(from) == address { direction = "sent" } transactions = append(transactions, map[string]interface{}{ "hash": hash, "from": from, "to": to, "value": value, "block_number": blockNumber, "timestamp": timestamp, "status": status, "direction": direction, }) } // Get total count var total int countQuery := `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)` s.db.QueryRow(r.Context(), countQuery, s.chainID, address).Scan(&total) response := map[string]interface{}{ "data": transactions, "pagination": map[string]interface{}{ "page": page, "limit": limit, "total": total, "total_pages": (total + limit - 1) / limit, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // HandleAddressTokens handles GET /api/v1/track2/address/:addr/tokens func (s *Server) HandleAddressTokens(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") return } if !s.requireDB(w) { return } path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/address/") parts := strings.Split(path, "/") if len(parts) < 2 || parts[1] != "tokens" { writeError(w, http.StatusBadRequest, "bad_request", "Invalid path") return } address, err := normalizeTrack2Address(parts[0]) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } query := ` SELECT token_contract, balance, last_updated_timestamp FROM token_balances WHERE LOWER(address) = $1 AND chain_id = $2 AND balance > 0 ORDER BY balance DESC ` rows, err := s.db.Query(r.Context(), query, address, s.chainID) if err != nil { writeError(w, http.StatusInternalServerError, "database_error", err.Error()) return } defer rows.Close() tokens := []map[string]interface{}{} for rows.Next() { var contract, balance string var lastUpdated interface{} if err := rows.Scan(&contract, &balance, &lastUpdated); err != nil { continue } tokens = append(tokens, map[string]interface{}{ "contract": contract, "balance": balance, "balance_formatted": nil, "last_updated": lastUpdated, }) } response := map[string]interface{}{ "data": map[string]interface{}{ "address": address, "tokens": tokens, "total_tokens": len(tokens), }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // HandleTokenInfo handles GET /api/v1/track2/token/:contract func (s *Server) HandleTokenInfo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") return } if !s.requireDB(w) { return } path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/token/") contract, err := normalizeTrack2Address(path) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } // Get token info from token_transfers query := ` SELECT ( SELECT COUNT(*) FROM ( SELECT from_address AS address FROM token_transfers WHERE token_contract = $1 AND chain_id = $2 AND timestamp >= NOW() - INTERVAL '24 hours' AND from_address IS NOT NULL AND from_address <> '' UNION SELECT to_address AS address FROM token_transfers WHERE token_contract = $1 AND chain_id = $2 AND timestamp >= NOW() - INTERVAL '24 hours' AND to_address IS NOT NULL AND to_address <> '' ) holder_addresses ) as holders, COUNT(*) as transfers_24h, SUM(value) as volume_24h FROM token_transfers WHERE token_contract = $1 AND chain_id = $2 AND timestamp >= NOW() - INTERVAL '24 hours' ` var holders, transfers24h int var volume24h string err = s.db.QueryRow(r.Context(), query, contract, s.chainID).Scan(&holders, &transfers24h, &volume24h) if err != nil { writeError(w, http.StatusNotFound, "not_found", "Token not found") return } response := map[string]interface{}{ "data": map[string]interface{}{ "contract": contract, "holders": holders, "transfers_24h": transfers24h, "volume_24h": volume24h, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // HandleSearch handles GET /api/v1/track2/search?q= func (s *Server) HandleSearch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") return } if !s.requireDB(w) { return } query := strings.TrimSpace(r.URL.Query().Get("q")) if query == "" { writeError(w, http.StatusBadRequest, "bad_request", "Query parameter 'q' is required") return } // Try to detect type and search var result map[string]interface{} // Check if it's a block number if blockNum, err := strconv.ParseInt(query, 10, 64); err == nil { var hash string err := s.db.QueryRow(r.Context(), `SELECT hash FROM blocks WHERE chain_id = $1 AND number = $2`, s.chainID, blockNum).Scan(&hash) if err == nil { result = map[string]interface{}{ "type": "block", "result": map[string]interface{}{ "number": blockNum, "hash": hash, }, } } } else if track2HashPattern.MatchString(query) { hash, err := normalizeTrack2Hash(query) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } var txHash string err = s.db.QueryRow(r.Context(), `SELECT hash FROM transactions WHERE chain_id = $1 AND LOWER(hash) = $2`, s.chainID, hash).Scan(&txHash) if err == nil { result = map[string]interface{}{ "type": "transaction", "result": map[string]interface{}{ "hash": txHash, }, } } } else if common.IsHexAddress(query) { address, err := normalizeTrack2Address(query) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } var exists bool existsQuery := ` SELECT EXISTS ( SELECT 1 FROM addresses WHERE chain_id = $1 AND LOWER(address) = $2 UNION SELECT 1 FROM transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2) UNION SELECT 1 FROM token_balances WHERE chain_id = $1 AND LOWER(address) = $2 ) ` err = s.db.QueryRow(r.Context(), existsQuery, s.chainID, address).Scan(&exists) if err == nil && exists { var balance string err = s.db.QueryRow(r.Context(), `SELECT COALESCE(SUM(balance), '0') FROM token_balances WHERE LOWER(address) = $1 AND chain_id = $2`, address, s.chainID).Scan(&balance) if err != nil { balance = "0" } result = map[string]interface{}{ "type": "address", "result": map[string]interface{}{ "address": address, "balance": balance, }, } } } if result == nil { writeError(w, http.StatusNotFound, "not_found", "No results found") return } response := map[string]interface{}{ "data": result, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // HandleInternalTransactions handles GET /api/v1/track2/address/:addr/internal-txs func (s *Server) HandleInternalTransactions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") return } if !s.requireDB(w) { return } path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/address/") parts := strings.Split(path, "/") if len(parts) < 2 || parts[1] != "internal-txs" { writeError(w, http.StatusBadRequest, "bad_request", "Invalid path") return } address, err := normalizeTrack2Address(parts[0]) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { page = 1 } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit < 1 || limit > 100 { limit = 20 } offset := (page - 1) * limit query := ` SELECT transaction_hash, from_address, to_address, value, block_number, timestamp FROM internal_transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2) ORDER BY block_number DESC, timestamp DESC LIMIT $3 OFFSET $4 ` rows, err := s.db.Query(r.Context(), query, s.chainID, address, limit, offset) if err != nil { writeError(w, http.StatusInternalServerError, "database_error", err.Error()) return } defer rows.Close() internalTxs := []map[string]interface{}{} for rows.Next() { var txHash, from, to, value string var blockNumber int64 var timestamp interface{} if err := rows.Scan(&txHash, &from, &to, &value, &blockNumber, ×tamp); err != nil { continue } internalTxs = append(internalTxs, map[string]interface{}{ "transaction_hash": txHash, "from": from, "to": to, "value": value, "block_number": blockNumber, "timestamp": timestamp, }) } var total int countQuery := `SELECT COUNT(*) FROM internal_transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)` s.db.QueryRow(r.Context(), countQuery, s.chainID, address).Scan(&total) response := map[string]interface{}{ "data": internalTxs, "pagination": map[string]interface{}{ "page": page, "limit": limit, "total": total, "total_pages": (total + limit - 1) / limit, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } 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 (s *Server) requireDB(w http.ResponseWriter) bool { if s.db == nil { writeError(w, http.StatusServiceUnavailable, "service_unavailable", "database not configured") return false } return true } func normalizeTrack2Address(value string) (string, error) { trimmed := strings.TrimSpace(value) if !common.IsHexAddress(trimmed) { return "", fmt.Errorf("invalid address format") } return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil } func normalizeTrack2Hash(value string) (string, error) { trimmed := strings.TrimSpace(value) if !track2HashPattern.MatchString(trimmed) { return "", fmt.Errorf("invalid transaction hash") } if _, err := hex.DecodeString(trimmed[2:]); err != nil { return "", fmt.Errorf("invalid transaction hash") } return strings.ToLower(trimmed), nil }