diff --git a/backend-go/.gitignore b/backend-go/.gitignore new file mode 100644 index 0000000..a9f210f --- /dev/null +++ b/backend-go/.gitignore @@ -0,0 +1,11 @@ +bin/ +.env +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +vendor/ +tmp/ diff --git a/backend-go/Makefile b/backend-go/Makefile new file mode 100644 index 0000000..3c14c94 --- /dev/null +++ b/backend-go/Makefile @@ -0,0 +1,43 @@ +.PHONY: build build-api build-poller run dev poller poller-dev dev-all clean tidy + +# Build both binaries +build: build-api build-poller + +build-api: + go build -o bin/api ./cmd/api + +build-poller: + go build -o bin/poller ./cmd/poller + +# Run API server +run: build-api + ./bin/api + +# Run API server with auto-reload (requires air: go install github.com/air-verse/air@latest) +dev: + air -c .air.api.toml 2>/dev/null || go run ./cmd/api + +# Run poller +poller: build-poller + ./bin/poller + +poller-dev: + air -c .air.poller.toml 2>/dev/null || go run ./cmd/poller + +# Run both API and poller concurrently +dev-all: + @echo "Starting API and Poller..." + @(go run ./cmd/api &) && go run ./cmd/poller + +clean: + rm -rf bin/ + +tidy: + go mod tidy + +# Database setup +db-setup: + psql -d koin_ping_dev -f infra/schema.sql + +vet: + go vet ./... diff --git a/backend-go/cmd/api/main.go b/backend-go/cmd/api/main.go new file mode 100644 index 0000000..1ba221b --- /dev/null +++ b/backend-go/cmd/api/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "github.com/joho/godotenv" + "github.com/kjannette/koin-ping/backend-go/internal/config" + "github.com/kjannette/koin-ping/backend-go/internal/database" + "github.com/kjannette/koin-ping/backend-go/internal/firebase" + "github.com/kjannette/koin-ping/backend-go/internal/handlers" + "github.com/kjannette/koin-ping/backend-go/internal/middleware" + "github.com/kjannette/koin-ping/backend-go/internal/models" +) + +func main() { + _ = godotenv.Load() // .env is optional; env vars can also be set externally + + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + pool, err := database.Connect(cfg.DSN()) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer database.Close() + + if err := firebase.Init(cfg.FirebaseProjectID); err != nil { + log.Fatalf("Failed to initialize Firebase: %v", err) + } + + addressModel := models.NewAddressModel(pool) + alertRuleModel := models.NewAlertRuleModel(pool) + alertEventModel := models.NewAlertEventModel(pool) + notifConfigModel := models.NewNotificationConfigModel(pool) + + addressHandler := handlers.NewAddressHandler(addressModel) + alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel) + alertEventHandler := handlers.NewAlertEventHandler(alertEventModel) + notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel) + + mux := http.NewServeMux() + + // Public routes + mux.HandleFunc("GET /api/health", handlers.HealthCheck) + mux.HandleFunc("GET /api/status", handlers.SystemStatus) + + // Authenticated routes — addresses + mux.Handle("POST /api/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create))) + mux.Handle("GET /api/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List))) + mux.Handle("DELETE /api/addresses/{addressId}", middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) + + // Authenticated routes — alert rules + mux.Handle("POST /api/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) + mux.Handle("GET /api/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) + mux.Handle("PATCH /api/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) + mux.Handle("DELETE /api/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove))) + + // Authenticated routes — alert events + mux.Handle("GET /api/alert-events", middleware.Authenticate(http.HandlerFunc(alertEventHandler.List))) + + // Authenticated routes — notification config + mux.Handle("GET /api/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) + mux.Handle("PUT /api/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) + mux.Handle("DELETE /api/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) + + handler := corsMiddleware(mux) + + addr := fmt.Sprintf(":%d", cfg.Port) + log.Printf("Server running on port %d", cfg.Port) + log.Printf("Environment: %s", cfg.NodeEnv) + + if err := http.ListenAndServe(addr, handler); err != nil { + log.Fatalf("Server failed: %v", err) + os.Exit(1) + } +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/backend-go/cmd/poller/main.go b/backend-go/cmd/poller/main.go new file mode 100644 index 0000000..9c941b8 --- /dev/null +++ b/backend-go/cmd/poller/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/joho/godotenv" + "github.com/kjannette/koin-ping/backend-go/internal/config" + "github.com/kjannette/koin-ping/backend-go/internal/database" + "github.com/kjannette/koin-ping/backend-go/internal/models" + "github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum" + "github.com/kjannette/koin-ping/backend-go/internal/services" +) + +func main() { + _ = godotenv.Load() + + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + if cfg.EthRPCURL == "" { + log.Fatal("ERROR: ETH_RPC_URL environment variable is required") + } + + pool, err := database.Connect(cfg.DSN()) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer database.Close() + + eth, err := ethereum.NewJsonRpcEthereum(cfg.EthRPCURL) + if err != nil { + log.Fatalf("Failed to create Ethereum observer: %v", err) + } + + addressModel := models.NewAddressModel(pool) + alertRuleModel := models.NewAlertRuleModel(pool) + alertEventModel := models.NewAlertEventModel(pool) + checkpointModel := models.NewCheckpointModel(pool) + notifConfigModel := models.NewNotificationConfigModel(pool) + + observer := services.NewObserverService(eth, addressModel, checkpointModel) + evaluator := services.NewEvaluatorService(eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + log.Println() + log.Println(strings.Repeat("=", 60)) + log.Println("Shutting down poller gracefully...") + log.Println(strings.Repeat("=", 60)) + cancel() + }() + + interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond + + log.Println(strings.Repeat("=", 60)) + log.Println("Koin Ping Observer Poller Starting") + log.Println(strings.Repeat("=", 60)) + log.Printf("RPC URL: %s", cfg.EthRPCURL) + log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/1000) + log.Println(strings.Repeat("=", 60)) + + runCycle(ctx, observer, evaluator) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Println("Poller stopped") + return + case <-ticker.C: + runCycle(ctx, observer, evaluator) + } + } +} + +func runCycle(ctx context.Context, observer *services.ObserverService, evaluator *services.EvaluatorService) { + startTime := time.Now() + log.Printf("[%s] Starting observation cycle...", time.Now().UTC().Format(time.RFC3339)) + + observations, err := observer.RunOnce(ctx) + if err != nil { + log.Printf("[%s] Observation cycle failed: %v", time.Now().UTC().Format(time.RFC3339), err) + return + } + + alertsFired, err := evaluator.Evaluate(ctx, observations) + if err != nil { + log.Printf("[%s] Evaluation failed: %v", time.Now().UTC().Format(time.RFC3339), err) + return + } + + duration := time.Since(startTime) + log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s", + time.Now().UTC().Format(time.RFC3339), + len(observations), alertsFired, + fmt.Sprintf("%dms", duration.Milliseconds())) +} diff --git a/backend-go/go.mod b/backend-go/go.mod new file mode 100644 index 0000000..139e67d --- /dev/null +++ b/backend-go/go.mod @@ -0,0 +1,68 @@ +module github.com/kjannette/koin-ping/backend-go + +go 1.25.0 + +require ( + firebase.google.com/go/v4 v4.19.0 + github.com/jackc/pgx/v5 v5.8.0 + google.golang.org/api v0.269.0 +) + +require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/firestore v1.21.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/longrunning v0.8.0 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + cloud.google.com/go/storage v1.56.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect + github.com/MicahParks/keyfunc v1.9.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/appengine/v2 v2.0.6 // indirect + google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/grpc v1.79.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/backend-go/go.sum b/backend-go/go.sum new file mode 100644 index 0000000..3cb2390 --- /dev/null +++ b/backend-go/go.sum @@ -0,0 +1,182 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM= +cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI= +cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= +firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= +github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= +google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= +google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= +google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend-go/infra/migrations/001_add_user_id.sql b/backend-go/infra/migrations/001_add_user_id.sql new file mode 100644 index 0000000..c2890cc --- /dev/null +++ b/backend-go/infra/migrations/001_add_user_id.sql @@ -0,0 +1,16 @@ + +ALTER TABLE addresses +ADD COLUMN user_id VARCHAR(128); + +UPDATE addresses +SET user_id = 'legacy_user' +WHERE user_id IS NULL; + +ALTER TABLE addresses +ALTER COLUMN user_id SET NOT NULL; + +CREATE INDEX idx_addresses_user_id ON addresses(user_id); + +ALTER TABLE addresses DROP CONSTRAINT IF EXISTS addresses_address_key; + +ALTER TABLE addresses ADD CONSTRAINT addresses_user_address_unique UNIQUE (user_id, address); diff --git a/backend-go/infra/migrations/002_add_notification_config.sql b/backend-go/infra/migrations/002_add_notification_config.sql new file mode 100644 index 0000000..127d532 --- /dev/null +++ b/backend-go/infra/migrations/002_add_notification_config.sql @@ -0,0 +1,13 @@ + +CREATE TABLE user_notification_configs ( + user_id VARCHAR(128) PRIMARY KEY, + discord_webhook_url TEXT, + telegram_chat_id VARCHAR(128), + telegram_bot_token VARCHAR(255), + email VARCHAR(255), + notification_enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_notification_configs_enabled ON user_notification_configs(notification_enabled); diff --git a/backend-go/infra/schema.sql b/backend-go/infra/schema.sql new file mode 100644 index 0000000..92cf66e --- /dev/null +++ b/backend-go/infra/schema.sql @@ -0,0 +1,71 @@ + +DROP TABLE IF EXISTS alert_events CASCADE; +DROP TABLE IF EXISTS alert_rules CASCADE; +DROP TABLE IF EXISTS address_checkpoints CASCADE; +DROP TABLE IF EXISTS user_notification_configs CASCADE; +DROP TABLE IF EXISTS addresses CASCADE; + +CREATE TABLE addresses ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL, -- Firebase user ID (multi-user support) + address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars) + label VARCHAR(255), -- Optional human-readable label + created_at TIMESTAMP DEFAULT NOW(), + + -- Unique users can track the same address independently + UNIQUE(user_id, address) +); + + +CREATE TABLE alert_rules ( + id SERIAL PRIMARY KEY, + address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below' + threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it) + enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT valid_alert_type CHECK ( + type IN ('incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below') + ), + + CONSTRAINT positive_threshold CHECK ( + threshold IS NULL OR threshold > 0 + ) +); + + +CREATE TABLE alert_events ( + id SERIAL PRIMARY KEY, + alert_rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + message TEXT NOT NULL, -- Human-readable alert message + address_label VARCHAR(255), -- Denormalized for display (avoids joins) + tx_hash VARCHAR(66), -- Transaction hash that triggered alert (nullable for balance_below) + timestamp TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE address_checkpoints ( + address_id INTEGER PRIMARY KEY REFERENCES addresses(id) ON DELETE CASCADE, + last_checked_block INTEGER NOT NULL, -- Last block number that was checked for this address + last_checked_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE user_notification_configs ( + user_id VARCHAR(128) PRIMARY KEY, + discord_webhook_url TEXT, -- Discord webhook URL (nullable) + telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable) + telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable, future use) + email VARCHAR(255), -- Email for notifications (nullable) + notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Create indexes for common queries +CREATE INDEX idx_addresses_user_id ON addresses(user_id); +CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id); +CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled); +CREATE INDEX idx_alert_events_timestamp ON alert_events(timestamp DESC); +CREATE INDEX idx_alert_events_alert_rule_id ON alert_events(alert_rule_id); +CREATE INDEX idx_address_checkpoints_last_checked_at ON address_checkpoints(last_checked_at); +CREATE INDEX idx_notification_configs_enabled ON user_notification_configs(notification_enabled); diff --git a/backend-go/internal/config/config.go b/backend-go/internal/config/config.go new file mode 100644 index 0000000..4f37d8e --- /dev/null +++ b/backend-go/internal/config/config.go @@ -0,0 +1,79 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +type Config struct { + Port int + DatabaseURL string + DBHost string + DBPort int + DBUser string + DBPassword string + DBName string + FirebaseProjectID string + EthRPCURL string + PollIntervalMS int + NodeEnv string +} + +func Load() (*Config, error) { + cfg := &Config{ + Port: getEnvInt("PORT", 3001), + DatabaseURL: os.Getenv("DATABASE_URL"), + DBHost: getEnv("DB_HOST", "localhost"), + DBPort: getEnvInt("DB_PORT", 5432), + DBUser: os.Getenv("DB_USER"), + DBPassword: os.Getenv("DB_PASSWORD"), + DBName: os.Getenv("DB_NAME"), + FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"), + EthRPCURL: os.Getenv("ETH_RPC_URL"), + PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", 60000), + NodeEnv: getEnv("NODE_ENV", "development"), + } + + if cfg.PollIntervalMS < 1000 { + return nil, fmt.Errorf("POLL_INTERVAL_MS must be >= 1000, got %d", cfg.PollIntervalMS) + } + + return cfg, nil +} + +func (c *Config) DSN() string { + if c.DatabaseURL != "" { + // pgx defaults to sslmode=prefer, which fails against local Postgres. + // Append sslmode=disable if not already specified. + if !strings.Contains(c.DatabaseURL, "sslmode=") { + sep := "?" + if strings.Contains(c.DatabaseURL, "?") { + sep = "&" + } + return c.DatabaseURL + sep + "sslmode=disable" + } + return c.DatabaseURL + } + return fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName, + ) +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return fallback +} diff --git a/backend-go/internal/database/postgres.go b/backend-go/internal/database/postgres.go new file mode 100644 index 0000000..7ba1cef --- /dev/null +++ b/backend-go/internal/database/postgres.go @@ -0,0 +1,52 @@ +package database + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var pool *pgxpool.Pool + +func Connect(dsn string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse database config: %w", err) + } + + cfg.MaxConns = 20 + cfg.MinConns = 2 + cfg.MaxConnIdleTime = 30 * time.Second + cfg.MaxConnLifetime = 5 * time.Minute + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + p, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("create connection pool: %w", err) + } + + if err := p.Ping(ctx); err != nil { + p.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + log.Println("Connected to PostgreSQL database") + pool = p + return p, nil +} + +func Pool() *pgxpool.Pool { + return pool +} + +func Close() { + if pool != nil { + pool.Close() + log.Println("Database connection pool closed") + } +} diff --git a/backend-go/internal/domain/types.go b/backend-go/internal/domain/types.go new file mode 100644 index 0000000..d0ded03 --- /dev/null +++ b/backend-go/internal/domain/types.go @@ -0,0 +1,115 @@ +package domain + +import "time" + +type Address struct { + ID int `json:"id"` + UserID string `json:"user_id"` + Address string `json:"address"` + Label *string `json:"label"` + CreatedAt time.Time `json:"created_at"` +} + +type AlertType string + +const ( + AlertIncomingTx AlertType = "incoming_tx" + AlertOutgoingTx AlertType = "outgoing_tx" + AlertLargeTransfer AlertType = "large_transfer" + AlertBalanceBelow AlertType = "balance_below" +) + +var ValidAlertTypes = []AlertType{ + AlertIncomingTx, + AlertOutgoingTx, + AlertLargeTransfer, + AlertBalanceBelow, +} + +var ThresholdRequiredTypes = []AlertType{ + AlertLargeTransfer, + AlertBalanceBelow, +} + +func IsValidAlertType(t string) bool { + for _, v := range ValidAlertTypes { + if string(v) == t { + return true + } + } + return false +} + +func IsThresholdRequired(t AlertType) bool { + for _, v := range ThresholdRequiredTypes { + if v == t { + return true + } + } + return false +} + +type AlertRule struct { + ID int `json:"id"` + AddressID int `json:"address_id"` + Type AlertType `json:"type"` + Threshold *float64 `json:"threshold"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` +} + +type AlertEvent struct { + ID int `json:"id"` + AlertRuleID int `json:"alert_rule_id"` + Message string `json:"message"` + AddressLabel *string `json:"address_label"` + TxHash *string `json:"tx_hash"` + Timestamp time.Time `json:"timestamp"` +} + +type AddressCheckpoint struct { + AddressID int `json:"address_id"` + LastCheckedBlock int `json:"last_checked_block"` + LastCheckedAt time.Time `json:"last_checked_at"` +} + +type CheckpointDetail struct { + AddressID int `json:"address_id"` + Address string `json:"address"` + Label *string `json:"label"` + LastCheckedBlock int `json:"last_checked_block"` + LastCheckedAt time.Time `json:"last_checked_at"` +} + +type NotificationConfig struct { + UserID string `json:"user_id"` + DiscordWebhookURL *string `json:"discord_webhook_url"` + TelegramChatID *string `json:"telegram_chat_id"` + TelegramBotToken *string `json:"telegram_bot_token,omitempty"` + Email *string `json:"email"` + NotificationEnabled bool `json:"notification_enabled"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type NormalizedTx struct { + Hash string `json:"hash"` + From string `json:"from"` + To *string `json:"to"` + Value string `json:"value"` // Wei as string for precision + BlockNumber int `json:"block_number"` + BlockTimestamp int64 `json:"block_timestamp"` +} + +type Direction string + +const ( + DirectionIncoming Direction = "incoming" + DirectionOutgoing Direction = "outgoing" +) + +type ObservedTx struct { + NormalizedTx + AddressID int `json:"address_id"` + Direction Direction `json:"direction"` +} diff --git a/backend-go/internal/firebase/admin.go b/backend-go/internal/firebase/admin.go new file mode 100644 index 0000000..4903ba8 --- /dev/null +++ b/backend-go/internal/firebase/admin.go @@ -0,0 +1,49 @@ +package firebase + +import ( + "context" + "fmt" + "sync" + + fb "firebase.google.com/go/v4" + "firebase.google.com/go/v4/auth" + "google.golang.org/api/option" +) + +var ( + authClient *auth.Client + once sync.Once + initErr error +) + +func Init(projectID string) error { + once.Do(func() { + ctx := context.Background() + + var app *fb.App + var err error + + if projectID != "" { + cfg := &fb.Config{ProjectID: projectID} + app, err = fb.NewApp(ctx, cfg, option.WithoutAuthentication()) + } else { + app, err = fb.NewApp(ctx, nil) + } + if err != nil { + initErr = fmt.Errorf("initialize firebase app: %w", err) + return + } + + authClient, err = app.Auth(ctx) + if err != nil { + initErr = fmt.Errorf("initialize firebase auth: %w", err) + return + } + }) + + return initErr +} + +func Auth() *auth.Client { + return authClient +} diff --git a/backend-go/internal/handlers/address.go b/backend-go/internal/handlers/address.go new file mode 100644 index 0000000..da3d6b7 --- /dev/null +++ b/backend-go/internal/handlers/address.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "regexp" + "strings" + + "github.com/kjannette/koin-ping/backend-go/internal/domain" + "github.com/kjannette/koin-ping/backend-go/internal/middleware" + "github.com/kjannette/koin-ping/backend-go/internal/models" +) + +var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`) + +type AddressHandler struct { + addresses *models.AddressModel +} + +func NewAddressHandler(addresses *models.AddressModel) *AddressHandler { + return &AddressHandler{addresses: addresses} +} + +func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var body struct { + Address string `json:"address"` + Label *string `json:"label"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") + return + } + + if body.Address == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Address is required") + return + } + + if !ethAddressRe.MatchString(body.Address) { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Ethereum address format") + return + } + + log.Printf("User %s creating address: %s", userID, body.Address) + + addr, err := h.addresses.Create(r.Context(), userID, body.Address, body.Label) + if err != nil { + if strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "unique") { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "You are already tracking this address") + return + } + log.Printf("Error creating address: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address") + return + } + + log.Printf("Address created with ID: %d", addr.ID) + writeJSON(w, http.StatusCreated, addr) +} + +func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + log.Printf("User %s listing addresses", userID) + + addresses, err := h.addresses.ListByUser(r.Context(), userID) + if err != nil { + log.Printf("Error listing addresses: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list addresses") + return + } + + if addresses == nil { + addresses = []domain.Address{} + } + + log.Printf("Found %d addresses for user", len(addresses)) + writeJSON(w, http.StatusOK, addresses) +} + +func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + addressID, ok := parseIntParam(r.PathValue("addressId")) + if !ok { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") + return + } + + log.Printf("User %s deleting address ID: %d", userID, addressID) + + deleted, err := h.addresses.Remove(r.Context(), addressID, userID) + if err != nil { + log.Printf("Error deleting address: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete address") + return + } + + if !deleted { + log.Printf("Address %d not found or not owned by user", addressID) + writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") + return + } + + log.Printf("Address %d deleted", addressID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend-go/internal/handlers/alert_event.go b/backend-go/internal/handlers/alert_event.go new file mode 100644 index 0000000..598e923 --- /dev/null +++ b/backend-go/internal/handlers/alert_event.go @@ -0,0 +1,89 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + "time" + + "github.com/kjannette/koin-ping/backend-go/internal/domain" + "github.com/kjannette/koin-ping/backend-go/internal/middleware" + "github.com/kjannette/koin-ping/backend-go/internal/models" +) + +type AlertEventHandler struct { + alertEvents *models.AlertEventModel +} + +func NewAlertEventHandler(alertEvents *models.AlertEventModel) *AlertEventHandler { + return &AlertEventHandler{alertEvents: alertEvents} +} + +func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + limitStr := r.URL.Query().Get("limit") + limit := 20 + if limitStr != "" { + if n, err := strconv.Atoi(limitStr); err == nil { + limit = n + } + } + + log.Printf("User %s listing alert events (limit: %d)", userID, limit) + + if limit < 1 || limit > 100 { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Limit must be between 1 and 100") + return + } + + events, err := h.alertEvents.ListRecentByUser(r.Context(), userID, limit) + if err != nil { + log.Printf("Error listing alert events: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alert events") + return + } + + log.Printf("Found %d alert events for user", len(events)) + + // MVP scaffolding: return mock data if DB is empty + if len(events) == 0 { + events = mockEvents(limit) + } + + writeJSON(w, http.StatusOK, events) +} + +func mockEvents(limit int) []domain.AlertEvent { + label1 := "Treasury Wallet" + label2 := "Cold Storage" + + mocks := []domain.AlertEvent{ + { + ID: 1, + AlertRuleID: 1, + Message: "Incoming transaction detected: 5.5 ETH received", + AddressLabel: &label1, + Timestamp: time.Now().Add(-2 * time.Hour), + }, + { + ID: 2, + AlertRuleID: 2, + Message: "Balance dropped below threshold: Current balance 8.2 ETH", + AddressLabel: &label1, + Timestamp: time.Now().Add(-5 * time.Hour), + }, + { + ID: 3, + AlertRuleID: 3, + Message: "Outgoing transaction detected: 2.0 ETH sent", + AddressLabel: &label2, + Timestamp: time.Now().Add(-24 * time.Hour), + }, + } + + if limit < len(mocks) { + return mocks[:limit] + } + return mocks +} diff --git a/backend-go/internal/handlers/alert_rule.go b/backend-go/internal/handlers/alert_rule.go new file mode 100644 index 0000000..01a8799 --- /dev/null +++ b/backend-go/internal/handlers/alert_rule.go @@ -0,0 +1,203 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + "github.com/kjannette/koin-ping/backend-go/internal/domain" + "github.com/kjannette/koin-ping/backend-go/internal/middleware" + "github.com/kjannette/koin-ping/backend-go/internal/models" +) + +type AlertRuleHandler struct { + alertRules *models.AlertRuleModel + addresses *models.AddressModel +} + +func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler { + return &AlertRuleHandler{alertRules: alertRules, addresses: addresses} +} + +func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + addressID, ok := parseIntParam(r.PathValue("addressId")) + if !ok { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") + return + } + + var body struct { + Type string `json:"type"` + Threshold *float64 `json:"threshold"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") + return + } + + log.Printf("User %s creating alert for address ID: %d", userID, addressID) + + if body.Type == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required") + return + } + + if !domain.IsValidAlertType(body.Type) { + types := make([]string, len(domain.ValidAlertTypes)) + for i, t := range domain.ValidAlertTypes { + types[i] = string(t) + } + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", + fmt.Sprintf("Invalid alert type. Must be one of: %s", strings.Join(types, ", "))) + return + } + + alertType := domain.AlertType(body.Type) + if domain.IsThresholdRequired(alertType) { + if body.Threshold == nil || *body.Threshold <= 0 { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", + fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type)) + return + } + } + + addr, err := h.addresses.FindByID(r.Context(), addressID, &userID) + if err != nil { + log.Printf("Error finding address: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") + return + } + if addr == nil { + log.Printf("Address %d not found or not owned by user", addressID) + writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") + return + } + + newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, body.Threshold) + if err != nil { + log.Printf("Error creating alert rule: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") + return + } + + log.Printf("Alert rule created with ID: %d", newAlert.ID) + writeJSON(w, http.StatusCreated, newAlert) +} + +func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + addressID, ok := parseIntParam(r.PathValue("addressId")) + if !ok { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") + return + } + + log.Printf("User %s listing alerts for address ID: %d", userID, addressID) + + addr, err := h.addresses.FindByID(r.Context(), addressID, &userID) + if err != nil { + log.Printf("Error finding address: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts") + return + } + if addr == nil { + log.Printf("Address %d not found or not owned by user", addressID) + writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") + return + } + + alerts, err := h.alertRules.ListByAddress(r.Context(), addressID) + if err != nil { + log.Printf("Error listing alerts: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts") + return + } + + if alerts == nil { + alerts = []domain.AlertRule{} + } + + log.Printf("Found %d alert rules", len(alerts)) + writeJSON(w, http.StatusOK, alerts) +} + +func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + alertID, ok := parseIntParam(r.PathValue("alertId")) + if !ok { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID") + return + } + + var body struct { + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") + return + } + + log.Printf("User %s updating alert ID: %d", userID, alertID) + + if body.Enabled == nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value") + return + } + + alert, err := h.alertRules.FindByID(r.Context(), alertID, &userID) + if err != nil { + log.Printf("Error finding alert: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") + return + } + if alert == nil { + log.Printf("Alert %d not found or not owned by user", alertID) + writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found") + return + } + + updated, err := h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled) + if err != nil { + log.Printf("Error updating alert: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") + return + } + + log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled) + writeJSON(w, http.StatusOK, updated) +} + +func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + alertID, ok := parseIntParam(r.PathValue("alertId")) + if !ok { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID") + return + } + + log.Printf("User %s deleting alert ID: %d", userID, alertID) + + alert, err := h.alertRules.FindByID(r.Context(), alertID, &userID) + if err != nil { + log.Printf("Error finding alert: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert") + return + } + if alert == nil { + log.Printf("Alert %d not found or not owned by user", alertID) + writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found") + return + } + + if _, err := h.alertRules.Remove(r.Context(), alertID); err != nil { + log.Printf("Error deleting alert: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert") + return + } + + log.Printf("Alert %d deleted", alertID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend-go/internal/handlers/helpers.go b/backend-go/internal/handlers/helpers.go new file mode 100644 index 0000000..0979e81 --- /dev/null +++ b/backend-go/internal/handlers/helpers.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" +) + +type errorBody struct { + Error string `json:"error"` + Message string `json:"message"` +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, errorBody{Error: code, Message: message}) +} + +func parseIntParam(s string) (int, bool) { + n, err := strconv.Atoi(s) + if err != nil { + return 0, false + } + return n, true +} diff --git a/backend-go/internal/handlers/notification_config.go b/backend-go/internal/handlers/notification_config.go new file mode 100644 index 0000000..52bd6b5 --- /dev/null +++ b/backend-go/internal/handlers/notification_config.go @@ -0,0 +1,127 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "regexp" + "strings" + + "github.com/kjannette/koin-ping/backend-go/internal/domain" + "github.com/kjannette/koin-ping/backend-go/internal/middleware" + "github.com/kjannette/koin-ping/backend-go/internal/models" +) + +var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + +type NotificationConfigHandler struct { + configs *models.NotificationConfigModel +} + +func NewNotificationConfigHandler(configs *models.NotificationConfigModel) *NotificationConfigHandler { + return &NotificationConfigHandler{configs: configs} +} + +func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + log.Printf("User %s getting notification config", userID) + + cfg, err := h.configs.GetConfig(r.Context(), userID) + if err != nil { + log.Printf("Error getting notification config: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get notification config") + return + } + + if cfg == nil { + writeJSON(w, http.StatusOK, domain.NotificationConfig{ + UserID: userID, + NotificationEnabled: true, + }) + return + } + + log.Println("Config found") + writeJSON(w, http.StatusOK, cfg) +} + +func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var body struct { + DiscordWebhookURL *string `json:"discord_webhook_url"` + TelegramChatID *string `json:"telegram_chat_id"` + Email *string `json:"email"` + NotificationEnabled *bool `json:"notification_enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") + return + } + + log.Printf("User %s updating notification config", userID) + + if body.DiscordWebhookURL == nil && body.TelegramChatID == nil && + body.Email == nil && body.NotificationEnabled == nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", + "At least one configuration field must be provided") + return + } + + if body.DiscordWebhookURL != nil && *body.DiscordWebhookURL != "" && + !strings.HasPrefix(*body.DiscordWebhookURL, "https://discord.com/api/webhooks/") { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", + "Invalid Discord webhook URL format") + return + } + + if body.Email != nil && *body.Email != "" && !emailRe.MatchString(*body.Email) { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", + "Invalid email address format") + return + } + + enabled := true + if body.NotificationEnabled != nil { + enabled = *body.NotificationEnabled + } + + cfg := domain.NotificationConfig{ + DiscordWebhookURL: body.DiscordWebhookURL, + TelegramChatID: body.TelegramChatID, + Email: body.Email, + NotificationEnabled: enabled, + } + + updated, err := h.configs.UpsertConfig(r.Context(), userID, cfg) + if err != nil { + log.Printf("Error updating notification config: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", + "Failed to update notification configuration") + return + } + + log.Println("Notification config updated") + writeJSON(w, http.StatusOK, updated) +} + +func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + log.Printf("User %s deleting notification config", userID) + + deleted, err := h.configs.Remove(r.Context(), userID) + if err != nil { + log.Printf("Error deleting notification config: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", + "Failed to delete notification configuration") + return + } + + if !deleted { + writeError(w, http.StatusNotFound, "NOT_FOUND", "No notification configuration found") + return + } + + log.Println("Notification config deleted") + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend-go/internal/handlers/status.go b/backend-go/internal/handlers/status.go new file mode 100644 index 0000000..621adaa --- /dev/null +++ b/backend-go/internal/handlers/status.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "net/http" + "time" +) + +func HealthCheck(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "service": "koin-ping-backend", + }) +} + +func SystemStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "latestBlock": 0, + "lag": 0, + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) +} diff --git a/backend-go/internal/middleware/auth.go b/backend-go/internal/middleware/auth.go new file mode 100644 index 0000000..2bce577 --- /dev/null +++ b/backend-go/internal/middleware/auth.go @@ -0,0 +1,98 @@ +package middleware + +import ( + "context" + "encoding/json" + "log" + "net/http" + "strings" + + fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase" +) + +type contextKey string + +const ( + UserIDKey contextKey = "user_id" + UserEmailKey contextKey = "user_email" +) + +type errorResponse struct { + Error string `json:"error"` + Message string `json:"message"` +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func Authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + log.Println("No Authorization header or invalid format") + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "No authentication token provided", + }) + return + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + if token == "" { + log.Println("Empty token") + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "Invalid token format", + }) + return + } + + log.Println("Verifying Firebase token...") + decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token) + if err != nil { + log.Printf("Token verification failed: %v", err) + + errMsg := err.Error() + if strings.Contains(errMsg, "expired") { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "TOKEN_EXPIRED", + Message: "Authentication token has expired", + }) + return + } + + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "Failed to verify authentication token", + }) + return + } + + userID := decoded.UID + email, _ := decoded.Claims["email"].(string) + + log.Printf("Token verified! User ID: %s, Email: %s", userID, email) + + ctx := context.WithValue(r.Context(), UserIDKey, userID) + ctx = context.WithValue(ctx, UserEmailKey, email) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func GetUserID(ctx context.Context) string { + if v, ok := ctx.Value(UserIDKey).(string); ok { + return v + } + return "" +} + +func GetUserEmail(ctx context.Context) string { + if v, ok := ctx.Value(UserEmailKey).(string); ok { + return v + } + return "" +} diff --git a/backend-go/internal/models/address.go b/backend-go/internal/models/address.go new file mode 100644 index 0000000..ef4125d --- /dev/null +++ b/backend-go/internal/models/address.go @@ -0,0 +1,117 @@ +package models + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/kjannette/koin-ping/backend-go/internal/domain" +) + +type AddressModel struct { + pool *pgxpool.Pool +} + +func NewAddressModel(pool *pgxpool.Pool) *AddressModel { + return &AddressModel{pool: pool} +} + +func (m *AddressModel) Create(ctx context.Context, userID, address string, label *string) (*domain.Address, error) { + var a domain.Address + err := m.pool.QueryRow(ctx, + `INSERT INTO addresses (user_id, address, label) + VALUES ($1, $2, $3) + RETURNING id, user_id, address, label, created_at`, + userID, address, label, + ).Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt) + if err != nil { + return nil, err + } + return &a, nil +} + +func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain.Address, error) { + rows, err := m.pool.Query(ctx, + `SELECT id, user_id, address, label, created_at + FROM addresses + WHERE user_id = $1 + ORDER BY created_at DESC`, + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var addresses []domain.Address + for rows.Next() { + var a domain.Address + if err := rows.Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt); err != nil { + return nil, err + } + addresses = append(addresses, a) + } + return addresses, rows.Err() +} + +// ListAll returns all addresses system-wide (used by the poller). +func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) { + rows, err := m.pool.Query(ctx, + `SELECT id, user_id, address, label, created_at + FROM addresses + ORDER BY created_at DESC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var addresses []domain.Address + for rows.Next() { + var a domain.Address + if err := rows.Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt); err != nil { + return nil, err + } + addresses = append(addresses, a) + } + return addresses, rows.Err() +} + +func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*domain.Address, error) { + var a domain.Address + var err error + + if userID != nil { + err = m.pool.QueryRow(ctx, + `SELECT id, user_id, address, label, created_at + FROM addresses + WHERE id = $1 AND user_id = $2`, + id, *userID, + ).Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt) + } else { + err = m.pool.QueryRow(ctx, + `SELECT id, user_id, address, label, created_at + FROM addresses + WHERE id = $1`, + id, + ).Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt) + } + + if err != nil { + if err.Error() == "no rows in result set" { + return nil, nil + } + return nil, err + } + return &a, nil +} + +func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) { + tag, err := m.pool.Exec(ctx, + `DELETE FROM addresses WHERE id = $1 AND user_id = $2`, + id, userID, + ) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} diff --git a/backend-go/internal/models/alert_event.go b/backend-go/internal/models/alert_event.go new file mode 100644 index 0000000..a1cb7db --- /dev/null +++ b/backend-go/internal/models/alert_event.go @@ -0,0 +1,81 @@ +package models + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/kjannette/koin-ping/backend-go/internal/domain" +) + +type AlertEventModel struct { + pool *pgxpool.Pool +} + +func NewAlertEventModel(pool *pgxpool.Pool) *AlertEventModel { + return &AlertEventModel{pool: pool} +} + +func (m *AlertEventModel) ListRecentByUser(ctx context.Context, userID string, limit int) ([]domain.AlertEvent, error) { + rows, err := m.pool.Query(ctx, + `SELECT ae.id, ae.alert_rule_id, ae.message, ae.address_label, ae.tx_hash, ae.timestamp + FROM alert_events ae + JOIN alert_rules ar ON ar.id = ae.alert_rule_id + JOIN addresses a ON a.id = ar.address_id + WHERE a.user_id = $1 + ORDER BY ae.timestamp DESC + LIMIT $2`, + userID, limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var events []domain.AlertEvent + for rows.Next() { + var e domain.AlertEvent + if err := rows.Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp); err != nil { + return nil, err + } + events = append(events, e) + } + return events, rows.Err() +} + +func (m *AlertEventModel) ListRecent(ctx context.Context, limit int) ([]domain.AlertEvent, error) { + rows, err := m.pool.Query(ctx, + `SELECT id, alert_rule_id, message, address_label, tx_hash, timestamp + FROM alert_events + ORDER BY timestamp DESC + LIMIT $1`, + limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var events []domain.AlertEvent + for rows.Next() { + var e domain.AlertEvent + if err := rows.Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp); err != nil { + return nil, err + } + events = append(events, e) + } + return events, rows.Err() +} + +func (m *AlertEventModel) Create(ctx context.Context, alertRuleID int, message string, addressLabel *string, txHash *string) (*domain.AlertEvent, error) { + var e domain.AlertEvent + err := m.pool.QueryRow(ctx, + `INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash) + VALUES ($1, $2, $3, $4) + RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`, + alertRuleID, message, addressLabel, txHash, + ).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp) + if err != nil { + return nil, err + } + return &e, nil +} diff --git a/backend-go/internal/models/alert_rule.go b/backend-go/internal/models/alert_rule.go new file mode 100644 index 0000000..ea45e45 --- /dev/null +++ b/backend-go/internal/models/alert_rule.go @@ -0,0 +1,113 @@ +package models + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/kjannette/koin-ping/backend-go/internal/domain" +) + +type AlertRuleModel struct { + pool *pgxpool.Pool +} + +func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel { + return &AlertRuleModel{pool: pool} +} + +func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold *float64) (*domain.AlertRule, error) { + var r domain.AlertRule + err := m.pool.QueryRow(ctx, + `INSERT INTO alert_rules (address_id, type, threshold, enabled) + VALUES ($1, $2, $3, TRUE) + RETURNING id, address_id, type, threshold, enabled, created_at`, + addressID, string(alertType), threshold, + ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) + if err != nil { + return nil, err + } + return &r, nil +} + +func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) { + rows, err := m.pool.Query(ctx, + `SELECT id, address_id, type, threshold, enabled, created_at + FROM alert_rules + WHERE address_id = $1 + ORDER BY created_at DESC`, + addressID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var rules []domain.AlertRule + for rows.Next() { + var r domain.AlertRule + if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt); err != nil { + return nil, err + } + rules = append(rules, r) + } + return rules, rows.Err() +} + +func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (*domain.AlertRule, error) { + var r domain.AlertRule + var err error + + if userID != nil { + err = m.pool.QueryRow(ctx, + `SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.enabled, ar.created_at + FROM alert_rules ar + JOIN addresses a ON a.id = ar.address_id + WHERE ar.id = $1 AND a.user_id = $2`, + id, *userID, + ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) + } else { + err = m.pool.QueryRow(ctx, + `SELECT id, address_id, type, threshold, enabled, created_at + FROM alert_rules + WHERE id = $1`, + id, + ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) + } + + if err != nil { + if err.Error() == "no rows in result set" { + return nil, nil + } + return nil, err + } + return &r, nil +} + +func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool) (*domain.AlertRule, error) { + var r domain.AlertRule + err := m.pool.QueryRow(ctx, + `UPDATE alert_rules + SET enabled = $2 + WHERE id = $1 + RETURNING id, address_id, type, threshold, enabled, created_at`, + id, enabled, + ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, nil + } + return nil, err + } + return &r, nil +} + +func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) { + tag, err := m.pool.Exec(ctx, + `DELETE FROM alert_rules WHERE id = $1`, + id, + ) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} diff --git a/backend-go/internal/models/checkpoint.go b/backend-go/internal/models/checkpoint.go new file mode 100644 index 0000000..94008b9 --- /dev/null +++ b/backend-go/internal/models/checkpoint.go @@ -0,0 +1,84 @@ +package models + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/kjannette/koin-ping/backend-go/internal/domain" +) + +type CheckpointModel struct { + pool *pgxpool.Pool +} + +func NewCheckpointModel(pool *pgxpool.Pool) *CheckpointModel { + return &CheckpointModel{pool: pool} +} + +// GetLastCheckedBlock returns the last checked block for an address, or -1 if never checked. +func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) { + var block int + err := m.pool.QueryRow(ctx, + `SELECT last_checked_block FROM address_checkpoints WHERE address_id = $1`, + addressID, + ).Scan(&block) + if err != nil { + if err.Error() == "no rows in result set" { + return 0, false, nil + } + return 0, false, err + } + return block, true, nil +} + +func (m *CheckpointModel) UpdateLastCheckedBlock(ctx context.Context, addressID, blockNumber int) (*domain.AddressCheckpoint, error) { + var cp domain.AddressCheckpoint + err := m.pool.QueryRow(ctx, + `INSERT INTO address_checkpoints (address_id, last_checked_block, last_checked_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (address_id) + DO UPDATE SET + last_checked_block = $2, + last_checked_at = NOW() + RETURNING address_id, last_checked_block, last_checked_at`, + addressID, blockNumber, + ).Scan(&cp.AddressID, &cp.LastCheckedBlock, &cp.LastCheckedAt) + if err != nil { + return nil, err + } + return &cp, nil +} + +func (m *CheckpointModel) ListAll(ctx context.Context) ([]domain.CheckpointDetail, error) { + rows, err := m.pool.Query(ctx, + `SELECT ac.address_id, a.address, a.label, ac.last_checked_block, ac.last_checked_at + FROM address_checkpoints ac + JOIN addresses a ON a.id = ac.address_id + ORDER BY ac.last_checked_at DESC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var details []domain.CheckpointDetail + for rows.Next() { + var d domain.CheckpointDetail + if err := rows.Scan(&d.AddressID, &d.Address, &d.Label, &d.LastCheckedBlock, &d.LastCheckedAt); err != nil { + return nil, err + } + details = append(details, d) + } + return details, rows.Err() +} + +func (m *CheckpointModel) Remove(ctx context.Context, addressID int) (bool, error) { + tag, err := m.pool.Exec(ctx, + `DELETE FROM address_checkpoints WHERE address_id = $1`, + addressID, + ) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} diff --git a/backend-go/internal/models/notification_config.go b/backend-go/internal/models/notification_config.go new file mode 100644 index 0000000..7e6b175 --- /dev/null +++ b/backend-go/internal/models/notification_config.go @@ -0,0 +1,95 @@ +package models + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/kjannette/koin-ping/backend-go/internal/domain" +) + +type NotificationConfigModel struct { + pool *pgxpool.Pool +} + +func NewNotificationConfigModel(pool *pgxpool.Pool) *NotificationConfigModel { + return &NotificationConfigModel{pool: pool} +} + +func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string) (*domain.NotificationConfig, error) { + var c domain.NotificationConfig + err := m.pool.QueryRow(ctx, + `SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, + email, notification_enabled, created_at, updated_at + FROM user_notification_configs + WHERE user_id = $1`, + userID, + ).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken, + &c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, nil + } + return nil, err + } + return &c, nil +} + +func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID string, cfg domain.NotificationConfig) (*domain.NotificationConfig, error) { + var c domain.NotificationConfig + err := m.pool.QueryRow(ctx, + `INSERT INTO user_notification_configs + (user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, email, notification_enabled, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (user_id) + DO UPDATE SET + discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url), + telegram_chat_id = COALESCE($3, user_notification_configs.telegram_chat_id), + telegram_bot_token = COALESCE($4, user_notification_configs.telegram_bot_token), + email = COALESCE($5, user_notification_configs.email), + notification_enabled = $6, + updated_at = NOW() + RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, + email, notification_enabled, created_at, updated_at`, + userID, cfg.DiscordWebhookURL, cfg.TelegramChatID, cfg.TelegramBotToken, + cfg.Email, cfg.NotificationEnabled, + ).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken, + &c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt) + if err != nil { + return nil, err + } + return &c, nil +} + +func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bool, error) { + tag, err := m.pool.Exec(ctx, + `DELETE FROM user_notification_configs WHERE user_id = $1`, + userID, + ) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) { + rows, err := m.pool.Query(ctx, + `SELECT user_id, discord_webhook_url, telegram_chat_id, email + FROM user_notification_configs + WHERE notification_enabled = TRUE`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var configs []domain.NotificationConfig + for rows.Next() { + var c domain.NotificationConfig + if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.Email); err != nil { + return nil, err + } + c.NotificationEnabled = true + configs = append(configs, c) + } + return configs, rows.Err() +} diff --git a/backend-go/internal/notifications/discord.go b/backend-go/internal/notifications/discord.go new file mode 100644 index 0000000..128a8a3 --- /dev/null +++ b/backend-go/internal/notifications/discord.go @@ -0,0 +1,124 @@ +package notifications + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +type AlertMetadata struct { + TxHash string + AddressLabel string + AlertType string + Address string +} + +type discordEmbed struct { + Title string `json:"title"` + Description string `json:"description"` + Color int `json:"color"` + Fields []discordField `json:"fields"` + Timestamp string `json:"timestamp"` + Footer discordFooter `json:"footer"` +} + +type discordField struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline"` +} + +type discordFooter struct { + Text string `json:"text"` +} + +type discordPayload struct { + Content *string `json:"content"` + Embeds []discordEmbed `json:"embeds,omitempty"` +} + +func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bool, error) { + fields := []discordField{ + {Name: "Address", Value: meta.AddressLabel, Inline: true}, + {Name: "Blockchain Address", Value: fmt.Sprintf("`%s`", meta.Address), Inline: false}, + } + + if meta.TxHash != "" { + fields = append(fields, discordField{ + Name: "Transaction", + Value: fmt.Sprintf("[View on Etherscan](https://etherscan.io/tx/%s)", meta.TxHash), + Inline: false, + }) + } + + payload := discordPayload{ + Content: nil, + Embeds: []discordEmbed{ + { + Title: "Koin Ping Alert", + Description: message, + Color: colorForAlertType(meta.AlertType), + Fields: fields, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Footer: discordFooter{Text: "Koin Ping"}, + }, + }, + } + + body, err := json.Marshal(payload) + if err != nil { + return false, fmt.Errorf("marshal discord payload: %w", err) + } + + resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body)) + if err != nil { + log.Printf("Failed to send Discord notification: %v", err) + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Printf("Discord webhook failed: HTTP %d", resp.StatusCode) + return false, fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode) + } + + return true, nil +} + +func TestDiscordWebhook(webhookURL string) (bool, error) { + payload := map[string]string{ + "content": "Koin Ping test notification - Your Discord webhook is configured correctly!", + } + + body, err := json.Marshal(payload) + if err != nil { + return false, err + } + + resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body)) + if err != nil { + log.Printf("Discord webhook test failed: %v", err) + return false, err + } + defer resp.Body.Close() + + return resp.StatusCode >= 200 && resp.StatusCode < 300, nil +} + +func colorForAlertType(alertType string) int { + switch alertType { + case "incoming_tx": + return 0x00ff00 // Green + case "outgoing_tx": + return 0xff9900 // Orange + case "large_transfer": + return 0xff0000 // Red + case "balance_below": + return 0xff0000 // Red + default: + return 0x0099ff // Blue + } +} diff --git a/backend-go/internal/protocols/ethereum/jsonrpc.go b/backend-go/internal/protocols/ethereum/jsonrpc.go new file mode 100644 index 0000000..1e80cf5 --- /dev/null +++ b/backend-go/internal/protocols/ethereum/jsonrpc.go @@ -0,0 +1,211 @@ +package ethereum + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "strings" + "time" + + "github.com/kjannette/koin-ping/backend-go/internal/domain" +) + +const rpcTimeoutMS = 30000 + +type JsonRpcEthereum struct { + rpcURL string + client *http.Client +} + +func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) { + if rpcURL == "" { + return nil, fmt.Errorf("JsonRpcEthereum requires a valid RPC URL") + } + return &JsonRpcEthereum{ + rpcURL: rpcURL, + client: &http.Client{ + Timeout: time.Duration(rpcTimeoutMS) * time.Millisecond, + }, + }, nil +} + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id"` + Method string `json:"method"` + Params []interface{} `json:"params"` +} + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id"` + Result json.RawMessage `json:"result"` + Error *rpcError `json:"error"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) { + if params == nil { + params = []interface{}{} + } + + body, err := json.Marshal(rpcRequest{ + JSONRPC: "2.0", + ID: time.Now().UnixMilli(), + Method: method, + Params: params, + }) + if err != nil { + return nil, fmt.Errorf("marshal RPC request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create RPC request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := j.client.Do(req) + if err != nil { + return nil, fmt.Errorf("RPC call failed [%s]: %w", method, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method) + } + + var rpcResp rpcResponse + if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { + return nil, fmt.Errorf("decode RPC response: %w", err) + } + + if rpcResp.Error != nil { + return nil, fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code) + } + + return rpcResp.Result, nil +} + +func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) { + result, err := j.callRPC(ctx, "eth_blockNumber") + if err != nil { + return 0, err + } + + var hexBlock string + if err := json.Unmarshal(result, &hexBlock); err != nil { + return 0, fmt.Errorf("unmarshal block number: %w", err) + } + + return hexToInt(hexBlock) +} + +type rpcBlock struct { + Timestamp string `json:"timestamp"` + Transactions []rpcTx `json:"transactions"` +} + +type rpcTx struct { + Hash string `json:"hash"` + From string `json:"from"` + To *string `json:"to"` + Value string `json:"value"` + BlockNumber string `json:"blockNumber"` +} + +func (j *JsonRpcEthereum) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) { + hexBlock := fmt.Sprintf("0x%x", blockNumber) + + result, err := j.callRPC(ctx, "eth_getBlockByNumber", hexBlock, true) + if err != nil { + return nil, err + } + + if string(result) == "null" { + return nil, nil + } + + var block rpcBlock + if err := json.Unmarshal(result, &block); err != nil { + return nil, fmt.Errorf("unmarshal block: %w", err) + } + + blockTimestamp, err := hexToInt64(block.Timestamp) + if err != nil { + return nil, fmt.Errorf("parse block timestamp: %w", err) + } + + txs := make([]domain.NormalizedTx, 0, len(block.Transactions)) + for _, tx := range block.Transactions { + bn, _ := hexToInt(tx.BlockNumber) + + var toLower *string + if tx.To != nil { + s := strings.ToLower(*tx.To) + toLower = &s + } + + txs = append(txs, domain.NormalizedTx{ + Hash: tx.Hash, + From: strings.ToLower(tx.From), + To: toLower, + Value: hexToDecimalString(tx.Value), + BlockNumber: bn, + BlockTimestamp: blockTimestamp, + }) + } + + return txs, nil +} + +func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (string, error) { + result, err := j.callRPC(ctx, "eth_getBalance", address, "latest") + if err != nil { + return "", err + } + + var hexBalance string + if err := json.Unmarshal(result, &hexBalance); err != nil { + return "", fmt.Errorf("unmarshal balance: %w", err) + } + + return hexToDecimalString(hexBalance), nil +} + +func hexToInt(hex string) (int, error) { + hex = strings.TrimPrefix(hex, "0x") + n, ok := new(big.Int).SetString(hex, 16) + if !ok { + return 0, fmt.Errorf("invalid hex: %s", hex) + } + return int(n.Int64()), nil +} + +func hexToInt64(hex string) (int64, error) { + hex = strings.TrimPrefix(hex, "0x") + n, ok := new(big.Int).SetString(hex, 16) + if !ok { + return 0, fmt.Errorf("invalid hex: %s", hex) + } + return n.Int64(), nil +} + +func hexToDecimalString(hex string) string { + if hex == "" || hex == "0x" || hex == "0x0" { + return "0" + } + clean := strings.TrimPrefix(hex, "0x") + n, ok := new(big.Int).SetString(clean, 16) + if !ok { + return "0" + } + return n.String() +} diff --git a/backend-go/internal/protocols/ethereum/observer.go b/backend-go/internal/protocols/ethereum/observer.go new file mode 100644 index 0000000..862b2a8 --- /dev/null +++ b/backend-go/internal/protocols/ethereum/observer.go @@ -0,0 +1,15 @@ +package ethereum + +import ( + "context" + + "github.com/kjannette/koin-ping/backend-go/internal/domain" +) + +// EthereumObserver defines the interface for blockchain interaction. +// Any concrete implementation (JSON-RPC, WebSocket, mock) must satisfy this. +type EthereumObserver interface { + GetLatestBlockNumber(ctx context.Context) (int, error) + GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) + GetBalance(ctx context.Context, address string) (string, error) +} diff --git a/backend-go/internal/services/evaluator.go b/backend-go/internal/services/evaluator.go new file mode 100644 index 0000000..9dfc9b2 --- /dev/null +++ b/backend-go/internal/services/evaluator.go @@ -0,0 +1,232 @@ +package services + +import ( + "context" + "fmt" + "log" + + "github.com/kjannette/koin-ping/backend-go/internal/domain" + "github.com/kjannette/koin-ping/backend-go/internal/models" + "github.com/kjannette/koin-ping/backend-go/internal/notifications" + "github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum" + "github.com/kjannette/koin-ping/backend-go/internal/wei" +) + +type EvaluatorService struct { + eth ethereum.EthereumObserver + alertRules *models.AlertRuleModel + alertEvents *models.AlertEventModel + addresses *models.AddressModel + notifConfigs *models.NotificationConfigModel +} + +func NewEvaluatorService( + eth ethereum.EthereumObserver, + alertRules *models.AlertRuleModel, + alertEvents *models.AlertEventModel, + addresses *models.AddressModel, + notifConfigs *models.NotificationConfigModel, +) *EvaluatorService { + return &EvaluatorService{ + eth: eth, + alertRules: alertRules, + alertEvents: alertEvents, + addresses: addresses, + notifConfigs: notifConfigs, + } +} + +func (s *EvaluatorService) Evaluate(ctx context.Context, observations []domain.ObservedTx) (int, error) { + alertsFired := 0 + + for _, obs := range observations { + fired, err := s.evaluateObservation(ctx, obs) + if err != nil { + log.Printf("Error evaluating observation for address ID %d: %v", obs.AddressID, err) + continue + } + alertsFired += fired + } + + return alertsFired, nil +} + +func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.ObservedTx) (int, error) { + rules, err := s.alertRules.ListByAddress(ctx, obs.AddressID) + if err != nil { + return 0, err + } + + alertsFired := 0 + for _, rule := range rules { + if !rule.Enabled { + continue + } + + matches, err := s.ruleMatches(ctx, rule, obs) + if err != nil { + log.Printf("Error matching rule %d: %v", rule.ID, err) + continue + } + + if matches { + if err := s.fireAlert(ctx, rule, obs); err != nil { + log.Printf("Error firing alert for rule %d: %v", rule.ID, err) + continue + } + alertsFired++ + } + } + + return alertsFired, nil +} + +func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) { + switch rule.Type { + case domain.AlertIncomingTx: + return obs.Direction == domain.DirectionIncoming, nil + case domain.AlertOutgoingTx: + return obs.Direction == domain.DirectionOutgoing, nil + case domain.AlertLargeTransfer: + return s.matchesLargeTransfer(rule, obs) + case domain.AlertBalanceBelow: + return s.matchesBalanceBelow(ctx, rule, obs) + default: + log.Printf("Unknown rule type: %s", rule.Type) + return false, nil + } +} + +func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) { + if rule.Threshold == nil { + return false, nil + } + + thresholdWei, err := wei.FromEth(*rule.Threshold) + if err != nil { + return false, err + } + + return wei.GreaterThanOrEqual(obs.Value, thresholdWei) +} + +// matchesBalanceBelow only triggers after outgoing transactions, since incoming +// transactions increase balance and can't cause it to drop below threshold. +func (s *EvaluatorService) matchesBalanceBelow(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) { + if rule.Threshold == nil { + return false, nil + } + + if obs.Direction != domain.DirectionOutgoing { + return false, nil + } + + addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil) + if err != nil { + return false, err + } + if addr == nil { + log.Printf("Address ID %d not found", obs.AddressID) + return false, nil + } + + balanceWei, err := s.eth.GetBalance(ctx, addr.Address) + if err != nil { + return false, err + } + + thresholdWei, err := wei.FromEth(*rule.Threshold) + if err != nil { + return false, err + } + + return wei.LessThan(balanceWei, thresholdWei) +} + +func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) error { + addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil) + if err != nil { + return err + } + + addressLabel := "Unknown" + if addr != nil { + if addr.Label != nil { + addressLabel = *addr.Label + } else { + addressLabel = addr.Address + } + } + + message := s.buildMessage(rule, obs) + txHash := &obs.Hash + + _, err = s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash) + if err != nil { + return err + } + + log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash) + + // Send Discord notification (non-fatal on failure) + if addr != nil { + go s.sendNotification(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address) + } + + return nil +} + +func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) { + notifConfig, err := s.notifConfigs.GetConfig(ctx, userID) + if err != nil { + log.Printf("Failed to get notification config: %v", err) + return + } + + if notifConfig == nil || !notifConfig.NotificationEnabled || notifConfig.DiscordWebhookURL == nil { + return + } + + sent, err := notifications.SendDiscordNotification( + *notifConfig.DiscordWebhookURL, + message, + notifications.AlertMetadata{ + TxHash: obs.Hash, + AddressLabel: addressLabel, + AlertType: string(rule.Type), + Address: address, + }, + ) + + if err != nil || !sent { + log.Printf("Discord notification failed for user %s: %v", userID, err) + } else { + log.Printf("Discord notification sent to user %s", userID) + } +} + +func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string { + switch rule.Type { + case domain.AlertIncomingTx: + ethStr, _ := wei.FormatAsEth(obs.Value, 4) + return fmt.Sprintf("Incoming transaction: %s received", ethStr) + case domain.AlertOutgoingTx: + ethStr, _ := wei.FormatAsEth(obs.Value, 4) + return fmt.Sprintf("Outgoing transaction: %s sent", ethStr) + case domain.AlertLargeTransfer: + ethStr, _ := wei.FormatAsEth(obs.Value, 4) + threshold := float64(0) + if rule.Threshold != nil { + threshold = *rule.Threshold + } + return fmt.Sprintf("Large transfer detected: %s (threshold: %g ETH)", ethStr, threshold) + case domain.AlertBalanceBelow: + threshold := float64(0) + if rule.Threshold != nil { + threshold = *rule.Threshold + } + return fmt.Sprintf("Balance dropped below threshold of %g ETH", threshold) + default: + return "Alert triggered" + } +} diff --git a/backend-go/internal/services/observer.go b/backend-go/internal/services/observer.go new file mode 100644 index 0000000..66cd127 --- /dev/null +++ b/backend-go/internal/services/observer.go @@ -0,0 +1,132 @@ +package services + +import ( + "context" + "log" + "strings" + + "github.com/kjannette/koin-ping/backend-go/internal/domain" + "github.com/kjannette/koin-ping/backend-go/internal/models" + "github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum" +) + +const maxBlocksPerRun = 100 + +type ObserverService struct { + eth ethereum.EthereumObserver + addresses *models.AddressModel + checkpoint *models.CheckpointModel +} + +func NewObserverService(eth ethereum.EthereumObserver, addresses *models.AddressModel, checkpoint *models.CheckpointModel) *ObserverService { + return &ObserverService{ + eth: eth, + addresses: addresses, + checkpoint: checkpoint, + } +} + +func (s *ObserverService) RunOnce(ctx context.Context) ([]domain.ObservedTx, error) { + addresses, err := s.addresses.ListAll(ctx) + if err != nil { + return nil, err + } + + if len(addresses) == 0 { + return nil, nil + } + + latestBlock, err := s.eth.GetLatestBlockNumber(ctx) + if err != nil { + return nil, err + } + + var observations []domain.ObservedTx + for _, addr := range addresses { + obs, err := s.observeAddress(ctx, addr, latestBlock) + if err != nil { + log.Printf("Error observing address %s: %v", addr.Address, err) + continue + } + observations = append(observations, obs...) + } + + return observations, nil +} + +func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Address, latestBlock int) ([]domain.ObservedTx, error) { + lastChecked, found, err := s.checkpoint.GetLastCheckedBlock(ctx, addr.ID) + if err != nil { + return nil, err + } + + startBlock := s.getStartBlock(lastChecked, found, latestBlock) + endBlock := s.getEndBlock(startBlock, latestBlock) + + if startBlock > endBlock { + return nil, nil + } + + var observations []domain.ObservedTx + for blockNumber := startBlock; blockNumber <= endBlock; blockNumber++ { + blockTxs, err := s.eth.GetBlockTransactions(ctx, blockNumber) + if err != nil { + return nil, err + } + + relevant := filterRelevantTransactions(blockTxs, addr.Address) + for _, tx := range relevant { + observations = append(observations, createObservedTx(tx, addr)) + } + } + + if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil { + return nil, err + } + + return observations, nil +} + +func (s *ObserverService) getStartBlock(lastChecked int, found bool, latestBlock int) int { + if !found { + return latestBlock + } + return lastChecked + 1 +} + +func (s *ObserverService) getEndBlock(startBlock, latestBlock int) int { + end := startBlock + maxBlocksPerRun - 1 + if end > latestBlock { + return latestBlock + } + return end +} + +func filterRelevantTransactions(txs []domain.NormalizedTx, trackedAddress string) []domain.NormalizedTx { + addrLower := strings.ToLower(trackedAddress) + var relevant []domain.NormalizedTx + + for _, tx := range txs { + fromMatch := strings.ToLower(tx.From) == addrLower + toMatch := tx.To != nil && strings.ToLower(*tx.To) == addrLower + if fromMatch || toMatch { + relevant = append(relevant, tx) + } + } + + return relevant +} + +func createObservedTx(tx domain.NormalizedTx, addr domain.Address) domain.ObservedTx { + addrLower := strings.ToLower(addr.Address) + direction := domain.DirectionOutgoing + if tx.To != nil && strings.ToLower(*tx.To) == addrLower { + direction = domain.DirectionIncoming + } + + return domain.ObservedTx{ + NormalizedTx: tx, + AddressID: addr.ID, + Direction: direction, + } +} diff --git a/backend-go/internal/wei/converter.go b/backend-go/internal/wei/converter.go new file mode 100644 index 0000000..0f998b3 --- /dev/null +++ b/backend-go/internal/wei/converter.go @@ -0,0 +1,100 @@ +package wei + +import ( + "fmt" + "math/big" + "strings" +) + +var weiPerEth = new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) + +// ToEth converts a Wei string to a float64 ETH value. +// Use for display only — not for precision-sensitive comparisons. +func ToEth(weiString string) (float64, error) { + if weiString == "" || weiString == "0" { + return 0, nil + } + + w, ok := new(big.Int).SetString(weiString, 10) + if !ok { + return 0, fmt.Errorf("invalid Wei value: %s", weiString) + } + + wf := new(big.Float).SetInt(w) + ef := new(big.Float).SetInt(weiPerEth) + result, _ := new(big.Float).Quo(wf, ef).Float64() + return result, nil +} + +// FromEth converts an ETH float64 to a Wei string. +// Handles decimal precision by splitting on the decimal point. +func FromEth(eth float64) (string, error) { + if eth == 0 { + return "0", nil + } + + s := fmt.Sprintf("%.18f", eth) + parts := strings.SplitN(s, ".", 2) + whole := parts[0] + decimal := "" + if len(parts) == 2 { + decimal = parts[1] + } + + // Pad or trim decimal to exactly 18 digits + if len(decimal) < 18 { + decimal = decimal + strings.Repeat("0", 18-len(decimal)) + } else { + decimal = decimal[:18] + } + + wholeBig, ok := new(big.Int).SetString(whole, 10) + if !ok { + return "", fmt.Errorf("invalid ETH value: %f", eth) + } + decimalBig, ok := new(big.Int).SetString(decimal, 10) + if !ok { + return "", fmt.Errorf("invalid ETH decimal: %s", decimal) + } + + result := new(big.Int).Mul(wholeBig, weiPerEth) + result.Add(result, decimalBig) + return result.String(), nil +} + +func Compare(weiA, weiB string) (int, error) { + a, ok := new(big.Int).SetString(weiA, 10) + if !ok { + return 0, fmt.Errorf("invalid Wei value: %s", weiA) + } + b, ok := new(big.Int).SetString(weiB, 10) + if !ok { + return 0, fmt.Errorf("invalid Wei value: %s", weiB) + } + return a.Cmp(b), nil +} + +func GreaterThanOrEqual(weiA, weiB string) (bool, error) { + cmp, err := Compare(weiA, weiB) + if err != nil { + return false, err + } + return cmp >= 0, nil +} + +func LessThan(weiA, weiB string) (bool, error) { + cmp, err := Compare(weiA, weiB) + if err != nil { + return false, err + } + return cmp < 0, nil +} + +// FormatAsEth formats a Wei string as "X.XXXX ETH". +func FormatAsEth(weiString string, decimals int) (string, error) { + eth, err := ToEth(weiString) + if err != nil { + return "", err + } + return fmt.Sprintf("%.*f ETH", decimals, eth), nil +}