diff --git a/backend-go/cmd/api/main.go b/backend-go/cmd/api/main.go
index cef0028..560ca72 100644
--- a/backend-go/cmd/api/main.go
+++ b/backend-go/cmd/api/main.go
@@ -13,10 +13,11 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
+ "github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
)
func main() {
- _ = godotenv.Load() // .env is optional; env vars can also be set externally
+ _ = godotenv.Load()
cfg, err := config.Load()
if err != nil {
@@ -33,41 +34,47 @@ func main() {
log.Fatalf("Failed to initialize Firebase: %v", err)
}
+ var eth ethereum.EthereumObserver
+ if cfg.EthRPCURL != "" {
+ eth, err = ethereum.NewJsonRpcEthereum(cfg.EthRPCURL)
+ if err != nil {
+ log.Printf("Warning: Failed to create Ethereum observer: %v", err)
+ }
+ }
+
addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool)
notifConfigModel := models.NewNotificationConfigModel(pool)
+ checkpointModel := models.NewCheckpointModel(pool)
addressHandler := handlers.NewAddressHandler(addressModel)
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel)
+ statusHandler := handlers.NewStatusHandler(eth, checkpointModel)
mux := http.NewServeMux()
- b := cfg.APIBasePath // e.g. "/v1"
+ b := cfg.APIBasePath
- // Public routes
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
- mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus)
+ mux.HandleFunc("GET "+b+"/status", statusHandler.SystemStatus)
- // Authenticated routes — addresses
mux.Handle("POST "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
mux.Handle("GET "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
mux.Handle("DELETE "+b+"/addresses/{addressId}", middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
- // Authenticated routes — alert rules
mux.Handle("POST "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
mux.Handle("GET "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
mux.Handle("PATCH "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
mux.Handle("DELETE "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
- // Authenticated routes — alert events
mux.Handle("GET "+b+"/alert-events", middleware.Authenticate(http.HandlerFunc(alertEventHandler.List)))
- // Authenticated routes — notification config
mux.Handle("GET "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
mux.Handle("PUT "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
mux.Handle("DELETE "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
+ mux.Handle("POST "+b+"/notification-config/test", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestWebhook)))
handler := corsMiddleware(mux)
diff --git a/backend-go/cmd/poller/main.go b/backend-go/cmd/poller/main.go
index 9c941b8..a94581a 100644
--- a/backend-go/cmd/poller/main.go
+++ b/backend-go/cmd/poller/main.go
@@ -14,6 +14,7 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/config"
"github.com/kjannette/koin-ping/backend-go/internal/database"
"github.com/kjannette/koin-ping/backend-go/internal/models"
+ "github.com/kjannette/koin-ping/backend-go/internal/notifications"
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend-go/internal/services"
)
@@ -47,8 +48,16 @@ func main() {
checkpointModel := models.NewCheckpointModel(pool)
notifConfigModel := models.NewNotificationConfigModel(pool)
+ smtpCfg := notifications.SMTPConfig{
+ Host: cfg.SMTPHost,
+ Port: cfg.SMTPPort,
+ Username: cfg.SMTPUser,
+ Password: cfg.SMTPPassword,
+ From: cfg.SMTPFrom,
+ }
+
observer := services.NewObserverService(eth, addressModel, checkpointModel)
- evaluator := services.NewEvaluatorService(eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel)
+ evaluator := services.NewEvaluatorService(eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel, smtpCfg)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
diff --git a/backend-go/infra/migrations/003_replace_telegram_with_slack.sql b/backend-go/infra/migrations/003_replace_telegram_with_slack.sql
new file mode 100644
index 0000000..8808d94
--- /dev/null
+++ b/backend-go/infra/migrations/003_replace_telegram_with_slack.sql
@@ -0,0 +1,4 @@
+ALTER TABLE user_notification_configs
+ DROP COLUMN IF EXISTS telegram_chat_id,
+ DROP COLUMN IF EXISTS telegram_bot_token,
+ ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT;
diff --git a/backend-go/infra/schema.sql b/backend-go/infra/schema.sql
index 92cf66e..e422b0f 100644
--- a/backend-go/infra/schema.sql
+++ b/backend-go/infra/schema.sql
@@ -53,8 +53,7 @@ CREATE TABLE address_checkpoints (
CREATE TABLE user_notification_configs (
user_id VARCHAR(128) PRIMARY KEY,
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
- telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
- telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable, future use)
+ slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable)
email VARCHAR(255), -- Email for notifications (nullable)
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
created_at TIMESTAMP DEFAULT NOW(),
diff --git a/backend-go/internal/config/config.go b/backend-go/internal/config/config.go
index 29f969b..a07377c 100644
--- a/backend-go/internal/config/config.go
+++ b/backend-go/internal/config/config.go
@@ -20,6 +20,12 @@ type Config struct {
EthRPCURL string
PollIntervalMS int
NodeEnv string
+
+ SMTPHost string
+ SMTPPort int
+ SMTPUser string
+ SMTPPassword string
+ SMTPFrom string
}
func Load() (*Config, error) {
@@ -36,6 +42,12 @@ func Load() (*Config, error) {
EthRPCURL: os.Getenv("ETH_RPC_URL"),
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", 60000),
NodeEnv: getEnv("NODE_ENV", "development"),
+
+ SMTPHost: os.Getenv("SMTP_HOST"),
+ SMTPPort: getEnvInt("SMTP_PORT", 587),
+ SMTPUser: os.Getenv("SMTP_USER"),
+ SMTPPassword: os.Getenv("SMTP_PASSWORD"),
+ SMTPFrom: os.Getenv("SMTP_FROM"),
}
if cfg.PollIntervalMS < 1000 {
diff --git a/backend-go/internal/config/config_test.go b/backend-go/internal/config/config_test.go
new file mode 100644
index 0000000..72a6b06
--- /dev/null
+++ b/backend-go/internal/config/config_test.go
@@ -0,0 +1,115 @@
+package config
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestLoad_Defaults(t *testing.T) {
+ for _, key := range []string{"PORT", "API_BASE_PATH", "POLL_INTERVAL_MS", "NODE_ENV", "SMTP_PORT"} {
+ os.Unsetenv(key)
+ }
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if cfg.Port != 3001 {
+ t.Errorf("Port = %d, want 3001", cfg.Port)
+ }
+ if cfg.APIBasePath != "/v1" {
+ t.Errorf("APIBasePath = %q, want /v1", cfg.APIBasePath)
+ }
+ if cfg.PollIntervalMS != 60000 {
+ t.Errorf("PollIntervalMS = %d, want 60000", cfg.PollIntervalMS)
+ }
+ if cfg.NodeEnv != "development" {
+ t.Errorf("NodeEnv = %q, want development", cfg.NodeEnv)
+ }
+ if cfg.SMTPPort != 587 {
+ t.Errorf("SMTPPort = %d, want 587", cfg.SMTPPort)
+ }
+}
+
+func TestLoad_InvalidPollInterval(t *testing.T) {
+ os.Setenv("POLL_INTERVAL_MS", "500")
+ defer os.Unsetenv("POLL_INTERVAL_MS")
+
+ _, err := Load()
+ if err == nil {
+ t.Fatal("expected error for poll interval < 1000")
+ }
+}
+
+func TestDSN_DatabaseURL(t *testing.T) {
+ cfg := &Config{DatabaseURL: "postgres://user:pass@localhost/db"}
+ dsn := cfg.DSN()
+ if !strings.Contains(dsn, "sslmode=disable") {
+ t.Error("DSN should append sslmode=disable")
+ }
+ if !strings.HasPrefix(dsn, "postgres://user:pass@localhost/db") {
+ t.Error("DSN should preserve original URL")
+ }
+}
+
+func TestDSN_DatabaseURL_WithSSLMode(t *testing.T) {
+ cfg := &Config{DatabaseURL: "postgres://user:pass@localhost/db?sslmode=require"}
+ dsn := cfg.DSN()
+ if strings.Count(dsn, "sslmode=") != 1 {
+ t.Error("DSN should not duplicate sslmode")
+ }
+}
+
+func TestDSN_Components(t *testing.T) {
+ cfg := &Config{
+ DBHost: "myhost",
+ DBPort: 5433,
+ DBUser: "myuser",
+ DBPassword: "mypass",
+ DBName: "mydb",
+ }
+ dsn := cfg.DSN()
+ if !strings.Contains(dsn, "host=myhost") {
+ t.Error("DSN should contain host")
+ }
+ if !strings.Contains(dsn, "port=5433") {
+ t.Error("DSN should contain port")
+ }
+ if !strings.Contains(dsn, "user=myuser") {
+ t.Error("DSN should contain user")
+ }
+ if !strings.Contains(dsn, "dbname=mydb") {
+ t.Error("DSN should contain dbname")
+ }
+}
+
+func TestGetEnv(t *testing.T) {
+ os.Setenv("TEST_KEY_KOINPING", "value")
+ defer os.Unsetenv("TEST_KEY_KOINPING")
+
+ if got := getEnv("TEST_KEY_KOINPING", "fallback"); got != "value" {
+ t.Errorf("got %q, want %q", got, "value")
+ }
+ if got := getEnv("NONEXISTENT_KEY_KOINPING", "fallback"); got != "fallback" {
+ t.Errorf("got %q, want %q", got, "fallback")
+ }
+}
+
+func TestGetEnvInt(t *testing.T) {
+ os.Setenv("TEST_INT_KOINPING", "42")
+ defer os.Unsetenv("TEST_INT_KOINPING")
+
+ if got := getEnvInt("TEST_INT_KOINPING", 0); got != 42 {
+ t.Errorf("got %d, want 42", got)
+ }
+ if got := getEnvInt("NONEXISTENT_INT_KOINPING", 99); got != 99 {
+ t.Errorf("got %d, want 99", got)
+ }
+
+ os.Setenv("TEST_INT_KOINPING", "notanumber")
+ if got := getEnvInt("TEST_INT_KOINPING", 99); got != 99 {
+ t.Errorf("got %d, want 99 for invalid int", got)
+ }
+}
diff --git a/backend-go/internal/domain/types.go b/backend-go/internal/domain/types.go
index d0ded03..3141d16 100644
--- a/backend-go/internal/domain/types.go
+++ b/backend-go/internal/domain/types.go
@@ -82,12 +82,11 @@ type CheckpointDetail struct {
}
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"`
+ UserID string `json:"user_id"`
+ DiscordWebhookURL *string `json:"discord_webhook_url"`
+ SlackWebhookURL *string `json:"slack_webhook_url"`
+ Email *string `json:"email"`
+ NotificationEnabled bool `json:"notification_enabled"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
diff --git a/backend-go/internal/domain/types_test.go b/backend-go/internal/domain/types_test.go
new file mode 100644
index 0000000..5c0b1d3
--- /dev/null
+++ b/backend-go/internal/domain/types_test.go
@@ -0,0 +1,35 @@
+package domain
+
+import "testing"
+
+func TestIsValidAlertType(t *testing.T) {
+ valid := []string{"incoming_tx", "outgoing_tx", "large_transfer", "balance_below"}
+ for _, v := range valid {
+ if !IsValidAlertType(v) {
+ t.Errorf("IsValidAlertType(%q) = false, want true", v)
+ }
+ }
+
+ invalid := []string{"", "invalid", "INCOMING_TX", "send", "receive"}
+ for _, v := range invalid {
+ if IsValidAlertType(v) {
+ t.Errorf("IsValidAlertType(%q) = true, want false", v)
+ }
+ }
+}
+
+func TestIsThresholdRequired(t *testing.T) {
+ required := []AlertType{AlertLargeTransfer, AlertBalanceBelow}
+ for _, at := range required {
+ if !IsThresholdRequired(at) {
+ t.Errorf("IsThresholdRequired(%q) = false, want true", at)
+ }
+ }
+
+ notRequired := []AlertType{AlertIncomingTx, AlertOutgoingTx}
+ for _, at := range notRequired {
+ if IsThresholdRequired(at) {
+ t.Errorf("IsThresholdRequired(%q) = true, want false", at)
+ }
+ }
+}
diff --git a/backend-go/internal/handlers/alert_event.go b/backend-go/internal/handlers/alert_event.go
index 598e923..6e0e5a4 100644
--- a/backend-go/internal/handlers/alert_event.go
+++ b/backend-go/internal/handlers/alert_event.go
@@ -4,9 +4,7 @@ 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"
)
@@ -46,44 +44,5 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
log.Printf("Found %d alert events for user", len(events))
- // MVP scaffolding: return mock data if DB is empty
- if len(events) == 0 {
- events = mockEvents(limit)
- }
-
writeJSON(w, http.StatusOK, events)
}
-
-func mockEvents(limit int) []domain.AlertEvent {
- label1 := "Treasury Wallet"
- label2 := "Cold Storage"
-
- mocks := []domain.AlertEvent{
- {
- ID: 1,
- AlertRuleID: 1,
- Message: "Incoming transaction detected: 5.5 ETH received",
- AddressLabel: &label1,
- Timestamp: time.Now().Add(-2 * time.Hour),
- },
- {
- ID: 2,
- AlertRuleID: 2,
- Message: "Balance dropped below threshold: Current balance 8.2 ETH",
- AddressLabel: &label1,
- Timestamp: time.Now().Add(-5 * time.Hour),
- },
- {
- ID: 3,
- AlertRuleID: 3,
- Message: "Outgoing transaction detected: 2.0 ETH sent",
- AddressLabel: &label2,
- Timestamp: time.Now().Add(-24 * time.Hour),
- },
- }
-
- if limit < len(mocks) {
- return mocks[:limit]
- }
- return mocks
-}
diff --git a/backend-go/internal/handlers/notification_config.go b/backend-go/internal/handlers/notification_config.go
index d39c534..6e77130 100644
--- a/backend-go/internal/handlers/notification_config.go
+++ b/backend-go/internal/handlers/notification_config.go
@@ -10,6 +10,7 @@ import (
"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"
+ "github.com/kjannette/koin-ping/backend-go/internal/notifications"
)
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
@@ -50,7 +51,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
var body struct {
DiscordWebhookURL *string `json:"discord_webhook_url"`
- TelegramChatID *string `json:"telegram_chat_id"`
+ SlackWebhookURL *string `json:"slack_webhook_url"`
Email *string `json:"email"`
NotificationEnabled *bool `json:"notification_enabled"`
}
@@ -62,7 +63,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
log.Printf("User %s updating notification config", userID)
- if body.DiscordWebhookURL == nil && body.TelegramChatID == nil &&
+ if body.DiscordWebhookURL == nil && body.SlackWebhookURL == nil &&
body.Email == nil && body.NotificationEnabled == nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"At least one configuration field must be provided")
@@ -76,6 +77,13 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
return
}
+ if body.SlackWebhookURL != nil && *body.SlackWebhookURL != "" &&
+ !strings.HasPrefix(*body.SlackWebhookURL, "https://hooks.slack.com/") {
+ writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
+ "Invalid Slack webhook URL format")
+ return
+ }
+
if body.Email != nil && *body.Email != "" && !emailRe.MatchString(*body.Email) {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"Invalid email address format")
@@ -89,7 +97,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
cfg := domain.NotificationConfig{
DiscordWebhookURL: body.DiscordWebhookURL,
- TelegramChatID: body.TelegramChatID,
+ SlackWebhookURL: body.SlackWebhookURL,
Email: body.Email,
NotificationEnabled: enabled,
}
@@ -126,3 +134,50 @@ func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http.
log.Println("Notification config deleted")
w.WriteHeader(http.StatusNoContent)
}
+
+func (h *NotificationConfigHandler) TestWebhook(w http.ResponseWriter, r *http.Request) {
+ _ = middleware.GetUserID(r.Context())
+
+ var body struct {
+ Type string `json:"type"`
+ URL string `json:"url"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
+ return
+ }
+
+ if body.URL == "" {
+ writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "URL is required")
+ return
+ }
+
+ var ok bool
+ var err error
+
+ switch body.Type {
+ case "slack":
+ if !strings.HasPrefix(body.URL, "https://hooks.slack.com/") {
+ writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Slack webhook URL")
+ return
+ }
+ ok, err = notifications.TestSlackWebhook(body.URL)
+ case "discord":
+ if !strings.HasPrefix(body.URL, "https://discord.com/api/webhooks/") {
+ writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Discord webhook URL")
+ return
+ }
+ ok, err = notifications.TestDiscordWebhook(body.URL)
+ default:
+ writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Type must be 'slack' or 'discord'")
+ return
+ }
+
+ if err != nil {
+ log.Printf("Webhook test failed: %v", err)
+ writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "error": err.Error()})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]interface{}{"success": ok})
+}
diff --git a/backend-go/internal/handlers/status.go b/backend-go/internal/handlers/status.go
index 621adaa..74ebb99 100644
--- a/backend-go/internal/handlers/status.go
+++ b/backend-go/internal/handlers/status.go
@@ -1,8 +1,13 @@
package handlers
import (
+ "context"
+ "log"
"net/http"
"time"
+
+ "github.com/kjannette/koin-ping/backend-go/internal/models"
+ "github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
)
func HealthCheck(w http.ResponseWriter, r *http.Request) {
@@ -13,11 +18,62 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) {
})
}
-func SystemStatus(w http.ResponseWriter, r *http.Request) {
+type StatusHandler struct {
+ eth ethereum.EthereumObserver
+ checkpoints *models.CheckpointModel
+}
+
+func NewStatusHandler(eth ethereum.EthereumObserver, checkpoints *models.CheckpointModel) *StatusHandler {
+ return &StatusHandler{eth: eth, checkpoints: checkpoints}
+}
+
+func (h *StatusHandler) SystemStatus(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+
+ status := "healthy"
+
+ if h.eth == nil {
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "latestBlock": 0,
+ "lag": 0,
+ "trackedAddresses": 0,
+ "status": "no_rpc",
+ "timestamp": time.Now().UTC().Format(time.RFC3339),
+ })
+ return
+ }
+
+ latestBlock, err := h.eth.GetLatestBlockNumber(ctx)
+ if err != nil {
+ log.Printf("Failed to get latest block: %v", err)
+ status = "degraded"
+ latestBlock = 0
+ }
+
+ lag := 0
+ checkpoints, err := h.checkpoints.ListAll(ctx)
+ if err != nil {
+ log.Printf("Failed to list checkpoints: %v", err)
+ } else if len(checkpoints) > 0 && latestBlock > 0 {
+ minChecked := checkpoints[0].LastCheckedBlock
+ for _, cp := range checkpoints[1:] {
+ if cp.LastCheckedBlock < minChecked {
+ minChecked = cp.LastCheckedBlock
+ }
+ }
+ lag = latestBlock - minChecked
+ }
+
+ if lag > 50 {
+ status = "syncing"
+ }
+
writeJSON(w, http.StatusOK, map[string]interface{}{
- "latestBlock": 0,
- "lag": 0,
- "status": "healthy",
- "timestamp": time.Now().UTC().Format(time.RFC3339),
+ "latestBlock": latestBlock,
+ "lag": lag,
+ "trackedAddresses": len(checkpoints),
+ "status": status,
+ "timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
diff --git a/backend-go/internal/models/notification_config.go b/backend-go/internal/models/notification_config.go
index 7e6b175..601b337 100644
--- a/backend-go/internal/models/notification_config.go
+++ b/backend-go/internal/models/notification_config.go
@@ -18,12 +18,12 @@ func NewNotificationConfigModel(pool *pgxpool.Pool) *NotificationConfigModel {
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,
+ `SELECT user_id, discord_webhook_url, slack_webhook_url,
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,
+ ).Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL,
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if err.Error() == "no rows in result set" {
@@ -38,21 +38,20 @@ func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID strin
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())
+ (user_id, discord_webhook_url, slack_webhook_url, email, notification_enabled, updated_at)
+ VALUES ($1, $2, $3, $4, $5, 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,
+ slack_webhook_url = COALESCE($3, user_notification_configs.slack_webhook_url),
+ email = COALESCE($4, user_notification_configs.email),
+ notification_enabled = $5,
updated_at = NOW()
- RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
+ RETURNING user_id, discord_webhook_url, slack_webhook_url,
email, notification_enabled, created_at, updated_at`,
- userID, cfg.DiscordWebhookURL, cfg.TelegramChatID, cfg.TelegramBotToken,
+ userID, cfg.DiscordWebhookURL, cfg.SlackWebhookURL,
cfg.Email, cfg.NotificationEnabled,
- ).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
+ ).Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL,
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, err
@@ -73,7 +72,7 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
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
+ `SELECT user_id, discord_webhook_url, slack_webhook_url, email
FROM user_notification_configs
WHERE notification_enabled = TRUE`,
)
@@ -85,7 +84,7 @@ func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.Not
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 {
+ if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL, &c.Email); err != nil {
return nil, err
}
c.NotificationEnabled = true
diff --git a/backend-go/internal/notifications/discord_test.go b/backend-go/internal/notifications/discord_test.go
new file mode 100644
index 0000000..f5fed8d
--- /dev/null
+++ b/backend-go/internal/notifications/discord_test.go
@@ -0,0 +1,99 @@
+package notifications
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestSendDiscordNotification(t *testing.T) {
+ var receivedPayload discordPayload
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Errorf("expected POST, got %s", r.Method)
+ }
+
+ body, _ := io.ReadAll(r.Body)
+ json.Unmarshal(body, &receivedPayload)
+
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ meta := AlertMetadata{
+ TxHash: "0xabc123",
+ AddressLabel: "Treasury",
+ AlertType: "incoming_tx",
+ Address: "0x1234567890abcdef1234567890abcdef12345678",
+ }
+
+ sent, err := SendDiscordNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !sent {
+ t.Fatal("expected sent to be true")
+ }
+
+ if len(receivedPayload.Embeds) != 1 {
+ t.Fatalf("expected 1 embed, got %d", len(receivedPayload.Embeds))
+ }
+ embed := receivedPayload.Embeds[0]
+ if embed.Title != "Koin Ping Alert" {
+ t.Errorf("title = %q, want %q", embed.Title, "Koin Ping Alert")
+ }
+ if embed.Color != 0x00ff00 {
+ t.Errorf("color = %x, want %x (green for incoming_tx)", embed.Color, 0x00ff00)
+ }
+ if len(embed.Fields) != 3 {
+ t.Errorf("expected 3 fields (address, blockchain address, tx), got %d", len(embed.Fields))
+ }
+}
+
+func TestSendDiscordNotification_ServerError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ }))
+ defer server.Close()
+
+ sent, err := SendDiscordNotification(server.URL, "test", AlertMetadata{})
+ if err == nil {
+ t.Fatal("expected error for bad response")
+ }
+ if sent {
+ t.Fatal("expected sent to be false")
+ }
+}
+
+func TestTestDiscordWebhook(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ ok, err := TestDiscordWebhook(server.URL)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !ok {
+ t.Fatal("expected ok to be true")
+ }
+}
+
+func TestColorForAlertType(t *testing.T) {
+ tests := map[string]int{
+ "incoming_tx": 0x00ff00,
+ "outgoing_tx": 0xff9900,
+ "large_transfer": 0xff0000,
+ "balance_below": 0xff0000,
+ "unknown": 0x0099ff,
+ }
+ for alertType, want := range tests {
+ if got := colorForAlertType(alertType); got != want {
+ t.Errorf("colorForAlertType(%q) = %x, want %x", alertType, got, want)
+ }
+ }
+}
diff --git a/backend-go/internal/notifications/email.go b/backend-go/internal/notifications/email.go
new file mode 100644
index 0000000..3f68ede
--- /dev/null
+++ b/backend-go/internal/notifications/email.go
@@ -0,0 +1,111 @@
+package notifications
+
+import (
+ "fmt"
+ "log"
+ "net/smtp"
+ "strings"
+)
+
+type SMTPConfig struct {
+ Host string
+ Port int
+ Username string
+ Password string
+ From string
+}
+
+func (c *SMTPConfig) IsConfigured() bool {
+ return c.Host != "" && c.From != ""
+}
+
+func (c *SMTPConfig) addr() string {
+ return fmt.Sprintf("%s:%d", c.Host, c.Port)
+}
+
+func SendEmailNotification(cfg SMTPConfig, toEmail, message string, meta AlertMetadata) (bool, error) {
+ if !cfg.IsConfigured() {
+ return false, fmt.Errorf("SMTP is not configured")
+ }
+
+ subject := fmt.Sprintf("Koin Ping Alert: %s", humanAlertType(meta.AlertType))
+
+ body := buildEmailBody(message, meta)
+
+ msg := strings.Join([]string{
+ fmt.Sprintf("From: %s", cfg.From),
+ fmt.Sprintf("To: %s", toEmail),
+ fmt.Sprintf("Subject: %s", subject),
+ "MIME-Version: 1.0",
+ "Content-Type: text/html; charset=UTF-8",
+ "",
+ body,
+ }, "\r\n")
+
+ var auth smtp.Auth
+ if cfg.Username != "" {
+ auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
+ }
+
+ err := smtp.SendMail(cfg.addr(), auth, cfg.From, []string{toEmail}, []byte(msg))
+ if err != nil {
+ log.Printf("Failed to send email notification to %s: %v", toEmail, err)
+ return false, err
+ }
+
+ return true, nil
+}
+
+func TestEmailNotification(cfg SMTPConfig, toEmail string) (bool, error) {
+ meta := AlertMetadata{AlertType: "test"}
+ return SendEmailNotification(cfg, toEmail,
+ "This is a test notification from Koin Ping. Your email is configured correctly!", meta)
+}
+
+func buildEmailBody(message string, meta AlertMetadata) string {
+ txSection := ""
+ if meta.TxHash != "" {
+ txSection = fmt.Sprintf(
+ `
| Transaction | `+
+ `View on Etherscan |
`,
+ meta.TxHash,
+ )
+ }
+
+ return fmt.Sprintf(`
+
+
+
+
+
Koin Ping Alert
+
+
+
%s
+
+ | Address | %s |
+ | Blockchain Address | %s |
+ %s
+
+
+
+ Sent by Koin Ping
+
+
+
+`, message, meta.AddressLabel, meta.Address, txSection)
+}
+
+func humanAlertType(alertType string) string {
+ switch alertType {
+ case "incoming_tx":
+ return "Incoming Transaction"
+ case "outgoing_tx":
+ return "Outgoing Transaction"
+ case "large_transfer":
+ return "Large Transfer"
+ case "balance_below":
+ return "Balance Below Threshold"
+ default:
+ return "Alert"
+ }
+}
diff --git a/backend-go/internal/notifications/email_test.go b/backend-go/internal/notifications/email_test.go
new file mode 100644
index 0000000..baf6a0e
--- /dev/null
+++ b/backend-go/internal/notifications/email_test.go
@@ -0,0 +1,102 @@
+package notifications
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSMTPConfig_IsConfigured(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg SMTPConfig
+ want bool
+ }{
+ {"fully configured", SMTPConfig{Host: "smtp.example.com", Port: 587, From: "noreply@example.com"}, true},
+ {"missing host", SMTPConfig{Port: 587, From: "noreply@example.com"}, false},
+ {"missing from", SMTPConfig{Host: "smtp.example.com", Port: 587}, false},
+ {"empty", SMTPConfig{}, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.cfg.IsConfigured(); got != tt.want {
+ t.Errorf("IsConfigured() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSMTPConfig_Addr(t *testing.T) {
+ cfg := SMTPConfig{Host: "smtp.example.com", Port: 587}
+ if got := cfg.addr(); got != "smtp.example.com:587" {
+ t.Errorf("addr() = %q, want %q", got, "smtp.example.com:587")
+ }
+}
+
+func TestBuildEmailBody(t *testing.T) {
+ meta := AlertMetadata{
+ TxHash: "0xabc123",
+ AddressLabel: "Treasury",
+ AlertType: "incoming_tx",
+ Address: "0x1234567890abcdef1234567890abcdef12345678",
+ }
+
+ body := buildEmailBody("Incoming transaction: 5.5 ETH received", meta)
+
+ if !strings.Contains(body, "Koin Ping Alert") {
+ t.Error("body should contain title")
+ }
+ if !strings.Contains(body, "Incoming transaction: 5.5 ETH received") {
+ t.Error("body should contain message")
+ }
+ if !strings.Contains(body, "Treasury") {
+ t.Error("body should contain address label")
+ }
+ if !strings.Contains(body, "0x1234567890abcdef") {
+ t.Error("body should contain blockchain address")
+ }
+ if !strings.Contains(body, "etherscan.io/tx/0xabc123") {
+ t.Error("body should contain etherscan link")
+ }
+}
+
+func TestBuildEmailBody_NoTxHash(t *testing.T) {
+ meta := AlertMetadata{
+ AddressLabel: "Cold Storage",
+ AlertType: "balance_below",
+ Address: "0xabcdef",
+ }
+
+ body := buildEmailBody("Balance dropped below threshold", meta)
+
+ if strings.Contains(body, "etherscan.io") {
+ t.Error("body should not contain etherscan link when no tx hash")
+ }
+}
+
+func TestHumanAlertType(t *testing.T) {
+ tests := map[string]string{
+ "incoming_tx": "Incoming Transaction",
+ "outgoing_tx": "Outgoing Transaction",
+ "large_transfer": "Large Transfer",
+ "balance_below": "Balance Below Threshold",
+ "unknown": "Alert",
+ "test": "Alert",
+ }
+ for alertType, want := range tests {
+ if got := humanAlertType(alertType); got != want {
+ t.Errorf("humanAlertType(%q) = %q, want %q", alertType, got, want)
+ }
+ }
+}
+
+func TestSendEmailNotification_NotConfigured(t *testing.T) {
+ cfg := SMTPConfig{}
+ sent, err := SendEmailNotification(cfg, "user@example.com", "test", AlertMetadata{})
+ if err == nil {
+ t.Fatal("expected error for unconfigured SMTP")
+ }
+ if sent {
+ t.Fatal("expected sent to be false")
+ }
+}
diff --git a/backend-go/internal/notifications/slack.go b/backend-go/internal/notifications/slack.go
new file mode 100644
index 0000000..83bd417
--- /dev/null
+++ b/backend-go/internal/notifications/slack.go
@@ -0,0 +1,128 @@
+package notifications
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+)
+
+type slackBlock struct {
+ Type string `json:"type"`
+ Text *slackText `json:"text,omitempty"`
+ Fields []slackText `json:"fields,omitempty"`
+ Elements []slackText `json:"elements,omitempty"`
+}
+
+type slackText struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+}
+
+type slackPayload struct {
+ Blocks []slackBlock `json:"blocks"`
+}
+
+func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) {
+ etherscanLink := ""
+ if meta.TxHash != "" {
+ etherscanLink = fmt.Sprintf("<%s|View on Etherscan>", "https://etherscan.io/tx/"+meta.TxHash)
+ }
+
+ blocks := []slackBlock{
+ {
+ Type: "header",
+ Text: &slackText{Type: "plain_text", Text: emojiForAlertType(meta.AlertType) + " Koin Ping Alert"},
+ },
+ {
+ Type: "section",
+ Text: &slackText{Type: "mrkdwn", Text: message},
+ },
+ {
+ Type: "section",
+ Fields: []slackText{
+ {Type: "mrkdwn", Text: fmt.Sprintf("*Address:*\n%s", meta.AddressLabel)},
+ {Type: "mrkdwn", Text: fmt.Sprintf("*Blockchain Address:*\n`%s`", meta.Address)},
+ },
+ },
+ }
+
+ if etherscanLink != "" {
+ blocks = append(blocks, slackBlock{
+ Type: "section",
+ Text: &slackText{Type: "mrkdwn", Text: fmt.Sprintf("*Transaction:* %s", etherscanLink)},
+ })
+ }
+
+ blocks = append(blocks, slackBlock{
+ Type: "context",
+ Elements: []slackText{
+ {Type: "mrkdwn", Text: fmt.Sprintf("Koin Ping | %s", time.Now().UTC().Format(time.RFC3339))},
+ },
+ })
+
+ payload := slackPayload{Blocks: blocks}
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return false, fmt.Errorf("marshal slack payload: %w", err)
+ }
+
+ resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
+ if err != nil {
+ log.Printf("Failed to send Slack notification: %v", err)
+ return false, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
+ return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
+ }
+
+ return true, nil
+}
+
+func TestSlackWebhook(webhookURL string) (bool, error) {
+ payload := slackPayload{
+ Blocks: []slackBlock{
+ {
+ Type: "section",
+ Text: &slackText{
+ Type: "mrkdwn",
+ Text: "Koin Ping test notification — Your Slack 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("Slack webhook test failed: %v", err)
+ return false, err
+ }
+ defer resp.Body.Close()
+
+ return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
+}
+
+func emojiForAlertType(alertType string) string {
+ switch alertType {
+ case "incoming_tx":
+ return "📥"
+ case "outgoing_tx":
+ return "📤"
+ case "large_transfer":
+ return "🚨"
+ case "balance_below":
+ return "⚠️"
+ default:
+ return "🔔"
+ }
+}
diff --git a/backend-go/internal/notifications/slack_test.go b/backend-go/internal/notifications/slack_test.go
new file mode 100644
index 0000000..55f1c8a
--- /dev/null
+++ b/backend-go/internal/notifications/slack_test.go
@@ -0,0 +1,128 @@
+package notifications
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestSendSlackNotification(t *testing.T) {
+ var receivedPayload slackPayload
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Errorf("expected POST, got %s", r.Method)
+ }
+ if r.Header.Get("Content-Type") != "application/json" {
+ t.Errorf("expected application/json content type")
+ }
+
+ body, _ := io.ReadAll(r.Body)
+ json.Unmarshal(body, &receivedPayload)
+
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ meta := AlertMetadata{
+ TxHash: "0xabc123",
+ AddressLabel: "Treasury",
+ AlertType: "incoming_tx",
+ Address: "0x1234567890abcdef1234567890abcdef12345678",
+ }
+
+ sent, err := SendSlackNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !sent {
+ t.Fatal("expected sent to be true")
+ }
+
+ if len(receivedPayload.Blocks) < 3 {
+ t.Fatalf("expected at least 3 blocks, got %d", len(receivedPayload.Blocks))
+ }
+ if receivedPayload.Blocks[0].Type != "header" {
+ t.Errorf("first block should be header, got %s", receivedPayload.Blocks[0].Type)
+ }
+}
+
+func TestSendSlackNotification_ServerError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ sent, err := SendSlackNotification(server.URL, "test", AlertMetadata{})
+ if err == nil {
+ t.Fatal("expected error for server error response")
+ }
+ if sent {
+ t.Fatal("expected sent to be false")
+ }
+}
+
+func TestSendSlackNotification_NoTxHash(t *testing.T) {
+ var receivedPayload slackPayload
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ json.Unmarshal(body, &receivedPayload)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ meta := AlertMetadata{
+ AddressLabel: "Treasury",
+ AlertType: "balance_below",
+ Address: "0x1234567890abcdef1234567890abcdef12345678",
+ }
+
+ sent, err := SendSlackNotification(server.URL, "Balance dropped below threshold", meta)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !sent {
+ t.Fatal("expected sent to be true")
+ }
+
+ for _, block := range receivedPayload.Blocks {
+ if block.Text != nil && block.Type == "section" {
+ if block.Text.Text == "*Transaction:*" {
+ t.Error("should not include transaction block when TxHash is empty")
+ }
+ }
+ }
+}
+
+func TestTestSlackWebhook(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ ok, err := TestSlackWebhook(server.URL)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !ok {
+ t.Fatal("expected ok to be true")
+ }
+}
+
+func TestEmojiForAlertType(t *testing.T) {
+ tests := map[string]string{
+ "incoming_tx": "📥",
+ "outgoing_tx": "📤",
+ "large_transfer": "🚨",
+ "balance_below": "⚠️",
+ "unknown": "🔔",
+ }
+ for alertType, want := range tests {
+ if got := emojiForAlertType(alertType); got != want {
+ t.Errorf("emojiForAlertType(%q) = %q, want %q", alertType, got, want)
+ }
+ }
+}
diff --git a/backend-go/internal/protocols/ethereum/jsonrpc_test.go b/backend-go/internal/protocols/ethereum/jsonrpc_test.go
new file mode 100644
index 0000000..026d474
--- /dev/null
+++ b/backend-go/internal/protocols/ethereum/jsonrpc_test.go
@@ -0,0 +1,96 @@
+package ethereum
+
+import (
+ "math/big"
+ "testing"
+)
+
+func TestHexToInt(t *testing.T) {
+ tests := []struct {
+ hex string
+ want int
+ }{
+ {"0x0", 0},
+ {"0x1", 1},
+ {"0xa", 10},
+ {"0xff", 255},
+ {"0x100", 256},
+ {"0x1234", 4660},
+ }
+
+ for _, tt := range tests {
+ got, err := hexToInt(tt.hex)
+ if err != nil {
+ t.Fatalf("hexToInt(%q) error: %v", tt.hex, err)
+ }
+ if got != tt.want {
+ t.Errorf("hexToInt(%q) = %d, want %d", tt.hex, got, tt.want)
+ }
+ }
+}
+
+func TestHexToInt_Invalid(t *testing.T) {
+ _, err := hexToInt("xyz")
+ if err == nil {
+ t.Fatal("expected error for invalid hex")
+ }
+}
+
+func TestHexToInt64(t *testing.T) {
+ got, err := hexToInt64("0x5f5e100")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != 100000000 {
+ t.Errorf("got %d, want 100000000", got)
+ }
+}
+
+func TestHexToDecimalString(t *testing.T) {
+ tests := []struct {
+ hex string
+ want string
+ }{
+ {"0x0", "0"},
+ {"0x", "0"},
+ {"", "0"},
+ {"0x1", "1"},
+ {"0xde0b6b3a7640000", "1000000000000000000"},
+ }
+
+ for _, tt := range tests {
+ got := hexToDecimalString(tt.hex)
+ if got != tt.want {
+ t.Errorf("hexToDecimalString(%q) = %q, want %q", tt.hex, got, tt.want)
+ }
+ }
+}
+
+func TestHexToDecimalString_LargeValues(t *testing.T) {
+ expected := new(big.Int).Mul(
+ big.NewInt(100),
+ new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil),
+ )
+ hex := "0x" + expected.Text(16)
+ got := hexToDecimalString(hex)
+ if got != expected.String() {
+ t.Errorf("got %q, want %q", got, expected.String())
+ }
+}
+
+func TestNewJsonRpcEthereum_EmptyURL(t *testing.T) {
+ _, err := NewJsonRpcEthereum("")
+ if err == nil {
+ t.Fatal("expected error for empty URL")
+ }
+}
+
+func TestNewJsonRpcEthereum_ValidURL(t *testing.T) {
+ eth, err := NewJsonRpcEthereum("https://example.com/rpc")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if eth == nil {
+ t.Fatal("expected non-nil client")
+ }
+}
diff --git a/backend-go/internal/services/evaluator.go b/backend-go/internal/services/evaluator.go
index 9dfc9b2..a68529f 100644
--- a/backend-go/internal/services/evaluator.go
+++ b/backend-go/internal/services/evaluator.go
@@ -18,6 +18,7 @@ type EvaluatorService struct {
alertEvents *models.AlertEventModel
addresses *models.AddressModel
notifConfigs *models.NotificationConfigModel
+ smtpConfig notifications.SMTPConfig
}
func NewEvaluatorService(
@@ -26,6 +27,7 @@ func NewEvaluatorService(
alertEvents *models.AlertEventModel,
addresses *models.AddressModel,
notifConfigs *models.NotificationConfigModel,
+ smtpCfg notifications.SMTPConfig,
) *EvaluatorService {
return &EvaluatorService{
eth: eth,
@@ -33,6 +35,7 @@ func NewEvaluatorService(
alertEvents: alertEvents,
addresses: addresses,
notifConfigs: notifConfigs,
+ smtpConfig: smtpCfg,
}
}
@@ -168,40 +171,56 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
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)
+ go s.sendNotifications(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) {
+func (s *EvaluatorService) sendNotifications(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 {
+ if notifConfig == nil || !notifConfig.NotificationEnabled {
return
}
- sent, err := notifications.SendDiscordNotification(
- *notifConfig.DiscordWebhookURL,
- message,
- notifications.AlertMetadata{
- TxHash: obs.Hash,
- AddressLabel: addressLabel,
- AlertType: string(rule.Type),
- Address: address,
- },
- )
+ meta := 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)
+ if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
+ sent, err := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
+ 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)
+ }
+ }
+
+ if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" {
+ sent, err := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
+ if err != nil || !sent {
+ log.Printf("Slack notification failed for user %s: %v", userID, err)
+ } else {
+ log.Printf("Slack notification sent to user %s", userID)
+ }
+ }
+
+ if notifConfig.Email != nil && *notifConfig.Email != "" && s.smtpConfig.IsConfigured() {
+ sent, err := notifications.SendEmailNotification(s.smtpConfig, *notifConfig.Email, message, meta)
+ if err != nil || !sent {
+ log.Printf("Email notification failed for user %s: %v", userID, err)
+ } else {
+ log.Printf("Email notification sent to user %s (%s)", userID, *notifConfig.Email)
+ }
}
}
diff --git a/backend-go/internal/services/observer_test.go b/backend-go/internal/services/observer_test.go
new file mode 100644
index 0000000..d5251cd
--- /dev/null
+++ b/backend-go/internal/services/observer_test.go
@@ -0,0 +1,113 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/kjannette/koin-ping/backend-go/internal/domain"
+)
+
+func TestFilterRelevantTransactions(t *testing.T) {
+ to1 := "0xaaaa"
+ to2 := "0xbbbb"
+ to3 := "0xcccc"
+
+ txs := []domain.NormalizedTx{
+ {Hash: "0x1", From: "0xaaaa", To: &to2, Value: "1000"},
+ {Hash: "0x2", From: "0xbbbb", To: &to1, Value: "2000"},
+ {Hash: "0x3", From: "0xcccc", To: &to3, Value: "3000"},
+ {Hash: "0x4", From: "0xdddd", To: nil, Value: "0"},
+ }
+
+ t.Run("finds outgoing", func(t *testing.T) {
+ result := filterRelevantTransactions(txs, "0xAAAA")
+ if len(result) != 2 {
+ t.Fatalf("expected 2 relevant txs (1 from, 1 to), got %d", len(result))
+ }
+ })
+
+ t.Run("finds incoming", func(t *testing.T) {
+ result := filterRelevantTransactions(txs, "0xBBBB")
+ if len(result) != 2 {
+ t.Fatalf("expected 2 relevant txs, got %d", len(result))
+ }
+ })
+
+ t.Run("no match", func(t *testing.T) {
+ result := filterRelevantTransactions(txs, "0x9999")
+ if len(result) != 0 {
+ t.Fatalf("expected 0 relevant txs, got %d", len(result))
+ }
+ })
+
+ t.Run("case insensitive", func(t *testing.T) {
+ result := filterRelevantTransactions(txs, "0xAAAA")
+ if len(result) == 0 {
+ t.Fatal("should match case-insensitively")
+ }
+ })
+}
+
+func TestCreateObservedTx(t *testing.T) {
+ to := "0xaaaa"
+ tx := domain.NormalizedTx{
+ Hash: "0xhash",
+ From: "0xbbbb",
+ To: &to,
+ Value: "1000",
+ }
+
+ t.Run("incoming direction", func(t *testing.T) {
+ addr := domain.Address{ID: 1, Address: "0xaaaa"}
+ obs := createObservedTx(tx, addr)
+ if obs.Direction != domain.DirectionIncoming {
+ t.Errorf("expected incoming, got %s", obs.Direction)
+ }
+ if obs.AddressID != 1 {
+ t.Errorf("expected address ID 1, got %d", obs.AddressID)
+ }
+ })
+
+ t.Run("outgoing direction", func(t *testing.T) {
+ addr := domain.Address{ID: 2, Address: "0xbbbb"}
+ obs := createObservedTx(tx, addr)
+ if obs.Direction != domain.DirectionOutgoing {
+ t.Errorf("expected outgoing, got %s", obs.Direction)
+ }
+ })
+}
+
+func TestGetStartBlock(t *testing.T) {
+ s := &ObserverService{}
+
+ t.Run("no checkpoint uses latest", func(t *testing.T) {
+ got := s.getStartBlock(0, false, 1000)
+ if got != 1000 {
+ t.Errorf("expected 1000, got %d", got)
+ }
+ })
+
+ t.Run("with checkpoint uses next block", func(t *testing.T) {
+ got := s.getStartBlock(999, true, 1000)
+ if got != 1000 {
+ t.Errorf("expected 1000, got %d", got)
+ }
+ })
+}
+
+func TestGetEndBlock(t *testing.T) {
+ s := &ObserverService{}
+
+ t.Run("caps at latest", func(t *testing.T) {
+ got := s.getEndBlock(990, 1000)
+ if got != 1000 {
+ t.Errorf("expected 1000, got %d", got)
+ }
+ })
+
+ t.Run("caps at maxBlocksPerRun", func(t *testing.T) {
+ got := s.getEndBlock(0, 200)
+ if got != maxBlocksPerRun-1 {
+ t.Errorf("expected %d, got %d", maxBlocksPerRun-1, got)
+ }
+ })
+}
diff --git a/backend-go/internal/wei/converter_test.go b/backend-go/internal/wei/converter_test.go
new file mode 100644
index 0000000..36043ca
--- /dev/null
+++ b/backend-go/internal/wei/converter_test.go
@@ -0,0 +1,179 @@
+package wei
+
+import (
+ "math"
+ "testing"
+)
+
+func TestToEth(t *testing.T) {
+ tests := []struct {
+ name string
+ wei string
+ want float64
+ wantErr bool
+ }{
+ {"zero string", "0", 0, false},
+ {"empty string", "", 0, false},
+ {"1 ETH", "1000000000000000000", 1.0, false},
+ {"0.5 ETH", "500000000000000000", 0.5, false},
+ {"10 ETH", "10000000000000000000", 10.0, false},
+ {"small amount", "1000000000000000", 0.001, false},
+ {"1 Wei", "1", 1e-18, false},
+ {"invalid", "notanumber", 0, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := ToEth(tt.wei)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if math.Abs(got-tt.want) > 1e-10 {
+ t.Fatalf("ToEth(%q) = %v, want %v", tt.wei, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFromEth(t *testing.T) {
+ tests := []struct {
+ name string
+ eth float64
+ want string
+ wantErr bool
+ }{
+ {"zero", 0, "0", false},
+ {"1 ETH", 1.0, "1000000000000000000", false},
+ {"0.5 ETH", 0.5, "500000000000000000", false},
+ {"10 ETH", 10.0, "10000000000000000000", false},
+ {"0.001 ETH", 0.001, "1000000000000000", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := FromEth(tt.eth)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != tt.want {
+ t.Fatalf("FromEth(%v) = %q, want %q", tt.eth, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestRoundTrip(t *testing.T) {
+ values := []float64{0.001, 0.1, 1.0, 5.5, 10.0, 100.0}
+ for _, eth := range values {
+ weiStr, err := FromEth(eth)
+ if err != nil {
+ t.Fatalf("FromEth(%v) error: %v", eth, err)
+ }
+ back, err := ToEth(weiStr)
+ if err != nil {
+ t.Fatalf("ToEth(%q) error: %v", weiStr, err)
+ }
+ if math.Abs(back-eth) > 1e-10 {
+ t.Fatalf("round-trip %v → %q → %v (diff: %v)", eth, weiStr, back, back-eth)
+ }
+ }
+}
+
+func TestCompare(t *testing.T) {
+ tests := []struct {
+ name string
+ a, b string
+ want int
+ }{
+ {"equal", "1000", "1000", 0},
+ {"a > b", "2000", "1000", 1},
+ {"a < b", "1000", "2000", -1},
+ {"zero equal", "0", "0", 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Compare(tt.a, tt.b)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != tt.want {
+ t.Fatalf("Compare(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGreaterThanOrEqual(t *testing.T) {
+ ok, err := GreaterThanOrEqual("2000", "1000")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !ok {
+ t.Fatal("expected true")
+ }
+
+ ok, err = GreaterThanOrEqual("1000", "1000")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !ok {
+ t.Fatal("expected true for equal")
+ }
+
+ ok, err = GreaterThanOrEqual("500", "1000")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ok {
+ t.Fatal("expected false")
+ }
+}
+
+func TestLessThan(t *testing.T) {
+ ok, err := LessThan("500", "1000")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !ok {
+ t.Fatal("expected true")
+ }
+
+ ok, err = LessThan("1000", "1000")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ok {
+ t.Fatal("expected false for equal")
+ }
+}
+
+func TestFormatAsEth(t *testing.T) {
+ got, err := FormatAsEth("1000000000000000000", 4)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != "1.0000 ETH" {
+ t.Fatalf("got %q, want %q", got, "1.0000 ETH")
+ }
+
+ got, err = FormatAsEth("500000000000000000", 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != "0.50 ETH" {
+ t.Fatalf("got %q, want %q", got, "0.50 ETH")
+ }
+}
diff --git a/frontend/src/api/notificationConfig.jsx b/frontend/src/api/notificationConfig.jsx
index 73010fb..0063918 100644
--- a/frontend/src/api/notificationConfig.jsx
+++ b/frontend/src/api/notificationConfig.jsx
@@ -1,5 +1,3 @@
-// API client for notification configuration
-
import { getAuthHeaders } from './authHeaders';
import { API_BASE } from './config';
@@ -38,7 +36,7 @@ export async function getNotificationConfig() {
* Update notification configuration
* @param {Object} config - Configuration to update
* @param {string} [config.discord_webhook_url] - Discord webhook URL
- * @param {string} [config.telegram_chat_id] - Telegram chat ID
+ * @param {string} [config.slack_webhook_url] - Slack webhook URL
* @param {string} [config.email] - Email address
* @param {boolean} [config.notification_enabled] - Enable/disable notifications
* @returns {Promise