375 lines
10 KiB
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, ×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 (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, ×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 (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,
|
|
},
|
|
})
|
|
}
|