- Backend REST/gateway/track routes, analytics, Blockscout proxy paths. - Frontend wallet and liquidity surfaces; MetaMask token list alignment. - Deployment docs, verification scripts, address inventory updates. Check: go build ./... under backend/ (pass). Made-with: Cursor
269 lines
7.1 KiB
Go
269 lines
7.1 KiB
Go
package track3
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ethereum/go-ethereum/common"
|
|
"github.com/explorer/backend/analytics"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Server handles Track 3 endpoints
|
|
type Server struct {
|
|
db *pgxpool.Pool
|
|
flowTracker *analytics.FlowTracker
|
|
bridgeAnalytics *analytics.BridgeAnalytics
|
|
tokenDist *analytics.TokenDistribution
|
|
riskAnalyzer *analytics.AddressRiskAnalyzer
|
|
chainID int
|
|
}
|
|
|
|
// NewServer creates a new Track 3 server
|
|
func NewServer(db *pgxpool.Pool, chainID int) *Server {
|
|
return &Server{
|
|
db: db,
|
|
flowTracker: analytics.NewFlowTracker(db, chainID),
|
|
bridgeAnalytics: analytics.NewBridgeAnalytics(db),
|
|
tokenDist: analytics.NewTokenDistribution(db, chainID),
|
|
riskAnalyzer: analytics.NewAddressRiskAnalyzer(db, chainID),
|
|
chainID: chainID,
|
|
}
|
|
}
|
|
|
|
// HandleFlows handles GET /api/v1/track3/analytics/flows
|
|
func (s *Server) HandleFlows(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
|
|
}
|
|
|
|
from, err := normalizeTrack3OptionalAddress(r.URL.Query().Get("from"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
to, err := normalizeTrack3OptionalAddress(r.URL.Query().Get("to"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
token, err := normalizeTrack3OptionalAddress(r.URL.Query().Get("token"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit < 1 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
|
|
var startDate, endDate *time.Time
|
|
if startStr := r.URL.Query().Get("start_date"); startStr != "" {
|
|
t, err := time.Parse(time.RFC3339, startStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "invalid start_date")
|
|
return
|
|
}
|
|
startDate = &t
|
|
}
|
|
if endStr := r.URL.Query().Get("end_date"); endStr != "" {
|
|
t, err := time.Parse(time.RFC3339, endStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "invalid end_date")
|
|
return
|
|
}
|
|
endDate = &t
|
|
}
|
|
|
|
flows, err := s.flowTracker.GetFlows(r.Context(), from, to, token, startDate, endDate, limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": map[string]interface{}{
|
|
"flows": flows,
|
|
},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleBridge handles GET /api/v1/track3/analytics/bridge
|
|
func (s *Server) HandleBridge(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
|
|
}
|
|
|
|
var chainFrom, chainTo *int
|
|
if cf := r.URL.Query().Get("chain_from"); cf != "" {
|
|
c, err := strconv.Atoi(cf)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "invalid chain_from")
|
|
return
|
|
}
|
|
chainFrom = &c
|
|
}
|
|
if ct := r.URL.Query().Get("chain_to"); ct != "" {
|
|
c, err := strconv.Atoi(ct)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "invalid chain_to")
|
|
return
|
|
}
|
|
chainTo = &c
|
|
}
|
|
|
|
var startDate, endDate *time.Time
|
|
if startStr := r.URL.Query().Get("start_date"); startStr != "" {
|
|
t, err := time.Parse(time.RFC3339, startStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "invalid start_date")
|
|
return
|
|
}
|
|
startDate = &t
|
|
}
|
|
if endStr := r.URL.Query().Get("end_date"); endStr != "" {
|
|
t, err := time.Parse(time.RFC3339, endStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "invalid end_date")
|
|
return
|
|
}
|
|
endDate = &t
|
|
}
|
|
|
|
stats, err := s.bridgeAnalytics.GetBridgeStats(r.Context(), chainFrom, chainTo, startDate, endDate)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": stats,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleTokenDistribution handles GET /api/v1/track3/analytics/token-distribution
|
|
func (s *Server) HandleTokenDistribution(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/track3/analytics/token-distribution/")
|
|
contract, err := normalizeTrack3RequiredAddress(path)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
topN, _ := strconv.Atoi(r.URL.Query().Get("top_n"))
|
|
if topN < 1 || topN > 1000 {
|
|
topN = 100
|
|
}
|
|
|
|
stats, err := s.tokenDist.GetTokenDistribution(r.Context(), contract, topN)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": stats,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleAddressRisk handles GET /api/v1/track3/analytics/address-risk/:addr
|
|
func (s *Server) HandleAddressRisk(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/track3/analytics/address-risk/")
|
|
address, err := normalizeTrack3RequiredAddress(path)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
analysis, err := s.riskAnalyzer.AnalyzeAddress(r.Context(), address)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"data": analysis,
|
|
}
|
|
|
|
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 normalizeTrack3OptionalAddress(value string) (string, error) {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return "", nil
|
|
}
|
|
if !common.IsHexAddress(trimmed) {
|
|
return "", fmt.Errorf("invalid address format")
|
|
}
|
|
return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil
|
|
}
|
|
|
|
func normalizeTrack3RequiredAddress(value string) (string, error) {
|
|
normalized, err := normalizeTrack3OptionalAddress(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if normalized == "" {
|
|
return "", fmt.Errorf("address required")
|
|
}
|
|
return normalized, nil
|
|
}
|