crossover

This commit is contained in:
KS Jannette
2026-02-27 15:07:43 -05:00
parent 0adfd70853
commit 5f81a4b1cc
31 changed files with 2890 additions and 0 deletions

View 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
}

View 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")
}
}

View 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"`
}

View 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
}

View 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)
}

View 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
}

View 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)
}

View 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
}

View 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)
}

View 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),
})
}

View 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 ""
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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()
}

View 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
}
}

View 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()
}

View 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)
}

View 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"
}
}

View 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,
}
}

View 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
}