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

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

View File

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

View File

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

View File

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

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

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

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

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

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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