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,8 +1,15 @@
|
||||
package track4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/explorer/backend/auth"
|
||||
@@ -11,48 +18,52 @@ import (
|
||||
|
||||
// Server handles Track 4 endpoints
|
||||
type Server struct {
|
||||
db *pgxpool.Pool
|
||||
roleMgr *auth.RoleManager
|
||||
chainID int
|
||||
db *pgxpool.Pool
|
||||
roleMgr roleManager
|
||||
chainID int
|
||||
}
|
||||
|
||||
// NewServer creates a new Track 4 server
|
||||
func NewServer(db *pgxpool.Pool, chainID int) *Server {
|
||||
return &Server{
|
||||
db: db,
|
||||
roleMgr: auth.NewRoleManager(db),
|
||||
chainID: chainID,
|
||||
db: db,
|
||||
roleMgr: auth.NewRoleManager(db),
|
||||
chainID: chainID,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleBridgeEvents handles GET /api/v1/track4/operator/bridge/events
|
||||
func (s *Server) HandleBridgeEvents(w http.ResponseWriter, r *http.Request) {
|
||||
// Get operator address from context
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
if operatorAddr == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Operator address required")
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
// Check IP whitelist
|
||||
ipAddr := r.RemoteAddr
|
||||
if whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
operatorAddr, ipAddr, ok := s.requireOperatorAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Log operator event
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "bridge_events_read", &s.chainID, operatorAddr, "bridge/events", "read", map[string]interface{}{}, ipAddr, r.UserAgent())
|
||||
events, lastUpdate, err := s.loadBridgeEvents(r.Context(), 100)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "bridge_events_read", &s.chainID, operatorAddr, "bridge/events", "read", map[string]interface{}{"event_count": len(events)}, ipAddr, r.UserAgent())
|
||||
|
||||
controlState := map[string]interface{}{
|
||||
"paused": nil,
|
||||
"maintenance_mode": nil,
|
||||
"bridge_control_unavailable": true,
|
||||
}
|
||||
if !lastUpdate.IsZero() {
|
||||
controlState["last_update"] = lastUpdate.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// Return bridge events (simplified)
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"events": []map[string]interface{}{},
|
||||
"control_state": map[string]interface{}{
|
||||
"paused": false,
|
||||
"maintenance_mode": false,
|
||||
"last_update": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
"events": events,
|
||||
"control_state": controlState,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -62,21 +73,29 @@ func (s *Server) HandleBridgeEvents(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleValidators handles GET /api/v1/track4/operator/validators
|
||||
func (s *Server) HandleValidators(w http.ResponseWriter, r *http.Request) {
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
ipAddr := r.RemoteAddr
|
||||
|
||||
if whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "validators_read", &s.chainID, operatorAddr, "validators", "read", map[string]interface{}{}, ipAddr, r.UserAgent())
|
||||
operatorAddr, ipAddr, ok := s.requireOperatorAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
validators, err := s.loadValidatorStatus(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "validators_read", &s.chainID, operatorAddr, "validators", "read", map[string]interface{}{"validator_count": len(validators)}, ipAddr, r.UserAgent())
|
||||
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"validators": []map[string]interface{}{},
|
||||
"total_validators": 0,
|
||||
"active_validators": 0,
|
||||
"validators": validators,
|
||||
"total_validators": len(validators),
|
||||
"active_validators": len(validators),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -86,19 +105,38 @@ func (s *Server) HandleValidators(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleContracts handles GET /api/v1/track4/operator/contracts
|
||||
func (s *Server) HandleContracts(w http.ResponseWriter, r *http.Request) {
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
ipAddr := r.RemoteAddr
|
||||
|
||||
if whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "contracts_read", &s.chainID, operatorAddr, "contracts", "read", map[string]interface{}{}, ipAddr, r.UserAgent())
|
||||
operatorAddr, ipAddr, ok := s.requireOperatorAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
chainID := s.chainID
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("chain_id")); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid chain_id")
|
||||
return
|
||||
}
|
||||
chainID = parsed
|
||||
}
|
||||
|
||||
typeFilter := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("type")))
|
||||
contracts, err := s.loadContractStatus(r.Context(), chainID, typeFilter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "contracts_read", &s.chainID, operatorAddr, "contracts", "read", map[string]interface{}{"contract_count": len(contracts), "chain_id": chainID, "type": typeFilter}, ipAddr, r.UserAgent())
|
||||
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"contracts": []map[string]interface{}{},
|
||||
"contracts": contracts,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -108,35 +146,26 @@ func (s *Server) HandleContracts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleProtocolState handles GET /api/v1/track4/operator/protocol-state
|
||||
func (s *Server) HandleProtocolState(w http.ResponseWriter, r *http.Request) {
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
ipAddr := r.RemoteAddr
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
if whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
operatorAddr, ipAddr, ok := s.requireOperatorAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
state, err := s.loadProtocolState(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "protocol_state_read", &s.chainID, operatorAddr, "protocol/state", "read", map[string]interface{}{}, ipAddr, r.UserAgent())
|
||||
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"protocol_version": "1.0.0",
|
||||
"chain_id": s.chainID,
|
||||
"config": map[string]interface{}{
|
||||
"bridge_enabled": true,
|
||||
"max_transfer_amount": "1000000000000000000000000",
|
||||
},
|
||||
"state": map[string]interface{}{
|
||||
"total_locked": "50000000000000000000000000",
|
||||
"total_bridged": "10000000000000000000000000",
|
||||
"active_bridges": 2,
|
||||
},
|
||||
"last_updated": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"data": state})
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, statusCode int, code, message string) {
|
||||
@@ -150,3 +179,406 @@ func writeError(w http.ResponseWriter, statusCode int, code, message string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireOperatorAccess(w http.ResponseWriter, r *http.Request) (string, string, bool) {
|
||||
if s.db == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "database not configured")
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
operatorAddr = strings.TrimSpace(operatorAddr)
|
||||
if operatorAddr == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Operator address required")
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
ipAddr := clientIPAddress(r)
|
||||
whitelisted, err := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return "", "", false
|
||||
}
|
||||
if !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
return operatorAddr, ipAddr, true
|
||||
}
|
||||
|
||||
func (s *Server) loadBridgeEvents(ctx context.Context, limit int) ([]map[string]interface{}, time.Time, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT event_type, operator_address, target_resource, action, details, COALESCE(ip_address::text, ''), COALESCE(user_agent, ''), timestamp
|
||||
FROM operator_events
|
||||
WHERE (chain_id = $1 OR chain_id IS NULL)
|
||||
AND (
|
||||
event_type ILIKE '%bridge%'
|
||||
OR target_resource ILIKE 'bridge%'
|
||||
OR target_resource ILIKE '%bridge%'
|
||||
)
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $2
|
||||
`, s.chainID, limit)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, fmt.Errorf("failed to query bridge events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]map[string]interface{}, 0, limit)
|
||||
var latest time.Time
|
||||
for rows.Next() {
|
||||
var eventType, operatorAddress, targetResource, action, ipAddress, userAgent string
|
||||
var detailsBytes []byte
|
||||
var timestamp time.Time
|
||||
if err := rows.Scan(&eventType, &operatorAddress, &targetResource, &action, &detailsBytes, &ipAddress, &userAgent, ×tamp); err != nil {
|
||||
return nil, time.Time{}, fmt.Errorf("failed to scan bridge event: %w", err)
|
||||
}
|
||||
|
||||
details := map[string]interface{}{}
|
||||
if len(detailsBytes) > 0 && string(detailsBytes) != "null" {
|
||||
_ = json.Unmarshal(detailsBytes, &details)
|
||||
}
|
||||
|
||||
if latest.IsZero() {
|
||||
latest = timestamp
|
||||
}
|
||||
events = append(events, map[string]interface{}{
|
||||
"event_type": eventType,
|
||||
"operator_address": operatorAddress,
|
||||
"target_resource": targetResource,
|
||||
"action": action,
|
||||
"details": details,
|
||||
"ip_address": ipAddress,
|
||||
"user_agent": userAgent,
|
||||
"timestamp": timestamp.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
return events, latest, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) loadValidatorStatus(ctx context.Context) ([]map[string]interface{}, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT r.address, COALESCE(r.roles, '{}'), COALESCE(oe.last_seen, r.updated_at, r.approved_at), r.track_level
|
||||
FROM operator_roles r
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MAX(timestamp) AS last_seen
|
||||
FROM operator_events
|
||||
WHERE operator_address = r.address
|
||||
) oe ON TRUE
|
||||
WHERE r.approved = TRUE AND r.track_level >= 4
|
||||
ORDER BY COALESCE(oe.last_seen, r.updated_at, r.approved_at) DESC NULLS LAST, r.address
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query validator status: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
validators := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var address string
|
||||
var roles []string
|
||||
var lastSeen time.Time
|
||||
var trackLevel int
|
||||
if err := rows.Scan(&address, &roles, &lastSeen, &trackLevel); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan validator row: %w", err)
|
||||
}
|
||||
|
||||
roleScope := "operator"
|
||||
if inferred := inferOperatorScope(roles); inferred != "" {
|
||||
roleScope = inferred
|
||||
}
|
||||
|
||||
row := map[string]interface{}{
|
||||
"address": address,
|
||||
"status": "active",
|
||||
"stake": nil,
|
||||
"uptime": nil,
|
||||
"last_block": nil,
|
||||
"track_level": trackLevel,
|
||||
"roles": roles,
|
||||
"role_scope": roleScope,
|
||||
}
|
||||
if !lastSeen.IsZero() {
|
||||
row["last_seen"] = lastSeen.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
validators = append(validators, row)
|
||||
}
|
||||
|
||||
return validators, rows.Err()
|
||||
}
|
||||
|
||||
type contractRegistryEntry struct {
|
||||
Address string
|
||||
ChainID int
|
||||
Name string
|
||||
Type string
|
||||
}
|
||||
|
||||
func (s *Server) loadContractStatus(ctx context.Context, chainID int, typeFilter string) ([]map[string]interface{}, error) {
|
||||
type contractRow struct {
|
||||
Name string
|
||||
Status string
|
||||
Compiler string
|
||||
LastVerified *time.Time
|
||||
}
|
||||
|
||||
dbRows := map[string]contractRow{}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT LOWER(address), COALESCE(name, ''), verification_status, compiler_version, verified_at
|
||||
FROM contracts
|
||||
WHERE chain_id = $1
|
||||
`, chainID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query contracts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var address string
|
||||
var row contractRow
|
||||
if err := rows.Scan(&address, &row.Name, &row.Status, &row.Compiler, &row.LastVerified); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan contract row: %w", err)
|
||||
}
|
||||
dbRows[address] = row
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registryEntries, err := loadContractRegistry(chainID)
|
||||
if err != nil {
|
||||
registryEntries = nil
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
contracts := make([]map[string]interface{}, 0, len(registryEntries)+len(dbRows))
|
||||
appendRow := func(address, name, contractType, status, version string, lastVerified *time.Time) {
|
||||
if typeFilter != "" && contractType != typeFilter {
|
||||
return
|
||||
}
|
||||
row := map[string]interface{}{
|
||||
"address": address,
|
||||
"chain_id": chainID,
|
||||
"type": contractType,
|
||||
"name": name,
|
||||
"status": status,
|
||||
}
|
||||
if version != "" {
|
||||
row["version"] = version
|
||||
}
|
||||
if lastVerified != nil && !lastVerified.IsZero() {
|
||||
row["last_verified"] = lastVerified.UTC().Format(time.RFC3339)
|
||||
}
|
||||
contracts = append(contracts, row)
|
||||
seen[address] = true
|
||||
}
|
||||
|
||||
for _, entry := range registryEntries {
|
||||
lowerAddress := strings.ToLower(entry.Address)
|
||||
dbRow, ok := dbRows[lowerAddress]
|
||||
status := "registry_only"
|
||||
version := ""
|
||||
name := entry.Name
|
||||
var lastVerified *time.Time
|
||||
if ok {
|
||||
if dbRow.Name != "" {
|
||||
name = dbRow.Name
|
||||
}
|
||||
status = dbRow.Status
|
||||
version = dbRow.Compiler
|
||||
lastVerified = dbRow.LastVerified
|
||||
}
|
||||
appendRow(lowerAddress, name, entry.Type, status, version, lastVerified)
|
||||
}
|
||||
|
||||
for address, row := range dbRows {
|
||||
if seen[address] {
|
||||
continue
|
||||
}
|
||||
contractType := inferContractType(row.Name)
|
||||
appendRow(address, fallbackString(row.Name, address), contractType, row.Status, row.Compiler, row.LastVerified)
|
||||
}
|
||||
|
||||
sort.Slice(contracts, func(i, j int) bool {
|
||||
left, _ := contracts[i]["name"].(string)
|
||||
right, _ := contracts[j]["name"].(string)
|
||||
if left == right {
|
||||
return contracts[i]["address"].(string) < contracts[j]["address"].(string)
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
|
||||
return contracts, nil
|
||||
}
|
||||
|
||||
func (s *Server) loadProtocolState(ctx context.Context) (map[string]interface{}, error) {
|
||||
var totalBridged string
|
||||
var activeBridges int
|
||||
var lastBridgeAt *time.Time
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(amount)::text, '0'),
|
||||
COUNT(DISTINCT CONCAT(chain_from, ':', chain_to)),
|
||||
MAX(timestamp)
|
||||
FROM analytics_bridge_history
|
||||
WHERE status ILIKE 'success%'
|
||||
AND (chain_from = $1 OR chain_to = $1)
|
||||
`, s.chainID).Scan(&totalBridged, &activeBridges, &lastBridgeAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query protocol state: %w", err)
|
||||
}
|
||||
|
||||
registryEntries, _ := loadContractRegistry(s.chainID)
|
||||
bridgeEnabled := activeBridges > 0
|
||||
if !bridgeEnabled {
|
||||
for _, entry := range registryEntries {
|
||||
if entry.Type == "bridge" {
|
||||
bridgeEnabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocolVersion := strings.TrimSpace(os.Getenv("EXPLORER_PROTOCOL_VERSION"))
|
||||
if protocolVersion == "" {
|
||||
protocolVersion = strings.TrimSpace(os.Getenv("PROTOCOL_VERSION"))
|
||||
}
|
||||
if protocolVersion == "" {
|
||||
protocolVersion = "unknown"
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"protocol_version": protocolVersion,
|
||||
"chain_id": s.chainID,
|
||||
"config": map[string]interface{}{
|
||||
"bridge_enabled": bridgeEnabled,
|
||||
"max_transfer_amount": nil,
|
||||
"max_transfer_amount_unavailable": true,
|
||||
"fee_structure": nil,
|
||||
},
|
||||
"state": map[string]interface{}{
|
||||
"total_locked": nil,
|
||||
"total_locked_unavailable": true,
|
||||
"total_bridged": totalBridged,
|
||||
"active_bridges": activeBridges,
|
||||
},
|
||||
}
|
||||
|
||||
if lastBridgeAt != nil && !lastBridgeAt.IsZero() {
|
||||
data["last_updated"] = lastBridgeAt.UTC().Format(time.RFC3339)
|
||||
} else {
|
||||
data["last_updated"] = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func loadContractRegistry(chainID int) ([]contractRegistryEntry, error) {
|
||||
chainKey := strconv.Itoa(chainID)
|
||||
candidates := []string{}
|
||||
if env := strings.TrimSpace(os.Getenv("SMART_CONTRACTS_MASTER_JSON")); env != "" {
|
||||
candidates = append(candidates, env)
|
||||
}
|
||||
candidates = append(candidates,
|
||||
"config/smart-contracts-master.json",
|
||||
"../config/smart-contracts-master.json",
|
||||
"../../config/smart-contracts-master.json",
|
||||
filepath.Join("explorer-monorepo", "config", "smart-contracts-master.json"),
|
||||
)
|
||||
|
||||
var raw []byte
|
||||
for _, candidate := range candidates {
|
||||
if strings.TrimSpace(candidate) == "" {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(candidate)
|
||||
if err == nil && len(body) > 0 {
|
||||
raw = body
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("smart-contracts-master.json not found")
|
||||
}
|
||||
|
||||
var root struct {
|
||||
Chains map[string]struct {
|
||||
Contracts map[string]string `json:"contracts"`
|
||||
} `json:"chains"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &root); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse contract registry: %w", err)
|
||||
}
|
||||
|
||||
chain, ok := root.Chains[chainKey]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
entries := make([]contractRegistryEntry, 0, len(chain.Contracts))
|
||||
for name, address := range chain.Contracts {
|
||||
addr := strings.TrimSpace(address)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, contractRegistryEntry{
|
||||
Address: addr,
|
||||
ChainID: chainID,
|
||||
Name: name,
|
||||
Type: inferContractType(name),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].Name == entries[j].Name {
|
||||
return strings.ToLower(entries[i].Address) < strings.ToLower(entries[j].Address)
|
||||
}
|
||||
return entries[i].Name < entries[j].Name
|
||||
})
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func inferOperatorScope(roles []string) string {
|
||||
for _, role := range roles {
|
||||
lower := strings.ToLower(role)
|
||||
switch {
|
||||
case strings.Contains(lower, "validator"):
|
||||
return "validator"
|
||||
case strings.Contains(lower, "sequencer"):
|
||||
return "sequencer"
|
||||
case strings.Contains(lower, "bridge"):
|
||||
return "bridge"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func inferContractType(name string) string {
|
||||
lower := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.Contains(lower, "bridge"):
|
||||
return "bridge"
|
||||
case strings.Contains(lower, "router"):
|
||||
return "router"
|
||||
case strings.Contains(lower, "pool"), strings.Contains(lower, "pmm"), strings.Contains(lower, "amm"):
|
||||
return "liquidity"
|
||||
case strings.Contains(lower, "oracle"):
|
||||
return "oracle"
|
||||
case strings.Contains(lower, "vault"):
|
||||
return "vault"
|
||||
case strings.Contains(lower, "token"), strings.Contains(lower, "weth"), strings.Contains(lower, "cw"), strings.Contains(lower, "usdt"), strings.Contains(lower, "usdc"):
|
||||
return "token"
|
||||
default:
|
||||
return "contract"
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackString(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
63
backend/api/track4/endpoints_test.go
Normal file
63
backend/api/track4/endpoints_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package track4
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleValidatorsRejectsNonGET(t *testing.T) {
|
||||
server := NewServer(nil, 138)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/validators", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.HandleValidators(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405 for non-GET validators request, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleContractsRequiresDatabase(t *testing.T) {
|
||||
server := NewServer(nil, 138)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/track4/operator/contracts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.HandleContracts(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 when track4 DB is missing, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadContractRegistryReadsConfiguredFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
registryPath := filepath.Join(tempDir, "smart-contracts-master.json")
|
||||
err := os.WriteFile(registryPath, []byte(`{
|
||||
"chains": {
|
||||
"138": {
|
||||
"contracts": {
|
||||
"CCIP_ROUTER": "0x1111111111111111111111111111111111111111",
|
||||
"CHAIN138_BRIDGE": "0x2222222222222222222222222222222222222222"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write temp registry: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("SMART_CONTRACTS_MASTER_JSON", registryPath)
|
||||
entries, err := loadContractRegistry(138)
|
||||
if err != nil {
|
||||
t.Fatalf("loadContractRegistry returned error: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 registry entries, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Type == "" || entries[1].Type == "" {
|
||||
t.Fatal("expected contract types to be inferred")
|
||||
}
|
||||
}
|
||||
209
backend/api/track4/operator_scripts.go
Normal file
209
backend/api/track4/operator_scripts.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package track4
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type runScriptRequest struct {
|
||||
Script string `json:"script"`
|
||||
Args []string `json:"args"`
|
||||
}
|
||||
|
||||
// HandleRunScript handles POST /api/v1/track4/operator/run-script
|
||||
// Requires Track 4 auth, IP whitelist, OPERATOR_SCRIPTS_ROOT, and OPERATOR_SCRIPT_ALLOWLIST.
|
||||
func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
if operatorAddr == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Operator address required")
|
||||
return
|
||||
}
|
||||
ipAddr := clientIPAddress(r)
|
||||
if whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
return
|
||||
}
|
||||
|
||||
root := strings.TrimSpace(os.Getenv("OPERATOR_SCRIPTS_ROOT"))
|
||||
if root == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "OPERATOR_SCRIPTS_ROOT not configured")
|
||||
return
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil || rootAbs == "" {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "invalid OPERATOR_SCRIPTS_ROOT")
|
||||
return
|
||||
}
|
||||
|
||||
allowRaw := strings.TrimSpace(os.Getenv("OPERATOR_SCRIPT_ALLOWLIST"))
|
||||
if allowRaw == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "OPERATOR_SCRIPT_ALLOWLIST not configured")
|
||||
return
|
||||
}
|
||||
var allow []string
|
||||
for _, p := range strings.Split(allowRaw, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
allow = append(allow, p)
|
||||
}
|
||||
}
|
||||
if len(allow) == 0 {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "OPERATOR_SCRIPT_ALLOWLIST empty")
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody runScriptRequest
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&reqBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
script := strings.TrimSpace(reqBody.Script)
|
||||
if script == "" || strings.Contains(script, "..") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid script path")
|
||||
return
|
||||
}
|
||||
if len(reqBody.Args) > 24 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "too many args (max 24)")
|
||||
return
|
||||
}
|
||||
for _, a := range reqBody.Args {
|
||||
if strings.Contains(a, "\x00") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid arg")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
candidate := filepath.Join(rootAbs, filepath.Clean(script))
|
||||
if rel, err := filepath.Rel(rootAbs, candidate); err != nil || strings.HasPrefix(rel, "..") {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "script outside OPERATOR_SCRIPTS_ROOT")
|
||||
return
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(rootAbs, candidate)
|
||||
allowed := false
|
||||
base := filepath.Base(relPath)
|
||||
for _, a := range allow {
|
||||
if a == relPath || a == base || filepath.Clean(a) == relPath {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "script not in OPERATOR_SCRIPT_ALLOWLIST")
|
||||
return
|
||||
}
|
||||
|
||||
st, err := os.Stat(candidate)
|
||||
if err != nil || st.IsDir() {
|
||||
writeError(w, http.StatusNotFound, "not_found", "script not found")
|
||||
return
|
||||
}
|
||||
isShell := strings.HasSuffix(strings.ToLower(candidate), ".sh")
|
||||
if !isShell && st.Mode()&0o111 == 0 {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "refusing to run non-executable file (use .sh or chmod +x)")
|
||||
return
|
||||
}
|
||||
|
||||
timeout := 120 * time.Second
|
||||
if v := strings.TrimSpace(os.Getenv("OPERATOR_SCRIPT_TIMEOUT_SEC")); v != "" {
|
||||
if sec, err := parsePositiveInt(v); err == nil && sec > 0 && sec < 600 {
|
||||
timeout = time.Duration(sec) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "operator_script_run", &s.chainID, operatorAddr, "operator/run-script", "execute",
|
||||
map[string]interface{}{
|
||||
"script": relPath,
|
||||
"argc": len(reqBody.Args),
|
||||
}, ipAddr, r.UserAgent())
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if isShell {
|
||||
args := append([]string{candidate}, reqBody.Args...)
|
||||
cmd = exec.CommandContext(ctx, "/bin/bash", args...)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, candidate, reqBody.Args...)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
runErr := cmd.Run()
|
||||
|
||||
exit := 0
|
||||
timedOut := errors.Is(ctx.Err(), context.DeadlineExceeded)
|
||||
if runErr != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(runErr, &ee) {
|
||||
exit = ee.ExitCode()
|
||||
} else if timedOut {
|
||||
exit = -1
|
||||
} else {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", runErr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
status := "ok"
|
||||
if timedOut {
|
||||
status = "timed_out"
|
||||
} else if exit != 0 {
|
||||
status = "nonzero_exit"
|
||||
}
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "operator_script_result", &s.chainID, operatorAddr, "operator/run-script", status,
|
||||
map[string]interface{}{
|
||||
"script": relPath,
|
||||
"argc": len(reqBody.Args),
|
||||
"exit_code": exit,
|
||||
"timed_out": timedOut,
|
||||
"stdout_bytes": stdout.Len(),
|
||||
"stderr_bytes": stderr.Len(),
|
||||
}, ipAddr, r.UserAgent())
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"script": relPath,
|
||||
"exit_code": exit,
|
||||
"stdout": strings.TrimSpace(stdout.String()),
|
||||
"stderr": strings.TrimSpace(stderr.String()),
|
||||
"timed_out": timedOut,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func parsePositiveInt(s string) (int, error) {
|
||||
var n int
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, errors.New("not digits")
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
if n > 1e6 {
|
||||
return 0, errors.New("too large")
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, errors.New("zero")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
88
backend/api/track4/operator_scripts_test.go
Normal file
88
backend/api/track4/operator_scripts_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package track4
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubRoleManager struct {
|
||||
allowed bool
|
||||
gotIP string
|
||||
logs int
|
||||
}
|
||||
|
||||
func (s *stubRoleManager) IsIPWhitelisted(_ context.Context, _ string, ipAddress string) (bool, error) {
|
||||
s.gotIP = ipAddress
|
||||
return s.allowed, nil
|
||||
}
|
||||
|
||||
func (s *stubRoleManager) LogOperatorEvent(_ context.Context, _ string, _ *int, _ string, _ string, _ string, _ map[string]interface{}, _ string, _ string) error {
|
||||
s.logs++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandleRunScriptUsesForwardedClientIPAndRunsAllowlistedScript(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
scriptPath := filepath.Join(root, "echo.sh")
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/usr/bin/env bash\necho hello \"$1\"\n"), 0o644))
|
||||
|
||||
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
|
||||
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "echo.sh")
|
||||
t.Setenv("OPERATOR_SCRIPT_TIMEOUT_SEC", "30")
|
||||
t.Setenv("TRUST_PROXY_CIDRS", "10.0.0.0/8")
|
||||
|
||||
roleMgr := &stubRoleManager{allowed: true}
|
||||
s := &Server{roleMgr: roleMgr, chainID: 138}
|
||||
|
||||
reqBody := []byte(`{"script":"echo.sh","args":["world"]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader(reqBody))
|
||||
req = req.WithContext(context.WithValue(req.Context(), "user_address", "0x4A666F96fC8764181194447A7dFdb7d471b301C8"))
|
||||
req.RemoteAddr = "10.0.0.10:8080"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.10")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.HandleRunScript(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "203.0.113.9", roleMgr.gotIP)
|
||||
require.Equal(t, 2, roleMgr.logs)
|
||||
|
||||
var out struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
|
||||
require.Equal(t, "echo.sh", out.Data["script"])
|
||||
require.Equal(t, float64(0), out.Data["exit_code"])
|
||||
require.Equal(t, "hello world", out.Data["stdout"])
|
||||
require.Equal(t, false, out.Data["timed_out"])
|
||||
}
|
||||
|
||||
func TestHandleRunScriptRejectsNonAllowlistedScript(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "allowed.sh"), []byte("#!/usr/bin/env bash\necho ok\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "blocked.sh"), []byte("#!/usr/bin/env bash\necho blocked\n"), 0o644))
|
||||
|
||||
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
|
||||
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "allowed.sh")
|
||||
|
||||
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"blocked.sh"}`)))
|
||||
req = req.WithContext(context.WithValue(req.Context(), "user_address", "0x4A666F96fC8764181194447A7dFdb7d471b301C8"))
|
||||
req.RemoteAddr = "127.0.0.1:9999"
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.HandleRunScript(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
require.Contains(t, w.Body.String(), "script not in OPERATOR_SCRIPT_ALLOWLIST")
|
||||
}
|
||||
17
backend/api/track4/request_ip.go
Normal file
17
backend/api/track4/request_ip.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package track4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
httpmiddleware "github.com/explorer/backend/libs/go-http-middleware"
|
||||
)
|
||||
|
||||
type roleManager interface {
|
||||
IsIPWhitelisted(ctx context.Context, operatorAddress string, ipAddress string) (bool, error)
|
||||
LogOperatorEvent(ctx context.Context, eventType string, chainID *int, operatorAddress string, targetResource string, action string, details map[string]interface{}, ipAddress string, userAgent string) error
|
||||
}
|
||||
|
||||
func clientIPAddress(r *http.Request) string {
|
||||
return httpmiddleware.ClientIP(r)
|
||||
}
|
||||
Reference in New Issue
Block a user