feat: explorer API, wallet, CCIP scripts, and config refresh
- 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
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
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
|
||||
@@ -29,6 +35,9 @@ func (s *Server) HandleAddressTransactions(w http.ResponseWriter, r *http.Reques
|
||||
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, "/")
|
||||
@@ -37,7 +46,11 @@ func (s *Server) HandleAddressTransactions(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
address := strings.ToLower(parts[0])
|
||||
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
|
||||
@@ -51,7 +64,7 @@ func (s *Server) HandleAddressTransactions(w http.ResponseWriter, r *http.Reques
|
||||
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)
|
||||
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
|
||||
`
|
||||
@@ -92,7 +105,7 @@ func (s *Server) HandleAddressTransactions(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// Get total count
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`
|
||||
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{}{
|
||||
@@ -115,6 +128,9 @@ func (s *Server) HandleAddressTokens(w http.ResponseWriter, r *http.Request) {
|
||||
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, "/")
|
||||
@@ -123,12 +139,16 @@ func (s *Server) HandleAddressTokens(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
address := strings.ToLower(parts[0])
|
||||
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 address = $1 AND chain_id = $2 AND balance > 0
|
||||
WHERE LOWER(address) = $1 AND chain_id = $2 AND balance > 0
|
||||
ORDER BY balance DESC
|
||||
`
|
||||
|
||||
@@ -151,7 +171,7 @@ func (s *Server) HandleAddressTokens(w http.ResponseWriter, r *http.Request) {
|
||||
tokens = append(tokens, map[string]interface{}{
|
||||
"contract": contract,
|
||||
"balance": balance,
|
||||
"balance_formatted": balance, // TODO: Format with decimals
|
||||
"balance_formatted": nil,
|
||||
"last_updated": lastUpdated,
|
||||
})
|
||||
}
|
||||
@@ -174,14 +194,40 @@ func (s *Server) HandleTokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
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 := strings.ToLower(path)
|
||||
contract, err := normalizeTrack2Address(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Get token info from token_transfers
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(DISTINCT from_address) + COUNT(DISTINCT to_address) as holders,
|
||||
(
|
||||
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
|
||||
@@ -191,7 +237,7 @@ func (s *Server) HandleTokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var holders, transfers24h int
|
||||
var volume24h string
|
||||
err := s.db.QueryRow(r.Context(), query, contract, s.chainID).Scan(&holders, &transfers24h, &volume24h)
|
||||
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
|
||||
@@ -216,15 +262,16 @@ func (s *Server) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
query := strings.TrimSpace(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{}
|
||||
|
||||
@@ -241,13 +288,14 @@ func (s *Server) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
}
|
||||
}
|
||||
} else if len(query) == 64 || len(query) == 40 {
|
||||
// Could be address or transaction hash
|
||||
fullQuery := "0x" + query
|
||||
|
||||
// Check transaction
|
||||
} 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 hash = $2`, s.chainID, fullQuery).Scan(&txHash)
|
||||
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",
|
||||
@@ -255,18 +303,44 @@ func (s *Server) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
"hash": txHash,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Check address
|
||||
}
|
||||
} 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 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,
|
||||
},
|
||||
}
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +364,9 @@ func (s *Server) HandleInternalTransactions(w http.ResponseWriter, r *http.Reque
|
||||
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, "/")
|
||||
@@ -298,7 +375,11 @@ func (s *Server) HandleInternalTransactions(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
address := strings.ToLower(parts[0])
|
||||
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
|
||||
@@ -312,7 +393,7 @@ func (s *Server) HandleInternalTransactions(w http.ResponseWriter, r *http.Reque
|
||||
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)
|
||||
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
|
||||
`
|
||||
@@ -345,7 +426,7 @@ func (s *Server) HandleInternalTransactions(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM internal_transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`
|
||||
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{}{
|
||||
@@ -372,3 +453,30 @@ func writeError(w http.ResponseWriter, statusCode int, code, message string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user