mirror of
https://github.com/luxfi/indexer.git
synced 2026-09-24 00:25:37 +00:00
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:
+2
-1
@@ -39,4 +39,5 @@ AGENTS.md
|
||||
CLAUDE.md
|
||||
GEMINI.md
|
||||
QWEN.md
|
||||
indexer
|
||||
/indexer
|
||||
/evmchains
|
||||
|
||||
+4
-1
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user