- 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
483 lines
13 KiB
Go
483 lines
13 KiB
Go
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
|
|
}
|