general cleanup - removed old comments, enforced naming conventions etc
Some checks are pending
check / check (push) Waiting to run

This commit is contained in:
KS Jannette
2026-03-04 23:29:58 -05:00
parent 44dad43f1d
commit 2c86bba235
55 changed files with 68 additions and 101 deletions

View File

@@ -0,0 +1,196 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/kjannette/koin-ping/backend/internal/models"
)
const (
resendAPIURL = "https://api.resend.com/emails"
emailHTTPTimeout = 10 * time.Second
defaultDigestMaxItems = 50
)
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
// handles email setup and digest sending via Resend.
type EmailDigestService struct {
apiKey string
fromAddress string
alertEvents *models.AlertEventModel
notifCfgs *models.NotificationConfigModel
}
func NewEmailDigestService(
apiKey, fromAddress string,
alertEvents *models.AlertEventModel,
notifCfgs *models.NotificationConfigModel,
) *EmailDigestService {
return &EmailDigestService{
apiKey: apiKey,
fromAddress: fromAddress,
alertEvents: alertEvents,
notifCfgs: notifCfgs,
}
}
// Configured returns true when the Resend API key is present.
func (s *EmailDigestService) Configured() bool {
return s.apiKey != ""
}
// SetupEmail validates the email works by sending a welcome/confirmation
// message via Resend. Called when a user saves their email in notification settings.
func (s *EmailDigestService) SetupEmail(toAddress string) error {
if !s.Configured() {
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
}
html := `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Welcome to Koin Ping Email Alerts</h2>
<p>Your email has been successfully configured for alert notifications.</p>
<p>You will receive alert digests at this address when events are triggered
on your watched addresses.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="font-size: 12px; color: #999;">Sent by Koin Ping</p>
</div>`
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
}
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
if !s.Configured() {
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
}
events, err := s.alertEvents.ListRecentByUser(ctx, userID, defaultDigestMaxItems)
if err != nil {
return fmt.Errorf("fetch alert events: %w", err)
}
if len(events) == 0 {
log.Printf("No recent alerts for user %s — skipping digest", userID)
return nil
}
var rows string
for _, e := range events {
label := "—"
if e.AddressLabel != nil {
label = *e.AddressLabel
}
txLink := "—"
if e.TxHash != nil {
txLink = fmt.Sprintf(
`<a href="https://etherscan.io/tx/%s" style="color:#0066cc;">%s…</a>`,
*e.TxHash, (*e.TxHash)[:10],
)
}
rows += fmt.Sprintf(`
<tr>
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
<td style="padding:6px 8px; border-bottom:1px solid #eee; font-size:12px; color:#666;">%s</td>
</tr>`,
label, e.Message, txLink,
e.Timestamp.Format("Jan 2 15:04 UTC"),
)
}
html := fmt.Sprintf(`
<div style="font-family: sans-serif; max-width: 700px; margin: 0 auto;">
<h2 style="color: #333;">Koin Ping — Alert Digest</h2>
<p>Here are your recent alerts (%d total):</p>
<table style="width:100%%; border-collapse:collapse; font-size:14px;">
<thead>
<tr style="background:#f5f5f5;">
<th style="padding:8px; text-align:left;">Address</th>
<th style="padding:8px; text-align:left;">Alert</th>
<th style="padding:8px; text-align:left;">Tx</th>
<th style="padding:8px; text-align:left;">Time</th>
</tr>
</thead>
<tbody>%s</tbody>
</table>
<hr style="border:none; border-top:1px solid #eee; margin:24px 0;" />
<p style="font-size:12px; color:#999;">Sent by Koin Ping</p>
</div>`, len(events), rows)
subject := fmt.Sprintf("Koin Ping Digest — %d alerts", len(events))
return s.send(toAddress, subject, html)
}
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
if !s.Configured() {
return 0, nil
}
configs, err := s.notifCfgs.ListEnabled(ctx)
if err != nil {
return 0, fmt.Errorf("list enabled configs: %w", err)
}
sent := 0
for _, cfg := range configs {
if cfg.Email == nil || *cfg.Email == "" {
continue
}
if err := s.SendDigest(ctx, cfg.UserID, *cfg.Email); err != nil {
log.Printf("Failed to send digest to user %s: %v", cfg.UserID, err)
continue
}
sent++
}
return sent, nil
}
type resendEmailPayload struct {
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html"`
}
func (s *EmailDigestService) send(to, subject, html string) error {
payload := resendEmailPayload{
From: s.fromAddress,
To: to,
Subject: subject,
HTML: html,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal email payload: %w", err)
}
req, err := http.NewRequest(http.MethodPost, resendAPIURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.apiKey)
resp, err := digestHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("send email via Resend: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Resend API returned HTTP %d", resp.StatusCode) //nolint:err113
}
return nil
}

View File

@@ -0,0 +1,363 @@
package services
import (
"context"
"fmt"
"log"
"sync"
"time"
"golang.org/x/sync/semaphore"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/internal/notifications"
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend/internal/wei"
)
const (
notificationTimeout = 30 * time.Second
notificationMaxRetries = 3
notificationRetryBase = time.Second
maxConcurrentNotifications = 5
)
type EvaluatorService struct {
eth ethereum.EthereumObserver
alertRules *models.AlertRuleModel
alertEvents *models.AlertEventModel
addresses *models.AddressModel
notifConfigs *models.NotificationConfigModel
resendAPIKey string
emailFrom string
notifSem *semaphore.Weighted
notifWg sync.WaitGroup
}
func NewEvaluatorService(
eth ethereum.EthereumObserver,
alertRules *models.AlertRuleModel,
alertEvents *models.AlertEventModel,
addresses *models.AddressModel,
notifConfigs *models.NotificationConfigModel,
resendAPIKey string,
emailFrom string,
) *EvaluatorService {
return &EvaluatorService{
eth: eth,
alertRules: alertRules,
alertEvents: alertEvents,
addresses: addresses,
notifConfigs: notifConfigs,
resendAPIKey: resendAPIKey,
emailFrom: emailFrom,
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
}
}
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
event, err := s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
if err != nil {
return err
}
if event == nil {
log.Printf("[ALERT DEDUP] Rule %d (%s) - duplicate event skipped for TX: %s", rule.ID, rule.Type, obs.Hash)
return nil
}
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
if addr != nil {
userID := addr.UserID
address := addr.Address
if err := s.notifSem.Acquire(ctx, 1); err != nil {
log.Printf("Failed to acquire notification semaphore for rule %d: %v", rule.ID, err)
return nil
}
s.notifWg.Add(1)
go func() {
defer s.notifSem.Release(1)
defer s.notifWg.Done()
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
defer cancel()
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
}()
}
return nil
}
// WaitForNotifications blocks until all in-flight notification goroutines finish.
func (s *EvaluatorService) WaitForNotifications() {
s.notifWg.Wait()
}
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
var notifiers []notifications.Notifier
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
notifiers = append(notifiers, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
}
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
notifiers = append(notifiers, &notifications.TelegramNotifier{
BotToken: *cfg.TelegramBotToken,
ChatID: *cfg.TelegramChatID,
})
}
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
}
if cfg.Email != nil && *cfg.Email != "" {
notifiers = append(notifiers, &notifications.EmailNotifier{
APIKey: s.resendAPIKey,
From: s.emailFrom,
To: *cfg.Email,
})
}
return notifiers
}
func sendWithRetry(ctx context.Context, n notifications.Notifier, message string, meta notifications.AlertMetadata) error {
var lastErr error
for attempt := range notificationMaxRetries {
if attempt > 0 {
wait := notificationRetryBase * time.Duration(1<<(attempt-1))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
if err := n.Send(ctx, message, meta); err != nil {
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
lastErr = err
continue
}
return nil
}
return lastErr
}
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 {
return
}
meta := notifications.AlertMetadata{
TxHash: obs.Hash,
AddressLabel: addressLabel,
AlertType: string(rule.Type),
Address: address,
}
for _, n := range s.buildNotifiers(notifConfig) {
if err := sendWithRetry(ctx, n, message, meta); err != nil {
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
} else {
log.Printf("Notification sent to user %s via %T", userID, n)
}
}
}
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
if obs.IsTokenTransfer() {
return s.buildTokenMessage(rule, obs)
}
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"
}
}
const defaultTokenDecimals = 18
func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
symbol := "tokens"
if obs.TokenSymbol != nil {
symbol = *obs.TokenSymbol
}
amount := "unknown"
if obs.TokenValue != nil {
decimals := defaultTokenDecimals
if obs.TokenDecimals != nil {
decimals = *obs.TokenDecimals
}
amount = wei.FormatTokenAmount(*obs.TokenValue, decimals)
}
switch rule.Type {
case domain.AlertIncomingTx:
return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol)
case domain.AlertOutgoingTx:
return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol)
case domain.AlertLargeTransfer:
threshold := float64(0)
if rule.Threshold != nil {
threshold = *rule.Threshold
}
return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold)
default:
return fmt.Sprintf("Token transfer: %s %s", amount, symbol)
}
}

View File

@@ -0,0 +1,141 @@
package services
import (
"context"
"log"
"strings"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/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))
}
}
tokenTxs, err := s.eth.GetTokenTransfers(ctx, startBlock, endBlock, addr.Address)
if err != nil {
log.Printf("Error fetching token transfers for %s: %v", addr.Address, err)
} else {
for _, tx := range tokenTxs {
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,
}
}