crossover
This commit is contained in:
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