Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ go.work.sum
infra-config.yaml
infra-config.*.yaml
!infra-config.example.yaml
chaintracks-config.yaml
chaintracks-config.*.yaml
!chaintracks-config.example.yaml

# SQLite databases
storage.sqlite
Expand Down
41 changes: 38 additions & 3 deletions cmd/chaintracks/main.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,55 @@
package main

import (
"context"
"fmt"
"log/slog"
"os"

"github.com/bsv-blockchain/go-wallet-toolbox/internal/config"
"github.com/bsv-blockchain/go-wallet-toolbox/pkg/defs"
"github.com/bsv-blockchain/go-wallet-toolbox/pkg/services/chaintracks"
)

const (
envPrefix = "CHAINTRACKS"
configFile = "chaintracks-config.yaml"
)

func main() {
config := defs.DefaultChaintracksServerConfig() // TODO: Allow loading from file/env
server, err := chaintracks.NewServer(slog.Default(), config)
ctx := context.Background()
loader := config.NewLoader(defs.DefaultChaintracksServerConfig, envPrefix)

// optionally load from config file if it exists
_, err := os.Stat(configFile)
if !os.IsNotExist(err) {
err := loader.SetConfigFilePath(configFile)
if err != nil {
panic(fmt.Errorf("failed to set config file path: %w", err))
}
slog.Default().Info("loading config from file", "file", configFile)
} else {
slog.Default().Info("config file not found, proceeding with environment variables and defaults")
}

cfg, err := loader.Load()
if err != nil {
panic(fmt.Errorf("failed to load config: %w", err))
}

err = cfg.Validate()
if err != nil {
panic(fmt.Errorf("config validation failed: %w", err))
}

logger := chaintracks.MakeLogger(cfg.Logging)

server, err := chaintracks.NewServer(logger, cfg)
if err != nil {
panic(err)
}

if err := server.ListenAndServe(); err != nil {
if err := server.ListenAndServe(ctx); err != nil {
panic(err)
}
}
25 changes: 25 additions & 0 deletions cmd/chaintracks_config_gen/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package main

import (
"flag"
"fmt"
"log"

"github.com/bsv-blockchain/go-wallet-toolbox/internal/config"
"github.com/bsv-blockchain/go-wallet-toolbox/pkg/defs"
)

func main() {
outputFile := flag.String("output-file", "chaintracks-config.yaml", "Output configuration file path")
flag.StringVar(outputFile, "o", "chaintracks-config.yaml", "Output configuration file path (shorthand)")
flag.Parse()

cfg := defs.DefaultChaintracksServerConfig()

err := config.ToYAMLFile(cfg, *outputFile)
if err != nil {
log.Fatalf("Error writing configuration: %v\n", err)
}

fmt.Printf("Chaintracks configuration written to %s\n", *outputFile)
}
10 changes: 8 additions & 2 deletions pkg/defs/chaintracks.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ func (c *CDNBulkIngestorConfig) Validate() error {
// ChaintracksServerConfig holds the configuration for the Chaintracks HTTP server and its underlying service settings.
type ChaintracksServerConfig struct {
ChaintracksServiceConfig
Port uint `mapstructure:"port"`
RoutingPrefix string `mapstructure:"routing_prefix"`
Port uint `mapstructure:"port"`
RoutingPrefix string `mapstructure:"routing_prefix"`
Logging LogConfig `mapstructure:"logging"`
}

// Validate checks if the ChaintracksServerConfig fields contain valid values and returns an error if any are invalid.
Expand All @@ -149,6 +150,10 @@ func (c *ChaintracksServerConfig) Validate() error {
return fmt.Errorf("invalid chaintracks service config: %w", err)
}

if err := c.Logging.Validate(); err != nil {
return fmt.Errorf("invalid logging config: %w", err)
}

const maxPort = 65535
if c.Port == 0 || c.Port > maxPort {
return fmt.Errorf("invalid port: %d", c.Port)
Expand All @@ -164,5 +169,6 @@ func DefaultChaintracksServerConfig() ChaintracksServerConfig {
return ChaintracksServerConfig{
Port: 3011,
ChaintracksServiceConfig: DefaultChaintracksServiceConfig(),
Logging: DefaultLogConfig(),
}
}
33 changes: 33 additions & 0 deletions pkg/defs/logging.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package defs

import (
"fmt"
)

// LogLevel represents different log levels which can be configured.
type LogLevel string

Expand Down Expand Up @@ -29,3 +33,32 @@ const (
func ParseHandlerTypeStr(handlerType string) (LogHandler, error) {
return parseEnumCaseInsensitive(handlerType, JSONHandler, TextHandler)
}

// LogConfig is the configuration for the logging
type LogConfig struct {
Enabled bool `mapstructure:"enabled"`
Level LogLevel `mapstructure:"level"`
Handler LogHandler `mapstructure:"handler"`
}

// Validate validates the HTTP configuration
func (c *LogConfig) Validate() (err error) {
if c.Level, err = ParseLogLevelStr(string(c.Level)); err != nil {
return fmt.Errorf("invalid log level: %w", err)
}

if c.Handler, err = ParseHandlerTypeStr(string(c.Handler)); err != nil {
return fmt.Errorf("invalid log handler: %w", err)
}

return nil
}

// DefaultLogConfig returns a LogConfig with logging enabled, level set to info, and using the JSON handler for output.
func DefaultLogConfig() LogConfig {
return LogConfig{
Enabled: true,
Level: LogLevelInfo,
Handler: JSONHandler,
}
}
30 changes: 3 additions & 27 deletions pkg/infra/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type Config struct {
FeeModel defs.FeeModel `mapstructure:"fee_model"`
DBConfig defs.Database `mapstructure:"db"`
HTTPConfig HTTPConfig `mapstructure:"http"`
Logging LogConfig `mapstructure:"logging"`
Logging defs.LogConfig `mapstructure:"logging"`
Commission defs.Commission `mapstructure:"commission"`
Services defs.WalletServices `mapstructure:"wallet_services"`
Monitor defs.Monitor `mapstructure:"monitor"`
Expand Down Expand Up @@ -53,13 +53,6 @@ func (c *HTTPConfig) Validate() error {
return nil
}

// LogConfig is the configuration for the logging
type LogConfig struct {
Enabled bool `mapstructure:"enabled"`
Level defs.LogLevel `mapstructure:"level"`
Handler defs.LogHandler `mapstructure:"handler"`
}

// Defaults returns the default configuration
func Defaults() Config {
network := defs.NetworkMainnet
Expand All @@ -74,12 +67,8 @@ func Defaults() Config {
Port: 8100,
RequestPrice: 0,
},
FeeModel: defs.DefaultFeeModel(),
Logging: LogConfig{
Enabled: true,
Level: defs.LogLevelInfo,
Handler: defs.JSONHandler,
},
FeeModel: defs.DefaultFeeModel(),
Logging: defs.DefaultLogConfig(),
Commission: defs.DefaultCommission(),
Services: defs.DefaultServicesConfig(network),
Monitor: defs.DefaultMonitorConfig(),
Expand Down Expand Up @@ -153,19 +142,6 @@ func (c *DBConfig) Validate() (err error) {
return nil
}

// Validate validates the HTTP configuration
func (c *LogConfig) Validate() (err error) {
if c.Level, err = defs.ParseLogLevelStr(string(c.Level)); err != nil {
return fmt.Errorf("invalid log level: %w", err)
}

if c.Handler, err = defs.ParseHandlerTypeStr(string(c.Handler)); err != nil {
return fmt.Errorf("invalid log handler: %w", err)
}

return nil
}

// ToYAMLFile writes the configuration to a YAML file
func (c *Config) ToYAMLFile(filename string) error {
err := config.ToYAMLFile(c, filename)
Expand Down
138 changes: 121 additions & 17 deletions pkg/services/chaintracks/chaintracks_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"log/slog"
"net/http"

"github.com/bsv-blockchain/go-wallet-toolbox/pkg/defs"
"github.com/bsv-blockchain/go-wallet-toolbox/pkg/internal/logging"
servercommon "github.com/bsv-blockchain/go-wallet-toolbox/pkg/internal/server"
"github.com/bsv-blockchain/go-wallet-toolbox/pkg/services/chaintracks/models"
Expand All @@ -16,28 +15,31 @@ import (
// Handler implements the HTTP API endpoints for Chaintracks services, including routing, logging, and config access.
// It embeds an HTTP multiplexer, logger, and validated service configuration for BSV network operations.
type Handler struct {
logger *slog.Logger
mux *http.ServeMux
config defs.ChaintracksServiceConfig
logger *slog.Logger
mux *http.ServeMux
service *Service
}

// NewHandler creates a new Handler with the provided logger and ChaintracksServiceConfig.
// NewHandler validates the config and registers HTTP handlers for root, robots.txt, and getChain endpoints.
// Returns an initialized Handler or an error if validation fails.
func NewHandler(logger *slog.Logger, config defs.ChaintracksServiceConfig) (*Handler, error) {
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid chaintracks service config: %w", err)
}

func NewHandler(logger *slog.Logger, service *Service) (*Handler, error) {
handler := &Handler{
logger: logging.Child(logger, "chaintracks_handler"),
mux: http.NewServeMux(),
config: config,
logger: logging.Child(logger, "chaintracks_handler"),
mux: http.NewServeMux(),
service: service,
}

handler.mux.HandleFunc("/robots.txt", handler.handleRobotsTxt)
handler.mux.HandleFunc("/", handler.handleRoot)
handler.mux.HandleFunc("/getChain", handler.handleGetChain)
handler.mux.HandleFunc("GET /robots.txt", handler.handleRobotsTxt)
handler.mux.HandleFunc("GET /", handler.handleRoot)
handler.mux.HandleFunc("GET /getChain", handler.handleGetChain)
handler.mux.HandleFunc("GET /getInfo", handler.handleGetInfo)
handler.mux.HandleFunc("GET /getPresentHeight", handler.handlePresentHeight)
handler.mux.HandleFunc("GET /findChainTipHashHex", handler.handleFindTipHashHex)
handler.mux.HandleFunc("GET /findHeaderHexForHeight", handler.handleFindHeaderHexForHeight)

// FIXME: in TS the endpoint is named findChainTipHeaderHex but it returns full JSON, not the hex
handler.mux.HandleFunc("GET /findChainTipHeaderHex", handler.handleFindChainTipHeader)

return handler, nil
}
Expand All @@ -56,7 +58,7 @@ func (h *Handler) handleRobotsTxt(w http.ResponseWriter, r *http.Request) {

func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
if _, err := fmt.Fprintf(w, "Chaintracks %sNet Block Header Service", string(h.config.Chain)); err != nil {
if _, err := fmt.Fprintf(w, "Chaintracks %sNet Block Header Service", string(h.service.GetChain())); err != nil {
h.logger.Error("failed to write root response", slog.String("error", err.Error()))
}
}
Expand All @@ -65,7 +67,109 @@ func (h *Handler) handleGetChain(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

response := models.ResponseFrame[string]{
Value: to.Ptr(string(h.config.Chain)),
Value: to.Ptr(string(h.service.GetChain())),
Status: models.ResponseStatusSuccess,
}

h.writeJSONResponse(w, http.StatusOK, response)
}

func (h *Handler) handleGetInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

info, err := h.service.GetInfo(r.Context())
if err != nil {
h.logger.Error("failed to get info", slog.String("error", err.Error()))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

response := models.ResponseFrame[models.InfoResponse]{
Value: info,
Status: models.ResponseStatusSuccess,
}

h.writeJSONResponse(w, http.StatusOK, response)
}

func (h *Handler) handlePresentHeight(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

height, err := h.service.GetPresentHeight(r.Context())
if err != nil {
h.logger.Error("failed to get present height", slog.String("error", err.Error()))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

response := models.ResponseFrame[uint]{
Value: to.Ptr(height),
Status: models.ResponseStatusSuccess,
}

h.writeJSONResponse(w, http.StatusOK, response)
}

func (h *Handler) handleFindChainTipHeader(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

tipHeader, err := h.service.FindChainTipHeader(r.Context())
if err != nil {
h.logger.Error("failed to find chain tip header hex", slog.String("error", err.Error()))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

response := models.ResponseFrame[models.BlockHeader]{
Value: liveBlockHeaderToBlockHeaderDTO(tipHeader),
Status: models.ResponseStatusSuccess,
}

h.writeJSONResponse(w, http.StatusOK, response)
}

func (h *Handler) handleFindTipHashHex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

tipHash, err := h.service.FindChainTipHeader(r.Context())
if err != nil {
h.logger.Error("failed to find chain tip hash hex", slog.String("error", err.Error()))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

response := models.ResponseFrame[string]{
Value: to.Ptr(tipHash.Hash),
Status: models.ResponseStatusSuccess,
}

h.writeJSONResponse(w, http.StatusOK, response)
}

func (h *Handler) handleFindHeaderHexForHeight(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

heightParam := r.URL.Query().Get("height")
if heightParam == "" {
http.Error(w, "Missing 'height' query parameter", http.StatusBadRequest)
return
}

var height uint
if _, err := fmt.Sscanf(heightParam, "%d", &height); err != nil {
http.Error(w, "Invalid 'height' query parameter", http.StatusBadRequest)
return
}

header, err := h.service.FindHeaderForHeight(r.Context(), height)
if err != nil {
h.logger.Error("failed to find header hex for height", slog.String("error", err.Error()))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

response := models.ResponseFrame[models.BlockHeader]{
Value: liveBlockHeaderToBlockHeaderDTO(header),
Status: models.ResponseStatusSuccess,
}

Expand Down
Loading
Loading