Add full monorepo: virtual-banker, backend, frontend, docs, scripts, deployment
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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")
|
||||
}
|
||||
Reference in New Issue
Block a user