Add full monorepo: virtual-banker, backend, frontend, docs, scripts, deployment
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
32
backend/api/gateway/cmd/main.go
Normal file
32
backend/api/gateway/cmd/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
140
backend/api/gateway/gateway.go
Normal file
140
backend/api/gateway/gateway.go
Normal 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 ""
|
||||
}
|
||||
81
backend/api/graphql/resolvers.go
Normal file
81
backend/api/graphql/resolvers.go
Normal 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")
|
||||
}
|
||||
|
||||
102
backend/api/graphql/schema.graphql
Normal file
102
backend/api/graphql/schema.graphql
Normal 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!
|
||||
}
|
||||
|
||||
73
backend/api/labels/labels.go
Normal file
73
backend/api/labels/labels.go
Normal 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
|
||||
}
|
||||
|
||||
123
backend/api/middleware/auth.go
Normal file
123
backend/api/middleware/auth.go
Normal 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) + `}}`))
|
||||
}
|
||||
|
||||
63
backend/api/middleware/security.go
Normal file
63
backend/api/middleware/security.go
Normal 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)
|
||||
})
|
||||
}
|
||||
69
backend/api/rest/README.md
Normal file
69
backend/api/rest/README.md
Normal 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)
|
||||
|
||||
108
backend/api/rest/addresses.go
Normal file
108
backend/api/rest/addresses.go
Normal 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,
|
||||
})
|
||||
}
|
||||
231
backend/api/rest/api_test.go
Normal file
231
backend/api/rest/api_test.go
Normal 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
57
backend/api/rest/auth.go
Normal 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
134
backend/api/rest/blocks.go
Normal 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, ×tamp, ×tampISO, &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, ×tamp, ×tampISO, &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
BIN
backend/api/rest/cmd/api-server
Executable file
Binary file not shown.
57
backend/api/rest/cmd/main.go
Normal file
57
backend/api/rest/cmd/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
36
backend/api/rest/config.go
Normal file
36
backend/api/rest/config.go
Normal 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)
|
||||
}
|
||||
61
backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json
Normal file
61
backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
51
backend/api/rest/errors.go
Normal file
51
backend/api/rest/errors.go
Normal 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")
|
||||
}
|
||||
|
||||
215
backend/api/rest/etherscan.go
Normal file
215
backend/api/rest/etherscan.go
Normal 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, ×tamp, &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)
|
||||
}
|
||||
|
||||
82
backend/api/rest/features.go
Normal file
82
backend/api/rest/features.go
Normal 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
|
||||
}
|
||||
44
backend/api/rest/middleware.go
Normal file
44
backend/api/rest/middleware.go
Normal 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
166
backend/api/rest/routes.go
Normal 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)
|
||||
}
|
||||
53
backend/api/rest/search.go
Normal file
53
backend/api/rest/search.go
Normal 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
224
backend/api/rest/server.go
Normal 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, ×tamp, ×tampISO, &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
59
backend/api/rest/stats.go
Normal 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)
|
||||
}
|
||||
|
||||
430
backend/api/rest/swagger.yaml
Normal file
430
backend/api/rest/swagger.yaml
Normal 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"
|
||||
|
||||
113
backend/api/rest/track_routes.go
Normal file
113
backend/api/rest/track_routes.go
Normal 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))
|
||||
}
|
||||
|
||||
236
backend/api/rest/transactions.go
Normal file
236
backend/api/rest/transactions.go
Normal 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, ×tampISO); 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, ×tampISO,
|
||||
)
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
127
backend/api/rest/validation.go
Normal file
127
backend/api/rest/validation.go
Normal 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")
|
||||
}
|
||||
42
backend/api/search/cmd/main.go
Normal file
42
backend/api/search/cmd/main.go
Normal 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))
|
||||
}
|
||||
172
backend/api/search/search.go
Normal file
172
backend/api/search/search.go
Normal 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)
|
||||
}
|
||||
|
||||
90
backend/api/track1/cache.go
Normal file
90
backend/api/track1/cache.go
Normal 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
|
||||
}
|
||||
79
backend/api/track1/cache_test.go
Normal file
79
backend/api/track1/cache_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
391
backend/api/track1/endpoints.go
Normal file
391
backend/api/track1/endpoints.go
Normal 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)
|
||||
}
|
||||
83
backend/api/track1/rate_limiter.go
Normal file
83
backend/api/track1/rate_limiter.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
87
backend/api/track1/rate_limiter_test.go
Normal file
87
backend/api/track1/rate_limiter_test.go
Normal 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)
|
||||
}
|
||||
|
||||
88
backend/api/track1/redis_cache.go
Normal file
88
backend/api/track1/redis_cache.go
Normal 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
|
||||
}
|
||||
|
||||
135
backend/api/track1/redis_rate_limiter.go
Normal file
135
backend/api/track1/redis_rate_limiter.go
Normal 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
|
||||
}
|
||||
|
||||
178
backend/api/track1/rpc_gateway.go
Normal file
178
backend/api/track1/rpc_gateway.go
Normal 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)
|
||||
}
|
||||
|
||||
374
backend/api/track2/endpoints.go
Normal file
374
backend/api/track2/endpoints.go
Normal 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, ×tamp, &status); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
direction := "received"
|
||||
if strings.ToLower(from) == address {
|
||||
direction = "sent"
|
||||
}
|
||||
|
||||
transactions = append(transactions, map[string]interface{}{
|
||||
"hash": hash,
|
||||
"from": from,
|
||||
"to": to,
|
||||
"value": value,
|
||||
"block_number": blockNumber,
|
||||
"timestamp": timestamp,
|
||||
"status": status,
|
||||
"direction": direction,
|
||||
})
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`
|
||||
s.db.QueryRow(r.Context(), countQuery, s.chainID, address).Scan(&total)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"data": transactions,
|
||||
"pagination": map[string]interface{}{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"total_pages": (total + limit - 1) / limit,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleAddressTokens handles GET /api/v1/track2/address/:addr/tokens
|
||||
func (s *Server) HandleAddressTokens(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/address/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[1] != "tokens" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid path")
|
||||
return
|
||||
}
|
||||
|
||||
address := strings.ToLower(parts[0])
|
||||
|
||||
query := `
|
||||
SELECT token_contract, balance, last_updated_timestamp
|
||||
FROM token_balances
|
||||
WHERE address = $1 AND chain_id = $2 AND balance > 0
|
||||
ORDER BY balance DESC
|
||||
`
|
||||
|
||||
rows, err := s.db.Query(r.Context(), query, address, s.chainID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tokens := []map[string]interface{}{}
|
||||
for rows.Next() {
|
||||
var contract, balance string
|
||||
var lastUpdated interface{}
|
||||
|
||||
if err := rows.Scan(&contract, &balance, &lastUpdated); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
tokens = append(tokens, map[string]interface{}{
|
||||
"contract": contract,
|
||||
"balance": balance,
|
||||
"balance_formatted": balance, // TODO: Format with decimals
|
||||
"last_updated": lastUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"address": address,
|
||||
"tokens": tokens,
|
||||
"total_tokens": len(tokens),
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleTokenInfo handles GET /api/v1/track2/token/:contract
|
||||
func (s *Server) HandleTokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/token/")
|
||||
contract := strings.ToLower(path)
|
||||
|
||||
// Get token info from token_transfers
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(DISTINCT from_address) + COUNT(DISTINCT to_address) as holders,
|
||||
COUNT(*) as transfers_24h,
|
||||
SUM(value) as volume_24h
|
||||
FROM token_transfers
|
||||
WHERE token_contract = $1 AND chain_id = $2
|
||||
AND timestamp >= NOW() - INTERVAL '24 hours'
|
||||
`
|
||||
|
||||
var holders, transfers24h int
|
||||
var volume24h string
|
||||
err := s.db.QueryRow(r.Context(), query, contract, s.chainID).Scan(&holders, &transfers24h, &volume24h)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Token not found")
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"contract": contract,
|
||||
"holders": holders,
|
||||
"transfers_24h": transfers24h,
|
||||
"volume_24h": volume24h,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleSearch handles GET /api/v1/track2/search?q=
|
||||
func (s *Server) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Query parameter 'q' is required")
|
||||
return
|
||||
}
|
||||
|
||||
query = strings.ToLower(strings.TrimPrefix(query, "0x"))
|
||||
|
||||
// Try to detect type and search
|
||||
var result map[string]interface{}
|
||||
|
||||
// Check if it's a block number
|
||||
if blockNum, err := strconv.ParseInt(query, 10, 64); err == nil {
|
||||
var hash string
|
||||
err := s.db.QueryRow(r.Context(), `SELECT hash FROM blocks WHERE chain_id = $1 AND number = $2`, s.chainID, blockNum).Scan(&hash)
|
||||
if err == nil {
|
||||
result = map[string]interface{}{
|
||||
"type": "block",
|
||||
"result": map[string]interface{}{
|
||||
"number": blockNum,
|
||||
"hash": hash,
|
||||
},
|
||||
}
|
||||
}
|
||||
} else if len(query) == 64 || len(query) == 40 {
|
||||
// Could be address or transaction hash
|
||||
fullQuery := "0x" + query
|
||||
|
||||
// Check transaction
|
||||
var txHash string
|
||||
err := s.db.QueryRow(r.Context(), `SELECT hash FROM transactions WHERE chain_id = $1 AND hash = $2`, s.chainID, fullQuery).Scan(&txHash)
|
||||
if err == nil {
|
||||
result = map[string]interface{}{
|
||||
"type": "transaction",
|
||||
"result": map[string]interface{}{
|
||||
"hash": txHash,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Check address
|
||||
var balance string
|
||||
err := s.db.QueryRow(r.Context(), `SELECT COALESCE(SUM(balance), '0') FROM token_balances WHERE address = $1 AND chain_id = $2`, fullQuery, s.chainID).Scan(&balance)
|
||||
if err == nil {
|
||||
result = map[string]interface{}{
|
||||
"type": "address",
|
||||
"result": map[string]interface{}{
|
||||
"address": fullQuery,
|
||||
"balance": balance,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "No results found")
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"data": result,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleInternalTransactions handles GET /api/v1/track2/address/:addr/internal-txs
|
||||
func (s *Server) HandleInternalTransactions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/address/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[1] != "internal-txs" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid path")
|
||||
return
|
||||
}
|
||||
|
||||
address := strings.ToLower(parts[0])
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
query := `
|
||||
SELECT transaction_hash, from_address, to_address, value, block_number, timestamp
|
||||
FROM internal_transactions
|
||||
WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)
|
||||
ORDER BY block_number DESC, timestamp DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
rows, err := s.db.Query(r.Context(), query, s.chainID, address, limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
internalTxs := []map[string]interface{}{}
|
||||
for rows.Next() {
|
||||
var txHash, from, to, value string
|
||||
var blockNumber int64
|
||||
var timestamp interface{}
|
||||
|
||||
if err := rows.Scan(&txHash, &from, &to, &value, &blockNumber, ×tamp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
internalTxs = append(internalTxs, map[string]interface{}{
|
||||
"transaction_hash": txHash,
|
||||
"from": from,
|
||||
"to": to,
|
||||
"value": value,
|
||||
"block_number": blockNumber,
|
||||
"timestamp": timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM internal_transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`
|
||||
s.db.QueryRow(r.Context(), countQuery, s.chainID, address).Scan(&total)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"data": internalTxs,
|
||||
"pagination": map[string]interface{}{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"total_pages": (total + limit - 1) / limit,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, statusCode int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
167
backend/api/track3/endpoints.go
Normal file
167
backend/api/track3/endpoints.go
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
152
backend/api/track4/endpoints.go
Normal file
152
backend/api/track4/endpoints.go
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
74
backend/api/watchlists/watchlists.go
Normal file
74
backend/api/watchlists/watchlists.go
Normal 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
|
||||
}
|
||||
|
||||
29
backend/api/websocket/cmd/main.go
Normal file
29
backend/api/websocket/cmd/main.go
Normal 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))
|
||||
}
|
||||
|
||||
225
backend/api/websocket/server.go
Normal file
225
backend/api/websocket/server.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user