Merge pull request #1 from kjannette/go-get-em

move over
This commit is contained in:
S Jannette
2026-02-27 16:41:38 -05:00
committed by GitHub
54 changed files with 312 additions and 5626 deletions

16
backend-go/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Server
PORT=3001
API_BASE_PATH=/v1
NODE_ENV=development
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/koin_ping
# Ethereum JSON-RPC
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR-PROJECT-ID
# Polling interval (ms, minimum 1000)
POLL_INTERVAL_MS=60000
# Firebase
FIREBASE_PROJECT_ID=koin-ping

View File

@@ -5,6 +5,7 @@ Copy this to `.env` in the backend directory and fill in your values.
```bash ```bash
# Server Configuration # Server Configuration
PORT=3001 PORT=3001
API_BASE_PATH=/v1
NODE_ENV=development NODE_ENV=development
# Database Configuration # Database Configuration

4
backend-go/README.md Normal file
View File

@@ -0,0 +1,4 @@
Run Backend:
cd /Users/kjannette/workspace/koin_ping/backend-go
go run ./cmd/api

View File

@@ -44,34 +44,36 @@ func main() {
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel) notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel)
mux := http.NewServeMux() mux := http.NewServeMux()
b := cfg.APIBasePath // e.g. "/v1"
// Public routes // Public routes
mux.HandleFunc("GET /api/health", handlers.HealthCheck) mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
mux.HandleFunc("GET /api/status", handlers.SystemStatus) mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus)
// Authenticated routes — addresses // Authenticated routes — addresses
mux.Handle("POST /api/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create))) mux.Handle("POST "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
mux.Handle("GET /api/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List))) mux.Handle("GET "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
mux.Handle("DELETE /api/addresses/{addressId}", middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) mux.Handle("DELETE "+b+"/addresses/{addressId}", middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
// Authenticated routes — alert rules // Authenticated routes — alert rules
mux.Handle("POST /api/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) mux.Handle("POST "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
mux.Handle("GET /api/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) mux.Handle("GET "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
mux.Handle("PATCH /api/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) mux.Handle("PATCH "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
mux.Handle("DELETE /api/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove))) mux.Handle("DELETE "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
// Authenticated routes — alert events // Authenticated routes — alert events
mux.Handle("GET /api/alert-events", middleware.Authenticate(http.HandlerFunc(alertEventHandler.List))) mux.Handle("GET "+b+"/alert-events", middleware.Authenticate(http.HandlerFunc(alertEventHandler.List)))
// Authenticated routes — notification config // Authenticated routes — notification config
mux.Handle("GET /api/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) mux.Handle("GET "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
mux.Handle("PUT /api/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) mux.Handle("PUT "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
mux.Handle("DELETE /api/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) mux.Handle("DELETE "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
handler := corsMiddleware(mux) handler := corsMiddleware(mux)
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("Server running on port %d", cfg.Port) log.Printf("Server running on port %d", cfg.Port)
log.Printf("API base path: %s", cfg.APIBasePath)
log.Printf("Environment: %s", cfg.NodeEnv) log.Printf("Environment: %s", cfg.NodeEnv)
if err := http.ListenAndServe(addr, handler); err != nil { if err := http.ListenAndServe(addr, handler); err != nil {

View File

@@ -9,6 +9,7 @@ import (
type Config struct { type Config struct {
Port int Port int
APIBasePath string
DatabaseURL string DatabaseURL string
DBHost string DBHost string
DBPort int DBPort int
@@ -24,6 +25,7 @@ type Config struct {
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
Port: getEnvInt("PORT", 3001), Port: getEnvInt("PORT", 3001),
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
DBHost: getEnv("DB_HOST", "localhost"), DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnvInt("DB_PORT", 5432), DBPort: getEnvInt("DB_PORT", 5432),

View File

@@ -30,6 +30,7 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
Label *string `json:"label"` Label *string `json:"label"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode address request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return return
} }

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/kjannette/koin-ping/backend-go/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
@@ -30,15 +31,23 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
} }
var body struct { var body struct {
Type string `json:"type"` Type string `json:"type"`
Threshold *float64 `json:"threshold"` Threshold json.RawMessage `json:"threshold"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode alert request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return return
} }
log.Printf("User %s creating alert for address ID: %d", userID, addressID) threshold, err := parseThreshold(body.Threshold)
if err != nil {
log.Printf("Failed to parse threshold: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "threshold must be a valid number")
return
}
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
if body.Type == "" { if body.Type == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required")
@@ -57,7 +66,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
alertType := domain.AlertType(body.Type) alertType := domain.AlertType(body.Type)
if domain.IsThresholdRequired(alertType) { if domain.IsThresholdRequired(alertType) {
if body.Threshold == nil || *body.Threshold <= 0 { if threshold == nil || *threshold <= 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type)) fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type))
return return
@@ -76,7 +85,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, body.Threshold) newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold)
if err != nil { if err != nil {
log.Printf("Error creating alert rule: %v", err) log.Printf("Error creating alert rule: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
@@ -87,6 +96,37 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, newAlert) writeJSON(w, http.StatusCreated, newAlert)
} }
func parseThreshold(raw json.RawMessage) (*float64, error) {
if len(raw) == 0 {
return nil, nil
}
// Check null before number -- json.Unmarshal treats null as valid for float64 (sets to 0).
if string(raw) == "null" {
return nil, nil
}
var asNumber float64
if err := json.Unmarshal(raw, &asNumber); err == nil {
return &asNumber, nil
}
var asString string
if err := json.Unmarshal(raw, &asString); err == nil {
asString = strings.TrimSpace(asString)
if asString == "" {
return nil, nil
}
parsed, parseErr := strconv.ParseFloat(asString, 64)
if parseErr != nil {
return nil, parseErr
}
return &parsed, nil
}
return nil, fmt.Errorf("unsupported threshold format")
}
func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) { func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId")) addressID, ok := parseIntParam(r.PathValue("addressId"))
@@ -136,6 +176,7 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode update request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return return
} }

View File

@@ -0,0 +1,214 @@
package handlers
import (
"encoding/json"
"math"
"testing"
)
func TestParseThreshold(t *testing.T) {
t.Run("nil/empty raw message returns nil", func(t *testing.T) {
val, err := parseThreshold(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != nil {
t.Fatalf("expected nil, got %v", *val)
}
})
t.Run("empty slice returns nil", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != nil {
t.Fatalf("expected nil, got %v", *val)
}
})
t.Run("JSON null returns nil", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage("null"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != nil {
t.Fatalf("expected nil, got %v", *val)
}
})
t.Run("number 10 returns 10.0", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage("10"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val == nil {
t.Fatal("expected non-nil value")
}
if *val != 10.0 {
t.Fatalf("expected 10.0, got %v", *val)
}
})
t.Run("number 0.5 returns 0.5", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage("0.5"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val == nil || *val != 0.5 {
t.Fatalf("expected 0.5, got %v", val)
}
})
t.Run("string '10' returns 10.0", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage(`"10"`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val == nil || *val != 10.0 {
t.Fatalf("expected 10.0, got %v", val)
}
})
t.Run("string '0.001' returns 0.001", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage(`"0.001"`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val == nil || math.Abs(*val-0.001) > 1e-9 {
t.Fatalf("expected 0.001, got %v", val)
}
})
t.Run("string with spaces ' 10 ' returns 10.0", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage(`" 10 "`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val == nil || *val != 10.0 {
t.Fatalf("expected 10.0, got %v", val)
}
})
t.Run("empty string returns nil", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage(`""`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != nil {
t.Fatalf("expected nil, got %v", *val)
}
})
t.Run("whitespace-only string returns nil", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage(`" "`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != nil {
t.Fatalf("expected nil, got %v", *val)
}
})
t.Run("invalid string returns error", func(t *testing.T) {
_, err := parseThreshold(json.RawMessage(`"abc"`))
if err == nil {
t.Fatal("expected error for non-numeric string")
}
})
t.Run("boolean returns error", func(t *testing.T) {
_, err := parseThreshold(json.RawMessage("true"))
if err == nil {
t.Fatal("expected error for boolean")
}
})
t.Run("array returns error", func(t *testing.T) {
_, err := parseThreshold(json.RawMessage("[1,2]"))
if err == nil {
t.Fatal("expected error for array")
}
})
}
func TestDecodeAlertBody(t *testing.T) {
// Verifies that the struct used in Create handler can decode all
// payload shapes the frontend might send.
type alertBody struct {
Type string `json:"type"`
Threshold json.RawMessage `json:"threshold"`
}
tests := []struct {
name string
payload string
wantErr bool
}{
{
name: "incoming_tx without threshold",
payload: `{"type":"incoming_tx"}`,
},
{
name: "outgoing_tx without threshold",
payload: `{"type":"outgoing_tx"}`,
},
{
name: "large_transfer with number threshold",
payload: `{"type":"large_transfer","threshold":10}`,
},
{
name: "large_transfer with string threshold",
payload: `{"type":"large_transfer","threshold":"10"}`,
},
{
name: "balance_below with number threshold",
payload: `{"type":"balance_below","threshold":0.5}`,
},
{
name: "threshold null",
payload: `{"type":"incoming_tx","threshold":null}`,
},
{
name: "empty object",
payload: `{}`,
},
{
name: "empty body",
payload: ``,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body alertBody
err := json.Unmarshal([]byte(tt.payload), &body)
if tt.wantErr {
if err == nil {
t.Fatal("expected decode error")
}
return
}
if err != nil {
t.Fatalf("unexpected decode error: %v", err)
}
})
}
}
func TestOldStructFailsWithStringThreshold(t *testing.T) {
// Documents the original bug: *float64 cannot decode a string threshold.
type oldAlertBody struct {
Type string `json:"type"`
Threshold *float64 `json:"threshold"`
}
payload := `{"type":"large_transfer","threshold":"10"}`
var body oldAlertBody
err := json.Unmarshal([]byte(payload), &body)
if err == nil {
t.Fatal("expected error: old struct with *float64 should reject string threshold")
}
}

View File

@@ -55,6 +55,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
NotificationEnabled *bool `json:"notification_enabled"` NotificationEnabled *bool `json:"notification_enabled"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode notification config request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return return
} }

7
backend/.gitignore vendored
View File

@@ -1,7 +0,0 @@
node_modules/
.env
*.log
.DS_Store
dist/
build/

View File

@@ -1,56 +0,0 @@
# Koin Ping Backend
On-chain monitoring and alerting system backend.
## Environment Variables
Create a `.env` file in the backend directory with the following variables:
```env
PORT=3001
NODE_ENV=development
# PostgreSQL Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=koin_ping
# Blockchain RPC Endpoints (examples)
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR_INFURA_KEY
POLYGON_RPC_URL=https://polygon-rpc.com
# Polling Configuration
POLL_INTERVAL_MS=10000
```
## Getting Started
1. Install dependencies:
```bash
npm install
```
2. Set up your `.env` file with the required environment variables
3. Run the development server:
```bash
npm run dev
```
4. Run in production:
```bash
npm start
```
## Project Structure
- `api/` - API endpoints and server configuration
- `poller/` - Blockchain polling logic
- `alerts/` - Alert evaluation and management
- `notifications/` - Notification delivery system
- `domain/` - Domain models and business logic
- `infra/` - Infrastructure (database, external services)
- `shared/` - Shared utilities and helpers

View File

@@ -1,20 +0,0 @@
import express from 'express';
import { authenticate } from '../../middleware/authenticate.js';
import * as AddressController from '../../controllers/AddressController.js';
const router = express.Router();
// Apply authentication to all routes
router.use(authenticate);
// POST /addresses - Create Blockchain Address
router.post('/', AddressController.create);
// GET /addresses - List Addresses
router.get('/', AddressController.list);
// DELETE /addresses/:addressId - Delete Address
router.delete('/:addressId', AddressController.remove);
export default router;

View File

@@ -1,12 +0,0 @@
import express from 'express';
import { authenticate } from '../../middleware/authenticate.js';
import * as AlertEventController from '../../controllers/AlertEventController.js';
const router = express.Router();
router.use(authenticate);
router.get('/', AlertEventController.list);
export default router;

View File

@@ -1,18 +0,0 @@
import express from 'express';
import { authenticate } from '../../middleware/authenticate.js';
import * as AlertRuleController from '../../controllers/AlertRuleController.js';
const router = express.Router();
router.use(authenticate);
router.post('/:addressId/alerts', AlertRuleController.create);
router.get('/:addressId/alerts', AlertRuleController.listByAddress);
router.patch('/:alertId', AlertRuleController.updateStatus);
router.delete('/:alertId', AlertRuleController.remove);
export default router;

View File

@@ -1,16 +0,0 @@
import express from 'express';
import { authenticate } from '../../middleware/authenticate.js';
import * as NotificationConfigController from '../../controllers/NotificationConfigController.js';
const router = express.Router();
router.use(authenticate);
router.get('/', NotificationConfigController.getConfig);
router.put('/', NotificationConfigController.updateConfig);
router.delete('/', NotificationConfigController.deleteConfig);
export default router;

View File

@@ -1,17 +0,0 @@
import express from 'express';
const router = express.Router();
// GET /status - System Status (mock)
router.get('/', (req, res) => {
// TEMP - Mock response until poller service is complete
res.json({
latestBlock: 0,
lag: 0,
status: 'healthy',
timestamp: new Date().toISOString()
});
});
export default router;

View File

@@ -1,47 +0,0 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import addressesRouter from './routes/addresses.js';
import alertsRouter from './routes/alerts.js';
import alertEventsRouter from './routes/alertEvents.js';
import statusRouter from './routes/status.js';
import notificationConfigRouter from './routes/notificationConfig.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
// Health check
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
service: 'koin-ping-backend'
});
});
app.use('/api/addresses', addressesRouter);
app.use('/api/addresses', alertsRouter); // Alert routes include addressId in path
app.use('/api/alerts', alertsRouter); // Direct alert operations (PATCH, DELETE)
app.use('/api/alert-events', alertEventsRouter);
app.use('/api/status', statusRouter);
app.use('/api/notification-config', notificationConfigRouter);
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Something went wrong!',
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});

View File

@@ -1,82 +0,0 @@
import * as AddressModel from '../models/AddressModel.js';
/**
* Create a new address
*/
export async function create(req, res) {
const { address, label } = req.body;
const user_id = req.user_id; // From authenticate middleware
console.log(`User ${user_id} creating address: ${address}`);
if (!address) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Address is required'
});
}
// Validation: ETH address format
if (!/^0x[a-fA-F0-9]{40}$/.test(address)) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Invalid Ethereum address format'
});
}
try {
const newAddress = await AddressModel.create(user_id, address, label || null);
console.log(`Address created with ID: ${newAddress.id}`);
return res.status(201).json(newAddress);
} catch (error) {
// Handle duplicate address (unique constraint violation)
if (error.code === '23505') {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'You are already tracking this address'
});
}
throw error;
}
}
export async function list(req, res) {
const user_id = req.user_id; // From authenticate middleware
console.log(`User ${user_id} listing addresses`);
const addresses = await AddressModel.listByUser(user_id);
console.log(`Found ${addresses.length} addresses for user`);
return res.json(addresses);
}
export async function remove(req, res) {
const addressId = parseInt(req.params.addressId);
const user_id = req.user_id; // From authenticate middleware
console.log(`User ${user_id} deleting address ID: ${addressId}`);
if (isNaN(addressId)) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Invalid address ID'
});
}
const deleted = await AddressModel.remove(addressId, user_id);
if (!deleted) {
console.log(`Address ${addressId} not found or not owned by user`);
return res.status(404).json({
error: 'NOT_FOUND',
message: 'Address not found'
});
}
console.log(`Address ${addressId} deleted`);
return res.status(204).send();
}

View File

@@ -1,54 +0,0 @@
import * as AlertEventModel from '../models/AlertEventModel.js';
// Mock data for when DB is empty (MVP scaffolding)
const MOCK_EVENTS = [
{
id: 1,
alert_rule_id: 1,
message: 'Incoming transaction detected: 5.5 ETH received',
address_label: 'Treasury Wallet',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString() // 2 hours ago
},
{
id: 2,
alert_rule_id: 2,
message: 'Balance dropped below threshold: Current balance 8.2 ETH',
address_label: 'Treasury Wallet',
timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString() // 5 hours ago
},
{
id: 3,
alert_rule_id: 3,
message: 'Outgoing transaction detected: 2.0 ETH sent',
address_label: 'Cold Storage',
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() // 1 day ago
}
];
export async function list(req, res) {
const limit = parseInt(req.query.limit) || 20;
const user_id = req.user_id; // From authenticate middleware
console.log(`User ${user_id} listing alert events (limit: ${limit})`);
if (limit < 1 || limit > 100) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Limit must be between 1 and 100'
});
}
const events = await AlertEventModel.listRecentByUser(user_id, limit);
console.log(`Found ${events.length} alert events for user`);
// Return mock data if DB is empty (MVP scaffolding)
if (events.length === 0) {
return res.json(MOCK_EVENTS.slice(0, limit));
}
return res.json(events);
}

View File

@@ -1,185 +0,0 @@
import * as AlertRuleModel from '../models/AlertRuleModel.js';
import * as AddressModel from '../models/AddressModel.js';
const VALID_ALERT_TYPES = ['incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'];
const THRESHOLD_REQUIRED_TYPES = ['large_transfer', 'balance_below'];
/**
* Create a new alert rule
*/
export async function create(req, res) {
const addressId = parseInt(req.params.addressId);
const { type, threshold } = req.body;
const user_id = req.user_id; // From authenticate middleware
console.log(`User ${user_id} creating alert for address ID: ${addressId}`);
if (isNaN(addressId)) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Invalid address ID'
});
}
if (!type) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Alert type is required'
});
}
// Validation: type must be valid
if (!VALID_ALERT_TYPES.includes(type)) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: `Invalid alert type. Must be one of: ${VALID_ALERT_TYPES.join(', ')}`
});
}
if (THRESHOLD_REQUIRED_TYPES.includes(type)) {
if (!threshold || threshold === '') {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: `Alert type '${type}' requires a threshold value`
});
}
const thresholdNum = parseFloat(threshold);
if (isNaN(thresholdNum) || thresholdNum <= 0) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Threshold must be a positive number'
});
}
}
try {
// Verify address exists AND belongs to this user
const address = await AddressModel.findById(addressId, user_id);
if (!address) {
console.log(`Address ${addressId} not found or not owned by user`);
return res.status(404).json({
error: 'NOT_FOUND',
message: 'Address not found'
});
}
const newAlert = await AlertRuleModel.create(
addressId,
type,
threshold || null
);
console.log(`Alert rule created with ID: ${newAlert.id}`);
return res.status(201).json(newAlert);
} catch (error) {
console.error(error);
return res.status(500).json({
error: 'INTERNAL_ERROR',
message: 'Failed to create alert rule'
});
}
}
export async function listByAddress(req, res) {
const addressId = parseInt(req.params.addressId);
const user_id = req.user_id; // From authenticate middleware
console.log(`User ${user_id} listing alerts for address ID: ${addressId}`);
if (isNaN(addressId)) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Invalid address ID'
});
}
const address = await AddressModel.findById(addressId, user_id);
if (!address) {
console.log(`Address ${addressId} not found or not owned by user`);
return res.status(404).json({
error: 'NOT_FOUND',
message: 'Address not found'
});
}
const alerts = await AlertRuleModel.listByAddress(addressId);
console.log(`Found ${alerts.length} alert rules`);
return res.json(alerts);
}
export async function updateStatus(req, res) {
const alertId = parseInt(req.params.alertId);
const { enabled } = req.body;
const user_id = req.user_id;
console.log(`User ${user_id} updating alert ID: ${alertId}`);
// Validation: alertId must be valid
if (isNaN(alertId)) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Invalid alert ID'
});
}
// Validation: enabled must be boolean
if (typeof enabled !== 'boolean') {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'enabled must be a boolean value'
});
}
// Verify user owns this alert (through address ownership)
const alert = await AlertRuleModel.findById(alertId, user_id);
if (!alert) {
console.log(`Alert ${alertId} not found or not owned by user`);
return res.status(404).json({
error: 'NOT_FOUND',
message: 'Alert rule not found'
});
}
const updatedAlert = await AlertRuleModel.updateEnabled(alertId, enabled);
console.log(`Alert ${alertId} updated: enabled=${enabled}`);
return res.json(updatedAlert);
}
export async function remove(req, res) {
const alertId = parseInt(req.params.alertId);
const user_id = req.user_id;
console.log(`User ${user_id} deleting alert ID: ${alertId}`);
if (isNaN(alertId)) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Invalid alert ID'
});
}
const alert = await AlertRuleModel.findById(alertId, user_id);
if (!alert) {
console.log(`Alert ${alertId} not found or not owned by user`);
return res.status(404).json({
error: 'NOT_FOUND',
message: 'Alert rule not found'
});
}
const deleted = await AlertRuleModel.remove(alertId);
console.log(`Alert ${alertId} deleted`);
return res.status(204).send();
}

View File

@@ -1,105 +0,0 @@
import * as NotificationConfigModel from '../models/NotificationConfigModel.js';
/**
* Get notification config for the authenticated user
*/
export async function getConfig(req, res) {
const user_id = req.user_id; // From authenticate middleware
console.log(`User ${user_id} getting notification config`);
const config = await NotificationConfigModel.getConfig(user_id);
if (!config) {
// Return empty config if not set
return res.json({
user_id,
discord_webhook_url: null,
telegram_chat_id: null,
email: null,
notification_enabled: true
});
}
console.log(`Config found`);
return res.json(config);
}
/**
* Update notification config for the authenticated user
*/
export async function updateConfig(req, res) {
const user_id = req.user_id; // From authenticate middleware
const { discord_webhook_url, telegram_chat_id, email, notification_enabled } = req.body;
console.log(`User ${user_id} updating notification config`);
// Validation: at least one field should be provided
if (
discord_webhook_url === undefined &&
telegram_chat_id === undefined &&
email === undefined &&
notification_enabled === undefined
) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'At least one configuration field must be provided'
});
}
// Validation: Discord webhook URL format (basic check)
if (discord_webhook_url && !discord_webhook_url.startsWith('https://discord.com/api/webhooks/')) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Invalid Discord webhook URL format'
});
}
// Validation: Email format (basic check)
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Invalid email address format'
});
}
try {
const updatedConfig = await NotificationConfigModel.upsertConfig(user_id, {
discord_webhook_url,
telegram_chat_id,
email,
notification_enabled
});
console.log(`Notification config updated`);
return res.json(updatedConfig);
} catch (error) {
console.error('Error updating notification config:', error);
return res.status(500).json({
error: 'INTERNAL_ERROR',
message: 'Failed to update notification configuration'
});
}
}
/**
* Delete notification config for the authenticated user
*/
export async function deleteConfig(req, res) {
const user_id = req.user_id; // From authenticate middleware
console.log(`User ${user_id} deleting notification config`);
const deleted = await NotificationConfigModel.remove(user_id);
if (!deleted) {
return res.status(404).json({
error: 'NOT_FOUND',
message: 'No notification configuration found'
});
}
console.log(`Notification config deleted`);
return res.status(204).send();
}

View File

@@ -1,19 +0,0 @@
/**
* NormalizedTx - Standardized transaction representation
*
* Defines the minimal, normalized transaction data relevant
* for tracking blockchain activity.
*/
/**
* @typedef {Object} NormalizedTx
* @property {string} hash - Transaction hash (0x prefixed)
* @property {string} from - Sender address (lowercase, 0x prefixed)
* @property {string|null} to - Recipient address (lowercase, 0x prefixed, null for contract creation)
* @property {string} value - Value transferred in Wei (as string to preserve precision)
* @property {number} blockNumber - Block number where transaction was included
* @property {number} blockTimestamp - Unix timestamp of the block
*/
export default {};

View File

@@ -1,17 +0,0 @@
/**
* @typedef {import('./NormalizedTx.js').NormalizedTx} NormalizedTx
*/
/**
* @typedef {Object} ObservedTxMetadata
* @property {number} addressId - ID of the tracked address (from addresses table)
* @property {'incoming'|'outgoing'} direction - Direction relative to tracked address
*/
/**
* @typedef {NormalizedTx & ObservedTxMetadata} ObservedTx
*/
export default {};

View File

@@ -1,20 +0,0 @@
import admin from 'firebase-admin';
import dotenv from 'dotenv';
dotenv.config();
if (!admin.apps.length) {
admin.initializeApp({
projectId: process.env.FIREBASE_PROJECT_ID,
// For local development, you can use application default credentials
// or provide a service account key file
});
}
export const auth = admin.auth();
export default admin;

View File

@@ -1,94 +0,0 @@
# Database Setup
## Prerequisites
- PostgreSQL installed locally
- Database created (e.g., `koin_ping_dev`)
## Setup Steps
### 1. Create Database
```bash
# Using psql
createdb koin_ping_dev
# Or via psql command line
psql -U postgres
CREATE DATABASE koin_ping_dev;
\q
```
### 2. Set Environment Variables
Create a `.env` file in the `backend/` directory:
```env
DATABASE_URL=postgresql://your_user:your_password@localhost:5432/koin_ping_dev
```
Or if using individual variables (for `infra/database.js`):
```env
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_user
DB_PASSWORD=your_password
DB_NAME=koin_ping_dev
```
### 3. Run Schema
```bash
# From the backend
psql -d koin_ping_dev -f db/schema.sql
# Or the full connection string
psql postgresql://your_user:your_password@localhost:5432/koin_ping_dev -f db/schema.sql
```
### 4. Verify Tables
```bash
psql -d koin_ping_dev
# In psql:
\dt # List tables
\d addresses # Describe addresses table
\d alert_rules # Describe alert_rules table
\d alert_events # Describe alert_events table
```
## Tables
### addresses
- `id` - Primary key
- `address` - Ethereum address (unique)
- `label` - Optional display name
- `created_at` - Timestamp
### alert_rules
- `id` - Primary key
- `address_id` - Foreign key to addresses
- `type` - Alert type: `incoming_tx`, `outgoing_tx`, `large_transfer`, `balance_below`
- `threshold` - ETH amount (nullable)
- `enabled` - Active status
- `created_at` - Timestamp
### alert_events
- `id` - Primary key
- `alert_rule_id` - Foreign key to alert_rules
- `message` - Alert description
- `address_label` - Denormalized label for display
- `timestamp` - When alert fired
## Reset Database
To start fresh:
```bash
psql -d koin_ping_dev -f db/schema.sql
```
The schema includes `DROP TABLE IF EXISTS` statements for clean reruns.

View File

@@ -1,21 +0,0 @@
-- Run: psql -d koin_ping_dev -f add_notification_config.sql
-- Create user notification configuration tableStores notification preferences and webhook URLs for each user
CREATE TABLE user_notification_configs (
user_id VARCHAR(128) PRIMARY KEY,
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable, future use)
email VARCHAR(255), -- Email for notifications (nullable)
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create index for active notifications lookup
CREATE INDEX idx_notification_configs_enabled ON user_notification_configs(notification_enabled);
-- Note: No foreign key to a users table (doesn't exist yet)
-- user_id is the Firebase UID, validated by backend middleware

View File

@@ -1,16 +0,0 @@
ALTER TABLE addresses
ADD COLUMN user_id VARCHAR(128);
UPDATE addresses
SET user_id = 'legacy_user'
WHERE user_id IS NULL;
ALTER TABLE addresses
ALTER COLUMN user_id SET NOT NULL;
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
ALTER TABLE addresses DROP CONSTRAINT IF EXISTS addresses_address_key;
ALTER TABLE addresses ADD CONSTRAINT addresses_user_address_unique UNIQUE (user_id, address);

View File

@@ -1,32 +0,0 @@
import pg from "pg";
import dotenv from "dotenv";
dotenv.config();
const { Pool } = pg;
const pool = new Pool(
process.env.DATABASE_URL
? { connectionString: process.env.DATABASE_URL }
: {
host: process.env.DB_HOST || "localhost",
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
}
);
pool.on("connect", () => {
console.log("Connected to PostgreSQL database");
});
pool.on("error", (err) => {
console.error("Unexpected error on idle client", err);
process.exit(1);
});
export default pool;

View File

@@ -1,71 +0,0 @@
DROP TABLE IF EXISTS alert_events CASCADE;
DROP TABLE IF EXISTS alert_rules CASCADE;
DROP TABLE IF EXISTS address_checkpoints CASCADE;
DROP TABLE IF EXISTS user_notification_configs CASCADE;
DROP TABLE IF EXISTS addresses CASCADE;
CREATE TABLE addresses (
id SERIAL PRIMARY KEY,
user_id VARCHAR(128) NOT NULL, -- Firebase user ID (multi-user support)
address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
label VARCHAR(255), -- Optional human-readable label
created_at TIMESTAMP DEFAULT NOW(),
-- Unique users can track the same address independently
UNIQUE(user_id, address)
);
CREATE TABLE alert_rules (
id SERIAL PRIMARY KEY,
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT valid_alert_type CHECK (
type IN ('incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below')
),
CONSTRAINT positive_threshold CHECK (
threshold IS NULL OR threshold > 0
)
);
CREATE TABLE alert_events (
id SERIAL PRIMARY KEY,
alert_rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
message TEXT NOT NULL, -- Human-readable alert message
address_label VARCHAR(255), -- Denormalized for display (avoids joins)
tx_hash VARCHAR(66), -- Transaction hash that triggered alert (nullable for balance_below)
timestamp TIMESTAMP DEFAULT NOW()
);
CREATE TABLE address_checkpoints (
address_id INTEGER PRIMARY KEY REFERENCES addresses(id) ON DELETE CASCADE,
last_checked_block INTEGER NOT NULL, -- Last block number that was checked for this address
last_checked_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE user_notification_configs (
user_id VARCHAR(128) PRIMARY KEY,
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable, future use)
email VARCHAR(255), -- Email for notifications (nullable)
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create indexes for common queries
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);
CREATE INDEX idx_alert_events_timestamp ON alert_events(timestamp DESC);
CREATE INDEX idx_alert_events_alert_rule_id ON alert_events(alert_rule_id);
CREATE INDEX idx_address_checkpoints_last_checked_at ON address_checkpoints(last_checked_at);
CREATE INDEX idx_notification_configs_enabled ON user_notification_configs(notification_enabled);

View File

@@ -1,68 +0,0 @@
import { auth } from '../firebase/admin.js';
export async function authenticate(req, res, next) {
try {
// Get Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
console.log('No Authorization header or invalid format');
return res.status(401).json({
error: 'UNAUTHORIZED',
message: 'No authentication token provided'
});
}
const token = authHeader.split('Bearer ')[1];
if (!token) {
console.log('Empty token');
return res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Invalid token format'
});
}
console.log('Verifying Firebase token...');
const decodedToken = await auth.verifyIdToken(token);
const user_id = decodedToken.uid;
// SMOKE TEST: Console log the user_id
console.log('Token verified!');
console.log(' User ID:', user_id);
console.log(' Email:', decodedToken.email);
console.log(' Token issued at:', new Date(decodedToken.iat * 1000).toISOString());
req.user_id = user_id;
req.user_email = decodedToken.email;
next();
} catch (error) {
console.error('Token verification failed:', error.message);
// Handle specific Firebase errors
if (error.code === 'auth/id-token-expired') {
return res.status(401).json({
error: 'TOKEN_EXPIRED',
message: 'Authentication token has expired'
});
}
if (error.code === 'auth/argument-error') {
return res.status(401).json({
error: 'INVALID_TOKEN',
message: 'Invalid authentication token format'
});
}
return res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Failed to verify authentication token'
});
}
}

View File

@@ -1,76 +0,0 @@
import pool from '../infra/database.js';
/**
* Get the last checked block number for an address
* @param {number} addressId - Address ID from the addresses table
* @returns {Promise<number|null>} Last checked block number, or null if never checked
*/
export async function getLastCheckedBlock(addressId) {
const result = await pool.query(
`SELECT last_checked_block
FROM address_checkpoints
WHERE address_id = $1`,
[addressId]
);
return result.rows[0]?.last_checked_block || null;
}
/**
* Update the last checked block number for an address
* Creates a new checkpoint record if one doesn't exist
* @param {number} addressId - Address ID from the addresses table
* @param {number} blockNumber - Block number that was just checked
* @returns {Promise<Object>} Updated checkpoint record
*/
export async function updateLastCheckedBlock(addressId, blockNumber) {
const result = await pool.query(
`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]
);
return result.rows[0];
}
/**
* Get all address checkpoints (useful for debugging/monitoring)
* @returns {Promise<Array>} Array of checkpoint objects
*/
export async function listAll() {
const result = await pool.query(
`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`
);
return result.rows;
}
/**
* Delete checkpoint for an address (useful when address is removed)
* Note: This should happen automatically via CASCADE when address is deleted
* @param {number} addressId - Address ID
* @returns {Promise<boolean>} True if deleted, false if not found
*/
export async function remove(addressId) {
const result = await pool.query(
`DELETE FROM address_checkpoints WHERE address_id = $1`,
[addressId]
);
return result.rowCount > 0;
}

View File

@@ -1,84 +0,0 @@
import pool from '../infra/database.js';
/**
* Create a new address
* @param {string} user_id - Firebase user ID
* @param {string} address - Ethereum address
* @param {string|null} label - Optional label
* @returns {Promise<Object>} Created address object
*/
export async function create(user_id, address, label) {
const result = await pool.query(
`INSERT INTO addresses (user_id, address, label)
VALUES ($1, $2, $3)
RETURNING id, user_id, address, label, created_at`,
[user_id, address, label]
);
return result.rows[0];
}
/**
* List all addresses for a specific user
* @param {string} user_id - Firebase user ID
* @returns {Promise<Array>} Array of address objects
*/
export async function listByUser(user_id) {
const result = await pool.query(
`SELECT id, address, label, created_at
FROM addresses
WHERE user_id = $1
ORDER BY created_at DESC`,
[user_id]
);
return result.rows;
}
/**
* List all addresses (for system use only, e.g., poller)
* @returns {Promise<Array>} Array of address objects
*/
export async function list() {
const result = await pool.query(
`SELECT id, user_id, address, label, created_at
FROM addresses
ORDER BY created_at DESC`
);
return result.rows;
}
/**
* Find address by ID (for specific user)
* @param {number} id - Address ID
* @param {string} user_id - Firebase user ID (for ownership verification)
* @returns {Promise<Object|null>} Address object or null if not found or not owned by user
*/
export async function findById(id, user_id = null) {
const query = user_id
? `SELECT id, user_id, address, label, created_at
FROM addresses
WHERE id = $1 AND user_id = $2`
: `SELECT id, user_id, address, label, created_at
FROM addresses
WHERE id = $1`;
const params = user_id ? [id, user_id] : [id];
const result = await pool.query(query, params);
return result.rows[0] || null;
}
/**
* Remove address by ID (for specific user)
* @param {number} id - Address ID
* @param {string} user_id - Firebase user ID (for ownership verification)
* @returns {Promise<boolean>} True if deleted, false if not found or not owned by user
*/
export async function remove(id, user_id) {
const result = await pool.query(
`DELETE FROM addresses
WHERE id = $1 AND user_id = $2`,
[id, user_id]
);
return result.rowCount > 0;
}

View File

@@ -1,56 +0,0 @@
import pool from '../infra/database.js';
/**
* List recent alert events for a specific user
* @param {string} user_id - Firebase user ID
* @param {number} limit - Maximum number of events to return
* @returns {Promise<Array>} Array of alert event objects
*/
export async function listRecentByUser(user_id, limit = 20) {
const result = await pool.query(
`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`,
[user_id, limit]
);
return result.rows;
}
/**
* List recent alert events
* @param {number} limit - Maximum number of event
* @returns {Promise<Array>} Array of alert event objects
*/
export async function listRecent(limit = 20) {
const result = await pool.query(
`SELECT id, alert_rule_id, message, address_label, tx_hash, timestamp
FROM alert_events
ORDER BY timestamp DESC
LIMIT $1`,
[limit]
);
return result.rows;
}
/**
* Create a new alert event
* @param {number} alertRuleId - Alert rule ID
* @param {string} message - Alert message
* @param {string|null} addressLabel - Address label (denormalized)
* @param {string|null} txHash - Transaction hash (optional, null for non-tx alerts)
* @returns {Promise<Object>} Created alert event object
*/
export async function create(alertRuleId, message, addressLabel, txHash = null) {
const result = await pool.query(
`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]
);
return result.rows[0];
}

View File

@@ -1,92 +0,0 @@
import pool from '../infra/database.js';
/**
* Create new alertrule
* @param {number} addressId - Address ID
* @param {string} type - Alert type
* @param {string|null} threshold - Threshold value (nullable)
* @returns {Promise<Object>} Created alert rule object
*/
export async function create(addressId, type, threshold) {
const result = await pool.query(
`INSERT INTO alert_rules (address_id, type, threshold, enabled)
VALUES ($1, $2, $3, TRUE)
RETURNING id, address_id, type, threshold, enabled, created_at`,
[addressId, type, threshold]
);
return result.rows[0];
}
/**
* List alert rules by address ID
* @param {number} addressId - Address ID
* @returns {Promise<Array>} Array of alert rule objects
*/
export async function listByAddress(addressId) {
const result = await pool.query(
`SELECT id, address_id, type, threshold, enabled, created_at
FROM alert_rules
WHERE address_id = $1
ORDER BY created_at DESC`,
[addressId]
);
return result.rows;
}
/**
* Find alert rule by ID
* @param {number} id - Alert rule ID
* @param {string} user_id - Optional user ID for ownership verification
* @returns {Promise<Object|null>} Alert rule object or null if not found/not owned
*/
export async function findById(id, user_id = null) {
let query, params;
if (user_id) {
// Verify ownership
query = `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`;
params = [id, user_id];
} else {
query = `SELECT id, address_id, type, threshold, enabled, created_at
FROM alert_rules
WHERE id = $1`;
params = [id];
}
const result = await pool.query(query, params);
return result.rows[0] || null;
}
/**
* Update alert rule enabled status
* @param {number} id - Alert rule ID
* @param {boolean} enabled - Enabled status
* @returns {Promise<Object|null>} Updated alert rule or null if not found
*/
export async function updateEnabled(id, enabled) {
const result = await pool.query(
`UPDATE alert_rules
SET enabled = $2
WHERE id = $1
RETURNING id, address_id, type, threshold, enabled, created_at`,
[id, enabled]
);
return result.rows[0] || null;
}
/**
* Remove alert rule by ID
* @param {number} id - Alert rule ID
* @returns {Promise<boolean>} True if deleted, false if not found
*/
export async function remove(id) {
const result = await pool.query(
`DELETE FROM alert_rules WHERE id = $1`,
[id]
);
return result.rowCount > 0;
}

View File

@@ -1,86 +0,0 @@
import pool from '../infra/database.js';
/**
* Get notification config for a user
* @param {string} user_id - Firebase user ID
* @returns {Promise<Object|null>} Notification config or null if not set
*/
export async function getConfig(user_id) {
const result = await pool.query(
`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`,
[user_id]
);
return result.rows[0] || null;
}
/**
* Create or update notification config for a user
* @param {string} user_id - Firebase user ID
* @param {Object} config - Notification configuration
* @param {string} [config.discord_webhook_url] - Discord webhook URL
* @param {string} [config.telegram_chat_id] - Telegram chat ID
* @param {string} [config.email] - Email address
* @param {boolean} [config.notification_enabled] - Enable/disable notifications
* @returns {Promise<Object>} Updated config
*/
export async function upsertConfig(user_id, config) {
const {
discord_webhook_url,
telegram_chat_id,
telegram_bot_token,
email,
notification_enabled = true
} = config;
const result = await pool.query(
`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`,
[user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, email, notification_enabled]
);
return result.rows[0];
}
/**
* Delete notification config for a user
* @param {string} user_id - Firebase user ID
* @returns {Promise<boolean>} True if deleted, false if not found
*/
export async function remove(user_id) {
const result = await pool.query(
`DELETE FROM user_notification_configs WHERE user_id = $1`,
[user_id]
);
return result.rowCount > 0;
}
/**
* Get all users with notifications enabled (for system use)
* @returns {Promise<Array>} Array of user configs
*/
export async function listEnabled() {
const result = await pool.query(
`SELECT user_id, discord_webhook_url, telegram_chat_id, email
FROM user_notification_configs
WHERE notification_enabled = TRUE`
);
return result.rows;
}

View File

@@ -1,114 +0,0 @@
/**
* https://discord.com/developers/docs/resources/webhook#execute-webhook
*/
/**
* Send an alert notification to webhook
* @param {string} webhookUrl - Discord webhook URL
* @param {string} message - Alert message
* @param {Object} metadata - Additional alert metadata
* @param {string} metadata.txHash - Transaction hash (nullable)
* @param {string} metadata.addressLabel - Address label
* @param {string} metadata.alertType - Alert type
* @param {string} metadata.address - Blockchain address
* @returns {Promise<boolean>} True if sent successfully, false otherwise
*/
export async function sendDiscordNotification(webhookUrl, message, metadata) {
const { txHash, addressLabel, alertType, address } = metadata;
try {
const payload = {
content: null,
embeds: [
{
title: 'Koin Ping Alert',
description: message,
color: getColorForAlertType(alertType),
fields: [
{
name: 'Address',
value: addressLabel || 'Unknown',
inline: true,
},
{
name: 'Blockchain Address',
value: `\`${address}\``,
inline: false,
},
],
timestamp: new Date().toISOString(),
footer: {
text: 'Koin Ping',
},
},
],
};
if (txHash) {
payload.embeds[0].fields.push({
name: 'Transaction',
value: `[View on Etherscan](https://etherscan.io/tx/${txHash})`,
inline: false,
});
}
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Discord webhook failed: HTTP ${response.status} - ${errorText}`
);
}
return true;
} catch (error) {
console.error('Failed to send Discord notification:', error.message);
return false;
}
}
/**
* Get Discord embed color based on alert type
* @param {string} alertType - Alert type
* @returns {number}
*/
function getColorForAlertType(alertType) {
const colors = {
incoming_tx: 0x00ff00, // Green - incoming money
outgoing_tx: 0xff9900, // Orange - outgoing money
large_transfer: 0xff0000, // Red - large movement
balance_below: 0xff0000, // Red - low balance warning
};
return colors[alertType] || 0x0099ff;
}
/**
* Sends a test message to verify webhook works
* @param {string} webhookUrl
* @returns {Promise<boolean>}
*/
export async function testDiscordWebhook(webhookUrl) {
try {
const payload = {
content: 'Koin Ping test notification - Your Discord webhook is configured correctly!',
};
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return response.ok;
} catch (error) {
console.error('Discord webhook test failed:', error.message);
return false;
}
}

3225
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node api/server.js",
"dev": "nodemon api/server.js",
"poller": "node poller/observerPoller.js",
"poller:dev": "nodemon poller/observerPoller.js",
"dev:all": "concurrently \"npm run dev\" \"npm run poller:dev\" --names \"API,POLLER\" --prefix-colors \"cyan,magenta\""
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"firebase-admin": "^13.6.0",
"pg": "^8.16.3"
},
"devDependencies": {
"concurrently": "^9.2.1",
"nodemon": "^3.1.11"
}
}

View File

@@ -1,125 +0,0 @@
/**
* pure scheduling - no business logic, no DB, no blockchain details.
*/
import dotenv from 'dotenv';
import { ObserverService } from '../services/observerService.js';
import { EvaluatorService } from '../services/evaluatorService.js';
import { JsonRpcEthereum } from '../protocols/ethereum/JsonRpcEthereum.js';
dotenv.config();
const ETH_RPC_URL = process.env.ETH_RPC_URL;
const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS || '60000', 10);
if (!ETH_RPC_URL) {
console.error('ERROR: ETH_RPC_URL environment variable is required');
console.error('Please set it in your .env file');
process.exit(1);
}
if (isNaN(POLL_INTERVAL_MS) || POLL_INTERVAL_MS < 1000) {
console.error('ERROR: POLL_INTERVAL_MS must be a number >= 1000');
process.exit(1);
}
const eth = new JsonRpcEthereum(ETH_RPC_URL);
const observer = new ObserverService(eth);
const evaluator = new EvaluatorService(eth);
let isRunning = false;
/**
* Sleep for specified milliseconds
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Run observation cycle with error handling
*
* @returns {Promise<void>}
*/
async function runCycle() {
try {
const startTime = Date.now();
console.log(`[${new Date().toISOString()}] Starting observation cycle...`);
const observations = await observer.runOnce();
const alertsFired = await evaluator.evaluate(observations);
const duration = Date.now() - startTime;
console.log(
`[${new Date().toISOString()}] Cycle complete: ` +
`${observations.length} observations, ${alertsFired} alerts fired in ${duration}ms`
);
} catch (error) {
console.error(`[${new Date().toISOString()}] Observation cycle failed:`, error.message);
}
}
async function startPolling() {
if (isRunning) {
console.warn('Poller is already running');
return;
}
isRunning = true;
console.log('='.repeat(60));
console.log('Koin Ping Observer Poller Starting');
console.log('='.repeat(60));
console.log(`RPC URL: ${ETH_RPC_URL}`);
console.log(`Poll Interval: ${POLL_INTERVAL_MS}ms (${POLL_INTERVAL_MS / 1000}s)`);
console.log('='.repeat(60));
await runCycle();
while (isRunning) {
await sleep(POLL_INTERVAL_MS);
if (isRunning) {
await runCycle();
}
}
console.log('Poller stopped');
}
function stopPolling() {
if (!isRunning) {
return;
}
console.log('\n' + '='.repeat(60));
console.log('Shutting down poller gracefully...');
console.log('='.repeat(60));
isRunning = false;
}
// -------------------------
// Graceful Shutdown Handlers
// -------------------------
process.on('SIGINT', () => {
stopPolling();
setTimeout(() => process.exit(0), 2000);
});
process.on('SIGTERM', () => {
stopPolling();
setTimeout(() => process.exit(0), 2000);
});
if (import.meta.url === `file://${process.argv[1]}`) {
startPolling().catch((error) => {
console.error('Fatal error in poller:', error);
process.exit(1);
});
}
export { startPolling, stopPolling };

View File

@@ -1,61 +0,0 @@
/**
* @typedef {import('../../domain/NormalizedTx.js').NormalizedTx} NormalizedTx
*/
/**
* @typedef {Object} EthereumObserver
* @property {() => Promise<number>} getLatestBlockNumber - Get the most recent block number on the chain
* @property {(blockNumber: number) => Promise<NormalizedTx[]>} getBlockTransactions - Get all transactions in a specific block
* @property {(address: string) => Promise<string>} getBalance - Get the current balance of an address (in Wei as string)
*/
/**
* Validates that an object implements the EthereumObserver interface
* @param {any} obj - Object to validate
* @returns {boolean} True if object implements the interface
*/
export function isEthereumObserver(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
return (
typeof obj.getLatestBlockNumber === 'function' &&
typeof obj.getBlockTransactions === 'function' &&
typeof obj.getBalance === 'function'
);
}
/**
* Asserts that an object implements the EthereumObserver interface
* Throws an error if validation fails
* @param {any} obj - Object to validate
* @param {string} [name='object'] - Name for error messages
* @throws {Error} If object doesn't implement the interface
*/
export function assertEthereumObserver(obj, name = 'object') {
if (!isEthereumObserver(obj)) {
throw new Error(
`${name} must implement EthereumObserver interface ` +
`(getLatestBlockNumber, getBlockTransactions, getBalance)`
);
}
}
/**
* Example usage
*
* import { assertEthereumObserver } from './protocols/ethereum/EthereumObserver.js';
*
* export function createObserverService(ethereumObserver) {
* assertEthereumObserver(ethereumObserver, 'ethereumObserver');
* // ... use ethereumObserver safely
* }
*/
export default {
isEthereumObserver,
assertEthereumObserver,
};

View File

@@ -1,159 +0,0 @@
import { assertEthereumObserver } from './EthereumObserver.js';
/**
* @typedef {import('../../domain/NormalizedTx.js').NormalizedTx} NormalizedTx
*/
const RPC_TIMEOUT_MS = 30000; // 30 seconds
/**
* JsonRpcEthereum - Concrete implementation of EthereumObserver using JSON-RPC
* @implements {EthereumObserver}
*/
export class JsonRpcEthereum {
/**
* @param {string} rpcUrl - Ethereum JSON-RPC endpoint URL
*/
constructor(rpcUrl) {
if (!rpcUrl || typeof rpcUrl !== 'string') {
throw new Error('JsonRpcEthereum requires a valid RPC URL');
}
this.rpcUrl = rpcUrl;
}
/**
* Make a JSON-RPC call to the Ethereum node
* @private
* @param {string} method - RPC method name (e.g., 'eth_blockNumber')
* @param {any[]} params - Method parameters
* @returns {Promise<any>} RPC result
* @throws {Error} If RPC call fails or returns an error
*/
async _callRpc(method, params = []) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS);
try {
const response = await fetch(this.rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method,
params,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${response.statusText} for ${method}`
);
}
const payload = await response.json();
if (payload.error) {
throw new Error(
`RPC Error [${method}]: ${payload.error.message} (code: ${payload.error.code})`
);
}
return payload.result;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`RPC timeout after ${RPC_TIMEOUT_MS}ms for ${method}`);
}
throw new Error(`RPC call failed [${method}]: ${error.message}`);
}
}
/**
* Get the latest block number on the chain
* @returns {Promise<number>} Current block number
*/
async getLatestBlockNumber() {
const hexBlock = await this._callRpc('eth_blockNumber');
return parseInt(hexBlock, 16);
}
/**
* Get all transactions in a specific block
* @param {number} blockNumber - Block number to fetch
* @returns {Promise<NormalizedTx[]>} Array of normalized transactions
*/
async getBlockTransactions(blockNumber) {
const hexBlockNumber = '0x' + blockNumber.toString(16);
const block = await this._callRpc('eth_getBlockByNumber', [
hexBlockNumber,
true, // true = return full transaction objects, not just hashes
]);
if (!block || !block.transactions) {
return [];
}
const blockTimestamp = parseInt(block.timestamp, 16);
return block.transactions.map((tx) => ({
hash: tx.hash,
from: tx.from?.toLowerCase() || '',
to: tx.to?.toLowerCase() || null, // null for contract creation
value: this._hexToDecimalString(tx.value),
blockNumber: parseInt(tx.blockNumber, 16),
blockTimestamp: blockTimestamp,
}));
}
/**
* Get the current balance of an address
* @param {string} address - Ethereum address
* @returns {Promise<string>} Balance in Wei as decimal string
*/
async getBalance(address) {
const balanceHex = await this._callRpc('eth_getBalance', [
address,
'latest',
]);
return this._hexToDecimalString(balanceHex);
}
/**
* Convert hex string to decimal string (for large numbers)
* @private
* @param {string} hexValue - Hex value (with or without 0x prefix)
* @returns {string}
*/
_hexToDecimalString(hexValue) {
if (!hexValue || hexValue === '0x' || hexValue === '0x0') {
return '0';
}
try {
return BigInt(hexValue).toString();
} catch (error) {
throw new Error(`Invalid hex value: ${hexValue}`);
}
}
}
/**
* @param {string} rpcUrl - Ethereum JSON-RPC endpoint URL
* @returns {JsonRpcEthereum}
*/
export function createJsonRpcEthereum(rpcUrl) {
const instance = new JsonRpcEthereum(rpcUrl);
// Validate that it implements the interface
assertEthereumObserver(instance, 'JsonRpcEthereum');
return instance;
}

View File

@@ -1,258 +0,0 @@
import { assertEthereumObserver } from '../protocols/ethereum/EthereumObserver.js';
import * as AlertRuleModel from '../models/AlertRuleModel.js';
import * as AlertEventModel from '../models/AlertEventModel.js';
import * as AddressModel from '../models/AddressModel.js';
import * as NotificationConfigModel from '../models/NotificationConfigModel.js';
import { ethToWei, weiGreaterThanOrEqual, weiLessThan, formatWeiAsEth } from '../shared/weiConverter.js';
import { sendDiscordNotification } from '../notifications/discordNotifier.js';
/**
* @typedef {import('../domain/ObservedTx.js').ObservedTx} ObservedTx
* @typedef {import('../protocols/ethereum/EthereumObserver.js').EthereumObserver} EthereumObserver
*/
export class EvaluatorService {
/**
* @param {EthereumObserver} ethObserver - Implementation of EthereumObserver interface
*/
constructor(ethObserver) {
assertEthereumObserver(ethObserver, 'ethObserver');
this.eth = ethObserver;
}
/**
* Evaluate all observations against their associated alert rules
* @param {ObservedTx[]} observations
* @returns {Promise<number>}
*/
async evaluate(observations) {
let alertsFired = 0;
for (const obs of observations) {
try {
const fired = await this._evaluateObservation(obs);
alertsFired += fired;
} catch (error) {
console.error(
`Error evaluating observation for address ID ${obs.addressId}:`,
error.message
);
// Continue processing other observations - tbd
}
}
return alertsFired;
}
/**
* Evaluate a single observation against all rules
* @private
* @param {ObservedTx} obs
* @returns {Promise<number>}
*/
async _evaluateObservation(obs) {
const rules = await AlertRuleModel.listByAddress(obs.addressId);
let alertsFired = 0;
for (const rule of rules) {
// Skip disabled
if (!rule.enabled) {
continue;
}
const matches = await this._ruleMatches(rule, obs);
if (matches) {
await this._fireAlert(rule, obs);
alertsFired++;
}
}
return alertsFired;
}
/**
* Fire an alert by creating an alert event
* @private
* @param {Object} rule - Alert rule that matched
* @param {ObservedTx} obs - Observed transaction that triggered it
* @returns {Promise<void>}
*/
async _fireAlert(rule, obs) {
const address = await AddressModel.findById(obs.addressId);
const addressLabel = address?.label || address?.address || 'Unknown';
const message = this._buildMessage(rule, obs);
const txHash = obs.hash;
await AlertEventModel.create(rule.id, message, addressLabel, txHash);
console.log(
`[ALERT FIRED] Rule ${rule.id} (${rule.type}) - ${message} - TX: ${txHash}`
);
// Send Discord notification if configured
try {
const user_id = address.user_id;
const notificationConfig = await NotificationConfigModel.getConfig(user_id);
if (
notificationConfig?.discord_webhook_url &&
notificationConfig.notification_enabled
) {
const sent = await sendDiscordNotification(
notificationConfig.discord_webhook_url,
message,
{
txHash,
addressLabel,
alertType: rule.type,
address: address.address,
}
);
if (sent) {
console.log(`Discord notification sent to user ${user_id}`);
} else {
console.warn(`Discord notification failed for user ${user_id}`);
}
}
} catch (notificationError) {
// Don't fail the alert if notification fails
console.error(
`Failed to send Discord notification:`,
notificationError.message
);
}
}
/**
* Check if a rule matches an observation
* @private
* @param {Object} rule - Alert rule to evaluate
* @param {ObservedTx} obs - Observed transaction
* @returns {Promise<boolean>} True if rule conditions are met
*/
async _ruleMatches(rule, obs) {
switch (rule.type) {
case 'incoming_tx':
return this._matchesIncomingTx(obs);
case 'outgoing_tx':
return this._matchesOutgoingTx(obs);
case 'large_transfer':
return this._matchesLargeTransfer(rule, obs);
case 'balance_below':
return await this._matchesBalanceBelow(rule, obs);
default:
console.warn(`Unknown rule type: ${rule.type}`);
return false;
}
}
/**
* Match incoming transaction rule
* @private
*/
_matchesIncomingTx(obs) {
return obs.direction === 'incoming';
}
/**
* Match outgoing transaction rule
* @private
*/
_matchesOutgoingTx(obs) {
return obs.direction === 'outgoing';
}
/**
* Match large transfer rule
* @private
*
* IMPORTANT: Threshold is stored as ETH in DB, but we compare in Wei
* for precision. Both incoming and outgoing transfers are checked.
*/
_matchesLargeTransfer(rule, obs) {
if (!rule.threshold) {
return false;
}
const thresholdWei = ethToWei(Number(rule.threshold));
const transferValueWei = obs.value;
return weiGreaterThanOrEqual(transferValueWei, thresholdWei);
}
/**
* balance below threshold rule
* @private
*
* OPTIMIZATION: Only check balance after outgoing transactions
* (incoming transactions increase balance, so can't trigger this alert)
*
* IMPORTANT: Threshold stored as ETH in DB, but compared in Wei.
*/
async _matchesBalanceBelow(rule, obs) {
if (!rule.threshold) {
return false;
}
if (obs.direction !== 'outgoing') {
return false;
}
const address = await AddressModel.findById(obs.addressId);
if (!address) {
console.warn(`Address ID ${obs.addressId} not found`);
return false;
}
const balanceWei = await this.eth.getBalance(address.address);
// Convert ETH threshold from DB to Wei for comparison
const thresholdWei = ethToWei(Number(rule.threshold));
return weiLessThan(balanceWei, thresholdWei);
}
/**
* Build a human-readable alert message
* @private
* @param {Object} rule - Alert rule that matched
* @param {ObservedTx} obs - Observed transaction
* @returns {string} Human-readable message
*/
_buildMessage(rule, obs) {
switch (rule.type) {
case 'incoming_tx':
return `Incoming transaction: ${formatWeiAsEth(obs.value)} received`;
case 'outgoing_tx':
return `Outgoing transaction: ${formatWeiAsEth(obs.value)} sent`;
case 'large_transfer':
return `Large transfer detected: ${formatWeiAsEth(obs.value)} (threshold: ${rule.threshold} ETH)`;
case 'balance_below':
return `Balance dropped below threshold of ${rule.threshold} ETH`;
default:
return 'Alert triggered';
}
}
}
/**
* Factory function to create an EvaluatorService instance
* @param {EthereumObserver} ethObserver - Implementation of EthereumObserver interface
* @returns {EvaluatorService}
*/
export function createEvaluatorService(ethObserver) {
return new EvaluatorService(ethObserver);
}

View File

@@ -1,158 +0,0 @@
import { assertEthereumObserver } from '../protocols/ethereum/EthereumObserver.js';
import * as AddressModel from '../models/AddressModel.js';
import * as CheckpointModel from '../models/AddressCheckpointModel.js';
/**
* @typedef {import('../domain/NormalizedTx.js').NormalizedTx} NormalizedTx
* @typedef {import('../domain/ObservedTx.js').ObservedTx} ObservedTx
* @typedef {import('../protocols/ethereum/EthereumObserver.js').EthereumObserver} EthereumObserver
*/
// Safety limit: maximum blocks to process per address per run
// Prevents overwhelming RPC endpoints and ensures bounded execution time
const MAX_BLOCKS_PER_RUN = 100;
export class ObserverService {
/**
* @param {EthereumObserver} ethObserver - Implementation of EthereumObserver interface
*/
constructor(ethObserver) {
assertEthereumObserver(ethObserver, 'ethObserver');
this.eth = ethObserver;
}
/**
* Run one observation cycle across all tracked addresses
*
*
* @returns {Promise<ObservedTx[]>} All observations from this cycle
*/
async runOnce() {
const addresses = await AddressModel.list();
if (addresses.length === 0) {
return [];
}
const latestBlock = await this.eth.getLatestBlockNumber();
const observations = [];
for (const address of addresses) {
try {
const addressObservations = await this._observeAddress(address, latestBlock);
observations.push(...addressObservations);
} catch (error) {
console.error(`Error observing address ${address.address}:`, error);
}
}
return observations;
}
/**
* Observe a single address for activity
* @private
* @param {Object} address - Address record from database
* @param {number} latestBlock - Current latest block number
* @returns {Promise<ObservedTx[]>} Observations for this address
*/
async _observeAddress(address, latestBlock) {
const lastChecked = await CheckpointModel.getLastCheckedBlock(address.id);
const startBlock = this._getStartBlock(lastChecked, latestBlock);
const endBlock = this._getEndBlock(startBlock, latestBlock);
// No new blocks to check
if (startBlock > endBlock) {
return [];
}
const observations = [];
for (let blockNumber = startBlock; blockNumber <= endBlock; blockNumber++) {
const blockTxs = await this.eth.getBlockTransactions(blockNumber);
const relevantTxs = this._filterRelevantTransactions(blockTxs, address.address);
for (const tx of relevantTxs) {
observations.push(this._createObservedTx(tx, address));
}
}
await CheckpointModel.updateLastCheckedBlock(address.id, endBlock);
return observations;
}
/**
* Determine starting block for observation
* @private
* @param {number|null} lastChecked - Last checked block, or null if never checked
* @param {number} latestBlock - Current latest block
* @returns {number} Block number to start from
*/
_getStartBlock(lastChecked, latestBlock) {
if (lastChecked === null) {
return latestBlock;
}
return lastChecked + 1;
}
/**
* Determine ending block for observation (respects MAX_BLOCKS_PER_RUN)
* @private
* @param {number} startBlock
* @param {number} latestBlock
* @returns {number}
*/
_getEndBlock(startBlock, latestBlock) {
return Math.min(startBlock + MAX_BLOCKS_PER_RUN - 1, latestBlock);
}
/**
* Filter transactions to only those involving the tracked address
* @private
* @param {NormalizedTx[]} transactions
* @param {string} trackedAddress
* @returns {NormalizedTx[]}
*/
_filterRelevantTransactions(transactions, trackedAddress) {
const addressLower = trackedAddress.toLowerCase();
return transactions.filter((tx) => {
const fromMatch = tx.from?.toLowerCase() === addressLower;
const toMatch = tx.to?.toLowerCase() === addressLower;
return fromMatch || toMatch;
});
}
/**
* Create an ObservedTx with direction metadata
* @private
* @param {NormalizedTx} tx - Normalized transaction
* @param {Object} address - Address record from database
* @returns {ObservedTx} Transaction with observation metadata
*/
_createObservedTx(tx, address) {
const addressLower = address.address.toLowerCase();
const direction = tx.to?.toLowerCase() === addressLower ? 'incoming' : 'outgoing';
return {
...tx,
addressId: address.id,
direction,
};
}
}
/**
* Factory to create an ObserverService instance
* @param {EthereumObserver} ethObserver - Implementation of EthereumObserver interface
* @returns {ObserverService}
*/
export function createObserverService(ethObserver) {
return new ObserverService(ethObserver);
}

View File

@@ -1,95 +0,0 @@
const WEI_PER_ETH = 1000000000000000000n; // 10^18 Wei = 1 ETH
/**
* Convert Wei (as string) to ETH (as number)
* Use for display purposes only, not for calculations
* @param {string} weiString - Wei value as string
* @returns {number} ETH value as number
*/
export function weiToEth(weiString) {
if (!weiString || weiString === '0') {
return 0;
}
try {
const wei = BigInt(weiString);
// Convert to number (safe for display values)
return Number(wei) / Number(WEI_PER_ETH);
} catch (error) {
throw new Error(`Invalid Wei value: ${weiString}`);
}
}
/**
* Convert ETH (as number) to Wei (as string)
* Use when converting DB thresholds for comparison
* @param {number} eth - ETH value as number
* @returns {string} Wei value as string
*/
export function ethToWei(eth) {
if (typeof eth !== 'number' || isNaN(eth)) {
throw new Error(`Invalid ETH value: ${eth}`);
}
if (eth === 0) {
return '0';
}
// Convert to string with fixed precision to avoid floating point issues
const ethString = eth.toFixed(18); // Max precision
const [whole, decimal = ''] = ethString.split('.');
const wholePart = BigInt(whole) * WEI_PER_ETH;
const decimalPart = BigInt(decimal.padEnd(18, '0'));
return (wholePart + decimalPart).toString();
}
/**
* Compare two Wei values
* @param {string} weiA
* @param {string} weiB
* @returns {number}
*/
export function compareWei(weiA, weiB) {
const a = BigInt(weiA);
const b = BigInt(weiB);
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
/**
* Check if Wei value A is greater than or equal to Wei value B
* @param {string} weiA - First Wei value as string
* @param {string} weiB - Second Wei value as string
* @returns {boolean}
*/
export function weiGreaterThanOrEqual(weiA, weiB) {
return BigInt(weiA) >= BigInt(weiB);
}
/**
* Check if Wei value A is less than Wei value B
* @param {string} weiA
* @param {string} weiB
* @returns {boolean}
*/
export function weiLessThan(weiA, weiB) {
return BigInt(weiA) < BigInt(weiB);
}
/**
* @param {string} weiString
* @param {number} decimals - Number of decimal places (default: 4)
* @returns {string}
*/
export function formatWeiAsEth(weiString, decimals = 4) {
const eth = weiToEth(weiString);
return `${eth.toFixed(decimals)} ETH`;
}

View File

@@ -7,7 +7,7 @@
"": { "": {
"name": "frontend", "name": "frontend",
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "MIT",
"dependencies": { "dependencies": {
"firebase": "^12.7.0", "firebase": "^12.7.0",
"react": "^19.2.3", "react": "^19.2.3",

View File

@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "koin_ping blockchain monoitor UI",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -10,7 +10,8 @@
}, },
"keywords": [ "keywords": [
"cryptocurrency", "cryptocurrency",
"event moitoring" "blockchain",
"blockchain event moitoring"
], ],
"author": "Steven Jannette", "author": "Steven Jannette",
"license": "MIT", "license": "MIT",

View File

@@ -1,8 +1,7 @@
// API client for address management // API client for address management
import { getAuthHeaders, getAuthHeadersSimple } from './authHeaders'; import { getAuthHeaders, getAuthHeadersSimple } from './authHeaders';
import { API_BASE } from './config';
const API_BASE = '/api';
/** /**
* Create a new blockchain address to monitor * Create a new blockchain address to monitor

View File

@@ -1,8 +1,7 @@
// API client for alert event history // API client for alert event history
import { getAuthHeadersSimple } from './authHeaders'; import { getAuthHeadersSimple } from './authHeaders';
import { API_BASE } from './config';
const API_BASE = '/api';
/** /**
* Get all alert events (history) * Get all alert events (history)

View File

@@ -1,8 +1,7 @@
// API client for alert rule management // API client for alert rule management
import { getAuthHeaders, getAuthHeadersSimple } from './authHeaders'; import { getAuthHeaders, getAuthHeadersSimple } from './authHeaders';
import { API_BASE } from './config';
const API_BASE = '/api';
/** /**
* Alert types supported by the system * Alert types supported by the system

View File

@@ -0,0 +1 @@
export const API_BASE = import.meta.env.VITE_API_BASE || '/v1';

View File

@@ -1,8 +1,7 @@
// API client for notification configuration // API client for notification configuration
import { getAuthHeaders } from './authHeaders'; import { getAuthHeaders } from './authHeaders';
import { API_BASE } from './config';
const API_BASE = '/api';
/** /**
* Get notification configuration for current user * Get notification configuration for current user

View File

@@ -1,6 +1,6 @@
// API client for system status // API client for system status
const API_BASE = '/api'; import { API_BASE } from './config';
/** /**
* Get system status including latest processed block and health * Get system status including latest processed block and health

View File

@@ -28,7 +28,7 @@ export default function AlertForm({ onSubmit }) {
onSubmit({ onSubmit({
type, type,
threshold: needsThreshold ? threshold : undefined, threshold: needsThreshold ? Number(threshold) : undefined,
}); });
setThreshold(""); setThreshold("");

View File

@@ -7,7 +7,7 @@ export default defineConfig({
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {
'/api': { '/v1': {
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
}, },