crossover
This commit is contained in:
11
backend-go/.gitignore
vendored
Normal file
11
backend-go/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
bin/
|
||||||
|
.env
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
vendor/
|
||||||
|
tmp/
|
||||||
43
backend-go/Makefile
Normal file
43
backend-go/Makefile
Normal file
@@ -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 ./...
|
||||||
96
backend-go/cmd/api/main.go
Normal file
96
backend-go/cmd/api/main.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
113
backend-go/cmd/poller/main.go
Normal file
113
backend-go/cmd/poller/main.go
Normal file
@@ -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()))
|
||||||
|
}
|
||||||
68
backend-go/go.mod
Normal file
68
backend-go/go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
182
backend-go/go.sum
Normal file
182
backend-go/go.sum
Normal file
@@ -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=
|
||||||
16
backend-go/infra/migrations/001_add_user_id.sql
Normal file
16
backend-go/infra/migrations/001_add_user_id.sql
Normal file
@@ -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);
|
||||||
13
backend-go/infra/migrations/002_add_notification_config.sql
Normal file
13
backend-go/infra/migrations/002_add_notification_config.sql
Normal file
@@ -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);
|
||||||
71
backend-go/infra/schema.sql
Normal file
71
backend-go/infra/schema.sql
Normal file
@@ -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);
|
||||||
79
backend-go/internal/config/config.go
Normal file
79
backend-go/internal/config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
52
backend-go/internal/database/postgres.go
Normal file
52
backend-go/internal/database/postgres.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
115
backend-go/internal/domain/types.go
Normal file
115
backend-go/internal/domain/types.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
49
backend-go/internal/firebase/admin.go
Normal file
49
backend-go/internal/firebase/admin.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
108
backend-go/internal/handlers/address.go
Normal file
108
backend-go/internal/handlers/address.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
89
backend-go/internal/handlers/alert_event.go
Normal file
89
backend-go/internal/handlers/alert_event.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
203
backend-go/internal/handlers/alert_rule.go
Normal file
203
backend-go/internal/handlers/alert_rule.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
30
backend-go/internal/handlers/helpers.go
Normal file
30
backend-go/internal/handlers/helpers.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
127
backend-go/internal/handlers/notification_config.go
Normal file
127
backend-go/internal/handlers/notification_config.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
23
backend-go/internal/handlers/status.go
Normal file
23
backend-go/internal/handlers/status.go
Normal file
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
98
backend-go/internal/middleware/auth.go
Normal file
98
backend-go/internal/middleware/auth.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
117
backend-go/internal/models/address.go
Normal file
117
backend-go/internal/models/address.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
81
backend-go/internal/models/alert_event.go
Normal file
81
backend-go/internal/models/alert_event.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
113
backend-go/internal/models/alert_rule.go
Normal file
113
backend-go/internal/models/alert_rule.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
84
backend-go/internal/models/checkpoint.go
Normal file
84
backend-go/internal/models/checkpoint.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
95
backend-go/internal/models/notification_config.go
Normal file
95
backend-go/internal/models/notification_config.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
124
backend-go/internal/notifications/discord.go
Normal file
124
backend-go/internal/notifications/discord.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
211
backend-go/internal/protocols/ethereum/jsonrpc.go
Normal file
211
backend-go/internal/protocols/ethereum/jsonrpc.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
15
backend-go/internal/protocols/ethereum/observer.go
Normal file
15
backend-go/internal/protocols/ethereum/observer.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
232
backend-go/internal/services/evaluator.go
Normal file
232
backend-go/internal/services/evaluator.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
132
backend-go/internal/services/observer.go
Normal file
132
backend-go/internal/services/observer.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
100
backend-go/internal/wei/converter.go
Normal file
100
backend-go/internal/wei/converter.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user