feat: multi-chain EVM indexer with table prefix support

- Replace all hardcoded cchain_ table references with Tbl() prefix pattern
- Add chainSlug parameter to Repository, Adapter, Search, Stats, Charts
- New cmd/evmchains binary: runs all EVM chains in single process
- Config-driven via YAML (configs/evmchains.yaml)
- Management HTTP server on :9000 (/health, /chains)
- Shared PostgreSQL with per-chain table prefixes
- Update Dockerfile for multi-binary support
This commit is contained in:
2026-03-02 10:57:04 -08:00
parent aca3b359bd
commit 088d285887
10 changed files with 709 additions and 297 deletions
+2 -1
View File
@@ -39,4 +39,5 @@ AGENTS.md
CLAUDE.md
GEMINI.md
QWEN.md
indexer
/indexer
/evmchains
+4 -1
View File
@@ -16,6 +16,7 @@ COPY . .
# Build with version info - cross-compile via GOOS/GOARCH
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -tags postgres -ldflags "-X main.version=${VERSION}" -o /indexer ./cmd/indexer
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -tags postgres -ldflags "-X main.version=${VERSION}" -o /evmchains ./cmd/evmchains
# Runtime stage
FROM --platform=linux/amd64 alpine:3.19
@@ -25,6 +26,8 @@ RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /indexer /usr/local/bin/indexer
COPY --from=builder /evmchains /usr/local/bin/evmchains
COPY configs/ /app/configs/
# Default environment
ENV RPC_ENDPOINT=""
@@ -32,7 +35,7 @@ ENV DATABASE_URL=""
ENV HTTP_PORT="4200"
ENV POLL_INTERVAL="30s"
EXPOSE 4200 4100 4300 4400 4500 4600 4700
EXPOSE 4000 4100 4200 4300 4400 4500 4600 4700 5000 5100 5200 5300 9000
ENTRYPOINT ["indexer"]
CMD ["--help"]
+282
View File
@@ -0,0 +1,282 @@
// Copyright (c) 2025 Lux Partners Limited
// SPDX-License-Identifier: MIT
// Package main runs multiple EVM chain indexers in a single process.
// Each chain gets its own Blockscout-compatible API server on a dedicated port,
// while all chains share one PostgreSQL database using table prefixes.
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
_ "github.com/lib/pq"
"gopkg.in/yaml.v3"
"github.com/luxfi/indexer/evm/api"
)
var version = "dev"
// ChainDef defines a single EVM chain to index.
type ChainDef struct {
Slug string `yaml:"slug"`
Name string `yaml:"name"`
ChainID int64 `yaml:"chain_id"`
Port int `yaml:"port"`
RPC string `yaml:"rpc"`
Enabled bool `yaml:"enabled"`
}
// Config is the top-level config for the multi-chain EVM indexer.
type Config struct {
DatabaseURL string `yaml:"database_url"`
Chains []ChainDef `yaml:"chains"`
}
func main() {
var (
configFile = flag.String("config", "", "Path to evmchains.yaml config file")
dbURL = flag.String("db", "", "PostgreSQL connection URL (overrides config)")
showVersion = flag.Bool("version", false, "Show version and exit")
listChains = flag.Bool("list", false, "List configured chains and exit")
)
flag.Parse()
if *showVersion {
fmt.Printf("lux-evmchains %s\n", version)
os.Exit(0)
}
cfg, err := loadConfig(*configFile)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if *listChains {
printChains(cfg)
os.Exit(0)
}
// CLI flag overrides config
if *dbURL != "" {
cfg.DatabaseURL = *dbURL
}
// Env var overrides config if not set by flag
if cfg.DatabaseURL == "" {
cfg.DatabaseURL = os.Getenv("DATABASE_URL")
}
if cfg.DatabaseURL == "" {
log.Fatal("DATABASE_URL is required (set via --db flag, config file, or env var)")
}
// Count enabled chains
var enabled int
for _, c := range cfg.Chains {
if c.Enabled {
enabled++
}
}
if enabled == 0 {
log.Fatal("No enabled chains in config")
}
// Connect to PostgreSQL (shared across all chains)
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
db.SetMaxOpenConns(enabled * 5)
db.SetMaxIdleConns(enabled * 2)
db.SetConnMaxLifetime(5 * time.Minute)
if err := db.Ping(); err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
log.Printf("Connected to PostgreSQL (max_conns=%d)", enabled*5)
// Setup context with signal handling
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigChan
log.Printf("Received signal %v, shutting down...", sig)
cancel()
}()
// Start each enabled chain's API server
var wg sync.WaitGroup
for _, chain := range cfg.Chains {
if !chain.Enabled {
log.Printf("[%s] Skipping (disabled)", chain.Slug)
continue
}
wg.Add(1)
go func(c ChainDef) {
defer wg.Done()
runChain(ctx, db, c)
}(chain)
}
// Start management HTTP server on port 9000
go startManagementServer(ctx, cfg)
log.Printf("Started %d EVM chain indexers", enabled)
// Wait for shutdown
<-ctx.Done()
log.Println("Waiting for all chain servers to stop...")
wg.Wait()
log.Println("All chain servers stopped")
}
// runChain starts a Blockscout-compatible API server for a single chain.
func runChain(ctx context.Context, db *sql.DB, chain ChainDef) {
log.Printf("[%s] Starting API server on port %d (chain_id=%d, rpc=%s)",
chain.Slug, chain.Port, chain.ChainID, chain.RPC)
cfg := api.Config{
HTTPPort: chain.Port,
ChainID: chain.ChainID,
ChainName: chain.Name,
ChainSlug: chain.Slug,
RPCEndpoint: chain.RPC,
}
server := api.NewServer(cfg, db)
if err := server.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[%s] API server error: %v", chain.Slug, err)
}
log.Printf("[%s] API server stopped", chain.Slug)
}
// loadConfig loads the YAML config, searching default paths if none given.
func loadConfig(path string) (*Config, error) {
if path == "" {
// Try default locations
defaults := []string{
"configs/evmchains.yaml",
"config/evmchains.yaml",
"/etc/lux/indexer/evmchains.yaml",
}
for _, p := range defaults {
if _, err := os.Stat(p); err == nil {
path = p
break
}
}
}
if path == "" {
return defaultConfig(), nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
// Expand environment variables
expanded := os.ExpandEnv(string(data))
var cfg Config
if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
log.Printf("Loaded config from %s (%d chains)", path, len(cfg.Chains))
return &cfg, nil
}
// defaultConfig returns the 5 Lux mainnet EVM chains.
func defaultConfig() *Config {
return &Config{
Chains: []ChainDef{
{Slug: "cchain", Name: "Lux C-Chain", ChainID: 96369, Port: 4000, RPC: "http://luxd-headless.lux-mainnet.svc:9630/ext/bc/C/rpc", Enabled: true},
{Slug: "zoo", Name: "Zoo", ChainID: 200200, Port: 5000, RPC: "http://luxd-headless.lux-mainnet.svc:9630/ext/bc/Zoo/rpc", Enabled: true},
{Slug: "hanzo", Name: "Hanzo", ChainID: 36963, Port: 5100, RPC: "http://luxd-headless.lux-mainnet.svc:9630/ext/bc/Hanzo/rpc", Enabled: true},
{Slug: "spc", Name: "SPC", ChainID: 36911, Port: 5200, RPC: "http://luxd-headless.lux-mainnet.svc:9630/ext/bc/SPC/rpc", Enabled: true},
{Slug: "pars", Name: "Pars", ChainID: 494949, Port: 5300, RPC: "http://luxd-headless.lux-mainnet.svc:9630/ext/bc/Pars/rpc", Enabled: true},
},
}
}
func printChains(cfg *Config) {
fmt.Printf("Configured EVM chains (%d total):\n\n", len(cfg.Chains))
fmt.Printf(" %-10s %-20s %-10s %-6s %s\n", "SLUG", "NAME", "CHAIN_ID", "PORT", "STATUS")
fmt.Printf(" %-10s %-20s %-10s %-6s %s\n", "----", "----", "--------", "----", "------")
for _, c := range cfg.Chains {
status := "enabled"
if !c.Enabled {
status = "disabled"
}
fmt.Printf(" %-10s %-20s %-10d %-6d %s\n", c.Slug, c.Name, c.ChainID, c.Port, status)
}
}
// startManagementServer runs a simple HTTP server for health/stats.
func startManagementServer(ctx context.Context, cfg *Config) {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "ok",
"version": version,
"chains": len(cfg.Chains),
})
})
mux.HandleFunc("/chains", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var chains []map[string]interface{}
for _, c := range cfg.Chains {
chains = append(chains, map[string]interface{}{
"slug": c.Slug,
"name": c.Name,
"chain_id": c.ChainID,
"port": c.Port,
"enabled": c.Enabled,
"rpc": c.RPC,
})
}
json.NewEncoder(w).Encode(chains)
})
server := &http.Server{
Addr: ":9000",
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(shutdownCtx)
}()
log.Printf("Management API on :9000 (/health, /chains)")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("Management server error: %v", err)
}
}
+42
View File
@@ -0,0 +1,42 @@
# Lux EVM Chains Indexer Configuration
# Runs all 5 Lux mainnet EVM chains in a single process.
# Each chain gets its own Blockscout-compatible API on a dedicated port.
# All chains share one PostgreSQL database with table prefixes.
database_url: "${DATABASE_URL:-postgres://indexer:[email protected]:5432/indexer?sslmode=disable}"
chains:
- slug: cchain
name: "Lux C-Chain"
chain_id: 96369
port: 4000
rpc: "${CCHAIN_RPC:-http://luxd-headless.lux-mainnet.svc:9630/ext/bc/C/rpc}"
enabled: true
- slug: zoo
name: "Zoo"
chain_id: 200200
port: 5000
rpc: "${ZOO_RPC:-http://luxd-headless.lux-mainnet.svc:9630/ext/bc/2m7XTBQ1knXzpTd5tD5mKhBoBGfYzLjgWCy4UPBZFrJwJi9PDn/rpc}"
enabled: true
- slug: hanzo
name: "Hanzo"
chain_id: 36963
port: 5100
rpc: "${HANZO_RPC:-http://luxd-headless.lux-mainnet.svc:9630/ext/bc/2qGPCad983bniHe6MxKYcHqhGnATAYnY78TiDVSmjGFfmUpoAq/rpc}"
enabled: true
- slug: spc
name: "SPC"
chain_id: 36911
port: 5200
rpc: "${SPC_RPC:-http://luxd-headless.lux-mainnet.svc:9630/ext/bc/2GWgFPEeVAoivJdbBfJrmY2DDtn8TCSV7R7diwqVbMDhPkSiYn/rpc}"
enabled: true
- slug: pars
name: "Pars"
chain_id: 494949
port: 5300
rpc: "${PARS_RPC:-http://luxd-headless.lux-mainnet.svc:9630/ext/bc/2ouFvjHhVc3qX3wJNBMERvrE9fHBPf3dQd6n1fyxNyhwi52YhY/rpc}"
enabled: true
+135 -101
View File
@@ -191,9 +191,20 @@ type Adapter struct {
httpClient *http.Client
tracerType TracerType
traceTimeout string // e.g. "120s"
prefix string // table name prefix, e.g. "cchain", "zoo", "hanzo"
mu sync.RWMutex
}
// Tbl returns the prefixed table name, e.g. Tbl("blocks") returns "cchain_blocks"
func (a *Adapter) Tbl(name string) string {
return a.prefix + "_" + name
}
// Idx returns the prefixed index name, e.g. Idx("tx_block") returns "idx_cchain_tx_block"
func (a *Adapter) Idx(name string) string {
return "idx_" + a.prefix + "_" + name
}
// AdapterOption configures the adapter
type AdapterOption func(*Adapter)
@@ -211,12 +222,21 @@ func WithTraceTimeout(timeout string) AdapterOption {
}
}
// New creates a new C-Chain adapter
// WithPrefix sets the table name prefix (e.g. "zoo", "hanzo", "spc", "pars")
func WithPrefix(prefix string) AdapterOption {
return func(a *Adapter) {
a.prefix = prefix
}
}
// New creates a new EVM chain adapter.
// Defaults to "cchain" prefix for backwards compatibility.
func New(rpcEndpoint string, opts ...AdapterOption) *Adapter {
a := &Adapter{
rpcEndpoint: rpcEndpoint,
tracerType: TracerCallTracer,
traceTimeout: "120s",
prefix: "cchain",
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
@@ -1254,13 +1274,13 @@ func (a *Adapter) call(ctx context.Context, method string, params interface{}) (
return result.Result, nil
}
// InitSchema creates C-Chain specific database tables using unified storage
// InitSchema creates chain-specific database tables using unified storage
func (a *Adapter) InitSchema(ctx context.Context, store storage.Store) error {
schema := storage.Schema{
Name: "cchain",
Name: a.prefix,
Tables: []storage.Table{
{
Name: "cchain_transactions",
Name: a.Tbl("transactions"),
Columns: []storage.Column{
{Name: "hash", Type: storage.TypeText, Primary: true},
{Name: "block_hash", Type: storage.TypeText, Nullable: false},
@@ -1281,7 +1301,7 @@ func (a *Adapter) InitSchema(ctx context.Context, store storage.Store) error {
},
},
{
Name: "cchain_addresses",
Name: a.Tbl("addresses"),
Columns: []storage.Column{
{Name: "hash", Type: storage.TypeText, Primary: true},
{Name: "balance", Type: storage.TypeText, Default: "'0'"},
@@ -1295,7 +1315,7 @@ func (a *Adapter) InitSchema(ctx context.Context, store storage.Store) error {
},
},
{
Name: "cchain_token_transfers",
Name: a.Tbl("token_transfers"),
Columns: []storage.Column{
{Name: "id", Type: storage.TypeText, Primary: true},
{Name: "tx_hash", Type: storage.TypeText, Nullable: false},
@@ -1311,7 +1331,7 @@ func (a *Adapter) InitSchema(ctx context.Context, store storage.Store) error {
},
},
{
Name: "cchain_tokens",
Name: a.Tbl("tokens"),
Columns: []storage.Column{
{Name: "address", Type: storage.TypeText, Primary: true},
{Name: "name", Type: storage.TypeText},
@@ -1326,7 +1346,7 @@ func (a *Adapter) InitSchema(ctx context.Context, store storage.Store) error {
},
},
{
Name: "cchain_logs",
Name: a.Tbl("logs"),
Columns: []storage.Column{
{Name: "tx_hash", Type: storage.TypeText, Nullable: false},
{Name: "log_index", Type: storage.TypeBigInt, Nullable: false},
@@ -1338,7 +1358,7 @@ func (a *Adapter) InitSchema(ctx context.Context, store storage.Store) error {
},
},
{
Name: "cchain_internal_transactions",
Name: a.Tbl("internal_transactions"),
Columns: []storage.Column{
{Name: "id", Type: storage.TypeText, Primary: true},
{Name: "tx_hash", Type: storage.TypeText, Nullable: false},
@@ -1361,7 +1381,7 @@ func (a *Adapter) InitSchema(ctx context.Context, store storage.Store) error {
},
},
{
Name: "cchain_token_balances",
Name: a.Tbl("token_balances"),
Columns: []storage.Column{
{Name: "token_address", Type: storage.TypeText, Nullable: false},
{Name: "holder_address", Type: storage.TypeText, Nullable: false},
@@ -1372,7 +1392,7 @@ func (a *Adapter) InitSchema(ctx context.Context, store storage.Store) error {
},
},
{
Name: "cchain_extended_stats",
Name: a.Tbl("extended_stats"),
Columns: []storage.Column{
{Name: "id", Type: storage.TypeInt, Primary: true},
{Name: "total_transactions", Type: storage.TypeBigInt, Default: "0"},
@@ -1390,24 +1410,24 @@ func (a *Adapter) InitSchema(ctx context.Context, store storage.Store) error {
},
},
Indexes: []storage.Index{
{Name: "idx_cchain_tx_block", Table: "cchain_transactions", Columns: []string{"block_number"}},
{Name: "idx_cchain_tx_from", Table: "cchain_transactions", Columns: []string{"tx_from"}},
{Name: "idx_cchain_tx_to", Table: "cchain_transactions", Columns: []string{"tx_to"}},
{Name: "idx_cchain_tx_timestamp", Table: "cchain_transactions", Columns: []string{"timestamp"}},
{Name: "idx_cchain_addr_balance", Table: "cchain_addresses", Columns: []string{"balance"}},
{Name: "idx_cchain_transfer_token", Table: "cchain_token_transfers", Columns: []string{"token_address"}},
{Name: "idx_cchain_transfer_from", Table: "cchain_token_transfers", Columns: []string{"tx_from"}},
{Name: "idx_cchain_transfer_to", Table: "cchain_token_transfers", Columns: []string{"tx_to"}},
{Name: "idx_cchain_transfer_block", Table: "cchain_token_transfers", Columns: []string{"block_number"}},
{Name: "idx_cchain_token_type", Table: "cchain_tokens", Columns: []string{"token_type"}},
{Name: "idx_cchain_logs_address", Table: "cchain_logs", Columns: []string{"address"}},
{Name: "idx_cchain_logs_block", Table: "cchain_logs", Columns: []string{"block_number"}},
{Name: "idx_cchain_internal_tx", Table: "cchain_internal_transactions", Columns: []string{"tx_hash"}},
{Name: "idx_cchain_internal_from", Table: "cchain_internal_transactions", Columns: []string{"tx_from"}},
{Name: "idx_cchain_internal_to", Table: "cchain_internal_transactions", Columns: []string{"tx_to"}},
{Name: "idx_cchain_internal_block", Table: "cchain_internal_transactions", Columns: []string{"block_number"}},
{Name: "idx_cchain_balance_holder", Table: "cchain_token_balances", Columns: []string{"holder_address"}},
{Name: "idx_cchain_balance_token", Table: "cchain_token_balances", Columns: []string{"token_address"}},
{Name: a.Idx("tx_block"), Table: a.Tbl("transactions"), Columns: []string{"block_number"}},
{Name: a.Idx("tx_from"), Table: a.Tbl("transactions"), Columns: []string{"tx_from"}},
{Name: a.Idx("tx_to"), Table: a.Tbl("transactions"), Columns: []string{"tx_to"}},
{Name: a.Idx("tx_timestamp"), Table: a.Tbl("transactions"), Columns: []string{"timestamp"}},
{Name: a.Idx("addr_balance"), Table: a.Tbl("addresses"), Columns: []string{"balance"}},
{Name: a.Idx("transfer_token"), Table: a.Tbl("token_transfers"), Columns: []string{"token_address"}},
{Name: a.Idx("transfer_from"), Table: a.Tbl("token_transfers"), Columns: []string{"tx_from"}},
{Name: a.Idx("transfer_to"), Table: a.Tbl("token_transfers"), Columns: []string{"tx_to"}},
{Name: a.Idx("transfer_block"), Table: a.Tbl("token_transfers"), Columns: []string{"block_number"}},
{Name: a.Idx("token_type"), Table: a.Tbl("tokens"), Columns: []string{"token_type"}},
{Name: a.Idx("logs_address"), Table: a.Tbl("logs"), Columns: []string{"address"}},
{Name: a.Idx("logs_block"), Table: a.Tbl("logs"), Columns: []string{"block_number"}},
{Name: a.Idx("internal_tx"), Table: a.Tbl("internal_transactions"), Columns: []string{"tx_hash"}},
{Name: a.Idx("internal_from"), Table: a.Tbl("internal_transactions"), Columns: []string{"tx_from"}},
{Name: a.Idx("internal_to"), Table: a.Tbl("internal_transactions"), Columns: []string{"tx_to"}},
{Name: a.Idx("internal_block"), Table: a.Tbl("internal_transactions"), Columns: []string{"block_number"}},
{Name: a.Idx("balance_holder"), Table: a.Tbl("token_balances"), Columns: []string{"holder_address"}},
{Name: a.Idx("balance_token"), Table: a.Tbl("token_balances"), Columns: []string{"token_address"}},
},
}
@@ -1416,20 +1436,20 @@ func (a *Adapter) InitSchema(ctx context.Context, store storage.Store) error {
}
// Initialize stats row
return store.Exec(ctx, "INSERT INTO cchain_extended_stats (id) VALUES (1) ON CONFLICT DO NOTHING")
return store.Exec(ctx, fmt.Sprintf("INSERT INTO %s (id) VALUES (1) ON CONFLICT DO NOTHING", a.Tbl("extended_stats")))
}
// GetStats returns C-Chain specific statistics using unified storage
// GetStats returns chain-specific statistics using unified storage
func (a *Adapter) GetStats(ctx context.Context, store storage.Store) (map[string]interface{}, error) {
stats := make(map[string]interface{})
// Get extended stats
rows, err := store.Query(ctx, `
rows, err := store.Query(ctx, fmt.Sprintf(`
SELECT total_transactions, total_addresses, total_contracts, total_tokens,
total_token_transfers, total_internal_transactions, total_gas_used,
avg_gas_price, avg_block_time, tps_24h, updated_at
FROM cchain_extended_stats WHERE id = 1
`)
FROM %s WHERE id = 1
`, a.Tbl("extended_stats")))
if err != nil {
return nil, err
}
@@ -1451,12 +1471,12 @@ func (a *Adapter) GetStats(ctx context.Context, store storage.Store) (map[string
stats["chain_id"] = ChainID
// Get top tokens by holder count
tokenRows, err := store.Query(ctx, `
tokenRows, err := store.Query(ctx, fmt.Sprintf(`
SELECT address, name, symbol, token_type, holder_count, tx_count
FROM cchain_tokens
FROM %s
ORDER BY holder_count DESC
LIMIT 10
`)
`, a.Tbl("tokens")))
if err != nil {
return stats, nil // Return stats without tokens on error
}
@@ -1479,68 +1499,70 @@ func (a *Adapter) GetStats(ctx context.Context, store storage.Store) (map[string
// StoreTransaction stores a transaction
func (a *Adapter) StoreTransaction(ctx context.Context, db *sql.DB, tx Transaction) error {
_, err := db.ExecContext(ctx, `
INSERT INTO cchain_transactions
_, err := db.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %s
(hash, block_hash, block_number, tx_from, tx_to, value, gas, gas_price, gas_used,
nonce, input, tx_index, tx_type, status, contract_address, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
ON CONFLICT (hash) DO UPDATE SET
status = EXCLUDED.status,
gas_used = EXCLUDED.gas_used
`, tx.Hash, tx.BlockHash, tx.BlockNumber, tx.From, tx.To, tx.Value, tx.Gas, tx.GasPrice,
`, a.Tbl("transactions")), tx.Hash, tx.BlockHash, tx.BlockNumber, tx.From, tx.To, tx.Value, tx.Gas, tx.GasPrice,
tx.GasUsed, tx.Nonce, tx.Input, tx.TransactionIndex, tx.Type, tx.Status, tx.ContractAddress, tx.Timestamp)
return err
}
// StoreAddress stores an address
func (a *Adapter) StoreAddress(ctx context.Context, db *sql.DB, addr Address) error {
_, err := db.ExecContext(ctx, `
INSERT INTO cchain_addresses
tbl := a.Tbl("addresses")
_, err := db.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %s
(hash, balance, tx_count, is_contract, contract_code, contract_creator, contract_tx_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (hash) DO UPDATE SET
balance = EXCLUDED.balance,
tx_count = cchain_addresses.tx_count + 1,
tx_count = %s.tx_count + 1,
updated_at = EXCLUDED.updated_at
`, addr.Hash, addr.Balance, addr.TxCount, addr.IsContract, addr.ContractCode,
`, tbl, tbl), addr.Hash, addr.Balance, addr.TxCount, addr.IsContract, addr.ContractCode,
addr.ContractCreator, addr.ContractTxHash, addr.CreatedAt, addr.UpdatedAt)
return err
}
// StoreTokenTransfer stores a token transfer
func (a *Adapter) StoreTokenTransfer(ctx context.Context, db *sql.DB, t TokenTransfer) error {
_, err := db.ExecContext(ctx, `
INSERT INTO cchain_token_transfers
_, err := db.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %s
(id, tx_hash, log_index, block_number, token_address, token_type, tx_from, tx_to, value, token_id, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO NOTHING
`, t.ID, t.TxHash, t.LogIndex, t.BlockNumber, t.TokenAddress, t.TokenType, t.From, t.To, t.Value, t.TokenID, t.Timestamp)
`, a.Tbl("token_transfers")), t.ID, t.TxHash, t.LogIndex, t.BlockNumber, t.TokenAddress, t.TokenType, t.From, t.To, t.Value, t.TokenID, t.Timestamp)
return err
}
// StoreToken stores a token
func (a *Adapter) StoreToken(ctx context.Context, db *sql.DB, t Token) error {
_, err := db.ExecContext(ctx, `
INSERT INTO cchain_tokens
tbl := a.Tbl("tokens")
_, err := db.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %s
(address, name, symbol, decimals, total_supply, token_type, holder_count, tx_count, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (address) DO UPDATE SET
total_supply = EXCLUDED.total_supply,
holder_count = EXCLUDED.holder_count,
tx_count = cchain_tokens.tx_count + 1,
tx_count = %s.tx_count + 1,
updated_at = EXCLUDED.updated_at
`, t.Address, t.Name, t.Symbol, t.Decimals, t.TotalSupply, t.TokenType, t.HolderCount, t.TxCount, t.CreatedAt, t.UpdatedAt)
`, tbl, tbl), t.Address, t.Name, t.Symbol, t.Decimals, t.TotalSupply, t.TokenType, t.HolderCount, t.TxCount, t.CreatedAt, t.UpdatedAt)
return err
}
// StoreLog stores an event log
func (a *Adapter) StoreLog(ctx context.Context, db *sql.DB, l Log) error {
topicsJSON, _ := json.Marshal(l.Topics)
_, err := db.ExecContext(ctx, `
INSERT INTO cchain_logs (tx_hash, log_index, block_number, address, topics, data, removed)
_, err := db.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %s (tx_hash, log_index, block_number, address, topics, data, removed)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (tx_hash, log_index) DO NOTHING
`, l.TxHash, l.LogIndex, l.BlockNumber, l.Address, topicsJSON, l.Data, l.Removed)
`, a.Tbl("logs")), l.TxHash, l.LogIndex, l.BlockNumber, l.Address, topicsJSON, l.Data, l.Removed)
return err
}
@@ -1549,14 +1571,14 @@ func (a *Adapter) StoreInternalTransaction(ctx context.Context, db *sql.DB, itx
// Convert trace address to PostgreSQL array format
traceAddrStr := intSliceToPostgresArray(itx.TraceAddress)
_, err := db.ExecContext(ctx, `
INSERT INTO cchain_internal_transactions
_, err := db.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %s
(id, tx_hash, block_number, trace_index, trace_address, call_type, tx_from, tx_to,
value, gas, gas_used, input, output, error, created_contract_address,
created_contract_code, init, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
ON CONFLICT (id) DO NOTHING
`, itx.ID, itx.TxHash, itx.BlockNumber, itx.TraceIndex, traceAddrStr, itx.CallType,
`, a.Tbl("internal_transactions")), itx.ID, itx.TxHash, itx.BlockNumber, itx.TraceIndex, traceAddrStr, itx.CallType,
itx.From, itx.To, itx.Value, itx.Gas, itx.GasUsed, itx.Input, itx.Output, itx.Error,
itx.CreatedContractAddress, itx.CreatedContractCode, itx.Init, itx.Timestamp)
return err
@@ -1581,64 +1603,65 @@ func (a *Adapter) StoreTokenBalance(ctx context.Context, db *sql.DB, tb TokenBal
tokenID = ""
}
_, err := db.ExecContext(ctx, `
INSERT INTO cchain_token_balances
_, err := db.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %s
(token_address, holder_address, balance, token_id, block_number, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (token_address, holder_address, COALESCE(token_id, '')) DO UPDATE SET
balance = EXCLUDED.balance,
block_number = EXCLUDED.block_number,
updated_at = EXCLUDED.updated_at
`, tb.TokenAddress, tb.HolderAddress, tb.Balance, tokenID, tb.BlockNumber, tb.UpdatedAt)
`, a.Tbl("token_balances")), tb.TokenAddress, tb.HolderAddress, tb.Balance, tokenID, tb.BlockNumber, tb.UpdatedAt)
return err
}
// StoreTokenBalanceHistory stores token balance history
func (a *Adapter) StoreTokenBalanceHistory(ctx context.Context, db *sql.DB, tb TokenBalance) error {
_, err := db.ExecContext(ctx, `
INSERT INTO cchain_token_balance_history
_, err := db.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %s
(token_address, holder_address, balance, token_id, block_number)
VALUES ($1, $2, $3, $4, $5)
`, tb.TokenAddress, tb.HolderAddress, tb.Balance, tb.TokenID, tb.BlockNumber)
`, a.Tbl("token_balance_history")), tb.TokenAddress, tb.HolderAddress, tb.Balance, tb.TokenID, tb.BlockNumber)
return err
}
// StoreAddressCoinBalance stores native coin balance for an address at a block
func (a *Adapter) StoreAddressCoinBalance(ctx context.Context, db *sql.DB, acb AddressCoinBalance) error {
_, err := db.ExecContext(ctx, `
INSERT INTO cchain_address_coin_balances
tbl := a.Tbl("address_coin_balances")
_, err := db.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %s
(address_hash, block_number, value, value_fetched_at, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (address_hash, block_number) DO UPDATE SET
value = CASE
WHEN EXCLUDED.value IS NOT NULL AND (cchain_address_coin_balances.value_fetched_at IS NULL
OR EXCLUDED.value_fetched_at > cchain_address_coin_balances.value_fetched_at)
WHEN EXCLUDED.value IS NOT NULL AND (%s.value_fetched_at IS NULL
OR EXCLUDED.value_fetched_at > %s.value_fetched_at)
THEN EXCLUDED.value
ELSE cchain_address_coin_balances.value
ELSE %s.value
END,
value_fetched_at = CASE
WHEN EXCLUDED.value IS NOT NULL AND (cchain_address_coin_balances.value_fetched_at IS NULL
OR EXCLUDED.value_fetched_at > cchain_address_coin_balances.value_fetched_at)
WHEN EXCLUDED.value IS NOT NULL AND (%s.value_fetched_at IS NULL
OR EXCLUDED.value_fetched_at > %s.value_fetched_at)
THEN EXCLUDED.value_fetched_at
ELSE cchain_address_coin_balances.value_fetched_at
ELSE %s.value_fetched_at
END,
updated_at = NOW()
`, acb.AddressHash, acb.BlockNumber, acb.Value, acb.ValueFetchedAt)
`, tbl, tbl, tbl, tbl, tbl, tbl, tbl), acb.AddressHash, acb.BlockNumber, acb.Value, acb.ValueFetchedAt)
return err
}
// UpdateTokenHolderCount updates the holder count for a token
func (a *Adapter) UpdateTokenHolderCount(ctx context.Context, db *sql.DB, tokenAddress string) error {
_, err := db.ExecContext(ctx, `
UPDATE cchain_tokens
_, err := db.ExecContext(ctx, fmt.Sprintf(`
UPDATE %s
SET holder_count = (
SELECT COUNT(DISTINCT holder_address)
FROM cchain_token_balances
FROM %s
WHERE token_address = $1 AND balance != '0'
),
updated_at = NOW()
WHERE address = $1
`, tokenAddress)
`, a.Tbl("tokens"), a.Tbl("token_balances")), tokenAddress)
return err
}
@@ -1851,26 +1874,37 @@ func (a *Adapter) fetchAndStoreCoinBalances(ctx context.Context, db *sql.DB, add
}
}
// UpdateExtendedStats updates C-Chain specific statistics
// UpdateExtendedStats updates chain-specific statistics
func (a *Adapter) UpdateExtendedStats(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
UPDATE cchain_extended_stats SET
total_transactions = (SELECT COUNT(*) FROM cchain_transactions),
total_addresses = (SELECT COUNT(*) FROM cchain_addresses),
total_contracts = (SELECT COUNT(*) FROM cchain_addresses WHERE is_contract = TRUE),
total_tokens = (SELECT COUNT(*) FROM cchain_tokens),
total_token_transfers = (SELECT COUNT(*) FROM cchain_token_transfers),
total_internal_transactions = (SELECT COUNT(*) FROM cchain_internal_transactions),
total_gas_used = COALESCE((SELECT SUM(gas_used) FROM cchain_transactions), 0),
avg_gas_price = COALESCE((SELECT AVG(CAST(gas_price AS NUMERIC)) FROM cchain_transactions WHERE gas_price IS NOT NULL), 0)::TEXT,
_, err := db.ExecContext(ctx, fmt.Sprintf(`
UPDATE %s SET
total_transactions = (SELECT COUNT(*) FROM %s),
total_addresses = (SELECT COUNT(*) FROM %s),
total_contracts = (SELECT COUNT(*) FROM %s WHERE is_contract = TRUE),
total_tokens = (SELECT COUNT(*) FROM %s),
total_token_transfers = (SELECT COUNT(*) FROM %s),
total_internal_transactions = (SELECT COUNT(*) FROM %s),
total_gas_used = COALESCE((SELECT SUM(gas_used) FROM %s), 0),
avg_gas_price = COALESCE((SELECT AVG(CAST(gas_price AS NUMERIC)) FROM %s WHERE gas_price IS NOT NULL), 0)::TEXT,
tps_24h = COALESCE((
SELECT COUNT(*)::FLOAT / 86400
FROM cchain_transactions
FROM %s
WHERE timestamp > NOW() - INTERVAL '24 hours'
), 0),
updated_at = NOW()
WHERE id = 1
`)
`,
a.Tbl("extended_stats"),
a.Tbl("transactions"),
a.Tbl("addresses"),
a.Tbl("addresses"),
a.Tbl("tokens"),
a.Tbl("token_transfers"),
a.Tbl("internal_transactions"),
a.Tbl("transactions"),
a.Tbl("transactions"),
a.Tbl("transactions"),
))
return err
}
@@ -2088,14 +2122,14 @@ func decodeString(data string) string {
// GetInternalTransactions returns internal transactions for a transaction
func (a *Adapter) GetInternalTransactions(ctx context.Context, db *sql.DB, txHash string) ([]InternalTransaction, error) {
rows, err := db.QueryContext(ctx, `
rows, err := db.QueryContext(ctx, fmt.Sprintf(`
SELECT id, tx_hash, block_number, trace_index, trace_address, call_type,
tx_from, tx_to, value, gas, gas_used, input, output, error,
created_contract_address, created_contract_code, init, timestamp
FROM cchain_internal_transactions
FROM %s
WHERE tx_hash = $1
ORDER BY trace_index
`, txHash)
`, a.Tbl("internal_transactions")), txHash)
if err != nil {
return nil, err
}
@@ -2160,13 +2194,13 @@ func postgresArrayToIntSlice(s string) []int {
// GetAddressCoinBalanceHistory returns coin balance history for an address
func (a *Adapter) GetAddressCoinBalanceHistory(ctx context.Context, db *sql.DB, address string, limit int) ([]AddressCoinBalance, error) {
rows, err := db.QueryContext(ctx, `
rows, err := db.QueryContext(ctx, fmt.Sprintf(`
SELECT address_hash, block_number, value, value_fetched_at
FROM cchain_address_coin_balances
FROM %s
WHERE address_hash = $1 AND value IS NOT NULL
ORDER BY block_number DESC
LIMIT $2
`, strings.ToLower(address), limit)
`, a.Tbl("address_coin_balances")), strings.ToLower(address), limit)
if err != nil {
return nil, err
}
@@ -2198,13 +2232,13 @@ func (a *Adapter) GetAddressCoinBalanceHistory(ctx context.Context, db *sql.DB,
// GetTokenBalanceHistory returns token balance history for an address
func (a *Adapter) GetTokenBalanceHistory(ctx context.Context, db *sql.DB, address string, limit int) ([]TokenBalance, error) {
rows, err := db.QueryContext(ctx, `
rows, err := db.QueryContext(ctx, fmt.Sprintf(`
SELECT token_address, holder_address, balance, token_id, block_number, created_at
FROM cchain_token_balance_history
FROM %s
WHERE holder_address = $1
ORDER BY block_number DESC
LIMIT $2
`, strings.ToLower(address), limit)
`, a.Tbl("token_balance_history")), strings.ToLower(address), limit)
if err != nil {
return nil, err
}
@@ -2234,13 +2268,13 @@ func (a *Adapter) GetTokenBalanceHistory(ctx context.Context, db *sql.DB, addres
// GetTokenHolders returns all holders of a token with non-zero balances
func (a *Adapter) GetTokenHolders(ctx context.Context, db *sql.DB, tokenAddress string, limit, offset int) ([]TokenBalance, error) {
rows, err := db.QueryContext(ctx, `
rows, err := db.QueryContext(ctx, fmt.Sprintf(`
SELECT token_address, holder_address, balance, token_id, block_number, updated_at
FROM cchain_token_balances
FROM %s
WHERE token_address = $1 AND balance != '0'
ORDER BY CAST(balance AS NUMERIC) DESC
LIMIT $2 OFFSET $3
`, strings.ToLower(tokenAddress), limit, offset)
`, a.Tbl("token_balances")), strings.ToLower(tokenAddress), limit, offset)
if err != nil {
return nil, err
}
+73 -60
View File
@@ -19,15 +19,28 @@ var (
ErrInvalidParam = errors.New("invalid parameter")
)
// Repository handles database queries for the API
// Repository handles database queries for the API.
// The prefix field determines table names (e.g. "cchain" → cchain_blocks).
// This allows one PostgreSQL database to hold data for multiple EVM chains.
type Repository struct {
db *sql.DB
chainID int64
prefix string // table name prefix, e.g. "cchain", "zoo", "hanzo"
}
// NewRepository creates a new repository
func NewRepository(db *sql.DB, chainID int64) *Repository {
return &Repository{db: db, chainID: chainID}
// NewRepository creates a new repository with chain-specific table prefix.
// If chainSlug is empty, defaults to "cchain" for backwards compatibility.
func NewRepository(db *sql.DB, chainID int64, chainSlug ...string) *Repository {
prefix := "cchain"
if len(chainSlug) > 0 && chainSlug[0] != "" {
prefix = chainSlug[0]
}
return &Repository{db: db, chainID: chainID, prefix: prefix}
}
// Tbl returns the prefixed table name, e.g. Tbl("blocks") → "cchain_blocks"
func (r *Repository) Tbl(name string) string {
return r.prefix + "_" + name
}
// BlockFilters for block listing
@@ -39,12 +52,12 @@ type BlockFilters struct {
func (r *Repository) GetBlocks(ctx context.Context, page, pageSize int, filters *BlockFilters) (*PaginatedResponse, error) {
offset := page * pageSize
query := `
query := fmt.Sprintf(`
SELECT b.id, b.parent_id, b.height, b.timestamp, b.status, b.tx_count, b.data, b.metadata
FROM cchain_blocks b
FROM %s b
ORDER BY b.height DESC
LIMIT $1 OFFSET $2
`
`, r.Tbl("blocks"))
rows, err := r.db.QueryContext(ctx, query, pageSize+1, offset)
if err != nil {
@@ -77,11 +90,11 @@ func (r *Repository) GetBlocks(ctx context.Context, page, pageSize int, filters
// GetBlockByNumber returns a block by height
func (r *Repository) GetBlockByNumber(ctx context.Context, height uint64) (*Block, error) {
query := `
query := fmt.Sprintf(`
SELECT b.id, b.parent_id, b.height, b.timestamp, b.status, b.tx_count, b.data, b.metadata
FROM cchain_blocks b
FROM %s b
WHERE b.height = $1
`
`, r.Tbl("blocks"))
row := r.db.QueryRowContext(ctx, query, height)
return r.scanBlockRow(row)
@@ -89,11 +102,11 @@ func (r *Repository) GetBlockByNumber(ctx context.Context, height uint64) (*Bloc
// GetBlockByHash returns a block by hash
func (r *Repository) GetBlockByHash(ctx context.Context, hash string) (*Block, error) {
query := `
query := fmt.Sprintf(`
SELECT b.id, b.parent_id, b.height, b.timestamp, b.status, b.tx_count, b.data, b.metadata
FROM cchain_blocks b
FROM %s b
WHERE b.id = $1
`
`, r.Tbl("blocks"))
row := r.db.QueryRowContext(ctx, query, strings.ToLower(hash))
return r.scanBlockRow(row)
@@ -164,7 +177,7 @@ func (r *Repository) buildBlock(id, parentID string, height uint64, timestamp ti
// Get latest block for confirmations
var latestHeight uint64
r.db.QueryRowContext(context.Background(), "SELECT COALESCE(MAX(height), 0) FROM cchain_blocks").Scan(&latestHeight)
r.db.QueryRowContext(context.Background(), fmt.Sprintf("SELECT COALESCE(MAX(height), 0) FROM %s", r.Tbl("blocks"))).Scan(&latestHeight)
if latestHeight > height {
block.Confirmations = int(latestHeight - height)
}
@@ -182,13 +195,13 @@ type TransactionFilters struct {
func (r *Repository) GetTransactions(ctx context.Context, page, pageSize int, filters *TransactionFilters) (*PaginatedResponse, error) {
offset := page * pageSize
query := `
query := fmt.Sprintf(`
SELECT hash, block_hash, block_number, tx_from, tx_to, value, gas, gas_price,
gas_used, nonce, input, tx_index, tx_type, status, contract_address, timestamp
FROM cchain_transactions
FROM %s
ORDER BY block_number DESC, tx_index DESC
LIMIT $1 OFFSET $2
`
`, r.Tbl("transactions"))
rows, err := r.db.QueryContext(ctx, query, pageSize+1, offset)
if err != nil {
@@ -226,12 +239,12 @@ func (r *Repository) GetTransactions(ctx context.Context, page, pageSize int, fi
// GetTransactionByHash returns a transaction by hash
func (r *Repository) GetTransactionByHash(ctx context.Context, hash string) (*Transaction, error) {
query := `
query := fmt.Sprintf(`
SELECT hash, block_hash, block_number, tx_from, tx_to, value, gas, gas_price,
gas_used, nonce, input, tx_index, tx_type, status, contract_address, timestamp
FROM cchain_transactions
FROM %s
WHERE hash = $1
`
`, r.Tbl("transactions"))
row := r.db.QueryRowContext(ctx, query, strings.ToLower(hash))
return r.scanTransactionRow(row)
@@ -241,14 +254,14 @@ func (r *Repository) GetTransactionByHash(ctx context.Context, hash string) (*Tr
func (r *Repository) GetTransactionsByBlock(ctx context.Context, blockNumber uint64, page, pageSize int) (*PaginatedResponse, error) {
offset := page * pageSize
query := `
query := fmt.Sprintf(`
SELECT hash, block_hash, block_number, tx_from, tx_to, value, gas, gas_price,
gas_used, nonce, input, tx_index, tx_type, status, contract_address, timestamp
FROM cchain_transactions
FROM %s
WHERE block_number = $1
ORDER BY tx_index ASC
LIMIT $2 OFFSET $3
`
`, r.Tbl("transactions"))
rows, err := r.db.QueryContext(ctx, query, blockNumber, pageSize+1, offset)
if err != nil {
@@ -366,12 +379,12 @@ func (r *Repository) buildTransaction(hash, blockHash string, blockNumber uint64
func (r *Repository) GetAddress(ctx context.Context, hash string) (*Address, error) {
hash = strings.ToLower(hash)
query := `
query := fmt.Sprintf(`
SELECT hash, balance, tx_count, is_contract, contract_code, contract_creator,
contract_tx_hash, created_at, updated_at
FROM cchain_addresses
FROM %s
WHERE hash = $1
`
`, r.Tbl("addresses"))
var balance string
var txCount uint64
@@ -411,14 +424,14 @@ func (r *Repository) GetAddressTransactions(ctx context.Context, address string,
address = strings.ToLower(address)
offset := page * pageSize
query := `
query := fmt.Sprintf(`
SELECT hash, block_hash, block_number, tx_from, tx_to, value, gas, gas_price,
gas_used, nonce, input, tx_index, tx_type, status, contract_address, timestamp
FROM cchain_transactions
FROM %s
WHERE tx_from = $1 OR tx_to = $1
ORDER BY block_number DESC, tx_index DESC
LIMIT $2 OFFSET $3
`
`, r.Tbl("transactions"))
rows, err := r.db.QueryContext(ctx, query, address, pageSize+1, offset)
if err != nil {
@@ -459,16 +472,16 @@ func (r *Repository) GetAddressTokenTransfers(ctx context.Context, address strin
address = strings.ToLower(address)
offset := page * pageSize
query := `
query := fmt.Sprintf(`
SELECT t.id, t.tx_hash, t.log_index, t.block_number, t.token_address,
t.token_type, t.tx_from, t.tx_to, t.value, t.token_id, t.timestamp,
COALESCE(tk.name, ''), COALESCE(tk.symbol, ''), COALESCE(tk.decimals, 18)
FROM cchain_token_transfers t
LEFT JOIN cchain_tokens tk ON t.token_address = tk.address
FROM %s t
LEFT JOIN %s tk ON t.token_address = tk.address
WHERE t.tx_from = $1 OR t.tx_to = $1
ORDER BY t.block_number DESC, t.log_index DESC
LIMIT $2 OFFSET $3
`
`, r.Tbl("token_transfers"), r.Tbl("tokens"))
rows, err := r.db.QueryContext(ctx, query, address, pageSize+1, offset)
if err != nil {
@@ -530,13 +543,13 @@ func (r *Repository) GetAddressTokenTransfers(ctx context.Context, address strin
func (r *Repository) GetTokens(ctx context.Context, page, pageSize int, tokenType string) (*PaginatedResponse, error) {
offset := page * pageSize
query := `
query := fmt.Sprintf(`
SELECT address, name, symbol, decimals, total_supply, token_type, holder_count, tx_count
FROM cchain_tokens
FROM %s
WHERE ($3 = '' OR token_type = $3)
ORDER BY holder_count DESC
LIMIT $1 OFFSET $2
`
`, r.Tbl("tokens"))
rows, err := r.db.QueryContext(ctx, query, pageSize+1, offset, tokenType)
if err != nil {
@@ -579,11 +592,11 @@ func (r *Repository) GetTokens(ctx context.Context, page, pageSize int, tokenTyp
func (r *Repository) GetToken(ctx context.Context, address string) (*Token, error) {
address = strings.ToLower(address)
query := `
query := fmt.Sprintf(`
SELECT address, name, symbol, decimals, total_supply, token_type, holder_count, tx_count
FROM cchain_tokens
FROM %s
WHERE address = $1
`
`, r.Tbl("tokens"))
var name, symbol, totalSupply, ttype string
var decimals uint8
@@ -617,9 +630,9 @@ func (r *Repository) GetSmartContract(ctx context.Context, address string) (*Sma
// First check if address is a contract
var isContract bool
var code sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT is_contract, contract_code FROM cchain_addresses WHERE hash = $1
`, address).Scan(&isContract, &code)
err := r.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT is_contract, contract_code FROM %s WHERE hash = $1
`, r.Tbl("addresses")), address).Scan(&isContract, &code)
if err != nil {
if err == sql.ErrNoRows {
@@ -714,12 +727,12 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]Sea
}
// Search tokens by name/symbol
tokenRows, err := r.db.QueryContext(ctx, `
tokenRows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT address, name, symbol, token_type
FROM cchain_tokens
FROM %s
WHERE LOWER(name) LIKE $1 OR LOWER(symbol) LIKE $1
LIMIT $2
`, "%"+lowerQuery+"%", limit)
`, r.Tbl("tokens")), "%"+lowerQuery+"%", limit)
if err == nil {
defer tokenRows.Close()
for tokenRows.Next() {
@@ -749,11 +762,11 @@ func (r *Repository) GetStats(ctx context.Context) (*ChainStats, error) {
stats := &ChainStats{}
// Get extended stats
row := r.db.QueryRowContext(ctx, `
row := r.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT total_transactions, total_addresses, total_contracts, total_tokens,
total_token_transfers, total_gas_used, avg_gas_price, avg_block_time, tps_24h
FROM cchain_extended_stats WHERE id = 1
`)
FROM %s WHERE id = 1
`, r.Tbl("extended_stats")))
var avgGasPrice string
var avgBlockTime, tps24h float64
@@ -769,13 +782,13 @@ func (r *Repository) GetStats(ctx context.Context) (*ChainStats, error) {
stats.GasPrice = avgGasPrice
// Get block count
r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM cchain_blocks").Scan(&stats.TotalBlocks)
r.db.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM %s", r.Tbl("blocks"))).Scan(&stats.TotalBlocks)
// Calculate transactions today
r.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM cchain_transactions
r.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COUNT(*) FROM %s
WHERE timestamp > NOW() - INTERVAL '24 hours'
`).Scan(&stats.TxnsToday)
`, r.Tbl("transactions"))).Scan(&stats.TxnsToday)
return stats, nil
}
@@ -822,11 +835,11 @@ func (r *Repository) GetLogs(ctx context.Context, address string, fromBlock, toB
query := fmt.Sprintf(`
SELECT tx_hash, log_index, block_number, address, topics, data, removed
FROM cchain_logs
FROM %s
%s
ORDER BY block_number DESC, log_index DESC
LIMIT $1 OFFSET $2
`, whereClause)
`, r.Tbl("logs"), whereClause)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
@@ -873,14 +886,14 @@ func (r *Repository) GetInternalTransactions(ctx context.Context, txHash string,
offset := page * pageSize
txHash = strings.ToLower(txHash)
query := `
query := fmt.Sprintf(`
SELECT id, tx_hash, block_number, trace_index, call_type, tx_from, tx_to,
value, gas, gas_used, input, output, error, timestamp
FROM cchain_internal_transactions
FROM %s
WHERE tx_hash = $1
ORDER BY trace_index ASC
LIMIT $2 OFFSET $3
`
`, r.Tbl("internal_transactions"))
rows, err := r.db.QueryContext(ctx, query, txHash, pageSize+1, offset)
if err != nil {
@@ -949,9 +962,9 @@ func (r *Repository) GetAddressBalance(ctx context.Context, address string) (str
address = strings.ToLower(address)
var balance string
err := r.db.QueryRowContext(ctx, `
SELECT COALESCE(balance, '0') FROM cchain_addresses WHERE hash = $1
`, address).Scan(&balance)
err := r.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COALESCE(balance, '0') FROM %s WHERE hash = $1
`, r.Tbl("addresses")), address).Scan(&balance)
if err == sql.ErrNoRows {
return "0", nil
+28 -27
View File
@@ -25,6 +25,7 @@ type Config struct {
HTTPPort int
ChainID int64
ChainName string
ChainSlug string // table prefix, e.g. "cchain", "zoo", "hanzo"
DatabaseURL string
RPCEndpoint string
}
@@ -44,7 +45,7 @@ type Server struct {
// NewServer creates a new API server
func NewServer(cfg Config, db *sql.DB) *Server {
repo := NewRepository(db, cfg.ChainID)
repo := NewRepository(db, cfg.ChainID, cfg.ChainSlug)
wsHub := NewWebSocketHub()
// Initialize account service
@@ -393,16 +394,16 @@ func (s *Server) handleTxTokenTransfers(w http.ResponseWriter, r *http.Request)
page, pageSize := getPagination(r)
// Query token transfers for this transaction
rows, err := s.db.QueryContext(r.Context(), `
rows, err := s.db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT t.id, t.tx_hash, t.log_index, t.block_number, t.token_address,
t.token_type, t.tx_from, t.tx_to, t.value, t.token_id, t.timestamp,
COALESCE(tk.name, ''), COALESCE(tk.symbol, ''), COALESCE(tk.decimals, 18)
FROM cchain_token_transfers t
LEFT JOIN cchain_tokens tk ON t.token_address = tk.address
FROM %s t
LEFT JOIN %s tk ON t.token_address = tk.address
WHERE t.tx_hash = $1
ORDER BY t.log_index ASC
LIMIT $2 OFFSET $3
`, strings.ToLower(hash), pageSize+1, page*pageSize)
`, s.repo.Tbl("token_transfers"), s.repo.Tbl("tokens")), strings.ToLower(hash), pageSize+1, page*pageSize)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err.Error())
@@ -509,12 +510,12 @@ func (s *Server) handleAddresses(w http.ResponseWriter, r *http.Request) {
// List addresses with balances
page, pageSize := getPagination(r)
rows, err := s.db.QueryContext(r.Context(), `
rows, err := s.db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT hash, balance, tx_count, is_contract
FROM cchain_addresses
FROM %s
ORDER BY CAST(balance AS NUMERIC) DESC
LIMIT $1 OFFSET $2
`, pageSize+1, page*pageSize)
`, s.repo.Tbl("addresses")), pageSize+1, page*pageSize)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err.Error())
@@ -612,15 +613,15 @@ func (s *Server) handleAddressTokens(w http.ResponseWriter, r *http.Request) {
hash = strings.ToLower(hash)
page, pageSize := getPagination(r)
rows, err := s.db.QueryContext(r.Context(), `
rows, err := s.db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT tb.token_address, tb.balance, tb.token_id,
t.name, t.symbol, t.decimals, t.token_type
FROM cchain_token_balances tb
LEFT JOIN cchain_tokens t ON tb.token_address = t.address
FROM %s tb
LEFT JOIN %s t ON tb.token_address = t.address
WHERE tb.holder_address = $1 AND CAST(tb.balance AS NUMERIC) > 0
ORDER BY CAST(tb.balance AS NUMERIC) DESC
LIMIT $2 OFFSET $3
`, hash, pageSize+1, page*pageSize)
`, s.repo.Tbl("token_balances"), s.repo.Tbl("tokens")), hash, pageSize+1, page*pageSize)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err.Error())
@@ -697,13 +698,13 @@ func (s *Server) handleAddressCounters(w http.ResponseWriter, r *http.Request) {
hash = strings.ToLower(hash)
var txCount, tokenTransferCount int64
s.db.QueryRowContext(r.Context(), `
SELECT COUNT(*) FROM cchain_transactions WHERE tx_from = $1 OR tx_to = $1
`, hash).Scan(&txCount)
s.db.QueryRowContext(r.Context(), fmt.Sprintf(`
SELECT COUNT(*) FROM %s WHERE tx_from = $1 OR tx_to = $1
`, s.repo.Tbl("transactions")), hash).Scan(&txCount)
s.db.QueryRowContext(r.Context(), `
SELECT COUNT(*) FROM cchain_token_transfers WHERE tx_from = $1 OR tx_to = $1
`, hash).Scan(&tokenTransferCount)
s.db.QueryRowContext(r.Context(), fmt.Sprintf(`
SELECT COUNT(*) FROM %s WHERE tx_from = $1 OR tx_to = $1
`, s.repo.Tbl("token_transfers")), hash).Scan(&tokenTransferCount)
s.writeJSON(w, http.StatusOK, map[string]interface{}{
"transactions_count": txCount,
@@ -747,13 +748,13 @@ func (s *Server) handleTokenTransfers(w http.ResponseWriter, r *http.Request) {
hash = strings.ToLower(hash)
page, pageSize := getPagination(r)
rows, err := s.db.QueryContext(r.Context(), `
rows, err := s.db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT id, tx_hash, log_index, block_number, tx_from, tx_to, value, token_id, timestamp
FROM cchain_token_transfers
FROM %s
WHERE token_address = $1
ORDER BY block_number DESC, log_index DESC
LIMIT $2 OFFSET $3
`, hash, pageSize+1, page*pageSize)
`, s.repo.Tbl("token_transfers")), hash, pageSize+1, page*pageSize)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err.Error())
@@ -800,13 +801,13 @@ func (s *Server) handleTokenHolders(w http.ResponseWriter, r *http.Request) {
hash = strings.ToLower(hash)
page, pageSize := getPagination(r)
rows, err := s.db.QueryContext(r.Context(), `
rows, err := s.db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT holder_address, balance
FROM cchain_token_balances
FROM %s
WHERE token_address = $1 AND CAST(balance AS NUMERIC) > 0
ORDER BY CAST(balance AS NUMERIC) DESC
LIMIT $2 OFFSET $3
`, hash, pageSize+1, page*pageSize)
`, s.repo.Tbl("token_balances")), hash, pageSize+1, page*pageSize)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err.Error())
@@ -850,12 +851,12 @@ func (s *Server) handleTokenInstance(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSmartContracts(w http.ResponseWriter, r *http.Request) {
page, pageSize := getPagination(r)
rows, err := s.db.QueryContext(r.Context(), `
SELECT hash, tx_count FROM cchain_addresses
rows, err := s.db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT hash, tx_count FROM %s
WHERE is_contract = TRUE
ORDER BY tx_count DESC
LIMIT $1 OFFSET $2
`, pageSize+1, page*pageSize)
`, s.repo.Tbl("addresses")), pageSize+1, page*pageSize)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err.Error())
+44 -31
View File
@@ -105,6 +105,12 @@ type Service struct {
db *sql.DB
config *Config
cache *chartCache
prefix string // table name prefix, e.g. "cchain", "zoo", "hanzo"
}
// Tbl returns the prefixed table name, e.g. Tbl("transactions") returns "cchain_transactions"
func (s *Service) Tbl(name string) string {
return s.prefix + "_" + name
}
// chartCache holds cached chart data
@@ -122,15 +128,21 @@ type chartCache struct {
blockStatsUpdated time.Time
}
// NewService creates a new chart service
func NewService(db *sql.DB, config *Config) *Service {
// NewService creates a new chart service.
// If chainSlug is empty, defaults to "cchain" for backwards compatibility.
func NewService(db *sql.DB, config *Config, chainSlug ...string) *Service {
if config == nil {
config = DefaultConfig()
}
prefix := "cchain"
if len(chainSlug) > 0 && chainSlug[0] != "" {
prefix = chainSlug[0]
}
return &Service{
db: db,
config: config,
cache: &chartCache{},
prefix: prefix,
}
}
@@ -163,18 +175,18 @@ func (s *Service) GetTransactionChart(ctx context.Context, days int) (*ChartResp
today := time.Now().UTC().Truncate(24 * time.Hour)
startDate := today.AddDate(0, 0, -days)
query := `
query := fmt.Sprintf(`
SELECT
DATE(timestamp) as date,
COUNT(*) as tx_count,
COALESCE(SUM(gas_used), 0) as gas_used,
COALESCE(SUM(gas_used * gas_price), 0) as total_fee,
COALESCE(AVG(gas_price), 0) as avg_gas_price
FROM cchain_transactions
FROM %s
WHERE timestamp >= $1 AND timestamp < $2
GROUP BY DATE(timestamp)
ORDER BY date DESC
`
`, s.Tbl("transactions"))
rows, err := s.db.QueryContext(ctx, query, startDate, today)
if err != nil {
@@ -245,18 +257,18 @@ func (s *Service) GetGasUsageChart(ctx context.Context, days int) (*ChartRespons
today := time.Now().UTC().Truncate(24 * time.Hour)
startDate := today.AddDate(0, 0, -days)
query := `
query := fmt.Sprintf(`
SELECT
DATE(timestamp) as date,
COALESCE(SUM(gas_used), 0) as gas_used,
COALESCE(SUM(gas_limit), 0) as gas_limit,
COALESCE(AVG(gas_price), 0) as avg_gas_price,
COUNT(DISTINCT block_number) as block_count
FROM cchain_transactions
FROM %s
WHERE timestamp >= $1 AND timestamp < $2
GROUP BY DATE(timestamp)
ORDER BY date DESC
`
`, s.Tbl("transactions"))
rows, err := s.db.QueryContext(ctx, query, startDate, today)
if err != nil {
@@ -335,18 +347,18 @@ func (s *Service) GetTokenTransferChart(ctx context.Context, days int) (*ChartRe
today := time.Now().UTC().Truncate(24 * time.Hour)
startDate := today.AddDate(0, 0, -days)
query := `
query := fmt.Sprintf(`
SELECT
DATE(timestamp) as date,
COUNT(*) as transfer_count,
COUNT(DISTINCT token_hash) as unique_tokens,
COUNT(DISTINCT from_address) as unique_from,
COUNT(DISTINCT to_address) as unique_to
FROM cchain_token_transfers
FROM %s
WHERE timestamp >= $1 AND timestamp < $2
GROUP BY DATE(timestamp)
ORDER BY date DESC
`
`, s.Tbl("token_transfers"))
rows, err := s.db.QueryContext(ctx, query, startDate, today)
if err != nil {
@@ -411,15 +423,15 @@ func (s *Service) GetAddressGrowthChart(ctx context.Context, days int) (*ChartRe
startDate := today.AddDate(0, 0, -days)
// Query new addresses per day (addresses first seen on that day)
query := `
query := fmt.Sprintf(`
SELECT
DATE(first_seen) as date,
COUNT(*) as new_addresses
FROM cchain_addresses
FROM %s
WHERE first_seen >= $1 AND first_seen < $2
GROUP BY DATE(first_seen)
ORDER BY date DESC
`
`, s.Tbl("addresses"))
rows, err := s.db.QueryContext(ctx, query, startDate, today)
if err != nil {
@@ -433,7 +445,7 @@ func (s *Service) GetAddressGrowthChart(ctx context.Context, days int) (*ChartRe
// Get total addresses before start date
var baseCount int64
err = s.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM cchain_addresses WHERE first_seen < $1", startDate).Scan(&baseCount)
fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE first_seen < $1", s.Tbl("addresses")), startDate).Scan(&baseCount)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("query base address count: %w", err)
}
@@ -496,14 +508,14 @@ func (s *Service) GetBlockChart(ctx context.Context, days int) (*ChartResponse,
today := time.Now().UTC().Truncate(24 * time.Hour)
startDate := today.AddDate(0, 0, -days)
query := `
query := fmt.Sprintf(`
WITH block_times AS (
SELECT
DATE(timestamp) as date,
block_number,
timestamp,
LAG(timestamp) OVER (ORDER BY block_number) as prev_timestamp
FROM cchain_transactions
FROM %s
WHERE timestamp >= $1 AND timestamp < $2
)
SELECT
@@ -514,7 +526,7 @@ func (s *Service) GetBlockChart(ctx context.Context, days int) (*ChartResponse,
WHERE prev_timestamp IS NOT NULL
GROUP BY date
ORDER BY date DESC
`
`, s.Tbl("transactions"))
rows, err := s.db.QueryContext(ctx, query, startDate, today)
if err != nil {
@@ -566,17 +578,17 @@ func (s *Service) GetContractDeploymentChart(ctx context.Context, days int) (*Ch
today := time.Now().UTC().Truncate(24 * time.Hour)
startDate := today.AddDate(0, 0, -days)
query := `
query := fmt.Sprintf(`
SELECT
DATE(first_seen) as date,
COUNT(*) as deployments
FROM cchain_addresses
FROM %s
WHERE is_contract = true
AND first_seen >= $1
AND first_seen < $2
GROUP BY DATE(first_seen)
ORDER BY date DESC
`
`, s.Tbl("addresses"))
rows, err := s.db.QueryContext(ctx, query, startDate, today)
if err != nil {
@@ -626,20 +638,21 @@ func (s *Service) GetTokenHoldersChart(ctx context.Context, tokenHash string, da
// This query counts unique holders by date based on transfer history
// It's an approximation since we don't track historical balances
query := `
tbl := s.Tbl("token_transfers")
query := fmt.Sprintf(`
SELECT
date,
COUNT(DISTINCT holder) as holder_count
FROM (
SELECT DATE(timestamp) as date, to_address as holder FROM cchain_token_transfers
SELECT DATE(timestamp) as date, to_address as holder FROM %s
WHERE token_hash = $1 AND timestamp >= $2 AND timestamp < $3
UNION
SELECT DATE(timestamp) as date, from_address as holder FROM cchain_token_transfers
SELECT DATE(timestamp) as date, from_address as holder FROM %s
WHERE token_hash = $1 AND timestamp >= $2 AND timestamp < $3
) sub
GROUP BY date
ORDER BY date DESC
`
`, tbl, tbl)
rows, err := s.db.QueryContext(ctx, query, tokenHash, startDate, today)
if err != nil {
@@ -685,15 +698,15 @@ func (s *Service) GetHourlyTransactionChart(ctx context.Context) (*ChartResponse
now := time.Now().UTC()
startTime := now.Add(-24 * time.Hour)
query := `
query := fmt.Sprintf(`
SELECT
DATE_TRUNC('hour', timestamp) as hour,
COUNT(*) as tx_count
FROM cchain_transactions
FROM %s
WHERE timestamp >= $1 AND timestamp < $2
GROUP BY DATE_TRUNC('hour', timestamp)
ORDER BY hour DESC
`
`, s.Tbl("transactions"))
rows, err := s.db.QueryContext(ctx, query, startTime, now)
if err != nil {
@@ -728,15 +741,15 @@ func (s *Service) GetHourlyGasChart(ctx context.Context) (*ChartResponse, error)
now := time.Now().UTC()
startTime := now.Add(-24 * time.Hour)
query := `
query := fmt.Sprintf(`
SELECT
DATE_TRUNC('hour', timestamp) as hour,
COALESCE(SUM(gas_used), 0) as gas_used
FROM cchain_transactions
FROM %s
WHERE timestamp >= $1 AND timestamp < $2
GROUP BY DATE_TRUNC('hour', timestamp)
ORDER BY hour DESC
`
`, s.Tbl("transactions"))
rows, err := s.db.QueryContext(ctx, query, startTime, now)
if err != nil {
+42 -31
View File
@@ -83,12 +83,23 @@ type RedirectResponse struct {
// Engine provides search functionality across blockchain entities
type Engine struct {
db *sql.DB
db *sql.DB
prefix string // table name prefix, e.g. "cchain", "zoo", "hanzo"
}
// NewEngine creates a new search engine
func NewEngine(db *sql.DB) *Engine {
return &Engine{db: db}
// Tbl returns the prefixed table name, e.g. Tbl("blocks") returns "cchain_blocks"
func (e *Engine) Tbl(name string) string {
return e.prefix + "_" + name
}
// NewEngine creates a new search engine.
// If chainSlug is empty, defaults to "cchain" for backwards compatibility.
func NewEngine(db *sql.DB, chainSlug ...string) *Engine {
prefix := "cchain"
if len(chainSlug) > 0 && chainSlug[0] != "" {
prefix = chainSlug[0]
}
return &Engine{db: db, prefix: prefix}
}
// Config holds search configuration
@@ -262,11 +273,11 @@ func (e *Engine) searchByAddressHash(ctx context.Context, address string, limit
var results []Result
// Search for address
row := e.db.QueryRowContext(ctx, `
row := e.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT hash, balance, tx_count, is_contract, created_at
FROM cchain_addresses
FROM %s
WHERE hash = $1
`, address)
`, e.Tbl("addresses")), address)
var addr struct {
Hash string
@@ -293,11 +304,11 @@ func (e *Engine) searchByAddressHash(ctx context.Context, address string, limit
}
// Search for token at this address
row = e.db.QueryRowContext(ctx, `
row = e.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT address, name, symbol, token_type, holder_count, total_supply, decimals
FROM cchain_tokens
FROM %s
WHERE address = $1
`, address)
`, e.Tbl("tokens")), address)
var token struct {
Address string
@@ -331,11 +342,11 @@ func (e *Engine) searchByTxHash(ctx context.Context, txHash string, limit int) (
txHash = strings.ToLower(txHash)
var results []Result
row := e.db.QueryRowContext(ctx, `
row := e.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT hash, block_number, timestamp
FROM cchain_transactions
FROM %s
WHERE hash = $1
`, txHash)
`, e.Tbl("transactions")), txHash)
var tx struct {
Hash string
@@ -423,16 +434,16 @@ func (e *Engine) searchByText(ctx context.Context, query string, limit int) ([]R
searchTerm := prepareSearchTerm(query)
// Search tokens by name or symbol
rows, err := e.db.QueryContext(ctx, `
rows, err := e.db.QueryContext(ctx, fmt.Sprintf(`
SELECT address, name, symbol, token_type, holder_count, total_supply
FROM cchain_tokens
FROM %s
WHERE
to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(symbol, '')) @@ to_tsquery($1)
OR LOWER(name) LIKE LOWER($2)
OR LOWER(symbol) LIKE LOWER($2)
ORDER BY holder_count DESC NULLS LAST
LIMIT $3
`, searchTerm, "%"+query+"%", limit)
`, e.Tbl("tokens")), searchTerm, "%"+query+"%", limit)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("search tokens: %w", err)
}
@@ -477,12 +488,12 @@ func (e *Engine) searchByText(ctx context.Context, query string, limit int) ([]R
}
// Search verified contracts by name (if contract verification table exists)
contractRows, err := e.db.QueryContext(ctx, `
contractRows, err := e.db.QueryContext(ctx, fmt.Sprintf(`
SELECT a.hash, a.is_contract, a.tx_count, a.created_at
FROM cchain_addresses a
FROM %s a
WHERE a.is_contract = true
LIMIT $1
`, limit)
`, e.Tbl("addresses")), limit)
if err != nil && err != sql.ErrNoRows {
// Contracts table might not exist, skip silently
return results, nil
@@ -524,16 +535,16 @@ func (e *Engine) SearchTokens(ctx context.Context, query string, limit int) ([]R
searchTerm := prepareSearchTerm(query)
rows, err := e.db.QueryContext(ctx, `
rows, err := e.db.QueryContext(ctx, fmt.Sprintf(`
SELECT address, name, symbol, token_type, holder_count, total_supply, tx_count
FROM cchain_tokens
FROM %s
WHERE
to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(symbol, '')) @@ to_tsquery($1)
OR LOWER(name) LIKE LOWER($2)
OR LOWER(symbol) LIKE LOWER($2)
ORDER BY holder_count DESC NULLS LAST, tx_count DESC
LIMIT $3
`, searchTerm, "%"+query+"%", limit)
`, e.Tbl("tokens")), searchTerm, "%"+query+"%", limit)
if err != nil {
return nil, fmt.Errorf("search tokens: %w", err)
}
@@ -583,13 +594,13 @@ func (e *Engine) SearchAddresses(ctx context.Context, query string, limit int) (
query = "0x" + query
}
rows, err := e.db.QueryContext(ctx, `
rows, err := e.db.QueryContext(ctx, fmt.Sprintf(`
SELECT hash, balance, tx_count, is_contract, created_at
FROM cchain_addresses
FROM %s
WHERE hash LIKE $1
ORDER BY tx_count DESC
LIMIT $2
`, query+"%", limit)
`, e.Tbl("addresses")), query+"%", limit)
if err != nil {
return nil, fmt.Errorf("search addresses: %w", err)
}
@@ -632,17 +643,17 @@ func (e *Engine) SearchAddresses(ctx context.Context, query string, limit int) (
func (e *Engine) transactionExists(ctx context.Context, hash string) (bool, error) {
var exists bool
err := e.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM cchain_transactions WHERE hash = $1)
`, strings.ToLower(hash)).Scan(&exists)
err := e.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT EXISTS(SELECT 1 FROM %s WHERE hash = $1)
`, e.Tbl("transactions")), strings.ToLower(hash)).Scan(&exists)
return exists, err
}
func (e *Engine) addressExists(ctx context.Context, hash string) (bool, error) {
var exists bool
err := e.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM cchain_addresses WHERE hash = $1)
`, strings.ToLower(hash)).Scan(&exists)
err := e.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT EXISTS(SELECT 1 FROM %s WHERE hash = $1)
`, e.Tbl("addresses")), strings.ToLower(hash)).Scan(&exists)
return exists, err
}
+57 -45
View File
@@ -90,6 +90,12 @@ type Service struct {
cache *Cache
updateMutex sync.Mutex
config Config
prefix string // table name prefix, e.g. "cchain", "zoo", "hanzo"
}
// Tbl returns the prefixed table name, e.g. Tbl("transactions") returns "cchain_transactions"
func (s *Service) Tbl(name string) string {
return s.prefix + "_" + name
}
// Config holds statistics service configuration
@@ -137,12 +143,18 @@ func NewCache(ttl time.Duration) *Cache {
}
}
// NewService creates a new statistics service
func NewService(db *sql.DB, config Config) *Service {
// NewService creates a new statistics service.
// If chainSlug is empty, defaults to "cchain" for backwards compatibility.
func NewService(db *sql.DB, config Config, chainSlug ...string) *Service {
prefix := "cchain"
if len(chainSlug) > 0 && chainSlug[0] != "" {
prefix = chainSlug[0]
}
return &Service{
db: db,
cache: NewCache(config.CacheTTL),
config: config,
prefix: prefix,
}
}
@@ -174,14 +186,14 @@ func (s *Service) updateGasPrices(ctx context.Context) (*GasPrices, error) {
s.cache.mu.RUnlock()
// Get recent transactions with gas prices
rows, err := s.db.QueryContext(ctx, `
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`
SELECT t.gas_price, t.gas_used, b.timestamp
FROM cchain_transactions t
FROM %s t
JOIN blocks b ON b.height = t.block_number
WHERE t.gas_price IS NOT NULL AND t.gas_price != '0'
ORDER BY t.block_number DESC
LIMIT $1
`, s.config.GasPriceBlocks*50) // Assume ~50 txs per block
`, s.Tbl("transactions")), s.config.GasPriceBlocks*50) // Assume ~50 txs per block
if err != nil {
return nil, fmt.Errorf("query gas prices: %w", err)
}
@@ -362,53 +374,53 @@ func (s *Service) updateCounters(ctx context.Context) (*Counters, error) {
}
// Total transactions
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM cchain_transactions`).Scan(&counters.TotalTransactions)
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s`, s.Tbl("transactions"))).Scan(&counters.TotalTransactions)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("count transactions: %w", err)
}
// Total addresses
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM cchain_addresses`).Scan(&counters.TotalAddresses)
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s`, s.Tbl("addresses"))).Scan(&counters.TotalAddresses)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("count addresses: %w", err)
}
// Total contracts
err = s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM cchain_addresses WHERE is_contract = true
`).Scan(&counters.TotalContracts)
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COUNT(*) FROM %s WHERE is_contract = true
`, s.Tbl("addresses"))).Scan(&counters.TotalContracts)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("count contracts: %w", err)
}
// Total tokens
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM cchain_tokens`).Scan(&counters.TotalTokens)
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s`, s.Tbl("tokens"))).Scan(&counters.TotalTokens)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("count tokens: %w", err)
}
// Total token transfers
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM cchain_token_transfers`).Scan(&counters.TotalTokenTransfers)
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s`, s.Tbl("token_transfers"))).Scan(&counters.TotalTokenTransfers)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("count token transfers: %w", err)
}
// TPS 24h
err = s.db.QueryRowContext(ctx, `
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COUNT(*)::float / 86400.0
FROM cchain_transactions
FROM %s
WHERE timestamp > NOW() - INTERVAL '24 hours'
`).Scan(&counters.TPS24h)
`, s.Tbl("transactions"))).Scan(&counters.TPS24h)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("calculate tps: %w", err)
}
// Gas used 24h
err = s.db.QueryRowContext(ctx, `
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COALESCE(SUM(gas_used), 0)
FROM cchain_transactions
FROM %s
WHERE timestamp > NOW() - INTERVAL '24 hours'
`).Scan(&counters.GasUsed24h)
`, s.Tbl("transactions"))).Scan(&counters.GasUsed24h)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("sum gas used: %w", err)
}
@@ -530,17 +542,17 @@ func (s *Service) GetTokenStats(ctx context.Context, tokenAddress string) (*Toke
}
// Get holder count
err := s.db.QueryRowContext(ctx, `
SELECT holder_count FROM cchain_tokens WHERE address = $1
`, tokenAddress).Scan(&stats.HolderCount)
err := s.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT holder_count FROM %s WHERE address = $1
`, s.Tbl("tokens")), tokenAddress).Scan(&stats.HolderCount)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("get holder count: %w", err)
}
// Get transfer count
err = s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM cchain_token_transfers WHERE token_address = $1
`, tokenAddress).Scan(&stats.TransferCount)
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COUNT(*) FROM %s WHERE token_address = $1
`, s.Tbl("token_transfers")), tokenAddress).Scan(&stats.TransferCount)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("get transfer count: %w", err)
}
@@ -559,45 +571,45 @@ func (s *Service) GetAddressStats(ctx context.Context, address string) (*Address
stats := &AddressStats{Address: address}
// Get transaction count
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM cchain_transactions
err := s.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COUNT(*) FROM %s
WHERE tx_from = $1 OR tx_to = $1
`, address).Scan(&stats.TransactionCount)
`, s.Tbl("transactions")), address).Scan(&stats.TransactionCount)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("count transactions: %w", err)
}
// Get token transfer count
err = s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM cchain_token_transfers
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COUNT(*) FROM %s
WHERE tx_from = $1 OR tx_to = $1
`, address).Scan(&stats.TokenTransferCount)
`, s.Tbl("token_transfers")), address).Scan(&stats.TokenTransferCount)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("count token transfers: %w", err)
}
// Get internal tx count
err = s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM cchain_internal_transactions
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COUNT(*) FROM %s
WHERE tx_from = $1 OR tx_to = $1
`, address).Scan(&stats.InternalTxCount)
`, s.Tbl("internal_transactions")), address).Scan(&stats.InternalTxCount)
if err != nil && err != sql.ErrNoRows {
// Table might not exist, continue
}
// Get total gas used
err = s.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(gas_used), 0) FROM cchain_transactions
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COALESCE(SUM(gas_used), 0) FROM %s
WHERE tx_from = $1
`, address).Scan(&stats.GasUsed)
`, s.Tbl("transactions")), address).Scan(&stats.GasUsed)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("sum gas used: %w", err)
}
// Get balance
err = s.db.QueryRowContext(ctx, `
SELECT balance FROM cchain_addresses WHERE hash = $1
`, address).Scan(&stats.Balance)
err = s.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT balance FROM %s WHERE hash = $1
`, s.Tbl("addresses")), address).Scan(&stats.Balance)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("get balance: %w", err)
}
@@ -614,13 +626,13 @@ func (s *Service) GetDailyTransactionStats(ctx context.Context, days int) ([]Dai
days = 365
}
rows, err := s.db.QueryContext(ctx, `
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`
SELECT DATE(timestamp) as date, COUNT(*) as count
FROM cchain_transactions
FROM %s
WHERE timestamp > NOW() - make_interval(days => $1)
GROUP BY DATE(timestamp)
ORDER BY date DESC
`, days)
`, s.Tbl("transactions")), days)
if err != nil {
return nil, fmt.Errorf("query daily stats: %w", err)
}
@@ -646,13 +658,13 @@ type DailyStats struct {
// GetHourlyGasUsage returns hourly gas usage for the last 24 hours
func (s *Service) GetHourlyGasUsage(ctx context.Context) ([]HourlyStats, error) {
rows, err := s.db.QueryContext(ctx, `
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`
SELECT DATE_TRUNC('hour', timestamp) as hour, COALESCE(SUM(gas_used), 0) as gas_used
FROM cchain_transactions
FROM %s
WHERE timestamp > NOW() - INTERVAL '24 hours'
GROUP BY DATE_TRUNC('hour', timestamp)
ORDER BY hour DESC
`)
`, s.Tbl("transactions")))
if err != nil {
return nil, fmt.Errorf("query hourly gas: %w", err)
}