Files
explorer-monorepo/backend/api/track2/endpoints.go

375 lines
10 KiB
Go

package track2
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
// 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
}
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 := strings.ToLower(parts[0])
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 (from_address = $2 OR 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, &timestamp, &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 (from_address = $2 OR 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
}
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 := strings.ToLower(parts[0])
query := `
SELECT token_contract, balance, last_updated_timestamp
FROM token_balances
WHERE 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": balance, // TODO: Format with decimals
"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
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/token/")
contract := strings.ToLower(path)
// Get token info from token_transfers
query := `
SELECT
COUNT(DISTINCT from_address) + COUNT(DISTINCT to_address) 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
}
query := r.URL.Query().Get("q")
if query == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Query parameter 'q' is required")
return
}
query = strings.ToLower(strings.TrimPrefix(query, "0x"))
// 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 len(query) == 64 || len(query) == 40 {
// Could be address or transaction hash
fullQuery := "0x" + query
// Check transaction
var txHash string
err := s.db.QueryRow(r.Context(), `SELECT hash FROM transactions WHERE chain_id = $1 AND hash = $2`, s.chainID, fullQuery).Scan(&txHash)
if err == nil {
result = map[string]interface{}{
"type": "transaction",
"result": map[string]interface{}{
"hash": txHash,
},
}
} else {
// Check address
var balance string
err := s.db.QueryRow(r.Context(), `SELECT COALESCE(SUM(balance), '0') FROM token_balances WHERE address = $1 AND chain_id = $2`, fullQuery, s.chainID).Scan(&balance)
if err == nil {
result = map[string]interface{}{
"type": "address",
"result": map[string]interface{}{
"address": fullQuery,
"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
}
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 := strings.ToLower(parts[0])
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 (from_address = $2 OR 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, &timestamp); 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 (from_address = $2 OR 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,
},
})
}