Add full monorepo: virtual-banker, backend, frontend, docs, scripts, deployment

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-02-10 11:32:49 -08:00
parent aafcd913c2
commit 88bc76da91
815 changed files with 125522 additions and 264 deletions

View File

@@ -0,0 +1,32 @@
package main
import (
"log"
"os"
"strconv"
"github.com/explorer/backend/api/gateway"
)
func main() {
apiURL := os.Getenv("API_URL")
if apiURL == "" {
apiURL = "http://localhost:8080"
}
gw, err := gateway.NewGateway(apiURL)
if err != nil {
log.Fatalf("Failed to create gateway: %v", err)
}
port := 8081
if envPort := os.Getenv("GATEWAY_PORT"); envPort != "" {
if p, err := strconv.Atoi(envPort); err == nil {
port = p
}
}
if err := gw.Start(port); err != nil {
log.Fatalf("Failed to start gateway: %v", err)
}
}

View File

@@ -0,0 +1,140 @@
package gateway
import (
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
)
// Gateway represents the API gateway
type Gateway struct {
apiURL *url.URL
rateLimiter *RateLimiter
auth *AuthMiddleware
}
// NewGateway creates a new API gateway
func NewGateway(apiURL string) (*Gateway, error) {
parsedURL, err := url.Parse(apiURL)
if err != nil {
return nil, fmt.Errorf("invalid API URL: %w", err)
}
return &Gateway{
apiURL: parsedURL,
rateLimiter: NewRateLimiter(),
auth: NewAuthMiddleware(),
}, nil
}
// Start starts the gateway server
func (g *Gateway) Start(port int) error {
mux := http.NewServeMux()
// Proxy to API server
proxy := httputil.NewSingleHostReverseProxy(g.apiURL)
mux.HandleFunc("/", g.handleRequest(proxy))
addr := fmt.Sprintf(":%d", port)
log.Printf("Starting API Gateway on %s", addr)
return http.ListenAndServe(addr, mux)
}
// handleRequest handles incoming requests with middleware
func (g *Gateway) handleRequest(proxy *httputil.ReverseProxy) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Add security headers
g.addSecurityHeaders(w)
// Authentication
if !g.auth.Authenticate(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Rate limiting
if !g.rateLimiter.Allow(r) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
// Add headers
r.Header.Set("X-Forwarded-For", r.RemoteAddr)
if apiKey := g.auth.GetAPIKey(r); apiKey != "" {
r.Header.Set("X-API-Key", apiKey)
}
// Add branding header
w.Header().Set("X-Explorer-Name", "SolaceScanScout")
w.Header().Set("X-Explorer-Version", "1.0.0")
// Proxy request
proxy.ServeHTTP(w, r)
}
}
// addSecurityHeaders adds security headers to responses
func (g *Gateway) addSecurityHeaders(w http.ResponseWriter) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// CSP will be set per route if needed
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
}
// RateLimiter handles rate limiting
type RateLimiter struct {
// Simple in-memory rate limiter (should use Redis in production)
limits map[string]*limitEntry
}
type limitEntry struct {
count int
resetAt int64
}
func NewRateLimiter() *RateLimiter {
return &RateLimiter{
limits: make(map[string]*limitEntry),
}
}
func (rl *RateLimiter) Allow(r *http.Request) bool {
_ = r.RemoteAddr // Will be used in production for per-IP limiting
// In production, use Redis with token bucket algorithm
// For now, simple per-IP limiting
return true // Simplified - implement proper rate limiting
}
// AuthMiddleware handles authentication
type AuthMiddleware struct {
// In production, validate against database
}
func NewAuthMiddleware() *AuthMiddleware {
return &AuthMiddleware{}
}
func (am *AuthMiddleware) Authenticate(r *http.Request) bool {
// Allow anonymous access for now
// In production, validate API key
apiKey := am.GetAPIKey(r)
return apiKey != "" || true // Allow anonymous for MVP
}
func (am *AuthMiddleware) GetAPIKey(r *http.Request) string {
// Check header first
if key := r.Header.Get("X-API-Key"); key != "" {
return key
}
// Check query parameter
if key := r.URL.Query().Get("api_key"); key != "" {
return key
}
return ""
}

View File

@@ -0,0 +1,81 @@
package graphql
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Resolver handles GraphQL queries
type Resolver struct {
db *pgxpool.Pool
chainID int
}
// NewResolver creates a new GraphQL resolver
func NewResolver(db *pgxpool.Pool, chainID int) *Resolver {
return &Resolver{
db: db,
chainID: chainID,
}
}
// BlockResolver resolves Block queries
type BlockResolver struct {
db *pgxpool.Pool
chainID int
block *Block
}
// Block represents a block in GraphQL
type Block struct {
ChainID int32
Number int32
Hash string
ParentHash string
Timestamp string
Miner string
TransactionCount int32
GasUsed int64
GasLimit int64
}
// TransactionResolver resolves Transaction queries
type TransactionResolver struct {
db *pgxpool.Pool
chainID int
tx *Transaction
}
// Transaction represents a transaction in GraphQL
type Transaction struct {
ChainID int32
Hash string
BlockNumber int32
From string
To *string
Value string
GasPrice *int64
GasUsed *int64
Status *int32
}
// ResolveBlock resolves block query
func (r *Resolver) ResolveBlock(ctx context.Context, args struct {
ChainID int32
Number *int32
}) (*BlockResolver, error) {
// Implementation would fetch block from database
return nil, fmt.Errorf("not implemented")
}
// ResolveTransaction resolves transaction query
func (r *Resolver) ResolveTransaction(ctx context.Context, args struct {
ChainID int32
Hash string
}) (*TransactionResolver, error) {
// Implementation would fetch transaction from database
return nil, fmt.Errorf("not implemented")
}

View File

@@ -0,0 +1,102 @@
type Query {
block(chainId: Int!, number: Int): Block
blockByHash(chainId: Int!, hash: String!): Block
blocks(chainId: Int!, page: Int, pageSize: Int): BlockConnection!
transaction(chainId: Int!, hash: String!): Transaction
transactions(chainId: Int!, page: Int, pageSize: Int): TransactionConnection!
address(chainId: Int!, address: String!): Address
}
type Block {
chainId: Int!
number: Int!
hash: String!
parentHash: String!
timestamp: String!
miner: String!
transactionCount: Int!
gasUsed: Int!
gasLimit: Int!
transactions: [Transaction!]!
}
type Transaction {
chainId: Int!
hash: String!
blockNumber: Int!
from: String!
to: String
value: String!
gasPrice: Int
gasUsed: Int
status: Int
logs: [Log!]!
trace: Trace
}
type Log {
address: String!
topics: [String!]!
data: String!
logIndex: Int!
}
type Trace {
calls: [CallTrace!]!
}
type CallTrace {
type: String!
from: String!
to: String!
value: String!
gas: Int!
gasUsed: Int!
input: String!
output: String!
}
type Address {
address: String!
chainId: Int!
transactionCount: Int!
tokenCount: Int!
isContract: Boolean!
label: String
tags: [String!]!
}
type BlockConnection {
edges: [BlockEdge!]!
pageInfo: PageInfo!
}
type BlockEdge {
node: Block!
cursor: String!
}
type TransactionConnection {
edges: [TransactionEdge!]!
pageInfo: PageInfo!
}
type TransactionEdge {
node: Transaction!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Subscription {
newBlock(chainId: Int!): Block!
newTransaction(chainId: Int!): Transaction!
}

View File

@@ -0,0 +1,73 @@
package labels
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// LabelService handles address labeling
type LabelService struct {
db *pgxpool.Pool
}
// NewLabelService creates a new label service
func NewLabelService(db *pgxpool.Pool) *LabelService {
return &LabelService{db: db}
}
// AddLabel adds a label to an address
func (l *LabelService) AddLabel(ctx context.Context, chainID int, address, label, labelType string, userID *string) error {
query := `
INSERT INTO address_labels (chain_id, address, label, label_type, user_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (chain_id, address, label_type, user_id) DO UPDATE SET
label = $3,
updated_at = NOW()
`
_, err := l.db.Exec(ctx, query, chainID, address, label, labelType, userID)
return err
}
// GetLabels gets labels for an address
func (l *LabelService) GetLabels(ctx context.Context, chainID int, address string) ([]Label, error) {
query := `
SELECT label, label_type, user_id, source, created_at
FROM address_labels
WHERE chain_id = $1 AND address = $2
ORDER BY created_at DESC
`
rows, err := l.db.Query(ctx, query, chainID, address)
if err != nil {
return nil, fmt.Errorf("failed to query labels: %w", err)
}
defer rows.Close()
var labels []Label
for rows.Next() {
var label Label
var userID *string
if err := rows.Scan(&label.Label, &label.LabelType, &userID, &label.Source, &label.CreatedAt); err != nil {
continue
}
if userID != nil {
label.UserID = *userID
}
labels = append(labels, label)
}
return labels, nil
}
// Label represents an address label
type Label struct {
Label string
LabelType string
UserID string
Source string
CreatedAt string
}

View File

@@ -0,0 +1,123 @@
package middleware
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/explorer/backend/auth"
"github.com/explorer/backend/featureflags"
)
// AuthMiddleware handles authentication and authorization
type AuthMiddleware struct {
walletAuth *auth.WalletAuth
}
// NewAuthMiddleware creates a new auth middleware
func NewAuthMiddleware(walletAuth *auth.WalletAuth) *AuthMiddleware {
return &AuthMiddleware{
walletAuth: walletAuth,
}
}
// RequireAuth is middleware that requires authentication
func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
address, track, err := m.extractAuth(r)
if err != nil {
writeUnauthorized(w)
return
}
// Add user context
ctx := context.WithValue(r.Context(), "user_address", address)
ctx = context.WithValue(ctx, "user_track", track)
ctx = context.WithValue(ctx, "authenticated", true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequireTrack is middleware that requires a specific track level
func (m *AuthMiddleware) RequireTrack(requiredTrack int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract track from context (set by RequireAuth or OptionalAuth)
track, ok := r.Context().Value("user_track").(int)
if !ok {
track = 1 // Default to Track 1 (public)
}
if !featureflags.HasAccess(track, requiredTrack) {
writeForbidden(w, requiredTrack)
return
}
next.ServeHTTP(w, r)
})
}
}
// OptionalAuth is middleware that optionally authenticates (for Track 1 endpoints)
func (m *AuthMiddleware) OptionalAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
address, track, err := m.extractAuth(r)
if err != nil {
// No auth provided, default to Track 1 (public)
ctx := context.WithValue(r.Context(), "user_address", "")
ctx = context.WithValue(ctx, "user_track", 1)
ctx = context.WithValue(ctx, "authenticated", false)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Auth provided, add user context
ctx := context.WithValue(r.Context(), "user_address", address)
ctx = context.WithValue(ctx, "user_track", track)
ctx = context.WithValue(ctx, "authenticated", true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// extractAuth extracts authentication information from request
func (m *AuthMiddleware) extractAuth(r *http.Request) (string, int, error) {
// Get Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return "", 0, http.ErrMissingFile
}
// Check for Bearer token
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
return "", 0, http.ErrMissingFile
}
token := parts[1]
// Validate JWT token
address, track, err := m.walletAuth.ValidateJWT(token)
if err != nil {
return "", 0, err
}
return address, track, nil
}
// writeUnauthorized writes a 401 Unauthorized response
func writeUnauthorized(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":{"code":"unauthorized","message":"Authentication required"}}`))
}
// writeForbidden writes a 403 Forbidden response
func writeForbidden(w http.ResponseWriter, requiredTrack int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":{"code":"forbidden","message":"Insufficient permissions","required_track":` + fmt.Sprintf("%d", requiredTrack) + `}}`))
}

View File

@@ -0,0 +1,63 @@
package middleware
import (
"net/http"
"strings"
)
// SecurityMiddleware adds security headers
type SecurityMiddleware struct{}
// NewSecurityMiddleware creates a new security middleware
func NewSecurityMiddleware() *SecurityMiddleware {
return &SecurityMiddleware{}
}
// AddSecurityHeaders adds security headers to responses
func (m *SecurityMiddleware) AddSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Content Security Policy
// unsafe-eval required by ethers.js v5 UMD from CDN (ABI decoding)
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org http://192.168.11.221:8545 ws://192.168.11.221:8546;")
// X-Frame-Options (click-jacking protection)
w.Header().Set("X-Frame-Options", "DENY")
// X-Content-Type-Options
w.Header().Set("X-Content-Type-Options", "nosniff")
// X-XSS-Protection
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Strict-Transport-Security
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// Referrer-Policy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Permissions-Policy
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
next.ServeHTTP(w, r)
})
}
// BlockWriteCalls blocks contract write calls except WETH operations
func (m *SecurityMiddleware) BlockWriteCalls(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only apply to POST requests (write operations)
if r.Method == http.MethodPost {
// Check if this is a WETH operation (allowed)
path := r.URL.Path
if !strings.Contains(path, "weth") && !strings.Contains(path, "wrap") && !strings.Contains(path, "unwrap") {
// Block other write operations for Track 1
if strings.HasPrefix(path, "/api/v1/track1") {
http.Error(w, "Write operations not allowed for Track 1 (public)", http.StatusForbidden)
return
}
}
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,69 @@
# REST API Server
REST API implementation for the ChainID 138 Explorer Platform.
## Structure
- `server.go` - Main server setup and route configuration
- `routes.go` - Route handlers and URL parsing
- `blocks.go` - Block-related endpoints
- `transactions.go` - Transaction-related endpoints
- `addresses.go` - Address-related endpoints
- `search.go` - Unified search endpoint
- `validation.go` - Input validation utilities
- `middleware.go` - HTTP middleware (logging, compression)
- `errors.go` - Error response utilities
## API Endpoints
### Blocks
- `GET /api/v1/blocks` - List blocks (paginated)
- `GET /api/v1/blocks/{chain_id}/{number}` - Get block by number
- `GET /api/v1/blocks/{chain_id}/hash/{hash}` - Get block by hash
### Transactions
- `GET /api/v1/transactions` - List transactions (paginated, filterable)
- `GET /api/v1/transactions/{chain_id}/{hash}` - Get transaction by hash
### Addresses
- `GET /api/v1/addresses/{chain_id}/{address}` - Get address information
### Search
- `GET /api/v1/search?q={query}` - Unified search (auto-detects type: block number, address, or transaction hash)
### Health
- `GET /health` - Health check endpoint
## Features
- Input validation (addresses, hashes, block numbers)
- Pagination support
- Query timeouts for database operations
- CORS headers
- Request logging
- Error handling with consistent error format
- Health checks with database connectivity
## Running
```bash
cd backend/api/rest
go run main.go
```
Or use the development script:
```bash
./scripts/run-dev.sh
```
## Configuration
Set environment variables:
- `DB_HOST` - Database host
- `DB_PORT` - Database port
- `DB_USER` - Database user
- `DB_PASSWORD` - Database password
- `DB_NAME` - Database name
- `PORT` - API server port (default: 8080)
- `CHAIN_ID` - Chain ID (default: 138)

View File

@@ -0,0 +1,108 @@
package rest
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"time"
)
// handleGetAddress handles GET /api/v1/addresses/{chain_id}/{address}
func (s *Server) handleGetAddress(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse address from URL
address := r.URL.Query().Get("address")
if address == "" {
http.Error(w, "Address required", http.StatusBadRequest)
return
}
// Validate address format
if !isValidAddress(address) {
http.Error(w, "Invalid address format", http.StatusBadRequest)
return
}
// Add query timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Get transaction count
var txCount int64
err := s.db.QueryRow(ctx,
`SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`,
s.chainID, address,
).Scan(&txCount)
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
// Get token count
var tokenCount int
err = s.db.QueryRow(ctx,
`SELECT COUNT(DISTINCT token_address) FROM token_transfers WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`,
s.chainID, address,
).Scan(&tokenCount)
if err != nil {
tokenCount = 0
}
// Get label
var label sql.NullString
s.db.QueryRow(ctx,
`SELECT label FROM address_labels WHERE chain_id = $1 AND address = $2 AND label_type = 'public' LIMIT 1`,
s.chainID, address,
).Scan(&label)
// Get tags
rows, _ := s.db.Query(ctx,
`SELECT tag FROM address_tags WHERE chain_id = $1 AND address = $2`,
s.chainID, address,
)
defer rows.Close()
tags := []string{}
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err == nil {
tags = append(tags, tag)
}
}
// Check if contract
var isContract bool
s.db.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM contracts WHERE chain_id = $1 AND address = $2)`,
s.chainID, address,
).Scan(&isContract)
// Get balance (if we have RPC access, otherwise 0)
balance := "0"
// TODO: Add RPC call to get balance if needed
response := map[string]interface{}{
"address": address,
"chain_id": s.chainID,
"balance": balance,
"transaction_count": txCount,
"token_count": tokenCount,
"is_contract": isContract,
"tags": tags,
}
if label.Valid {
response["label"] = label.String
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"data": response,
})
}

View File

@@ -0,0 +1,231 @@
package rest_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/explorer/backend/api/rest"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupTestServer creates a test server with a test database
func setupTestServer(t *testing.T) (*rest.Server, *http.ServeMux) {
// Use test database or in-memory database
// For now, we'll use a mock approach
db, err := setupTestDB(t)
if err != nil {
t.Skipf("Skipping test: database not available: %v", err)
return nil, nil
}
server := rest.NewServer(db, 138) // ChainID 138
mux := http.NewServeMux()
server.SetupRoutes(mux)
return server, mux
}
// setupTestDB creates a test database connection
func setupTestDB(t *testing.T) (*pgxpool.Pool, error) {
// In a real test, you would use a test database
// For now, return nil to skip database-dependent tests
// TODO: Set up test database connection
// This allows tests to run without a database connection
return nil, nil
}
// TestHealthEndpoint tests the health check endpoint
func TestHealthEndpoint(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "ok", response["status"])
}
// TestListBlocks tests the blocks list endpoint
func TestListBlocks(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/blocks?limit=10&page=1", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// Should return 200 or 500 depending on database connection
assert.True(t, w.Code == http.StatusOK || w.Code == http.StatusInternalServerError)
}
// TestGetBlockByNumber tests getting a block by number
func TestGetBlockByNumber(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/blocks/138/1000", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// Should return 200, 404, or 500 depending on database and block existence
assert.True(t, w.Code == http.StatusOK || w.Code == http.StatusNotFound || w.Code == http.StatusInternalServerError)
}
// TestListTransactions tests the transactions list endpoint
func TestListTransactions(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/transactions?limit=10&page=1", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.True(t, w.Code == http.StatusOK || w.Code == http.StatusInternalServerError)
}
// TestGetTransactionByHash tests getting a transaction by hash
func TestGetTransactionByHash(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/transactions/138/0x1234567890abcdef", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.True(t, w.Code == http.StatusOK || w.Code == http.StatusNotFound || w.Code == http.StatusInternalServerError)
}
// TestSearchEndpoint tests the unified search endpoint
func TestSearchEndpoint(t *testing.T) {
_, mux := setupTestServer(t)
testCases := []struct {
name string
query string
wantCode int
}{
{"block number", "?q=1000", http.StatusOK},
{"address", "?q=0x1234567890abcdef1234567890abcdef12345678", http.StatusOK},
{"transaction hash", "?q=0xabcdef1234567890abcdef1234567890abcdef12", http.StatusOK},
{"empty query", "?q=", http.StatusBadRequest},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v1/search"+tc.query, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.True(t, w.Code == tc.wantCode || w.Code == http.StatusInternalServerError)
})
}
}
// TestTrack1Endpoints tests Track 1 (public) endpoints
func TestTrack1Endpoints(t *testing.T) {
_, mux := setupTestServer(t)
testCases := []struct {
name string
endpoint string
method string
}{
{"latest blocks", "/api/v1/track1/blocks/latest", "GET"},
{"latest transactions", "/api/v1/track1/txs/latest", "GET"},
{"bridge status", "/api/v1/track1/bridge/status", "GET"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.endpoint, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// Track 1 endpoints should be accessible without auth
assert.True(t, w.Code == http.StatusOK || w.Code == http.StatusInternalServerError)
})
}
}
// TestCORSHeaders tests CORS headers are present
func TestCORSHeaders(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// Check for CORS headers (if implemented)
// This is a placeholder - actual implementation may vary
assert.NotNil(t, w.Header())
}
// TestErrorHandling tests error responses
func TestErrorHandling(t *testing.T) {
_, mux := setupTestServer(t)
// Test invalid block number
req := httptest.NewRequest("GET", "/api/v1/blocks/138/invalid", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.True(t, w.Code >= http.StatusBadRequest)
var errorResponse map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &errorResponse)
if err == nil {
assert.NotNil(t, errorResponse["error"])
}
}
// TestPagination tests pagination parameters
func TestPagination(t *testing.T) {
_, mux := setupTestServer(t)
testCases := []struct {
name string
query string
wantCode int
}{
{"valid pagination", "?limit=10&page=1", http.StatusOK},
{"large limit", "?limit=1000&page=1", http.StatusOK}, // Should be capped
{"invalid page", "?limit=10&page=0", http.StatusBadRequest},
{"negative limit", "?limit=-10&page=1", http.StatusBadRequest},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v1/blocks"+tc.query, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.True(t, w.Code == tc.wantCode || w.Code == http.StatusInternalServerError)
})
}
}
// TestRequestTimeout tests request timeout handling
func TestRequestTimeout(t *testing.T) {
// This would test timeout behavior
// Implementation depends on timeout middleware
t.Skip("Requires timeout middleware implementation")
}
// BenchmarkListBlocks benchmarks the blocks list endpoint
func BenchmarkListBlocks(b *testing.B) {
_, mux := setupTestServer(&testing.T{})
req := httptest.NewRequest("GET", "/api/v1/blocks?limit=10&page=1", nil)
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
}
}

57
backend/api/rest/auth.go Normal file
View File

@@ -0,0 +1,57 @@
package rest
import (
"encoding/json"
"net/http"
"github.com/explorer/backend/auth"
)
// handleAuthNonce handles POST /api/v1/auth/nonce
func (s *Server) handleAuthNonce(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
var req auth.NonceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
// Generate nonce
nonceResp, err := s.walletAuth.GenerateNonce(r.Context(), req.Address)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(nonceResp)
}
// handleAuthWallet handles POST /api/v1/auth/wallet
func (s *Server) handleAuthWallet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
var req auth.WalletAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
// Authenticate wallet
authResp, err := s.walletAuth.AuthenticateWallet(r.Context(), &req)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(authResp)
}

134
backend/api/rest/blocks.go Normal file
View File

@@ -0,0 +1,134 @@
package rest
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"time"
)
// handleGetBlockByNumber handles GET /api/v1/blocks/{chain_id}/{number}
func (s *Server) handleGetBlockByNumber(w http.ResponseWriter, r *http.Request, blockNumber int64) {
// Validate input (already validated in routes.go, but double-check)
if blockNumber < 0 {
writeValidationError(w, ErrInvalidBlockNumber)
return
}
// Add query timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
query := `
SELECT chain_id, number, hash, parent_hash, timestamp, timestamp_iso, miner,
transaction_count, gas_used, gas_limit, size, logs_bloom
FROM blocks
WHERE chain_id = $1 AND number = $2
`
var chainID, number, transactionCount int
var hash, parentHash, miner string
var timestamp time.Time
var timestampISO sql.NullString
var gasUsed, gasLimit, size int64
var logsBloom sql.NullString
err := s.db.QueryRow(ctx, query, s.chainID, blockNumber).Scan(
&chainID, &number, &hash, &parentHash, &timestamp, &timestampISO, &miner,
&transactionCount, &gasUsed, &gasLimit, &size, &logsBloom,
)
if err != nil {
http.Error(w, fmt.Sprintf("Block not found: %v", err), http.StatusNotFound)
return
}
block := map[string]interface{}{
"chain_id": chainID,
"number": number,
"hash": hash,
"parent_hash": parentHash,
"timestamp": timestamp,
"miner": miner,
"transaction_count": transactionCount,
"gas_used": gasUsed,
"gas_limit": gasLimit,
"size": size,
}
if timestampISO.Valid {
block["timestamp_iso"] = timestampISO.String
}
if logsBloom.Valid {
block["logs_bloom"] = logsBloom.String
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"data": block,
})
}
// handleGetBlockByHash handles GET /api/v1/blocks/{chain_id}/hash/{hash}
func (s *Server) handleGetBlockByHash(w http.ResponseWriter, r *http.Request, hash string) {
// Validate hash format (already validated in routes.go, but double-check)
if !isValidHash(hash) {
writeValidationError(w, ErrInvalidHash)
return
}
// Add query timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
query := `
SELECT chain_id, number, hash, parent_hash, timestamp, timestamp_iso, miner,
transaction_count, gas_used, gas_limit, size, logs_bloom
FROM blocks
WHERE chain_id = $1 AND hash = $2
`
var chainID, number, transactionCount int
var blockHash, parentHash, miner string
var timestamp time.Time
var timestampISO sql.NullString
var gasUsed, gasLimit, size int64
var logsBloom sql.NullString
err := s.db.QueryRow(ctx, query, s.chainID, hash).Scan(
&chainID, &number, &blockHash, &parentHash, &timestamp, &timestampISO, &miner,
&transactionCount, &gasUsed, &gasLimit, &size, &logsBloom,
)
if err != nil {
http.Error(w, fmt.Sprintf("Block not found: %v", err), http.StatusNotFound)
return
}
block := map[string]interface{}{
"chain_id": chainID,
"number": number,
"hash": blockHash,
"parent_hash": parentHash,
"timestamp": timestamp,
"miner": miner,
"transaction_count": transactionCount,
"gas_used": gasUsed,
"gas_limit": gasLimit,
"size": size,
}
if timestampISO.Valid {
block["timestamp_iso"] = timestampISO.String
}
if logsBloom.Valid {
block["logs_bloom"] = logsBloom.String
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"data": block,
})
}

BIN
backend/api/rest/cmd/api-server Executable file

Binary file not shown.

View File

@@ -0,0 +1,57 @@
package main
import (
"context"
"log"
"os"
"strconv"
"time"
"github.com/explorer/backend/api/rest"
"github.com/explorer/backend/database/config"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
ctx := context.Background()
// Load database configuration
dbConfig := config.LoadDatabaseConfig()
poolConfig, err := dbConfig.PoolConfig()
if err != nil {
log.Fatalf("Failed to create pool config: %v", err)
}
// Connect to database
db, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
// Configure connection pool
db.Config().MaxConns = 25
db.Config().MinConns = 5
db.Config().MaxConnLifetime = 5 * time.Minute
db.Config().MaxConnIdleTime = 10 * time.Minute
chainID := 138
if envChainID := os.Getenv("CHAIN_ID"); envChainID != "" {
if id, err := strconv.Atoi(envChainID); err == nil {
chainID = id
}
}
port := 8080
if envPort := os.Getenv("PORT"); envPort != "" {
if p, err := strconv.Atoi(envPort); err == nil {
port = p
}
}
// Create and start server
server := rest.NewServer(db, chainID)
if err := server.Start(port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

View File

@@ -0,0 +1,36 @@
package rest
import (
_ "embed"
"net/http"
)
//go:embed config/metamask/DUAL_CHAIN_NETWORKS.json
var dualChainNetworksJSON []byte
//go:embed config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json
var dualChainTokenListJSON []byte
// handleConfigNetworks serves GET /api/config/networks (Chain 138 + Ethereum Mainnet params for wallet_addEthereumChain).
func (s *Server) handleConfigNetworks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(dualChainNetworksJSON)
}
// handleConfigTokenList serves GET /api/config/token-list (Uniswap token list format for MetaMask).
func (s *Server) handleConfigTokenList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(dualChainTokenListJSON)
}

View File

@@ -0,0 +1,61 @@
{
"name": "MetaMask Multi-Chain Networks (Chain 138 + Ethereum Mainnet + ALL Mainnet)",
"version": { "major": 1, "minor": 1, "patch": 0 },
"chains": [
{
"chainId": "0x8a",
"chainIdDecimal": 138,
"chainName": "DeFi Oracle Meta Mainnet",
"rpcUrls": [
"https://rpc-http-pub.d-bis.org",
"https://rpc.d-bis.org",
"https://rpc2.d-bis.org",
"https://rpc.defi-oracle.io"
],
"nativeCurrency": {
"name": "Ether",
"symbol": "ETH",
"decimals": 18
},
"blockExplorerUrls": ["https://explorer.d-bis.org"],
"iconUrls": [
"https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"
]
},
{
"chainId": "0x1",
"chainIdDecimal": 1,
"chainName": "Ethereum Mainnet",
"rpcUrls": [
"https://eth.llamarpc.com",
"https://rpc.ankr.com/eth",
"https://ethereum.publicnode.com",
"https://1rpc.io/eth"
],
"nativeCurrency": {
"name": "Ether",
"symbol": "ETH",
"decimals": 18
},
"blockExplorerUrls": ["https://etherscan.io"],
"iconUrls": [
"https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"
]
},
{
"chainId": "0x9f2c4",
"chainIdDecimal": 651940,
"chainName": "ALL Mainnet",
"rpcUrls": ["https://mainnet-rpc.alltra.global"],
"nativeCurrency": {
"name": "Ether",
"symbol": "ETH",
"decimals": 18
},
"blockExplorerUrls": ["https://alltra.global"],
"iconUrls": [
"https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"
]
}
]
}

View File

@@ -0,0 +1,115 @@
{
"name": "Multi-Chain Token List (Chain 138 + Ethereum Mainnet + ALL Mainnet)",
"version": { "major": 1, "minor": 1, "patch": 0 },
"timestamp": "2026-01-30T00:00:00.000Z",
"logoURI": "https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png",
"tokens": [
{
"chainId": 138,
"address": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6",
"name": "ETH/USD Price Feed",
"symbol": "ETH-USD",
"decimals": 8,
"logoURI": "https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png",
"tags": ["oracle", "price-feed"]
},
{
"chainId": 138,
"address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"name": "Wrapped Ether",
"symbol": "WETH",
"decimals": 18,
"logoURI": "https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png",
"tags": ["defi", "wrapped"]
},
{
"chainId": 138,
"address": "0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f",
"name": "Wrapped Ether v10",
"symbol": "WETH10",
"decimals": 18,
"logoURI": "https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png",
"tags": ["defi", "wrapped"]
},
{
"chainId": 138,
"address": "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22",
"name": "Compliant Tether USD",
"symbol": "cUSDT",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png",
"tags": ["stablecoin", "defi", "compliant"]
},
{
"chainId": 138,
"address": "0xf22258f57794CC8E06237084b353Ab30fFfa640b",
"name": "Compliant USD Coin",
"symbol": "cUSDC",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png",
"tags": ["stablecoin", "defi", "compliant"]
},
{
"chainId": 1,
"address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"name": "Wrapped Ether",
"symbol": "WETH",
"decimals": 18,
"logoURI": "https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png",
"tags": ["defi", "wrapped"]
},
{
"chainId": 1,
"address": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"name": "Tether USD",
"symbol": "USDT",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png",
"tags": ["stablecoin", "defi"]
},
{
"chainId": 1,
"address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"name": "USD Coin",
"symbol": "USDC",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png",
"tags": ["stablecoin", "defi"]
},
{
"chainId": 1,
"address": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
"name": "Dai Stablecoin",
"symbol": "DAI",
"decimals": 18,
"logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png",
"tags": ["stablecoin", "defi"]
},
{
"chainId": 1,
"address": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419",
"name": "ETH/USD Price Feed",
"symbol": "ETH-USD",
"decimals": 8,
"logoURI": "https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png",
"tags": ["oracle", "price-feed"]
},
{
"chainId": 651940,
"address": "0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881",
"name": "USD Coin",
"symbol": "USDC",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png",
"tags": ["stablecoin", "defi"]
}
],
"tags": {
"defi": { "name": "DeFi", "description": "Decentralized Finance tokens" },
"wrapped": { "name": "Wrapped", "description": "Wrapped tokens representing native assets" },
"oracle": { "name": "Oracle", "description": "Oracle price feed contracts" },
"price-feed": { "name": "Price Feed", "description": "Price feed oracle contracts" },
"stablecoin": { "name": "Stablecoin", "description": "Stable value tokens pegged to fiat" },
"compliant": { "name": "Compliant", "description": "Regulatory compliant tokens" }
}
}

View File

@@ -0,0 +1,51 @@
package rest
import (
"encoding/json"
"net/http"
)
// ErrorResponse represents an API error response
type ErrorResponse struct {
Error ErrorDetail `json:"error"`
}
// ErrorDetail contains error details
type ErrorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
// writeError writes an error 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(ErrorResponse{
Error: ErrorDetail{
Code: code,
Message: message,
},
})
}
// writeNotFound writes a 404 error response
func writeNotFound(w http.ResponseWriter, resource string) {
writeError(w, http.StatusNotFound, "NOT_FOUND", resource+" not found")
}
// writeInternalError writes a 500 error response
func writeInternalError(w http.ResponseWriter, message string) {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", message)
}
// writeUnauthorized writes a 401 error response
func writeUnauthorized(w http.ResponseWriter) {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required")
}
// writeForbidden writes a 403 error response
func writeForbidden(w http.ResponseWriter) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "Access denied")
}

View File

@@ -0,0 +1,215 @@
package rest
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
)
// handleEtherscanAPI handles GET /api?module=...&action=...
// This provides Etherscan-compatible API endpoints
func (s *Server) handleEtherscanAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
module := r.URL.Query().Get("module")
action := r.URL.Query().Get("action")
// Etherscan-compatible response structure
type EtherscanResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Result interface{} `json:"result"`
}
// Validate required parameters
if module == "" || action == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
response := EtherscanResponse{
Status: "0",
Message: "Params 'module' and 'action' are required parameters",
Result: nil,
}
json.NewEncoder(w).Encode(response)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var response EtherscanResponse
switch module {
case "block":
switch action {
case "eth_block_number":
// Get latest block number
var blockNumber int64
err := s.db.QueryRow(ctx,
`SELECT MAX(number) FROM blocks WHERE chain_id = $1`,
s.chainID,
).Scan(&blockNumber)
if err != nil {
response = EtherscanResponse{
Status: "0",
Message: "Error",
Result: "0x0",
}
} else {
response = EtherscanResponse{
Status: "1",
Message: "OK",
Result: fmt.Sprintf("0x%x", blockNumber),
}
}
case "eth_get_block_by_number":
tag := r.URL.Query().Get("tag")
boolean := r.URL.Query().Get("boolean") == "true"
// Parse block number from tag (can be "latest", "0x...", or decimal)
var blockNumber int64
if tag == "latest" {
err := s.db.QueryRow(ctx,
`SELECT MAX(number) FROM blocks WHERE chain_id = $1`,
s.chainID,
).Scan(&blockNumber)
if err != nil {
response = EtherscanResponse{
Status: "0",
Message: "Error",
Result: nil,
}
break
}
} else if len(tag) > 2 && tag[:2] == "0x" {
// Hex format
parsed, err := strconv.ParseInt(tag[2:], 16, 64)
if err != nil {
response = EtherscanResponse{
Status: "0",
Message: "Invalid block number",
Result: nil,
}
break
}
blockNumber = parsed
} else {
// Decimal format
parsed, err := strconv.ParseInt(tag, 10, 64)
if err != nil {
response = EtherscanResponse{
Status: "0",
Message: "Invalid block number",
Result: nil,
}
break
}
blockNumber = parsed
}
// Get block data
var hash, parentHash, miner string
var timestamp time.Time
var transactionCount int
var gasUsed, gasLimit int64
var transactions []string
query := `
SELECT hash, parent_hash, timestamp, miner, transaction_count, gas_used, gas_limit
FROM blocks
WHERE chain_id = $1 AND number = $2
`
err := s.db.QueryRow(ctx, query, s.chainID, blockNumber).Scan(
&hash, &parentHash, &timestamp, &miner, &transactionCount, &gasUsed, &gasLimit,
)
if err != nil {
response = EtherscanResponse{
Status: "0",
Message: "Block not found",
Result: nil,
}
break
}
// If boolean is true, get full transaction objects
if boolean {
txQuery := `
SELECT hash FROM transactions
WHERE chain_id = $1 AND block_number = $2
ORDER BY transaction_index
`
rows, err := s.db.Query(ctx, txQuery, s.chainID, blockNumber)
if err == nil {
defer rows.Close()
for rows.Next() {
var txHash string
if err := rows.Scan(&txHash); err == nil {
transactions = append(transactions, txHash)
}
}
}
} else {
// Just get transaction hashes
txQuery := `
SELECT hash FROM transactions
WHERE chain_id = $1 AND block_number = $2
ORDER BY transaction_index
`
rows, err := s.db.Query(ctx, txQuery, s.chainID, blockNumber)
if err == nil {
defer rows.Close()
for rows.Next() {
var txHash string
if err := rows.Scan(&txHash); err == nil {
transactions = append(transactions, txHash)
}
}
}
}
blockResult := map[string]interface{}{
"number": fmt.Sprintf("0x%x", blockNumber),
"hash": hash,
"parentHash": parentHash,
"timestamp": fmt.Sprintf("0x%x", timestamp.Unix()),
"miner": miner,
"transactions": transactions,
"transactionCount": fmt.Sprintf("0x%x", transactionCount),
"gasUsed": fmt.Sprintf("0x%x", gasUsed),
"gasLimit": fmt.Sprintf("0x%x", gasLimit),
}
response = EtherscanResponse{
Status: "1",
Message: "OK",
Result: blockResult,
}
default:
response = EtherscanResponse{
Status: "0",
Message: "Invalid action",
Result: nil,
}
}
default:
response = EtherscanResponse{
Status: "0",
Message: "Invalid module",
Result: nil,
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,82 @@
package rest
import (
"encoding/json"
"net/http"
"github.com/explorer/backend/featureflags"
)
// handleFeatures handles GET /api/v1/features
// Returns available features for the current user based on their track level
func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
// Extract user track from context (set by auth middleware)
// Default to Track 1 (public) if not authenticated
userTrack := 1
if track, ok := r.Context().Value("user_track").(int); ok {
userTrack = track
}
// Get enabled features for this track
enabledFeatures := featureflags.GetEnabledFeatures(userTrack)
// Get permissions based on track
permissions := getPermissionsForTrack(userTrack)
response := map[string]interface{}{
"track": userTrack,
"features": enabledFeatures,
"permissions": permissions,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// getPermissionsForTrack returns permissions for a given track level
func getPermissionsForTrack(track int) []string {
permissions := []string{
"explorer.read.blocks",
"explorer.read.transactions",
"explorer.read.address.basic",
"explorer.read.bridge.status",
"weth.wrap",
"weth.unwrap",
}
if track >= 2 {
permissions = append(permissions,
"explorer.read.address.full",
"explorer.read.tokens",
"explorer.read.tx_history",
"explorer.read.internal_txs",
"explorer.search.enhanced",
)
}
if track >= 3 {
permissions = append(permissions,
"analytics.read.flows",
"analytics.read.bridge",
"analytics.read.token_distribution",
"analytics.read.address_risk",
)
}
if track >= 4 {
permissions = append(permissions,
"operator.read.bridge_events",
"operator.read.validators",
"operator.read.contracts",
"operator.read.protocol_state",
"operator.write.bridge_control",
)
}
return permissions
}

View File

@@ -0,0 +1,44 @@
package rest
import (
"log"
"net/http"
"time"
)
// responseWriter wraps http.ResponseWriter to capture status code
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// loggingMiddleware logs requests with timing
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
// Log request (in production, use structured logger)
log.Printf("%s %s %d %v", r.Method, r.URL.Path, wrapped.statusCode, duration)
})
}
// compressionMiddleware adds gzip compression (simplified - use gorilla/handlers in production)
func (s *Server) compressionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if client accepts gzip
if r.Header.Get("Accept-Encoding") != "" {
// In production, use gorilla/handlers.CompressHandler
// For now, just pass through
}
next.ServeHTTP(w, r)
})
}

166
backend/api/rest/routes.go Normal file
View File

@@ -0,0 +1,166 @@
package rest
import (
"fmt"
"net/http"
"strings"
)
// SetupRoutes sets up all API routes
func (s *Server) SetupRoutes(mux *http.ServeMux) {
// Block routes
mux.HandleFunc("/api/v1/blocks", s.handleListBlocks)
mux.HandleFunc("/api/v1/blocks/", s.handleBlockDetail)
// Transaction routes
mux.HandleFunc("/api/v1/transactions", s.handleListTransactions)
mux.HandleFunc("/api/v1/transactions/", s.handleTransactionDetail)
// Address routes
mux.HandleFunc("/api/v1/addresses/", s.handleAddressDetail)
// Search route
mux.HandleFunc("/api/v1/search", s.handleSearch)
// Stats route
mux.HandleFunc("/api/v2/stats", s.handleStats)
// Etherscan-compatible API route
mux.HandleFunc("/api", s.handleEtherscanAPI)
// Health check
mux.HandleFunc("/health", s.handleHealth)
// MetaMask / dual-chain config (Chain 138 + Ethereum Mainnet)
mux.HandleFunc("/api/config/networks", s.handleConfigNetworks)
mux.HandleFunc("/api/config/token-list", s.handleConfigTokenList)
// Feature flags endpoint
mux.HandleFunc("/api/v1/features", s.handleFeatures)
// Auth endpoints
mux.HandleFunc("/api/v1/auth/nonce", s.handleAuthNonce)
mux.HandleFunc("/api/v1/auth/wallet", s.handleAuthWallet)
// Track 1 routes (public, optional auth)
// Note: Track 1 endpoints should be registered with OptionalAuth middleware
// mux.HandleFunc("/api/v1/track1/blocks/latest", s.track1Server.handleLatestBlocks)
// mux.HandleFunc("/api/v1/track1/txs/latest", s.track1Server.handleLatestTransactions)
// mux.HandleFunc("/api/v1/track1/block/", s.track1Server.handleBlockDetail)
// mux.HandleFunc("/api/v1/track1/tx/", s.track1Server.handleTransactionDetail)
// mux.HandleFunc("/api/v1/track1/address/", s.track1Server.handleAddressBalance)
// mux.HandleFunc("/api/v1/track1/bridge/status", s.track1Server.handleBridgeStatus)
// Track 2 routes (require Track 2+)
// Note: Track 2 endpoints should be registered with RequireAuth + RequireTrack(2) middleware
// mux.HandleFunc("/api/v1/track2/address/", s.track2Server.handleAddressTransactions)
// mux.HandleFunc("/api/v1/track2/token/", s.track2Server.handleTokenInfo)
// mux.HandleFunc("/api/v1/track2/search", s.track2Server.handleSearch)
// Track 3 routes (require Track 3+)
// Note: Track 3 endpoints should be registered with RequireAuth + RequireTrack(3) middleware
// mux.HandleFunc("/api/v1/track3/analytics/flows", s.track3Server.handleFlows)
// mux.HandleFunc("/api/v1/track3/analytics/bridge", s.track3Server.handleBridge)
// mux.HandleFunc("/api/v1/track3/analytics/token-distribution/", s.track3Server.handleTokenDistribution)
// mux.HandleFunc("/api/v1/track3/analytics/address-risk/", s.track3Server.handleAddressRisk)
// Track 4 routes (require Track 4)
// Note: Track 4 endpoints should be registered with RequireAuth + RequireTrack(4) + IP whitelist middleware
// mux.HandleFunc("/api/v1/track4/operator/bridge/events", s.track4Server.handleBridgeEvents)
// mux.HandleFunc("/api/v1/track4/operator/validators", s.track4Server.handleValidators)
// mux.HandleFunc("/api/v1/track4/operator/contracts", s.track4Server.handleContracts)
// mux.HandleFunc("/api/v1/track4/operator/protocol-state", s.track4Server.handleProtocolState)
}
// handleBlockDetail handles GET /api/v1/blocks/{chain_id}/{number} or /api/v1/blocks/{chain_id}/hash/{hash}
func (s *Server) handleBlockDetail(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/blocks/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
writeValidationError(w, fmt.Errorf("invalid block path"))
return
}
// Validate chain ID
if err := validateChainID(parts[0], s.chainID); err != nil {
writeValidationError(w, err)
return
}
if parts[1] == "hash" && len(parts) == 3 {
// Validate hash format
if !isValidHash(parts[2]) {
writeValidationError(w, ErrInvalidHash)
return
}
// Get by hash
s.handleGetBlockByHash(w, r, parts[2])
} else {
// Validate and parse block number
blockNumber, err := validateBlockNumber(parts[1])
if err != nil {
writeValidationError(w, err)
return
}
s.handleGetBlockByNumber(w, r, blockNumber)
}
}
// handleGetBlockByNumber and handleGetBlockByHash are in blocks.go
// handleTransactionDetail handles GET /api/v1/transactions/{chain_id}/{hash}
func (s *Server) handleTransactionDetail(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/transactions/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
writeValidationError(w, fmt.Errorf("invalid transaction path"))
return
}
// Validate chain ID
if err := validateChainID(parts[0], s.chainID); err != nil {
writeValidationError(w, err)
return
}
// Validate hash format
hash := parts[1]
if !isValidHash(hash) {
writeValidationError(w, ErrInvalidHash)
return
}
s.handleGetTransactionByHash(w, r, hash)
}
// handleGetTransactionByHash is implemented in transactions.go
// handleAddressDetail handles GET /api/v1/addresses/{chain_id}/{address}
func (s *Server) handleAddressDetail(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/addresses/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
writeValidationError(w, fmt.Errorf("invalid address path"))
return
}
// Validate chain ID
if err := validateChainID(parts[0], s.chainID); err != nil {
writeValidationError(w, err)
return
}
// Validate address format
address := parts[1]
if !isValidAddress(address) {
writeValidationError(w, ErrInvalidAddress)
return
}
// Set address in query and call handler
r.URL.RawQuery = "address=" + address
s.handleGetAddress(w, r)
}

View File

@@ -0,0 +1,53 @@
package rest
import (
"fmt"
"net/http"
)
// handleSearch handles GET /api/v1/search
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
if query == "" {
writeValidationError(w, fmt.Errorf("search query required"))
return
}
// Validate and determine search type
searchType, value, err := validateSearchQuery(query)
if err != nil {
writeValidationError(w, err)
return
}
// Route to appropriate handler based on search type
switch searchType {
case "block":
blockNumber, err := validateBlockNumber(value)
if err != nil {
writeValidationError(w, err)
return
}
s.handleGetBlockByNumber(w, r, blockNumber)
case "transaction":
if !isValidHash(value) {
writeValidationError(w, ErrInvalidHash)
return
}
s.handleGetTransactionByHash(w, r, value)
case "address":
if !isValidAddress(value) {
writeValidationError(w, ErrInvalidAddress)
return
}
r.URL.RawQuery = "address=" + value
s.handleGetAddress(w, r)
default:
writeValidationError(w, fmt.Errorf("unsupported search type"))
}
}

224
backend/api/rest/server.go Normal file
View File

@@ -0,0 +1,224 @@
package rest
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/explorer/backend/auth"
"github.com/explorer/backend/api/middleware"
"github.com/jackc/pgx/v5/pgxpool"
)
// Server represents the REST API server
type Server struct {
db *pgxpool.Pool
chainID int
walletAuth *auth.WalletAuth
jwtSecret []byte
}
// NewServer creates a new REST API server
func NewServer(db *pgxpool.Pool, chainID int) *Server {
// Get JWT secret from environment or use default
jwtSecret := []byte(os.Getenv("JWT_SECRET"))
if len(jwtSecret) == 0 {
jwtSecret = []byte("change-me-in-production-use-strong-random-secret")
log.Println("WARNING: Using default JWT secret. Set JWT_SECRET environment variable in production!")
}
walletAuth := auth.NewWalletAuth(db, jwtSecret)
return &Server{
db: db,
chainID: chainID,
walletAuth: walletAuth,
jwtSecret: jwtSecret,
}
}
// Start starts the HTTP server
func (s *Server) Start(port int) error {
mux := http.NewServeMux()
s.SetupRoutes(mux)
// Initialize auth middleware
authMiddleware := middleware.NewAuthMiddleware(s.walletAuth)
// Setup track routes with proper middleware
s.SetupTrackRoutes(mux, authMiddleware)
// Initialize security middleware
securityMiddleware := middleware.NewSecurityMiddleware()
// Add middleware for all routes (outermost to innermost)
handler := securityMiddleware.AddSecurityHeaders(
authMiddleware.OptionalAuth( // Optional auth for Track 1, required for others
s.addMiddleware(
s.loggingMiddleware(
s.compressionMiddleware(mux),
),
),
),
)
addr := fmt.Sprintf(":%d", port)
log.Printf("Starting SolaceScanScout REST API server on %s", addr)
log.Printf("Tiered architecture enabled: Track 1 (public), Track 2-4 (authenticated)")
return http.ListenAndServe(addr, handler)
}
// addMiddleware adds common middleware to all routes
func (s *Server) addMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Add branding headers
w.Header().Set("X-Explorer-Name", "SolaceScanScout")
w.Header().Set("X-Explorer-Version", "1.0.0")
w.Header().Set("X-Powered-By", "SolaceScanScout")
// Add CORS headers for API routes
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key")
// Handle preflight
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
}
next.ServeHTTP(w, r)
})
}
// handleListBlocks handles GET /api/v1/blocks
func (s *Server) handleListBlocks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Validate pagination
page, pageSize, err := validatePagination(
r.URL.Query().Get("page"),
r.URL.Query().Get("page_size"),
)
if err != nil {
writeValidationError(w, err)
return
}
offset := (page - 1) * pageSize
query := `
SELECT chain_id, number, hash, timestamp, timestamp_iso, miner, transaction_count, gas_used, gas_limit
FROM blocks
WHERE chain_id = $1
ORDER BY number DESC
LIMIT $2 OFFSET $3
`
// Add query timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
rows, err := s.db.Query(ctx, query, s.chainID, pageSize, offset)
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
blocks := []map[string]interface{}{}
for rows.Next() {
var chainID, number, transactionCount int
var hash, miner string
var timestamp time.Time
var timestampISO sql.NullString
var gasUsed, gasLimit int64
if err := rows.Scan(&chainID, &number, &hash, &timestamp, &timestampISO, &miner, &transactionCount, &gasUsed, &gasLimit); err != nil {
continue
}
block := map[string]interface{}{
"chain_id": chainID,
"number": number,
"hash": hash,
"timestamp": timestamp,
"miner": miner,
"transaction_count": transactionCount,
"gas_used": gasUsed,
"gas_limit": gasLimit,
}
if timestampISO.Valid {
block["timestamp_iso"] = timestampISO.String
}
blocks = append(blocks, block)
}
response := map[string]interface{}{
"data": blocks,
"meta": map[string]interface{}{
"pagination": map[string]interface{}{
"page": page,
"page_size": pageSize,
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleGetBlock, handleListTransactions, handleGetTransaction, handleGetAddress
// are implemented in blocks.go, transactions.go, and addresses.go respectively
// handleHealth handles GET /health
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Explorer-Name", "SolaceScanScout")
w.Header().Set("X-Explorer-Version", "1.0.0")
// Check database connection
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
dbStatus := "ok"
if err := s.db.Ping(ctx); err != nil {
dbStatus = "error: " + err.Error()
}
health := map[string]interface{}{
"status": "healthy",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"services": map[string]string{
"database": dbStatus,
"api": "ok",
},
"chain_id": s.chainID,
"explorer": map[string]string{
"name": "SolaceScanScout",
"version": "1.0.0",
},
}
statusCode := http.StatusOK
if dbStatus != "ok" {
statusCode = http.StatusServiceUnavailable
health["status"] = "degraded"
}
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(health)
}

59
backend/api/rest/stats.go Normal file
View File

@@ -0,0 +1,59 @@
package rest
import (
"context"
"encoding/json"
"net/http"
"time"
)
// handleStats handles GET /api/v2/stats
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Get total blocks
var totalBlocks int64
err := s.db.QueryRow(ctx,
`SELECT COUNT(*) FROM blocks WHERE chain_id = $1`,
s.chainID,
).Scan(&totalBlocks)
if err != nil {
totalBlocks = 0
}
// Get total transactions
var totalTransactions int64
err = s.db.QueryRow(ctx,
`SELECT COUNT(*) FROM transactions WHERE chain_id = $1`,
s.chainID,
).Scan(&totalTransactions)
if err != nil {
totalTransactions = 0
}
// Get total addresses
var totalAddresses int64
err = s.db.QueryRow(ctx,
`SELECT COUNT(DISTINCT from_address) + COUNT(DISTINCT to_address) FROM transactions WHERE chain_id = $1`,
s.chainID,
).Scan(&totalAddresses)
if err != nil {
totalAddresses = 0
}
stats := map[string]interface{}{
"total_blocks": totalBlocks,
"total_transactions": totalTransactions,
"total_addresses": totalAddresses,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}

View File

@@ -0,0 +1,430 @@
openapi: 3.0.3
info:
title: SolaceScanScout API
description: |
Blockchain Explorer API for ChainID 138 with tiered access control.
## Authentication
Track 1 endpoints are public and require no authentication.
Track 2-4 endpoints require JWT authentication via wallet signature.
## Rate Limiting
- Track 1: 100 requests/minute per IP
- Track 2-4: Based on user tier and subscription
version: 1.0.0
contact:
name: API Support
email: support@d-bis.org
license:
name: MIT
url: https://opensource.org/licenses/MIT
servers:
- url: https://api.d-bis.org
description: Production server
- url: http://localhost:8080
description: Development server
tags:
- name: Health
description: Health check endpoints
- name: Blocks
description: Block-related endpoints
- name: Transactions
description: Transaction-related endpoints
- name: Addresses
description: Address-related endpoints
- name: Search
description: Unified search endpoints
- name: Track1
description: Public RPC gateway endpoints (no auth required)
- name: Track2
description: Indexed explorer endpoints (auth required)
- name: Track3
description: Analytics endpoints (Track 3+ required)
- name: Track4
description: Operator endpoints (Track 4 + IP whitelist)
paths:
/health:
get:
tags:
- Health
summary: Health check
description: Returns the health status of the API
operationId: getHealth
responses:
'200':
description: Service is healthy
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: ok
timestamp:
type: string
format: date-time
database:
type: string
example: connected
/api/v1/blocks:
get:
tags:
- Blocks
summary: List blocks
description: Returns a paginated list of blocks
operationId: listBlocks
parameters:
- name: limit
in: query
description: Number of blocks to return
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 20
- name: page
in: query
description: Page number
required: false
schema:
type: integer
minimum: 1
default: 1
- name: chain_id
in: query
description: Chain ID filter
required: false
schema:
type: integer
default: 138
responses:
'200':
description: List of blocks
content:
application/json:
schema:
$ref: '#/components/schemas/BlockListResponse'
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalServerError'
/api/v1/blocks/{chain_id}/{number}:
get:
tags:
- Blocks
summary: Get block by number
description: Returns block details by chain ID and block number
operationId: getBlockByNumber
parameters:
- name: chain_id
in: path
required: true
description: Chain ID
schema:
type: integer
example: 138
- name: number
in: path
required: true
description: Block number
schema:
type: integer
example: 1000
responses:
'200':
description: Block details
content:
application/json:
schema:
$ref: '#/components/schemas/Block'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
/api/v1/transactions:
get:
tags:
- Transactions
summary: List transactions
description: Returns a paginated list of transactions
operationId: listTransactions
parameters:
- name: limit
in: query
schema:
type: integer
default: 20
- name: page
in: query
schema:
type: integer
default: 1
- name: chain_id
in: query
schema:
type: integer
default: 138
responses:
'200':
description: List of transactions
content:
application/json:
schema:
$ref: '#/components/schemas/TransactionListResponse'
/api/v1/search:
get:
tags:
- Search
summary: Unified search
description: |
Searches for blocks, transactions, or addresses.
Automatically detects the type based on the query format.
operationId: search
parameters:
- name: q
in: query
required: true
description: Search query (block number, address, or transaction hash)
schema:
type: string
example: "0x1234567890abcdef"
responses:
'200':
description: Search results
content:
application/json:
schema:
$ref: '#/components/schemas/SearchResponse'
'400':
$ref: '#/components/responses/BadRequest'
/api/v1/track1/blocks/latest:
get:
tags:
- Track1
summary: Get latest blocks (Public)
description: Returns the latest blocks via RPC gateway. No authentication required.
operationId: getLatestBlocks
parameters:
- name: limit
in: query
schema:
type: integer
default: 10
maximum: 50
responses:
'200':
description: Latest blocks
content:
application/json:
schema:
$ref: '#/components/schemas/BlockListResponse'
/api/v1/track2/search:
get:
tags:
- Track2
summary: Advanced search (Auth Required)
description: Advanced search with indexed data. Requires Track 2+ authentication.
operationId: track2Search
security:
- bearerAuth: []
parameters:
- name: q
in: query
required: true
schema:
type: string
responses:
'200':
description: Search results
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: JWT token obtained from /api/v1/auth/wallet
schemas:
Block:
type: object
properties:
chain_id:
type: integer
example: 138
number:
type: integer
example: 1000
hash:
type: string
example: "0x1234567890abcdef"
parent_hash:
type: string
timestamp:
type: string
format: date-time
miner:
type: string
transaction_count:
type: integer
gas_used:
type: integer
gas_limit:
type: integer
Transaction:
type: object
properties:
chain_id:
type: integer
hash:
type: string
block_number:
type: integer
from_address:
type: string
to_address:
type: string
value:
type: string
gas:
type: integer
gas_price:
type: string
status:
type: string
enum: [success, failed]
BlockListResponse:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Block'
pagination:
$ref: '#/components/schemas/Pagination'
TransactionListResponse:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Transaction'
pagination:
$ref: '#/components/schemas/Pagination'
Pagination:
type: object
properties:
page:
type: integer
limit:
type: integer
total:
type: integer
total_pages:
type: integer
SearchResponse:
type: object
properties:
query:
type: string
results:
type: array
items:
type: object
properties:
type:
type: string
enum: [block, transaction, address]
data:
type: object
Error:
type: object
properties:
error:
type: object
properties:
code:
type: string
message:
type: string
responses:
BadRequest:
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: "bad_request"
message: "Invalid request parameters"
Unauthorized:
description: Unauthorized - Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: "unauthorized"
message: "Authentication required"
Forbidden:
description: Forbidden - Insufficient permissions
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: "forbidden"
message: "Insufficient permissions. Track 2+ required."
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: "not_found"
message: "Resource not found"
InternalServerError:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: "internal_error"
message: "An internal error occurred"

View File

@@ -0,0 +1,113 @@
package rest
import (
"net/http"
"os"
"strings"
"github.com/explorer/backend/api/middleware"
"github.com/explorer/backend/api/track1"
"github.com/explorer/backend/api/track2"
"github.com/explorer/backend/api/track3"
"github.com/explorer/backend/api/track4"
)
// SetupTrackRoutes sets up track-specific routes with proper middleware
func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware.AuthMiddleware) {
// Initialize Track 1 (RPC Gateway)
rpcURL := os.Getenv("RPC_URL")
if rpcURL == "" {
rpcURL = "http://localhost:8545"
}
// Use Redis if available, otherwise fall back to in-memory
cache, err := track1.NewCache()
if err != nil {
// Fallback to in-memory cache if Redis fails
cache = track1.NewInMemoryCache()
}
rateLimiter, err := track1.NewRateLimiter(track1.RateLimitConfig{
RequestsPerSecond: 10,
RequestsPerMinute: 100,
BurstSize: 20,
})
if err != nil {
// Fallback to in-memory rate limiter if Redis fails
rateLimiter = track1.NewInMemoryRateLimiter(track1.RateLimitConfig{
RequestsPerSecond: 10,
RequestsPerMinute: 100,
BurstSize: 20,
})
}
rpcGateway := track1.NewRPCGateway(rpcURL, cache, rateLimiter)
track1Server := track1.NewServer(rpcGateway)
// Track 1 routes (public, optional auth)
mux.HandleFunc("/api/v1/track1/blocks/latest", track1Server.HandleLatestBlocks)
mux.HandleFunc("/api/v1/track1/txs/latest", track1Server.HandleLatestTransactions)
mux.HandleFunc("/api/v1/track1/block/", track1Server.HandleBlockDetail)
mux.HandleFunc("/api/v1/track1/tx/", track1Server.HandleTransactionDetail)
mux.HandleFunc("/api/v1/track1/address/", track1Server.HandleAddressBalance)
mux.HandleFunc("/api/v1/track1/bridge/status", track1Server.HandleBridgeStatus)
// Initialize Track 2 server
track2Server := track2.NewServer(s.db, s.chainID)
// Track 2 routes (require Track 2+)
track2Middleware := authMiddleware.RequireTrack(2)
// Track 2 route handlers with auth
track2AuthHandler := func(handler http.HandlerFunc) http.HandlerFunc {
return authMiddleware.RequireAuth(track2Middleware(http.HandlerFunc(handler))).ServeHTTP
}
mux.HandleFunc("/api/v1/track2/search", track2AuthHandler(track2Server.HandleSearch))
// Address routes - need to parse path
mux.HandleFunc("/api/v1/track2/address/", track2AuthHandler(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
parts := strings.Split(strings.TrimPrefix(path, "/api/v1/track2/address/"), "/")
if len(parts) >= 2 {
if parts[1] == "txs" {
track2Server.HandleAddressTransactions(w, r)
} else if parts[1] == "tokens" {
track2Server.HandleAddressTokens(w, r)
} else if parts[1] == "internal-txs" {
track2Server.HandleInternalTransactions(w, r)
}
}
}))
mux.HandleFunc("/api/v1/track2/token/", track2AuthHandler(track2Server.HandleTokenInfo))
// Initialize Track 3 server
track3Server := track3.NewServer(s.db, s.chainID)
// Track 3 routes (require Track 3+)
track3Middleware := authMiddleware.RequireTrack(3)
track3AuthHandler := func(handler http.HandlerFunc) http.HandlerFunc {
return authMiddleware.RequireAuth(track3Middleware(http.HandlerFunc(handler))).ServeHTTP
}
mux.HandleFunc("/api/v1/track3/analytics/flows", track3AuthHandler(track3Server.HandleFlows))
mux.HandleFunc("/api/v1/track3/analytics/bridge", track3AuthHandler(track3Server.HandleBridge))
mux.HandleFunc("/api/v1/track3/analytics/token-distribution/", track3AuthHandler(track3Server.HandleTokenDistribution))
mux.HandleFunc("/api/v1/track3/analytics/address-risk/", track3AuthHandler(track3Server.HandleAddressRisk))
// Initialize Track 4 server
track4Server := track4.NewServer(s.db, s.chainID)
// Track 4 routes (require Track 4 + IP whitelist)
track4Middleware := authMiddleware.RequireTrack(4)
track4AuthHandler := func(handler http.HandlerFunc) http.HandlerFunc {
return authMiddleware.RequireAuth(track4Middleware(http.HandlerFunc(handler))).ServeHTTP
}
mux.HandleFunc("/api/v1/track4/operator/bridge/events", track4AuthHandler(track4Server.HandleBridgeEvents))
mux.HandleFunc("/api/v1/track4/operator/validators", track4AuthHandler(track4Server.HandleValidators))
mux.HandleFunc("/api/v1/track4/operator/contracts", track4AuthHandler(track4Server.HandleContracts))
mux.HandleFunc("/api/v1/track4/operator/protocol-state", track4AuthHandler(track4Server.HandleProtocolState))
}

View File

@@ -0,0 +1,236 @@
package rest
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
)
// handleListTransactions handles GET /api/v1/transactions
func (s *Server) handleListTransactions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Validate pagination
page, pageSize, err := validatePagination(
r.URL.Query().Get("page"),
r.URL.Query().Get("page_size"),
)
if err != nil {
writeValidationError(w, err)
return
}
offset := (page - 1) * pageSize
query := `
SELECT t.chain_id, t.hash, t.block_number, t.transaction_index, t.from_address, t.to_address,
t.value, t.gas_price, t.gas_used, t.status, t.created_at, t.timestamp_iso
FROM transactions t
WHERE t.chain_id = $1
`
args := []interface{}{s.chainID}
argIndex := 2
// Add filters
if blockNumber := r.URL.Query().Get("block_number"); blockNumber != "" {
if bn, err := strconv.ParseInt(blockNumber, 10, 64); err == nil {
query += fmt.Sprintf(" AND block_number = $%d", argIndex)
args = append(args, bn)
argIndex++
}
}
if fromAddress := r.URL.Query().Get("from_address"); fromAddress != "" {
query += fmt.Sprintf(" AND from_address = $%d", argIndex)
args = append(args, fromAddress)
argIndex++
}
if toAddress := r.URL.Query().Get("to_address"); toAddress != "" {
query += fmt.Sprintf(" AND to_address = $%d", argIndex)
args = append(args, toAddress)
argIndex++
}
query += " ORDER BY block_number DESC, transaction_index DESC"
query += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIndex, argIndex+1)
args = append(args, pageSize, offset)
// Add query timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
rows, err := s.db.Query(ctx, query, args...)
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
transactions := []map[string]interface{}{}
for rows.Next() {
var chainID, blockNumber, transactionIndex int
var hash, fromAddress string
var toAddress sql.NullString
var value string
var gasPrice, gasUsed sql.NullInt64
var status sql.NullInt64
var createdAt time.Time
var timestampISO sql.NullString
if err := rows.Scan(&chainID, &hash, &blockNumber, &transactionIndex, &fromAddress, &toAddress,
&value, &gasPrice, &gasUsed, &status, &createdAt, &timestampISO); err != nil {
continue
}
tx := map[string]interface{}{
"chain_id": chainID,
"hash": hash,
"block_number": blockNumber,
"transaction_index": transactionIndex,
"from_address": fromAddress,
"value": value,
"created_at": createdAt,
}
if timestampISO.Valid {
tx["timestamp_iso"] = timestampISO.String
}
if toAddress.Valid {
tx["to_address"] = toAddress.String
}
if gasPrice.Valid {
tx["gas_price"] = gasPrice.Int64
}
if gasUsed.Valid {
tx["gas_used"] = gasUsed.Int64
}
if status.Valid {
tx["status"] = status.Int64
}
transactions = append(transactions, tx)
}
response := map[string]interface{}{
"data": transactions,
"meta": map[string]interface{}{
"pagination": map[string]interface{}{
"page": page,
"page_size": pageSize,
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleGetTransactionByHash handles GET /api/v1/transactions/{chain_id}/{hash}
func (s *Server) handleGetTransactionByHash(w http.ResponseWriter, r *http.Request, hash string) {
// Validate hash format (already validated in routes.go, but double-check)
if !isValidHash(hash) {
writeValidationError(w, ErrInvalidHash)
return
}
// Add query timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
query := `
SELECT chain_id, hash, block_number, block_hash, transaction_index,
from_address, to_address, value, gas_price, max_fee_per_gas,
max_priority_fee_per_gas, gas_limit, gas_used, nonce, input_data,
status, contract_address, cumulative_gas_used, effective_gas_price,
created_at, timestamp_iso
FROM transactions
WHERE chain_id = $1 AND hash = $2
`
var chainID, blockNumber, transactionIndex int
var txHash, blockHash, fromAddress string
var toAddress sql.NullString
var value string
var gasPrice, maxFeePerGas, maxPriorityFeePerGas, gasLimit, gasUsed, nonce sql.NullInt64
var inputData sql.NullString
var status sql.NullInt64
var contractAddress sql.NullString
var cumulativeGasUsed int64
var effectiveGasPrice sql.NullInt64
var createdAt time.Time
var timestampISO sql.NullString
err := s.db.QueryRow(ctx, query, s.chainID, hash).Scan(
&chainID, &txHash, &blockNumber, &blockHash, &transactionIndex,
&fromAddress, &toAddress, &value, &gasPrice, &maxFeePerGas,
&maxPriorityFeePerGas, &gasLimit, &gasUsed, &nonce, &inputData,
&status, &contractAddress, &cumulativeGasUsed, &effectiveGasPrice,
&createdAt, &timestampISO,
)
if err != nil {
http.Error(w, fmt.Sprintf("Transaction not found: %v", err), http.StatusNotFound)
return
}
tx := map[string]interface{}{
"chain_id": chainID,
"hash": txHash,
"block_number": blockNumber,
"block_hash": blockHash,
"transaction_index": transactionIndex,
"from_address": fromAddress,
"value": value,
"gas_limit": gasLimit.Int64,
"cumulative_gas_used": cumulativeGasUsed,
"created_at": createdAt,
}
if timestampISO.Valid {
tx["timestamp_iso"] = timestampISO.String
}
if toAddress.Valid {
tx["to_address"] = toAddress.String
}
if gasPrice.Valid {
tx["gas_price"] = gasPrice.Int64
}
if maxFeePerGas.Valid {
tx["max_fee_per_gas"] = maxFeePerGas.Int64
}
if maxPriorityFeePerGas.Valid {
tx["max_priority_fee_per_gas"] = maxPriorityFeePerGas.Int64
}
if gasUsed.Valid {
tx["gas_used"] = gasUsed.Int64
}
if nonce.Valid {
tx["nonce"] = nonce.Int64
}
if inputData.Valid {
tx["input_data"] = inputData.String
}
if status.Valid {
tx["status"] = status.Int64
}
if contractAddress.Valid {
tx["contract_address"] = contractAddress.String
}
if effectiveGasPrice.Valid {
tx["effective_gas_price"] = effectiveGasPrice.Int64
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"data": tx,
})
}

View File

@@ -0,0 +1,127 @@
package rest
import (
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
)
// Validation errors
var (
ErrInvalidAddress = fmt.Errorf("invalid address format")
ErrInvalidHash = fmt.Errorf("invalid hash format")
ErrInvalidBlockNumber = fmt.Errorf("invalid block number")
)
// isValidHash validates if a string is a valid hex hash (0x + 64 hex chars)
func isValidHash(hash string) bool {
if !strings.HasPrefix(hash, "0x") {
return false
}
if len(hash) != 66 {
return false
}
_, err := hex.DecodeString(hash[2:])
return err == nil
}
// isValidAddress validates if a string is a valid Ethereum address (0x + 40 hex chars)
func isValidAddress(address string) bool {
if !strings.HasPrefix(address, "0x") {
return false
}
if len(address) != 42 {
return false
}
_, err := hex.DecodeString(address[2:])
return err == nil
}
// validateBlockNumber validates and parses block number
func validateBlockNumber(blockStr string) (int64, error) {
blockNumber, err := strconv.ParseInt(blockStr, 10, 64)
if err != nil {
return 0, ErrInvalidBlockNumber
}
if blockNumber < 0 {
return 0, ErrInvalidBlockNumber
}
return blockNumber, nil
}
// validateChainID validates chain ID matches expected
func validateChainID(chainIDStr string, expectedChainID int) error {
chainID, err := strconv.Atoi(chainIDStr)
if err != nil {
return fmt.Errorf("invalid chain ID format")
}
if chainID != expectedChainID {
return fmt.Errorf("chain ID mismatch: expected %d, got %d", expectedChainID, chainID)
}
return nil
}
// validatePagination validates and normalizes pagination parameters
func validatePagination(pageStr, pageSizeStr string) (page, pageSize int, err error) {
page = 1
if pageStr != "" {
page, err = strconv.Atoi(pageStr)
if err != nil || page < 1 {
return 0, 0, fmt.Errorf("invalid page number")
}
}
pageSize = 20
if pageSizeStr != "" {
pageSize, err = strconv.Atoi(pageSizeStr)
if err != nil || pageSize < 1 {
return 0, 0, fmt.Errorf("invalid page size")
}
if pageSize > 100 {
pageSize = 100 // Max page size
}
}
return page, pageSize, nil
}
// writeValidationError writes a validation error response
func writeValidationError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": map[string]interface{}{
"code": "VALIDATION_ERROR",
"message": err.Error(),
},
})
}
// validateSearchQuery validates search query format
func validateSearchQuery(query string) (searchType string, value string, err error) {
query = strings.TrimSpace(query)
if query == "" {
return "", "", fmt.Errorf("search query cannot be empty")
}
// Block number (numeric)
if matched, _ := regexp.MatchString(`^\d+$`, query); matched {
return "block", query, nil
}
// Address (0x + 40 hex chars)
if matched, _ := regexp.MatchString(`^0x[a-fA-F0-9]{40}$`, query); matched {
return "address", query, nil
}
// Transaction hash (0x + 64 hex chars)
if matched, _ := regexp.MatchString(`^0x[a-fA-F0-9]{64}$`, query); matched {
return "transaction", query, nil
}
return "", "", fmt.Errorf("invalid search query format")
}

View File

@@ -0,0 +1,42 @@
package main
import (
"log"
"net/http"
"os"
"github.com/elastic/go-elasticsearch/v8"
"github.com/explorer/backend/api/search"
"github.com/explorer/backend/search/config"
)
func main() {
searchConfig := config.LoadSearchConfig()
esConfig := elasticsearch.Config{
Addresses: []string{searchConfig.URL},
}
if searchConfig.Username != "" {
esConfig.Username = searchConfig.Username
esConfig.Password = searchConfig.Password
}
client, err := elasticsearch.NewClient(esConfig)
if err != nil {
log.Fatalf("Failed to create Elasticsearch client: %v", err)
}
service := search.NewSearchService(client, searchConfig.IndexPrefix)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/search", service.HandleSearch)
port := os.Getenv("SEARCH_PORT")
if port == "" {
port = "8082"
}
log.Printf("Starting search service on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, mux))
}

View File

@@ -0,0 +1,172 @@
package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/go-elasticsearch/v8/esapi"
)
// SearchService handles unified search
type SearchService struct {
client *elasticsearch.Client
indexPrefix string
}
// NewSearchService creates a new search service
func NewSearchService(client *elasticsearch.Client, indexPrefix string) *SearchService {
return &SearchService{
client: client,
indexPrefix: indexPrefix,
}
}
// Search performs unified search across all indices
func (s *SearchService) Search(ctx context.Context, query string, chainID *int, limit int) ([]SearchResult, error) {
// Build search query
var indices []string
if chainID != nil {
indices = []string{
fmt.Sprintf("%s-blocks-%d", s.indexPrefix, *chainID),
fmt.Sprintf("%s-transactions-%d", s.indexPrefix, *chainID),
fmt.Sprintf("%s-addresses-%d", s.indexPrefix, *chainID),
}
} else {
// Search all chains (simplified - would need to enumerate)
indices = []string{
fmt.Sprintf("%s-blocks-*", s.indexPrefix),
fmt.Sprintf("%s-transactions-*", s.indexPrefix),
fmt.Sprintf("%s-addresses-*", s.indexPrefix),
}
}
searchQuery := map[string]interface{}{
"query": map[string]interface{}{
"multi_match": map[string]interface{}{
"query": query,
"fields": []string{"hash", "address", "from_address", "to_address"},
"type": "best_fields",
},
},
"size": limit,
}
queryJSON, _ := json.Marshal(searchQuery)
queryString := string(queryJSON)
// Execute search
req := esapi.SearchRequest{
Index: indices,
Body: strings.NewReader(queryString),
Pretty: true,
}
res, err := req.Do(ctx, s.client)
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
defer res.Body.Close()
if res.IsError() {
return nil, fmt.Errorf("elasticsearch error: %s", res.String())
}
var result map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Parse results
results := []SearchResult{}
if hits, ok := result["hits"].(map[string]interface{}); ok {
if hitsList, ok := hits["hits"].([]interface{}); ok {
for _, hit := range hitsList {
if hitMap, ok := hit.(map[string]interface{}); ok {
if source, ok := hitMap["_source"].(map[string]interface{}); ok {
result := s.parseResult(source)
results = append(results, result)
}
}
}
}
}
return results, nil
}
// SearchResult represents a search result
type SearchResult struct {
Type string `json:"type"`
ChainID int `json:"chain_id"`
Data map[string]interface{} `json:"data"`
Score float64 `json:"score"`
}
func (s *SearchService) parseResult(source map[string]interface{}) SearchResult {
result := SearchResult{
Data: source,
}
if chainID, ok := source["chain_id"].(float64); ok {
result.ChainID = int(chainID)
}
// Determine type based on fields
if _, ok := source["block_number"]; ok {
result.Type = "block"
} else if _, ok := source["transaction_index"]; ok {
result.Type = "transaction"
} else if _, ok := source["address"]; ok {
result.Type = "address"
}
return result
}
// HandleSearch handles HTTP search requests
func (s *SearchService) HandleSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Query parameter 'q' is required", http.StatusBadRequest)
return
}
var chainID *int
if chainIDStr := r.URL.Query().Get("chain_id"); chainIDStr != "" {
if id, err := strconv.Atoi(chainIDStr); err == nil {
chainID = &id
}
}
limit := 50
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
limit = l
}
}
results, err := s.Search(r.Context(), query, chainID, limit)
if err != nil {
http.Error(w, fmt.Sprintf("Search failed: %v", err), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"query": query,
"results": results,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,90 @@
package track1
import (
"sync"
"time"
)
// InMemoryCache is a simple in-memory cache
// In production, use Redis for distributed caching
type InMemoryCache struct {
items map[string]*cacheItem
mu sync.RWMutex
}
// cacheItem represents a cached item
type cacheItem struct {
value []byte
expiresAt time.Time
}
// NewInMemoryCache creates a new in-memory cache
func NewInMemoryCache() *InMemoryCache {
cache := &InMemoryCache{
items: make(map[string]*cacheItem),
}
// Start cleanup goroutine
go cache.cleanup()
return cache
}
// Get retrieves a value from cache
func (c *InMemoryCache) Get(key string) ([]byte, error) {
c.mu.RLock()
defer c.mu.RUnlock()
item, exists := c.items[key]
if !exists {
return nil, ErrCacheMiss
}
if time.Now().After(item.expiresAt) {
return nil, ErrCacheMiss
}
return item.value, nil
}
// Set stores a value in cache with TTL
func (c *InMemoryCache) Set(key string, value []byte, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = &cacheItem{
value: value,
expiresAt: time.Now().Add(ttl),
}
return nil
}
// cleanup removes expired items
func (c *InMemoryCache) cleanup() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for key, item := range c.items {
if now.After(item.expiresAt) {
delete(c.items, key)
}
}
c.mu.Unlock()
}
}
// ErrCacheMiss is returned when a cache key is not found
var ErrCacheMiss = &CacheError{Message: "cache miss"}
// CacheError represents a cache error
type CacheError struct {
Message string
}
func (e *CacheError) Error() string {
return e.Message
}

View File

@@ -0,0 +1,79 @@
package track1
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInMemoryCache_GetSet(t *testing.T) {
cache := NewInMemoryCache()
key := "test-key"
value := []byte("test-value")
ttl := 5 * time.Minute
// Test Set
err := cache.Set(key, value, ttl)
require.NoError(t, err)
// Test Get
retrieved, err := cache.Get(key)
require.NoError(t, err)
assert.Equal(t, value, retrieved)
}
func TestInMemoryCache_Expiration(t *testing.T) {
cache := NewInMemoryCache()
key := "test-key"
value := []byte("test-value")
ttl := 100 * time.Millisecond
err := cache.Set(key, value, ttl)
require.NoError(t, err)
// Should be available immediately
retrieved, err := cache.Get(key)
require.NoError(t, err)
assert.Equal(t, value, retrieved)
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should be expired
_, err = cache.Get(key)
assert.Error(t, err)
assert.Equal(t, ErrCacheMiss, err)
}
func TestInMemoryCache_Miss(t *testing.T) {
cache := NewInMemoryCache()
_, err := cache.Get("non-existent-key")
assert.Error(t, err)
assert.Equal(t, ErrCacheMiss, err)
}
func TestInMemoryCache_Cleanup(t *testing.T) {
cache := NewInMemoryCache()
// Set multiple keys with short TTL
for i := 0; i < 10; i++ {
key := "test-key-" + string(rune(i))
cache.Set(key, []byte("value"), 50*time.Millisecond)
}
// Wait for expiration
time.Sleep(200 * time.Millisecond)
// All should be expired after cleanup
for i := 0; i < 10; i++ {
key := "test-key-" + string(rune(i))
_, err := cache.Get(key)
assert.Error(t, err)
}
}

View File

@@ -0,0 +1,391 @@
package track1
import (
"encoding/json"
"fmt"
"math/big"
"net/http"
"strconv"
"strings"
"time"
)
// Server handles Track 1 endpoints
type Server struct {
rpcGateway *RPCGateway
}
// NewServer creates a new Track 1 server
func NewServer(rpcGateway *RPCGateway) *Server {
return &Server{
rpcGateway: rpcGateway,
}
}
// HandleLatestBlocks handles GET /api/v1/track1/blocks/latest
func (s *Server) HandleLatestBlocks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
limit := 10
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
limit = l
}
}
// Get latest block number
blockNumResp, err := s.rpcGateway.GetBlockNumber(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "rpc_error", err.Error())
return
}
blockNumHex, ok := blockNumResp.Result.(string)
if !ok {
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid block number response")
return
}
// Parse block number
blockNum, err := hexToInt(blockNumHex)
if err != nil {
writeError(w, http.StatusInternalServerError, "parse_error", err.Error())
return
}
// Fetch blocks
blocks := []map[string]interface{}{}
for i := 0; i < limit && blockNum-int64(i) >= 0; i++ {
blockNumStr := fmt.Sprintf("0x%x", blockNum-int64(i))
blockResp, err := s.rpcGateway.GetBlockByNumber(r.Context(), blockNumStr, false)
if err != nil {
continue // Skip failed blocks
}
blockData, ok := blockResp.Result.(map[string]interface{})
if !ok {
continue
}
// Transform to our format
block := transformBlock(blockData)
blocks = append(blocks, block)
}
response := map[string]interface{}{
"data": blocks,
"pagination": map[string]interface{}{
"page": 1,
"limit": limit,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleLatestTransactions handles GET /api/v1/track1/txs/latest
func (s *Server) HandleLatestTransactions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
limit := 10
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
limit = l
}
}
// Get latest block number
blockNumResp, err := s.rpcGateway.GetBlockNumber(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "rpc_error", err.Error())
return
}
blockNumHex, ok := blockNumResp.Result.(string)
if !ok {
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid block number response")
return
}
blockNum, err := hexToInt(blockNumHex)
if err != nil {
writeError(w, http.StatusInternalServerError, "parse_error", err.Error())
return
}
// Fetch transactions from recent blocks
transactions := []map[string]interface{}{}
for i := 0; i < 20 && len(transactions) < limit && blockNum-int64(i) >= 0; i++ {
blockNumStr := fmt.Sprintf("0x%x", blockNum-int64(i))
blockResp, err := s.rpcGateway.GetBlockByNumber(r.Context(), blockNumStr, true)
if err != nil {
continue
}
blockData, ok := blockResp.Result.(map[string]interface{})
if !ok {
continue
}
txs, ok := blockData["transactions"].([]interface{})
if !ok {
continue
}
for _, tx := range txs {
if len(transactions) >= limit {
break
}
txData, ok := tx.(map[string]interface{})
if !ok {
continue
}
transactions = append(transactions, transformTransaction(txData))
}
}
response := map[string]interface{}{
"data": transactions,
"pagination": map[string]interface{}{
"page": 1,
"limit": limit,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleBlockDetail handles GET /api/v1/track1/block/:number
func (s *Server) HandleBlockDetail(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/track1/block/")
blockNumStr := fmt.Sprintf("0x%x", parseBlockNumber(path))
blockResp, err := s.rpcGateway.GetBlockByNumber(r.Context(), blockNumStr, false)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "Block not found")
return
}
blockData, ok := blockResp.Result.(map[string]interface{})
if !ok {
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid block response")
return
}
response := map[string]interface{}{
"data": transformBlock(blockData),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleTransactionDetail handles GET /api/v1/track1/tx/:hash
func (s *Server) HandleTransactionDetail(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/track1/tx/")
txHash := path
txResp, err := s.rpcGateway.GetTransactionByHash(r.Context(), txHash)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "Transaction not found")
return
}
txData, ok := txResp.Result.(map[string]interface{})
if !ok {
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid transaction response")
return
}
response := map[string]interface{}{
"data": transformTransaction(txData),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleAddressBalance handles GET /api/v1/track1/address/:addr/balance
func (s *Server) HandleAddressBalance(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/track1/address/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[1] != "balance" {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid path")
return
}
address := parts[0]
balanceResp, err := s.rpcGateway.GetBalance(r.Context(), address, "latest")
if err != nil {
writeError(w, http.StatusInternalServerError, "rpc_error", err.Error())
return
}
balanceHex, ok := balanceResp.Result.(string)
if !ok {
writeError(w, http.StatusInternalServerError, "invalid_response", "Invalid balance response")
return
}
balance, err := hexToBigInt(balanceHex)
if err != nil {
writeError(w, http.StatusInternalServerError, "parse_error", err.Error())
return
}
response := map[string]interface{}{
"data": map[string]interface{}{
"address": address,
"balance": balance.String(),
"balance_wei": balance.String(),
"balance_ether": weiToEther(balance),
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleBridgeStatus handles GET /api/v1/track1/bridge/status
func (s *Server) HandleBridgeStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
// Return bridge status (simplified - in production, query bridge contracts)
response := map[string]interface{}{
"data": map[string]interface{}{
"status": "operational",
"chains": map[string]interface{}{
"138": map[string]interface{}{
"name": "Defi Oracle Meta Mainnet",
"status": "operational",
"last_sync": time.Now().UTC().Format(time.RFC3339),
},
"1": map[string]interface{}{
"name": "Ethereum Mainnet",
"status": "operational",
"last_sync": time.Now().UTC().Format(time.RFC3339),
},
},
"total_transfers_24h": 150,
"total_volume_24h": "5000000000000000000000",
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// Helper functions
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 hexToInt(hex string) (int64, error) {
hex = strings.TrimPrefix(hex, "0x")
return strconv.ParseInt(hex, 16, 64)
}
func parseBlockNumber(s string) int64 {
num, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0
}
return num
}
func transformBlock(blockData map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"number": parseHexField(blockData["number"]),
"hash": blockData["hash"],
"parent_hash": blockData["parentHash"],
"timestamp": parseHexTimestamp(blockData["timestamp"]),
"transaction_count": countTransactions(blockData["transactions"]),
"gas_used": parseHexField(blockData["gasUsed"]),
"gas_limit": parseHexField(blockData["gasLimit"]),
"miner": blockData["miner"],
}
}
func transformTransaction(txData map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"hash": txData["hash"],
"from": txData["from"],
"to": txData["to"],
"value": txData["value"],
"block_number": parseHexField(txData["blockNumber"]),
"timestamp": parseHexTimestamp(txData["timestamp"]),
}
}
func parseHexField(field interface{}) interface{} {
if str, ok := field.(string); ok {
if num, err := hexToInt(str); err == nil {
return num
}
}
return field
}
func parseHexTimestamp(field interface{}) string {
if str, ok := field.(string); ok {
if num, err := hexToInt(str); err == nil {
return time.Unix(num, 0).Format(time.RFC3339)
}
}
return ""
}
func countTransactions(txs interface{}) int {
if txsList, ok := txs.([]interface{}); ok {
return len(txsList)
}
return 0
}
func hexToBigInt(hex string) (*big.Int, error) {
hex = strings.TrimPrefix(hex, "0x")
bigInt := new(big.Int)
bigInt, ok := bigInt.SetString(hex, 16)
if !ok {
return nil, fmt.Errorf("invalid hex number")
}
return bigInt, nil
}
func weiToEther(wei *big.Int) string {
ether := new(big.Float).Quo(new(big.Float).SetInt(wei), big.NewFloat(1e18))
return ether.Text('f', 18)
}

View File

@@ -0,0 +1,83 @@
package track1
import (
"sync"
"time"
)
// InMemoryRateLimiter is a simple in-memory rate limiter
// In production, use Redis for distributed rate limiting
type InMemoryRateLimiter struct {
limits map[string]*limitEntry
mu sync.RWMutex
config RateLimitConfig
}
// RateLimitConfig defines rate limit configuration
type RateLimitConfig struct {
RequestsPerSecond int
RequestsPerMinute int
BurstSize int
}
// limitEntry tracks rate limit state for a key
type limitEntry struct {
count int
resetAt time.Time
lastReset time.Time
}
// NewInMemoryRateLimiter creates a new in-memory rate limiter
func NewInMemoryRateLimiter(config RateLimitConfig) *InMemoryRateLimiter {
return &InMemoryRateLimiter{
limits: make(map[string]*limitEntry),
config: config,
}
}
// Allow checks if a request is allowed for the given key
func (rl *InMemoryRateLimiter) Allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
entry, exists := rl.limits[key]
if !exists {
rl.limits[key] = &limitEntry{
count: 1,
resetAt: now.Add(time.Minute),
lastReset: now,
}
return true
}
// Reset if minute has passed
if now.After(entry.resetAt) {
entry.count = 1
entry.resetAt = now.Add(time.Minute)
entry.lastReset = now
return true
}
// Check limits
if entry.count >= rl.config.RequestsPerMinute {
return false
}
entry.count++
return true
}
// Cleanup removes old entries (call periodically)
func (rl *InMemoryRateLimiter) Cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
for key, entry := range rl.limits {
if now.After(entry.resetAt.Add(5 * time.Minute)) {
delete(rl.limits, key)
}
}
}

View File

@@ -0,0 +1,87 @@
package track1
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestInMemoryRateLimiter_Allow(t *testing.T) {
config := RateLimitConfig{
RequestsPerSecond: 10,
RequestsPerMinute: 100,
BurstSize: 20,
}
limiter := NewInMemoryRateLimiter(config)
key := "test-key"
// Should allow first 100 requests
for i := 0; i < 100; i++ {
assert.True(t, limiter.Allow(key), "Request %d should be allowed", i)
}
// 101st request should be denied
assert.False(t, limiter.Allow(key), "Request 101 should be denied")
}
func TestInMemoryRateLimiter_Reset(t *testing.T) {
config := RateLimitConfig{
RequestsPerMinute: 10,
}
limiter := NewInMemoryRateLimiter(config)
key := "test-key"
// Exhaust limit
for i := 0; i < 10; i++ {
limiter.Allow(key)
}
assert.False(t, limiter.Allow(key))
// Wait for reset (1 minute)
time.Sleep(61 * time.Second)
// Should allow again after reset
assert.True(t, limiter.Allow(key))
}
func TestInMemoryRateLimiter_DifferentKeys(t *testing.T) {
config := RateLimitConfig{
RequestsPerMinute: 10,
}
limiter := NewInMemoryRateLimiter(config)
key1 := "key1"
key2 := "key2"
// Exhaust limit for key1
for i := 0; i < 10; i++ {
limiter.Allow(key1)
}
assert.False(t, limiter.Allow(key1))
// key2 should still have full limit
for i := 0; i < 10; i++ {
assert.True(t, limiter.Allow(key2), "Request %d for key2 should be allowed", i)
}
}
func TestInMemoryRateLimiter_Cleanup(t *testing.T) {
config := RateLimitConfig{
RequestsPerMinute: 10,
}
limiter := NewInMemoryRateLimiter(config)
key := "test-key"
limiter.Allow(key)
// Cleanup should remove old entries
limiter.Cleanup()
// Entry should still exist if not old enough
// This test verifies cleanup doesn't break functionality
assert.NotNil(t, limiter)
}

View File

@@ -0,0 +1,88 @@
package track1
import (
"context"
"os"
"time"
"github.com/redis/go-redis/v9"
)
// RedisCache is a Redis-based cache implementation
// Use this in production for distributed caching
type RedisCache struct {
client *redis.Client
ctx context.Context
}
// NewRedisCache creates a new Redis cache
func NewRedisCache(redisURL string) (*RedisCache, error) {
opts, err := redis.ParseURL(redisURL)
if err != nil {
return nil, err
}
client := redis.NewClient(opts)
ctx := context.Background()
// Test connection
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
return &RedisCache{
client: client,
ctx: ctx,
}, nil
}
// NewRedisCacheFromClient creates a new Redis cache from an existing client
func NewRedisCacheFromClient(client *redis.Client) *RedisCache {
return &RedisCache{
client: client,
ctx: context.Background(),
}
}
// Get retrieves a value from cache
func (c *RedisCache) Get(key string) ([]byte, error) {
val, err := c.client.Get(c.ctx, key).Bytes()
if err == redis.Nil {
return nil, ErrCacheMiss
}
if err != nil {
return nil, err
}
return val, nil
}
// Set stores a value in cache with TTL
func (c *RedisCache) Set(key string, value []byte, ttl time.Duration) error {
return c.client.Set(c.ctx, key, value, ttl).Err()
}
// Delete removes a key from cache
func (c *RedisCache) Delete(key string) error {
return c.client.Del(c.ctx, key).Err()
}
// Clear clears all cache keys (use with caution)
func (c *RedisCache) Clear() error {
return c.client.FlushDB(c.ctx).Err()
}
// Close closes the Redis connection
func (c *RedisCache) Close() error {
return c.client.Close()
}
// NewCache creates a cache based on environment
// Returns Redis cache if REDIS_URL is set, otherwise in-memory cache
func NewCache() (Cache, error) {
redisURL := os.Getenv("REDIS_URL")
if redisURL != "" {
return NewRedisCache(redisURL)
}
return NewInMemoryCache(), nil
}

View File

@@ -0,0 +1,135 @@
package track1
import (
"context"
"os"
"time"
"github.com/redis/go-redis/v9"
)
// RedisRateLimiter is a Redis-based rate limiter implementation
// Use this in production for distributed rate limiting
type RedisRateLimiter struct {
client *redis.Client
ctx context.Context
config RateLimitConfig
}
// NewRedisRateLimiter creates a new Redis rate limiter
func NewRedisRateLimiter(redisURL string, config RateLimitConfig) (*RedisRateLimiter, error) {
opts, err := redis.ParseURL(redisURL)
if err != nil {
return nil, err
}
client := redis.NewClient(opts)
ctx := context.Background()
// Test connection
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
return &RedisRateLimiter{
client: client,
ctx: ctx,
config: config,
}, nil
}
// NewRedisRateLimiterFromClient creates a new Redis rate limiter from an existing client
func NewRedisRateLimiterFromClient(client *redis.Client, config RateLimitConfig) *RedisRateLimiter {
return &RedisRateLimiter{
client: client,
ctx: context.Background(),
config: config,
}
}
// Allow checks if a request is allowed for the given key
// Uses sliding window algorithm with Redis
func (rl *RedisRateLimiter) Allow(key string) bool {
now := time.Now()
windowStart := now.Add(-time.Minute)
// Use sorted set to track requests in the current window
zsetKey := "ratelimit:" + key
// Remove old entries (outside the window)
rl.client.ZRemRangeByScore(rl.ctx, zsetKey, "0", formatTime(windowStart))
// Count requests in current window
count, err := rl.client.ZCard(rl.ctx, zsetKey).Result()
if err != nil {
// On error, allow the request (fail open)
return true
}
// Check if limit exceeded
if int(count) >= rl.config.RequestsPerMinute {
return false
}
// Add current request to the window
member := formatTime(now)
score := float64(now.Unix())
rl.client.ZAdd(rl.ctx, zsetKey, redis.Z{
Score: score,
Member: member,
})
// Set expiration on the key (cleanup)
rl.client.Expire(rl.ctx, zsetKey, time.Minute*2)
return true
}
// GetRemaining returns the number of requests remaining in the current window
func (rl *RedisRateLimiter) GetRemaining(key string) int {
now := time.Now()
windowStart := now.Add(-time.Minute)
zsetKey := "ratelimit:" + key
// Remove old entries
rl.client.ZRemRangeByScore(rl.ctx, zsetKey, "0", formatTime(windowStart))
// Count requests in current window
count, err := rl.client.ZCard(rl.ctx, zsetKey).Result()
if err != nil {
return rl.config.RequestsPerMinute
}
remaining := rl.config.RequestsPerMinute - int(count)
if remaining < 0 {
return 0
}
return remaining
}
// Reset resets the rate limit for a key
func (rl *RedisRateLimiter) Reset(key string) error {
zsetKey := "ratelimit:" + key
return rl.client.Del(rl.ctx, zsetKey).Err()
}
// Close closes the Redis connection
func (rl *RedisRateLimiter) Close() error {
return rl.client.Close()
}
// formatTime formats time for Redis sorted set
func formatTime(t time.Time) string {
return t.Format(time.RFC3339Nano)
}
// NewRateLimiter creates a rate limiter based on environment
// Returns Redis rate limiter if REDIS_URL is set, otherwise in-memory rate limiter
func NewRateLimiter(config RateLimitConfig) (RateLimiter, error) {
redisURL := os.Getenv("REDIS_URL")
if redisURL != "" {
return NewRedisRateLimiter(redisURL, config)
}
return NewInMemoryRateLimiter(config), nil
}

View File

@@ -0,0 +1,178 @@
package track1
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// RPCGateway handles RPC passthrough with caching
type RPCGateway struct {
rpcURL string
httpClient *http.Client
cache Cache
rateLimit RateLimiter
}
// Cache interface for caching RPC responses
type Cache interface {
Get(key string) ([]byte, error)
Set(key string, value []byte, ttl time.Duration) error
}
// RateLimiter interface for rate limiting
type RateLimiter interface {
Allow(key string) bool
}
// NewRPCGateway creates a new RPC gateway
func NewRPCGateway(rpcURL string, cache Cache, rateLimit RateLimiter) *RPCGateway {
return &RPCGateway{
rpcURL: rpcURL,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
cache: cache,
rateLimit: rateLimit,
}
}
// RPCRequest represents a JSON-RPC request
type RPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params []interface{} `json:"params"`
ID int `json:"id"`
}
// RPCResponse represents a JSON-RPC response
type RPCResponse struct {
JSONRPC string `json:"jsonrpc"`
Result interface{} `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
ID int `json:"id"`
}
// RPCError represents an RPC error
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Call makes an RPC call with caching and rate limiting
func (g *RPCGateway) Call(ctx context.Context, method string, params []interface{}, cacheKey string, cacheTTL time.Duration) (*RPCResponse, error) {
// Check cache first
if cacheKey != "" {
if cached, err := g.cache.Get(cacheKey); err == nil {
var response RPCResponse
if err := json.Unmarshal(cached, &response); err == nil {
return &response, nil
}
}
}
// Check rate limit
if !g.rateLimit.Allow("rpc") {
return nil, fmt.Errorf("rate limit exceeded")
}
// Make RPC call
req := RPCRequest{
JSONRPC: "2.0",
Method: method,
Params: params,
ID: 1,
}
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", g.rpcURL, bytes.NewBuffer(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := g.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("RPC call failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("RPC returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var rpcResp RPCResponse
if err := json.Unmarshal(body, &rpcResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("RPC error: %s (code: %d)", rpcResp.Error.Message, rpcResp.Error.Code)
}
// Cache response if cache key provided
if cacheKey != "" && rpcResp.Result != nil {
if cacheData, err := json.Marshal(rpcResp); err == nil {
g.cache.Set(cacheKey, cacheData, cacheTTL)
}
}
return &rpcResp, nil
}
// GetBlockByNumber gets a block by number
func (g *RPCGateway) GetBlockByNumber(ctx context.Context, blockNumber string, includeTxs bool) (*RPCResponse, error) {
cacheKey := fmt.Sprintf("block:%s:%v", blockNumber, includeTxs)
return g.Call(ctx, "eth_getBlockByNumber", []interface{}{blockNumber, includeTxs}, cacheKey, 10*time.Second)
}
// GetBlockByHash gets a block by hash
func (g *RPCGateway) GetBlockByHash(ctx context.Context, blockHash string, includeTxs bool) (*RPCResponse, error) {
cacheKey := fmt.Sprintf("block_hash:%s:%v", blockHash, includeTxs)
return g.Call(ctx, "eth_getBlockByHash", []interface{}{blockHash, includeTxs}, cacheKey, 10*time.Second)
}
// GetTransactionByHash gets a transaction by hash
func (g *RPCGateway) GetTransactionByHash(ctx context.Context, txHash string) (*RPCResponse, error) {
cacheKey := fmt.Sprintf("tx:%s", txHash)
return g.Call(ctx, "eth_getTransactionByHash", []interface{}{txHash}, cacheKey, 30*time.Second)
}
// GetBalance gets an address balance
func (g *RPCGateway) GetBalance(ctx context.Context, address string, blockNumber string) (*RPCResponse, error) {
if blockNumber == "" {
blockNumber = "latest"
}
cacheKey := fmt.Sprintf("balance:%s:%s", address, blockNumber)
return g.Call(ctx, "eth_getBalance", []interface{}{address, blockNumber}, cacheKey, 10*time.Second)
}
// GetBlockNumber gets the latest block number
func (g *RPCGateway) GetBlockNumber(ctx context.Context) (*RPCResponse, error) {
return g.Call(ctx, "eth_blockNumber", []interface{}{}, "block_number", 5*time.Second)
}
// GetTransactionCount gets transaction count for an address
func (g *RPCGateway) GetTransactionCount(ctx context.Context, address string, blockNumber string) (*RPCResponse, error) {
if blockNumber == "" {
blockNumber = "latest"
}
cacheKey := fmt.Sprintf("tx_count:%s:%s", address, blockNumber)
return g.Call(ctx, "eth_getTransactionCount", []interface{}{address, blockNumber}, cacheKey, 10*time.Second)
}

View File

@@ -0,0 +1,374 @@
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, &timestamp, &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, &timestamp); 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,
},
})
}

View File

@@ -0,0 +1,167 @@
package track3
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"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) {
from := r.URL.Query().Get("from")
to := r.URL.Query().Get("to")
token := r.URL.Query().Get("token")
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 != "" {
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
startDate = &t
}
}
if endStr := r.URL.Query().Get("end_date"); endStr != "" {
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
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) {
var chainFrom, chainTo *int
if cf := r.URL.Query().Get("chain_from"); cf != "" {
if c, err := strconv.Atoi(cf); err == nil {
chainFrom = &c
}
}
if ct := r.URL.Query().Get("chain_to"); ct != "" {
if c, err := strconv.Atoi(ct); err == nil {
chainTo = &c
}
}
var startDate, endDate *time.Time
if startStr := r.URL.Query().Get("start_date"); startStr != "" {
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
startDate = &t
}
}
if endStr := r.URL.Query().Get("end_date"); endStr != "" {
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
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) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track3/analytics/token-distribution/")
contract := strings.ToLower(path)
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) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track3/analytics/address-risk/")
address := strings.ToLower(path)
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,
},
})
}

View File

@@ -0,0 +1,152 @@
package track4
import (
"encoding/json"
"net/http"
"time"
"github.com/explorer/backend/auth"
"github.com/jackc/pgx/v5/pgxpool"
)
// Server handles Track 4 endpoints
type Server struct {
db *pgxpool.Pool
roleMgr *auth.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,
}
}
// 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")
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")
return
}
// Log operator event
s.roleMgr.LogOperatorEvent(r.Context(), "bridge_events_read", &s.chainID, operatorAddr, "bridge/events", "read", map[string]interface{}{}, ipAddr, r.UserAgent())
// 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),
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// 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")
return
}
s.roleMgr.LogOperatorEvent(r.Context(), "validators_read", &s.chainID, operatorAddr, "validators", "read", map[string]interface{}{}, ipAddr, r.UserAgent())
response := map[string]interface{}{
"data": map[string]interface{}{
"validators": []map[string]interface{}{},
"total_validators": 0,
"active_validators": 0,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// 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")
return
}
s.roleMgr.LogOperatorEvent(r.Context(), "contracts_read", &s.chainID, operatorAddr, "contracts", "read", map[string]interface{}{}, ipAddr, r.UserAgent())
response := map[string]interface{}{
"data": map[string]interface{}{
"contracts": []map[string]interface{}{},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// 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 whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted {
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
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)
}
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,
},
})
}

View File

@@ -0,0 +1,74 @@
package watchlists
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// WatchlistService handles watchlist operations
type WatchlistService struct {
db *pgxpool.Pool
}
// NewWatchlistService creates a new watchlist service
func NewWatchlistService(db *pgxpool.Pool) *WatchlistService {
return &WatchlistService{db: db}
}
// AddToWatchlist adds an address to a user's watchlist
func (w *WatchlistService) AddToWatchlist(ctx context.Context, userID string, chainID int, address, label string) error {
query := `
INSERT INTO watchlists (user_id, chain_id, address, label)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, chain_id, address) DO UPDATE SET
label = $4
`
_, err := w.db.Exec(ctx, query, userID, chainID, address, label)
return err
}
// RemoveFromWatchlist removes an address from watchlist
func (w *WatchlistService) RemoveFromWatchlist(ctx context.Context, userID string, chainID int, address string) error {
query := `DELETE FROM watchlists WHERE user_id = $1 AND chain_id = $2 AND address = $3`
_, err := w.db.Exec(ctx, query, userID, chainID, address)
return err
}
// GetWatchlist gets a user's watchlist
func (w *WatchlistService) GetWatchlist(ctx context.Context, userID string, chainID int) ([]WatchlistItem, error) {
query := `
SELECT chain_id, address, label, created_at
FROM watchlists
WHERE user_id = $1 AND chain_id = $2
ORDER BY created_at DESC
`
rows, err := w.db.Query(ctx, query, userID, chainID)
if err != nil {
return nil, fmt.Errorf("failed to query watchlist: %w", err)
}
defer rows.Close()
var items []WatchlistItem
for rows.Next() {
var item WatchlistItem
if err := rows.Scan(&item.ChainID, &item.Address, &item.Label, &item.CreatedAt); err != nil {
continue
}
items = append(items, item)
}
return items, nil
}
// WatchlistItem represents a watchlist item
type WatchlistItem struct {
ChainID int
Address string
Label string
CreatedAt string
}

View File

@@ -0,0 +1,29 @@
package main
import (
"log"
"net/http"
"os"
"strconv"
"github.com/explorer/backend/api/websocket"
)
func main() {
server := websocket.NewServer()
go server.Start()
http.HandleFunc("/ws", server.HandleWebSocket)
port := 8081
if envPort := os.Getenv("WS_PORT"); envPort != "" {
if p, err := strconv.Atoi(envPort); err == nil {
port = p
}
}
log.Printf("Starting WebSocket server on :%d", port)
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(port), nil))
}

View File

@@ -0,0 +1,225 @@
package websocket
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins in development
},
}
// Server represents the WebSocket server
type Server struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
mu sync.RWMutex
}
// Client represents a WebSocket client
type Client struct {
conn *websocket.Conn
send chan []byte
server *Server
subscriptions map[string]bool
}
// NewServer creates a new WebSocket server
func NewServer() *Server {
return &Server{
clients: make(map[*Client]bool),
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
// Start starts the WebSocket server
func (s *Server) Start() {
for {
select {
case client := <-s.register:
s.mu.Lock()
s.clients[client] = true
s.mu.Unlock()
log.Printf("Client connected. Total clients: %d", len(s.clients))
case client := <-s.unregister:
s.mu.Lock()
if _, ok := s.clients[client]; ok {
delete(s.clients, client)
close(client.send)
}
s.mu.Unlock()
log.Printf("Client disconnected. Total clients: %d", len(s.clients))
case message := <-s.broadcast:
s.mu.RLock()
for client := range s.clients {
select {
case client.send <- message:
default:
close(client.send)
delete(s.clients, client)
}
}
s.mu.RUnlock()
}
}
}
// HandleWebSocket handles WebSocket connections
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
client := &Client{
conn: conn,
send: make(chan []byte, 256),
server: s,
subscriptions: make(map[string]bool),
}
s.register <- client
go client.writePump()
go client.readPump()
}
// Broadcast sends a message to all connected clients
func (s *Server) Broadcast(message []byte) {
s.broadcast <- message
}
// readPump reads messages from the WebSocket connection
func (c *Client) readPump() {
defer func() {
c.server.unregister <- c
c.conn.Close()
}()
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket error: %v", err)
}
break
}
// Handle message
var msg map[string]interface{}
if err := json.Unmarshal(message, &msg); err != nil {
continue
}
c.handleMessage(msg)
}
}
// writePump writes messages to the WebSocket connection
func (c *Client) writePump() {
ticker := time.NewTicker(30 * time.Second)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
n := len(c.send)
for i := 0; i < n; i++ {
w.Write([]byte{'\n'})
w.Write(<-c.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// handleMessage handles incoming WebSocket messages
func (c *Client) handleMessage(msg map[string]interface{}) {
msgType, ok := msg["type"].(string)
if !ok {
return
}
switch msgType {
case "subscribe":
channel, _ := msg["channel"].(string)
c.subscriptions[channel] = true
c.sendMessage(map[string]interface{}{
"type": "subscribed",
"channel": channel,
})
case "unsubscribe":
channel, _ := msg["channel"].(string)
delete(c.subscriptions, channel)
c.sendMessage(map[string]interface{}{
"type": "unsubscribed",
"channel": channel,
})
case "ping":
c.sendMessage(map[string]interface{}{
"type": "pong",
"timestamp": time.Now().Unix(),
})
}
}
// sendMessage sends a message to the client
func (c *Client) sendMessage(msg map[string]interface{}) {
data, err := json.Marshal(msg)
if err != nil {
return
}
select {
case c.send <- data:
default:
close(c.send)
}
}