mirror of
https://github.com/luxfi/indexer.git
synced 2026-09-24 00:25:37 +00:00
303 lines
10 KiB
Go
303 lines
10 KiB
Go
// Package explorer provides a block explorer API layer.
|
|
// It reads chain data directly from an indexer's SQLite database (read-only)
|
|
// and exposes /v1/explorer/* endpoints for explorer frontends.
|
|
//
|
|
// Architecture:
|
|
//
|
|
// indexer → writes SQLite (WAL mode, sole writer)
|
|
// replicate → streams WAL to S3 (age PQ encrypted)
|
|
// Service → reads indexer SQLite (read-only), serves /v1/explorer/*
|
|
// frontend → consumes /v1/explorer/*
|
|
//
|
|
// The indexer's DB holds chain data (blocks, txs, tokens, contracts).
|
|
// White-label: no chain-specific branding — configure via Config.
|
|
package explorer
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
// ONE sqlite C library in this binary, under a name nothing else claims.
|
|
// csqlite is the house build of the amalgamation and registers "sqlite3",
|
|
// the name every sql.Open here already names. Upstream mattn compiles a
|
|
// second copy of the same C, so linking both defines every sqlite3_*
|
|
// symbol twice; hanzoai/sqlite wraps this same C but registers "sqlite",
|
|
// which modernc.org/sqlite also registers — and modernc arrives with
|
|
// hanzoai/replicate. Two packages, one name, and database/sql panics in
|
|
// init before main runs.
|
|
_ "github.com/hanzoai/csqlite"
|
|
)
|
|
|
|
// Config configures the explorer service.
|
|
type Config struct {
|
|
// IndexerDBPath is the path to the indexer's SQLite database.
|
|
// Example: ~/.lux/indexer/cchain/query/indexer.db
|
|
IndexerDBPath string
|
|
|
|
// ChainID is the chain this explorer instance serves (e.g., 96369 for mainnet C-Chain).
|
|
ChainID int64
|
|
|
|
// ChainName is the display name (e.g., "Lux C-Chain").
|
|
ChainName string
|
|
|
|
// CoinSymbol is the native coin symbol (e.g., "LUX").
|
|
CoinSymbol string
|
|
|
|
// CoinDecimals is the native coin decimals (default: 18).
|
|
CoinDecimals int
|
|
|
|
// GChainEndpoint is the G-Chain GraphQL endpoint on the node.
|
|
// Default: http://localhost:9650/v1/chain/G/graphql
|
|
// Set via GCHAIN_ENDPOINT env var.
|
|
GChainEndpoint string
|
|
|
|
// ChainDBPaths maps chain names to their SQLite DB paths for cross-chain search.
|
|
// Example: {"C": "/data/cchain/indexer.db", "Zoo": "/data/zoo/indexer.db"}
|
|
// When empty, cross-chain search only queries the local IndexerDBPath.
|
|
ChainDBPaths map[string]string
|
|
|
|
// APIPrefix is the URL prefix for all API routes (default: "/v1/explorer").
|
|
APIPrefix string
|
|
|
|
// RPCEndpoint is the node this chain is indexed from. Current state —
|
|
// native-coin balances — is read from it live rather than from a stale
|
|
// copy in the index; see chainstate.go. Unset means balances are
|
|
// reported as unavailable (null), never as zero.
|
|
RPCEndpoint string
|
|
}
|
|
|
|
// Service owns the state for explorer HTTP handlers.
|
|
// No DI framework — construct with NewService.
|
|
type Service struct {
|
|
config Config
|
|
db *sql.DB // read-only connection to indexer's SQLite
|
|
gchainProxy *graphqlProxy
|
|
chainDBs map[string]*sql.DB // pre-opened cross-chain DB connections
|
|
notifWorker *NotificationWorker
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewService creates a Service. It opens the indexer DB and starts the notification worker.
|
|
// Call Close() when done.
|
|
func NewService(config Config, logger *slog.Logger) (*Service, error) {
|
|
if config.CoinDecimals == 0 {
|
|
config.CoinDecimals = 18
|
|
}
|
|
if config.IndexerDBPath == "" {
|
|
return nil, fmt.Errorf("explorer: IndexerDBPath is required")
|
|
}
|
|
if config.GChainEndpoint == "" {
|
|
config.GChainEndpoint = os.Getenv("GCHAIN_ENDPOINT")
|
|
}
|
|
if config.GChainEndpoint == "" {
|
|
config.GChainEndpoint = defaultGChainEndpoint
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
|
|
s := &Service{config: config, logger: logger}
|
|
|
|
// Initialize G-Chain GraphQL proxy.
|
|
proxy, err := newGraphQLProxy(config.GChainEndpoint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("explorer: %w", err)
|
|
}
|
|
s.gchainProxy = proxy
|
|
|
|
if err := s.openIndexerDB(); err != nil {
|
|
return nil, err
|
|
}
|
|
s.notifWorker = NewNotificationWorker(s.db, "transactions", logger)
|
|
s.notifWorker.Start()
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Close shuts down the notification worker and closes all DB connections.
|
|
func (s *Service) Close() {
|
|
if s.notifWorker != nil {
|
|
s.notifWorker.Stop()
|
|
}
|
|
s.closeIndexerDB()
|
|
}
|
|
|
|
// openIndexerDB opens a read-only connection to the indexer's SQLite database.
|
|
func (s *Service) openIndexerDB() error {
|
|
dsn := fmt.Sprintf("file:%s?mode=ro&_journal_mode=WAL&_busy_timeout=5000&cache=shared", s.config.IndexerDBPath)
|
|
db, err := sql.Open("sqlite3", dsn)
|
|
if err != nil {
|
|
return fmt.Errorf("explorer: failed to open indexer db: %w", err)
|
|
}
|
|
|
|
// Read-only: allow many concurrent readers.
|
|
db.SetMaxOpenConns(8)
|
|
db.SetMaxIdleConns(4)
|
|
|
|
if err := db.Ping(); err != nil {
|
|
db.Close()
|
|
return fmt.Errorf("explorer: indexer db not ready: %w", err)
|
|
}
|
|
|
|
s.db = db
|
|
|
|
// Pre-open cross-chain DB connections for cross-chain search.
|
|
if len(s.config.ChainDBPaths) > 0 {
|
|
s.chainDBs = make(map[string]*sql.DB, len(s.config.ChainDBPaths))
|
|
for chain, dbPath := range s.config.ChainDBPaths {
|
|
cdb, err := sql.Open("sqlite3",
|
|
fmt.Sprintf("file:%s?mode=ro&_journal_mode=WAL&_busy_timeout=5000&cache=shared", dbPath))
|
|
if err != nil {
|
|
s.logger.Warn("explorer: failed to open cross-chain db",
|
|
slog.String("chain", chain), slog.String("error", err.Error()))
|
|
continue
|
|
}
|
|
cdb.SetMaxOpenConns(2)
|
|
s.chainDBs[chain] = cdb
|
|
}
|
|
}
|
|
|
|
s.logger.Info("explorer: connected to indexer database",
|
|
slog.String("path", s.config.IndexerDBPath),
|
|
slog.Int64("chain_id", s.config.ChainID),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) closeIndexerDB() {
|
|
for _, cdb := range s.chainDBs {
|
|
cdb.Close()
|
|
}
|
|
if s.db != nil {
|
|
s.db.Close()
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes mounts all explorer endpoints on the given chi.Router.
|
|
func RegisterRoutes(mux chi.Router, svc *Service) {
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Recoverer)
|
|
|
|
// Blocks
|
|
r.Get("/blocks", svc.handleListBlocks)
|
|
r.Get("/blocks/{block_hash_or_number}", svc.handleGetBlock)
|
|
r.Get("/blocks/{block_hash_or_number}/transactions", svc.handleBlockTransactions)
|
|
|
|
// Transactions
|
|
r.Get("/transactions", svc.handleListTransactions)
|
|
r.Get("/transactions/{transaction_hash}", svc.handleGetTransaction)
|
|
r.Get("/transactions/{transaction_hash}/token-transfers", svc.handleTxTokenTransfers)
|
|
r.Get("/transactions/{transaction_hash}/internal-transactions", svc.handleTxInternalTxs)
|
|
r.Get("/transactions/{transaction_hash}/logs", svc.handleTxLogs)
|
|
r.Get("/transactions/{transaction_hash}/raw-trace", svc.handleTxRawTrace)
|
|
|
|
// Addresses
|
|
r.Get("/addresses", svc.handleListAddresses)
|
|
r.Get("/addresses/{address_hash}", svc.handleGetAddress)
|
|
r.Get("/addresses/{address_hash}/transactions", svc.handleAddressTransactions)
|
|
r.Get("/addresses/{address_hash}/token-transfers", svc.handleAddressTokenTransfers)
|
|
r.Get("/addresses/{address_hash}/internal-transactions", svc.handleAddressInternalTxs)
|
|
r.Get("/addresses/{address_hash}/logs", svc.handleAddressLogs)
|
|
r.Get("/addresses/{address_hash}/tokens", svc.handleAddressTokens)
|
|
r.Get("/addresses/{address_hash}/coin-balance-history", svc.handleAddressCoinBalanceHistory)
|
|
r.Get("/addresses/{address_hash}/coin-balance-history-by-day", svc.handleAddressCoinBalanceByDay)
|
|
r.Get("/addresses/{address_hash}/counters", svc.handleAddressCounters)
|
|
|
|
// Tokens
|
|
r.Get("/tokens", svc.handleListTokens)
|
|
r.Get("/tokens/{address_hash}", svc.handleGetToken)
|
|
r.Get("/tokens/{address_hash}/transfers", svc.handleTokenTransfers)
|
|
r.Get("/tokens/{address_hash}/holders", svc.handleTokenHolders)
|
|
r.Get("/tokens/{address_hash}/instances", svc.handleTokenInstances)
|
|
r.Get("/tokens/{address_hash}/instances/{id}", svc.handleTokenInstance)
|
|
|
|
// Smart Contracts
|
|
r.Get("/smart-contracts", svc.handleListContracts)
|
|
r.Get("/smart-contracts/{address_hash}", svc.handleGetContract)
|
|
r.Post("/smart-contracts/{address_hash}/verify", svc.handleVerifyContract)
|
|
|
|
// Search
|
|
r.Get("/search", svc.handleSearch)
|
|
r.Get("/search/check-redirect", svc.handleSearchRedirect)
|
|
|
|
// Stats
|
|
r.Get("/stats", svc.handleStats)
|
|
r.Get("/stats/charts/transactions", svc.handleChartTransactions)
|
|
r.Get("/stats/charts/market", svc.handleChartMarket)
|
|
|
|
// GraphQL — GET serves playground, POST executes queries. Reject other methods.
|
|
r.Get("/graphql", svc.handleFederatedGraphQL)
|
|
r.Post("/graphql", svc.handleFederatedGraphQL)
|
|
r.Get("/local/graphql", svc.handleLocalGraphQL)
|
|
r.Post("/local/graphql", svc.handleLocalGraphQL)
|
|
r.Get("/search/cross-chain", svc.handleCrossChainSearch)
|
|
|
|
// Token distribution (Gini coefficient)
|
|
r.Get("/tokens/{address_hash}/distribution", svc.handleTokenDistribution)
|
|
|
|
// Webhooks (notification subscriptions)
|
|
r.Post("/webhooks", svc.handleRegisterWebhook)
|
|
r.Get("/webhooks", svc.handleListWebhooks)
|
|
r.Delete("/webhooks", svc.handleDeleteWebhook)
|
|
|
|
mux.Mount("/v1/explorer", r)
|
|
|
|
// Health at root
|
|
mux.Get("/health", svc.handleHealth)
|
|
|
|
svc.logger.Info("explorer routes registered",
|
|
slog.Int64("chain_id", svc.config.ChainID),
|
|
slog.String("chain", svc.config.ChainName),
|
|
slog.String("gchain_endpoint", svc.config.GChainEndpoint),
|
|
slog.Int("endpoints", 37),
|
|
)
|
|
}
|
|
|
|
// writeJSON writes a JSON response with the given status code.
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// writeHTML writes an HTML response with the given status code.
|
|
func writeHTML(w http.ResponseWriter, code int, body string) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
w.Write([]byte(body))
|
|
}
|
|
|
|
// apiError writes a JSON error matching the old base/core ApiError shape:
|
|
// {"status": <code>, "message": "<Msg.>", "data": {}}
|
|
func apiError(w http.ResponseWriter, code int, msg string) {
|
|
// Sentence-case to match old inflector.Sentenize behavior.
|
|
if len(msg) > 0 {
|
|
msg = strings.ToUpper(msg[:1]) + msg[1:]
|
|
if msg[len(msg)-1] != '.' {
|
|
msg += "."
|
|
}
|
|
}
|
|
writeJSON(w, code, map[string]any{
|
|
"status": code,
|
|
"message": msg,
|
|
"data": map[string]any{},
|
|
})
|
|
}
|
|
|
|
// notFoundError writes a standard JSON 404 response.
|
|
func notFoundError(w http.ResponseWriter, msg string) {
|
|
apiError(w, http.StatusNotFound, msg)
|
|
}
|
|
|
|
// badRequestError writes a standard JSON 400 response.
|
|
func badRequestError(w http.ResponseWriter, msg string) {
|
|
apiError(w, http.StatusBadRequest, msg)
|
|
}
|