Compare commits

..

25 Commits

Author SHA1 Message Date
KS Jannette
b3a08e8f25 css trick
Some checks are pending
check / check (push) Waiting to run
2026-03-02 00:28:13 -05:00
KS Jannette
8716be0201 fudge 2026-03-02 00:09:24 -05:00
KS Jannette
647be874e5 m 2026-03-02 00:09:01 -05:00
KS Jannette
c12c4fe742 add image 2026-03-01 23:14:25 -05:00
S Jannette
5f5253d495 Revise README for clarity and feature description
Updated the README to clarify features and improve readability.
2026-03-01 21:48:53 -05:00
S Jannette
bbe2374703 Merge pull request #11 from kjannette/history-reconfig-possible-bugfix
History reconfig possible bugfix
2026-03-01 20:38:56 -05:00
KS Jannette
205317c920 update docs
Some checks are pending
check / check (push) Waiting to run
2026-03-01 20:35:08 -05:00
KS Jannette
25850825b4 updated types and poller 2026-03-01 20:32:21 -05:00
S Jannette
f01115de86 Merge pull request #10 from kjannette/webhook-configs
webhook configs for users, changes to system
2026-03-01 19:08:42 -05:00
KS Jannette
6735000050 webhook configs for users, changes to system
Some checks are pending
check / check (push) Waiting to run
2026-03-01 19:06:32 -05:00
KS Jannette
42a64fc043 Add multi-step onboarding wizard to replace bare signup form
Introduces a 5-step guided flow (Create Account → Add Wallet → Alert
Rules → Notifications → Done) that takes new users from zero to fully
configured alerts in one linear session. /signup now redirects to
/onboarding; already-onboarded users who revisit /onboarding are
redirected to /addresses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 12:54:55 -05:00
S Jannette
9935817fa8 Merge pull request #9 from kjannette/setupTelegramonfig
Setup telegramonfig
2026-03-01 09:49:16 -05:00
KS Jannette
8d5716bdb7 Add guide for setup of Telegram
Some checks are pending
check / check (push) Waiting to run
2026-03-01 09:48:52 -05:00
KS Jannette
aafe96d9a1 Finish config 2026-03-01 09:40:54 -05:00
S Jannette
93168209b5 Merge pull request #8 from kjannette/audit1
Audit1
2026-03-01 07:55:57 -05:00
S Jannette
6840af1a47 Merge branch 'master' into audit1 2026-03-01 07:55:49 -05:00
KS Jannette
7c36f3c214 readme change
Some checks are pending
check / check (push) Waiting to run
2026-03-01 07:55:00 -05:00
S Jannette
c707b82f36 Revise README with new project overview
Updated project description and added overview section.
2026-03-01 07:51:57 -05:00
KS Jannette
37ff644aa7 audit: fix issues #1-#9 (mock data, notifier interface, retry/timeout, digest scheduling, real status, RPC retry, dedup, address edit UI)
- #1: remove mock alert events that poisoned production responses
- #2: add Notifier interface + Send implementations for all channels
- #3/#4: notification goroutines now carry a 30s context timeout and retry up to 3x with exponential backoff
- #5: schedule email digest in poller via DIGEST_INTERVAL_HOURS (default 24h)
- #6: SystemStatus queries real checkpoint data; returns starting/active/idle with latestBlock and lag
- #7: Ethereum RPC client retries on network errors, 429, and 5xx with 1s/2s backoff
- #8: alert_events dedup index + ON CONFLICT DO NOTHING to prevent duplicate events on poller restart
- #9: PATCH /v1/addresses/{id} for label editing; frontend address list gains inline edit and remove buttons
2026-03-01 07:49:08 -05:00
S Jannette
c80ed89c0a Merge pull request #7 from kjannette/resend-sdk
add sdk
2026-03-01 04:37:58 -05:00
KS Jannette
6590cbf4be add sdk
Some checks are pending
check / check (push) Waiting to run
2026-03-01 04:31:32 -05:00
KS Jannette
61a8c5fe8b hottie
Some checks are pending
check / check (push) Waiting to run
2026-03-01 03:54:06 -05:00
S Jannette
c79136a06e Merge pull request #6 from kjannette/db-updates-to-keep-up-withe-alerts-functionality
notifcation dongif setttings - save to DB
2026-03-01 03:37:04 -05:00
KS Jannette
a5590a438f notifcation dongif setttings - save to DB
Some checks are pending
check / check (push) Waiting to run
2026-03-01 03:36:32 -05:00
S Jannette
1c196cae9f Merge pull request #5 from kjannette/notifcations-logic-upgrade
Building out notification update channels
2026-03-01 03:23:31 -05:00
42 changed files with 1924 additions and 507 deletions

View File

@@ -1,9 +1,9 @@
# Koin Ping # Koin Ping
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
Koin Ping is an MIT-licensed blockchain monitoring system by Steven Jannette # Overview
that polls Ethereum addresses for on-chain activity and delivers real-time
alerts to users via a Go REST API backend and a React single-page application Koin Ping observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
frontend.
## Getting Started ## Getting Started
@@ -45,16 +45,16 @@ cd frontend && npm run dev
``` ```
The API listens on `http://localhost:3001` and the frontend on The API listens on `http://localhost:3001` and the frontend on
`http://localhost:3000` by default. `http://localhost:3000` by default for development.
## Rationale ## Rationale
Crypto users who hold or actively monitor addresses need a lightweight, reliable Crypto users may actively monitor addresses with webhooks integrating popular
way to know when on-chain activity occurs without polling block explorers messaging platforms: Discord, Slack, Telegram.
manually. Koin Ping fills that gap: it watches a set of Ethereum addresses, This lightweight, reliable framework makes instant awareness of on-chain
evaluates configurable alert rules (incoming transactions, outgoing activity trivial, without polling block explorers manually. Koin Ping watches
transactions, large transfers, balance thresholds), and notifies the user addresses, evaluates configurable alert rules (incoming transactions, outgoing
through Discord webhooks. transactions, "large" transfers, balance thresholds), and sends notifications.
## Design ## Design
@@ -99,19 +99,51 @@ to the database, and dispatches Discord notifications.
Firebase, communicates with the API via fetch, and renders the address/alert Firebase, communicates with the API via fetch, and renders the address/alert
management UI. management UI.
## TODO ## Setting Up Your Alert Platforms
- [ ] Switch frontend package manager from npm to yarn (per repo policy) Koin Ping can send real-time alerts to **Telegram**, **Discord**, **Slack**, and **Email**. Each channel is configured per-user through the **Notification Settings** panel on the Alerts page.
- [ ] Rename `go.mod` module path from `github.com/kjannette/koin-ping` to
`sneak.berlin/go/koin-ping` (per repo policy) Below are step-by-step guides for setting up each platform.
- [ ] Move database migrations to `internal/db/migrations/` and embed them in
the binary ---
- [ ] Pin all Docker base images by `@sha256` in Dockerfile
- [ ] Install `golangci-lint` locally and pass `make lint` ### Telegram
- [ ] Add vitest unit tests for the React frontend
- [ ] Add a `make db-reset` / `make migrate` target for schema management To receive alerts via Telegram, you need to create a bot and get your chat ID.
- [ ] Set HTTP server read/write timeouts (currently unbounded)
- [ ] Replace `log.Printf` calls with structured `log/slog` #### 1. Create a Telegram Bot
1. Open Telegram and search for **@BotFather** (look for the blue verified checkmark).
2. Open the conversation with BotFather and send: `/newbot`
3. BotFather will ask for a **display name** — enter something like `Koin Ping Alerts`.
4. BotFather will ask for a **username** — it must end in `bot`, e.g. `MyKoinPingBot`.
5. BotFather will reply with your **Bot Token** — a string that looks like `123456789:ABCdefGHIjklMNOpqrSTUvwxYZ`. Copy it.
#### 2. Get Your Chat ID
1. In Telegram, search for the bot username you just created and open the chat.
2. Tap **Start** or send any message (e.g. `hello`).
3. Open the following URL in your browser, replacing `YOUR_BOT_TOKEN` with the token from step 1:
```
https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates
```
4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value).
> **Tip:** If the `'result"` array is empty, make sure you sent a message to your bot first, then refresh the page.
#### 3. Save in Koin Ping
1. Go to the **Alerts** page in Koin Ping.
2. In the **Notification Settings** panel, find the **Telegram** section.
3. Paste your **Bot Token** and **Chat ID** into the corresponding fields.
4. Click **Save Settings**.
5. Click **Test All Channels** to verify — you should receive a test message from your bot in Telegram.
---
*Guides for Discord, Slack, and Email coming soon.*
## License ## License
@@ -120,3 +152,5 @@ MIT. See [LICENSE](LICENSE).
## Author ## Author
Steven Jannette Steven Jannette

View File

@@ -2,6 +2,14 @@ Start DB:
brew services start postgresql@15 brew services start postgresql@15
Run Backend:
cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api From the backend-go directory, you have a few options:
Option 1: Single command (both API + poller)
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all
Option 2: Two separate terminals
Terminal 1 (API server):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api
Terminal 2 (Poller):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller

BIN
backend-go/api Executable file

Binary file not shown.

View File

@@ -18,9 +18,9 @@ import (
) )
const ( const (
// serverReadTimeoutSeconds is the maximum duration to read a request. // max duration to read a request.
serverReadTimeoutSeconds = 5 serverReadTimeoutSeconds = 5
// serverWriteTimeoutSeconds is the maximum duration to write a response. // maxiduration to write a response.
serverWriteTimeoutSeconds = 10 serverWriteTimeoutSeconds = 10
) )
@@ -47,6 +47,7 @@ func main() {
addressModel := models.NewAddressModel(pool) addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool) alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool) alertEventModel := models.NewAlertEventModel(pool)
checkpointModel := models.NewCheckpointModel(pool)
notifConfigModel := models.NewNotificationConfigModel(pool) notifConfigModel := models.NewNotificationConfigModel(pool)
emailDigestSvc := services.NewEmailDigestService( emailDigestSvc := services.NewEmailDigestService(
@@ -58,13 +59,14 @@ func main() {
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel) alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg) notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel)
mux := http.NewServeMux() mux := http.NewServeMux()
b := cfg.APIBasePath // e.g. "/v1" b := cfg.APIBasePath // e.g. "/v1"
// Public routes // Public routes
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck) mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus) mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
// Authenticated routes — addresses // Authenticated routes — addresses
mux.Handle("POST "+b+"/addresses", mux.Handle("POST "+b+"/addresses",
@@ -73,8 +75,10 @@ func main() {
middleware.Authenticate(http.HandlerFunc(addressHandler.List))) middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
mux.Handle("DELETE "+b+"/addresses/{addressId}", mux.Handle("DELETE "+b+"/addresses/{addressId}",
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
mux.Handle("PATCH "+b+"/addresses/{addressId}",
middleware.Authenticate(http.HandlerFunc(addressHandler.UpdateLabel)))
// Authenticated routes alert rules // Authenticated routes for alert rules
mux.Handle("POST "+b+"/addresses/{addressId}/alerts", mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
mux.Handle("GET "+b+"/addresses/{addressId}/alerts", mux.Handle("GET "+b+"/addresses/{addressId}/alerts",

View File

@@ -62,6 +62,7 @@ func main() {
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel, eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
cfg.ResendAPIKey, cfg.EmailFrom, cfg.ResendAPIKey, cfg.EmailFrom,
) )
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -78,12 +79,14 @@ func main() {
}() }()
interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond
digestInterval := time.Duration(cfg.DigestIntervalHours) * time.Hour
log.Println(strings.Repeat("=", separatorWidth)) log.Println(strings.Repeat("=", separatorWidth))
log.Println("Koin Ping Observer Poller Starting") log.Println("Koin Ping Observer Poller Starting")
log.Println(strings.Repeat("=", separatorWidth)) log.Println(strings.Repeat("=", separatorWidth))
log.Printf("RPC URL: %s", cfg.EthRPCURL) log.Printf("RPC URL: %s", cfg.EthRPCURL)
log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/msPerSecond) log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/msPerSecond)
log.Printf("Digest Interval: %dh", cfg.DigestIntervalHours)
log.Println(strings.Repeat("=", separatorWidth)) log.Println(strings.Repeat("=", separatorWidth))
runCycle(ctx, observer, evaluator) runCycle(ctx, observer, evaluator)
@@ -91,6 +94,9 @@ func main() {
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
digestTicker := time.NewTicker(digestInterval)
defer digestTicker.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -99,6 +105,13 @@ func main() {
return return
case <-ticker.C: case <-ticker.C:
runCycle(ctx, observer, evaluator) runCycle(ctx, observer, evaluator)
case <-digestTicker.C:
sent, digestErr := digestSvc.SendDigestsForAllUsers(ctx)
if digestErr != nil {
log.Printf("Email digest failed: %v", digestErr)
} else {
log.Printf("Sent %d email digests", sent)
}
} }
} }
} }

View File

@@ -42,6 +42,7 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect github.com/joho/godotenv v1.5.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/resend/resend-go/v3 v3.1.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect

View File

@@ -92,6 +92,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v3 v3.1.1 h1:Uwpf/tZU+O/r/3nMWE6zUAMIG9dX/vTBS3wlQzYJKSw=
github.com/resend/resend-go/v3 v3.1.1/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@@ -0,0 +1,3 @@
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_events_dedup
ON alert_events (alert_rule_id, tx_hash)
WHERE tx_hash IS NOT NULL;

View File

@@ -13,6 +13,7 @@ const (
defaultDBPort = 5432 defaultDBPort = 5432
defaultPollIntervalMS = 60000 defaultPollIntervalMS = 60000
minPollIntervalMS = 1000 minPollIntervalMS = 1000
defaultDigestIntervalHours = 24
) )
type Config struct { type Config struct {
@@ -28,8 +29,9 @@ type Config struct {
EthRPCURL string EthRPCURL string
PollIntervalMS int PollIntervalMS int
NodeEnv string NodeEnv string
ResendAPIKey string ResendAPIKey string
EmailFrom string EmailFrom string
DigestIntervalHours int
} }
// Load reads configuration from environment variables and returns a Config. // Load reads configuration from environment variables and returns a Config.
@@ -47,8 +49,9 @@ func Load() (*Config, error) {
EthRPCURL: os.Getenv("ETH_RPC_URL"), EthRPCURL: os.Getenv("ETH_RPC_URL"),
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS), PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
NodeEnv: getEnv("NODE_ENV", "development"), NodeEnv: getEnv("NODE_ENV", "development"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"), ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"), EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
} }
if cfg.PollIntervalMS < minPollIntervalMS { if cfg.PollIntervalMS < minPollIntervalMS {

View File

@@ -1,4 +1,3 @@
// Package database manages PostgreSQL connection pools.
package database package database
import ( import (
@@ -11,21 +10,21 @@ import (
) )
const ( const (
// maxConnIdleSeconds is the maximum idle time for a connection.
maxConnIdleSeconds = 30 maxConnIdleSeconds = 30
// maxConnLifetimeMinutes is the maximum lifetime for a connection.
maxConnLifetimeMinutes = 5 maxConnLifetimeMinutes = 5
// connectTimeoutSeconds is the timeout for initial connection.
connectTimeoutSeconds = 10 connectTimeoutSeconds = 10
// maxConns is the maximum number of connections in the pool.
maxConns = 20 maxConns = 20
// minConns is the minimum number of connections in the pool.
minConns = 2 minConns = 2
) )
var pool *pgxpool.Pool //nolint:gochecknoglobals var pool *pgxpool.Pool //nolint:gochecknoglobals
// Connect establishes a PostgreSQL connection pool using the given DSN. // establishPostgreSQL connection pool using the given DSN.
func Connect(dsn string) (*pgxpool.Pool, error) { func Connect(dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn) cfg, err := pgxpool.ParseConfig(dsn)
if err != nil { if err != nil {
@@ -57,12 +56,10 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
return p, nil return p, nil
} }
// Pool returns the global connection pool.
func Pool() *pgxpool.Pool { func Pool() *pgxpool.Pool {
return pool return pool
} }
// Close closes the global connection pool.
func Close() { func Close() {
if pool != nil { if pool != nil {
pool.Close() pool.Close()

View File

@@ -1,9 +1,7 @@
// Package domain defines core domain types shared across the application.
package domain package domain
import "time" import "time"
// Address represents a tracked Ethereum address.
type Address struct { type Address struct {
ID int `json:"id"` ID int `json:"id"`
UserID string `json:"user_id"` //nolint:tagliatelle UserID string `json:"user_id"` //nolint:tagliatelle
@@ -12,13 +10,10 @@ type Address struct {
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
} }
// AlertType identifies the kind of alert rule.
type AlertType string type AlertType string
// String implements fmt.Stringer.
func (a AlertType) String() string { return string(a) } func (a AlertType) String() string { return string(a) }
// Alert type constants define the supported alert triggers.
const ( const (
AlertIncomingTx AlertType = "incoming_tx" AlertIncomingTx AlertType = "incoming_tx"
AlertOutgoingTx AlertType = "outgoing_tx" AlertOutgoingTx AlertType = "outgoing_tx"
@@ -26,7 +21,6 @@ const (
AlertBalanceBelow AlertType = "balance_below" AlertBalanceBelow AlertType = "balance_below"
) )
// ValidAlertTypes lists all alert types accepted by the API.
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
AlertIncomingTx, AlertIncomingTx,
AlertOutgoingTx, AlertOutgoingTx,
@@ -62,7 +56,6 @@ func IsThresholdRequired(t AlertType) bool {
return false return false
} }
// AlertRule represents a user-defined alert rule for an address.
type AlertRule struct { type AlertRule struct {
ID int `json:"id"` ID int `json:"id"`
AddressID int `json:"address_id"` //nolint:tagliatelle AddressID int `json:"address_id"` //nolint:tagliatelle
@@ -72,7 +65,6 @@ type AlertRule struct {
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
} }
// AlertEvent represents a fired alert event stored for history.
type AlertEvent struct { type AlertEvent struct {
ID int `json:"id"` ID int `json:"id"`
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
@@ -82,7 +74,6 @@ type AlertEvent struct {
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
} }
// AddressCheckpoint tracks the last block checked for an address.
type AddressCheckpoint struct { type AddressCheckpoint struct {
AddressID int `json:"address_id"` //nolint:tagliatelle AddressID int `json:"address_id"` //nolint:tagliatelle
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
@@ -119,21 +110,29 @@ type NormalizedTx struct {
Value string `json:"value"` // Wei as string for precision Value string `json:"value"` // Wei as string for precision
BlockNumber int `json:"block_number"` //nolint:tagliatelle BlockNumber int `json:"block_number"` //nolint:tagliatelle
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
// ERC-20 token transfer fields (nil for native ETH transfers)
TokenContract *string `json:"token_contract,omitempty"` //nolint:tagliatelle
TokenSymbol *string `json:"token_symbol,omitempty"` //nolint:tagliatelle
TokenDecimals *int `json:"token_decimals,omitempty"` //nolint:tagliatelle
TokenValue *string `json:"token_value,omitempty"` //nolint:tagliatelle
}
// IsTokenTransfer returns true if this transaction represents an ERC-20 token transfer.
func (tx NormalizedTx) IsTokenTransfer() bool {
return tx.TokenContract != nil
} }
// Direction indicates whether a transaction is incoming or outgoing.
type Direction string type Direction string
// String implements fmt.Stringer. // String implements fmt.Stringer.
func (d Direction) String() string { return string(d) } func (d Direction) String() string { return string(d) }
// Direction constants indicate the flow of a transaction relative to a watched address.
const ( const (
DirectionIncoming Direction = "incoming" DirectionIncoming Direction = "incoming"
DirectionOutgoing Direction = "outgoing" DirectionOutgoing Direction = "outgoing"
) )
// ObservedTx is a NormalizedTx enriched with address and direction context.
type ObservedTx struct { type ObservedTx struct {
NormalizedTx NormalizedTx
AddressID int `json:"address_id"` //nolint:tagliatelle AddressID int `json:"address_id"` //nolint:tagliatelle

View File

@@ -1,4 +1,3 @@
// Package firebase provides Firebase authentication integration.
package firebase package firebase
import ( import (
@@ -17,7 +16,6 @@ var ( //nolint:gochecknoglobals
errInit error //nolint:gochecknoglobals errInit error //nolint:gochecknoglobals
) )
// Init initializes the Firebase app and auth client using the given project ID.
func Init(projectID string) error { func Init(projectID string) error {
once.Do(func() { once.Do(func() {
ctx := context.Background() ctx := context.Background()
@@ -48,7 +46,6 @@ func Init(projectID string) error {
return errInit return errInit
} }
// Auth returns the initialized Firebase auth client.
func Auth() *auth.Client { func Auth() *auth.Client {
return authClient return authClient
} }

View File

@@ -15,17 +15,14 @@ import (
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`) var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
// AddressHandler handles HTTP requests for address management.
type AddressHandler struct { type AddressHandler struct {
addresses *models.AddressModel addresses *models.AddressModel
} }
// NewAddressHandler creates a new AddressHandler.
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler { func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
return &AddressHandler{addresses: addresses} return &AddressHandler{addresses: addresses}
} }
// Create handles POST requests to add a new tracked address.
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
@@ -92,6 +89,44 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, addresses) writeJSON(w, http.StatusOK, addresses)
} }
// handles PATCH requests to update an address label.
func (h *AddressHandler) UpdateLabel(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId"))
if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
return
}
var body struct {
Label *string `json:"label"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return
}
log.Printf("User %s updating label for address ID: %d", userID, addressID)
addr, err := h.addresses.UpdateLabel(r.Context(), addressID, userID, body.Label)
if err != nil {
log.Printf("Error updating address label: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update address")
return
}
if addr == nil {
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
return
}
writeJSON(w, http.StatusOK, addr)
}
// Remove handles DELETE requests to remove a tracked address. // Remove handles DELETE requests to remove a tracked address.
func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) { func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())

View File

@@ -4,7 +4,6 @@ import (
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/middleware" "github.com/kjannette/koin-ping/backend-go/internal/middleware"
@@ -51,45 +50,9 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
log.Printf("Found %d alert events for user", len(events)) log.Printf("Found %d alert events for user", len(events))
// MVP scaffolding: return mock data if DB is empty if events == nil {
if len(events) == 0 { events = []domain.AlertEvent{}
events = mockEvents(limit)
} }
writeJSON(w, http.StatusOK, events) writeJSON(w, http.StatusOK, events)
} }
func mockEvents(limit int) []domain.AlertEvent {
label1 := "Treasury Wallet"
label2 := "Cold Storage"
mocks := []domain.AlertEvent{
{
ID: 1,
AlertRuleID: 1,
Message: "Incoming transaction detected: 5.5 ETH received",
AddressLabel: &label1,
Timestamp: time.Now().Add(-2 * time.Hour),
},
{
ID: 2, //nolint:mnd
AlertRuleID: 2, //nolint:mnd
Message: "Balance dropped below threshold: Current balance 8.2 ETH",
AddressLabel: &label1,
Timestamp: time.Now().Add(-5 * time.Hour),
},
{
ID: 3, //nolint:mnd
AlertRuleID: 3, //nolint:mnd
Message: "Outgoing transaction detected: 2.0 ETH sent",
AddressLabel: &label2,
Timestamp: time.Now().Add(-24 * time.Hour),
},
}
if limit < len(mocks) {
return mocks[:limit]
}
return mocks
}

View File

@@ -14,21 +14,17 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
) )
// errThresholdFormat is returned when the threshold JSON cannot be decoded.
var errThresholdFormat = errors.New("unsupported threshold format") var errThresholdFormat = errors.New("unsupported threshold format")
// AlertRuleHandler handles HTTP requests for alert rule management.
type AlertRuleHandler struct { type AlertRuleHandler struct {
alertRules *models.AlertRuleModel alertRules *models.AlertRuleModel
addresses *models.AddressModel addresses *models.AddressModel
} }
// NewAlertRuleHandler creates a new AlertRuleHandler.
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler { func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses} return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
} }
// Create handles POST requests to create a new alert rule for an address.
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *AlertRuleHandler) Create(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"))

View File

@@ -1,10 +1,67 @@
package handlers package handlers
import ( import (
"log"
"net/http" "net/http"
"time" "time"
"github.com/kjannette/koin-ping/backend-go/internal/models"
) )
// StatusHandler handles the system status endpoint.
type StatusHandler struct {
checkpoints *models.CheckpointModel
}
// NewStatusHandler creates a new StatusHandler.
func NewStatusHandler(checkpoints *models.CheckpointModel) *StatusHandler {
return &StatusHandler{checkpoints: checkpoints}
}
// GetStatus returns real-time system status derived from checkpoint data.
func (h *StatusHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
block, checkedAt, err := h.checkpoints.GetLatestBlock(r.Context())
if err != nil {
log.Printf("Error querying latest block: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get system status")
return
}
latestBlock := 0
lag := 0
status := "starting"
if checkedAt != nil {
lag = int(time.Since(*checkedAt).Seconds())
if lag > 600 { //nolint:mnd
status = "idle"
} else {
status = "active"
}
}
if block != nil {
latestBlock = *block
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": status,
"latestBlock": latestBlock,
"lag": lag,
"lastCheckedAt": checkedAtStr(checkedAt),
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
func checkedAtStr(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format(time.RFC3339)
}
func HealthCheck(w http.ResponseWriter, r *http.Request) { func HealthCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok", "status": "ok",
@@ -12,12 +69,3 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) {
"service": "koin-ping-backend", "service": "koin-ping-backend",
}) })
} }
func SystemStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"latestBlock": 0,
"lag": 0,
"status": "healthy",
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}

View File

@@ -107,6 +107,24 @@ func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*d
return &a, nil return &a, nil
} }
// UpdateLabel updates the label for an address owned by userID.
// Returns nil, nil if no row matched (address not found or not owned by user).
func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, label *string) (*domain.Address, error) {
var a domain.Address
err := m.pool.QueryRow(ctx,
`UPDATE addresses SET label = $3 WHERE id = $1 AND user_id = $2
RETURNING id, user_id, address, label, created_at`,
id, userID, label,
).Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &a, nil
}
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) { func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
tag, err := m.pool.Exec(ctx, tag, err := m.pool.Exec(ctx,
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`, `DELETE FROM addresses WHERE id = $1 AND user_id = $2`,

View File

@@ -2,7 +2,9 @@ package models
import ( import (
"context" "context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
) )
@@ -71,10 +73,15 @@ func (m *AlertEventModel) Create(ctx context.Context, alertRuleID int, message s
err := m.pool.QueryRow(ctx, err := m.pool.QueryRow(ctx,
`INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash) `INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING
RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`, RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`,
alertRuleID, message, addressLabel, txHash, alertRuleID, message, addressLabel, txHash,
).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp) ).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Duplicate silently skipped by ON CONFLICT DO NOTHING
return nil, nil
}
return nil, err return nil, err
} }
return &e, nil return &e, nil

View File

@@ -3,6 +3,7 @@ package models
import ( import (
"context" "context"
"errors" "errors"
"time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -17,6 +18,20 @@ func NewCheckpointModel(pool *pgxpool.Pool) *CheckpointModel {
return &CheckpointModel{pool: pool} return &CheckpointModel{pool: pool}
} }
// GetLatestBlock returns the highest last_checked_block and its timestamp across all addresses.
// Returns nil, nil, nil when no checkpoints exist yet.
func (m *CheckpointModel) GetLatestBlock(ctx context.Context) (*int, *time.Time, error) {
var block *int
var checkedAt *time.Time
err := m.pool.QueryRow(ctx,
`SELECT MAX(last_checked_block), MAX(last_checked_at) FROM address_checkpoints`,
).Scan(&block, &checkedAt)
if err != nil {
return nil, nil, err
}
return block, checkedAt, nil
}
// GetLastCheckedBlock returns the last checked block for an address, or -1 if never checked. // GetLastCheckedBlock returns the last checked block for an address, or -1 if never checked.
func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) { func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) {
var block int var block int

View File

@@ -45,11 +45,11 @@ func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID strin
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (user_id) ON CONFLICT (user_id)
DO UPDATE SET DO UPDATE SET
discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url), discord_webhook_url = $2,
telegram_chat_id = COALESCE($3, user_notification_configs.telegram_chat_id), telegram_chat_id = $3,
telegram_bot_token = COALESCE($4, user_notification_configs.telegram_bot_token), telegram_bot_token = $4,
email = COALESCE($5, user_notification_configs.email), email = $5,
slack_webhook_url = COALESCE($6, user_notification_configs.slack_webhook_url), slack_webhook_url = $6,
notification_enabled = $7, notification_enabled = $7,
updated_at = NOW() updated_at = NOW()
RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,

View File

@@ -2,6 +2,7 @@ package notifications
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -25,11 +26,15 @@ var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
Timeout: discordHTTPTimeoutSeconds * time.Second, Timeout: discordHTTPTimeoutSeconds * time.Second,
} }
type AlertMetadata struct { // DiscordNotifier sends alert notifications via a Discord webhook.
TxHash string type DiscordNotifier struct {
AddressLabel string WebhookURL string
AlertType string }
Address string
// Send implements Notifier for Discord.
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendDiscordNotification(d.WebhookURL, message, meta)
return err
} }
type discordEmbed struct { type discordEmbed struct {

View File

@@ -2,6 +2,7 @@ package notifications
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -9,6 +10,19 @@ import (
"time" "time"
) )
// EmailNotifier sends alert notifications via email (Resend).
type EmailNotifier struct {
APIKey string
From string
To string
}
// Send implements Notifier for email.
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
return err
}
const emailHTTPTimeoutSeconds = 10 const emailHTTPTimeoutSeconds = 10
var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals

View File

@@ -0,0 +1,16 @@
package notifications
import "context"
// AlertMetadata holds context about the alert being sent.
type AlertMetadata struct {
TxHash string
AddressLabel string
AlertType string
Address string
}
// Notifier is the interface implemented by all notification channels.
type Notifier interface {
Send(ctx context.Context, message string, meta AlertMetadata) error
}

View File

@@ -2,6 +2,7 @@ package notifications
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -9,6 +10,17 @@ import (
"time" "time"
) )
// SlackNotifier sends alert notifications via a Slack webhook.
type SlackNotifier struct {
WebhookURL string
}
// Send implements Notifier for Slack.
func (s *SlackNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendSlackNotification(s.WebhookURL, message, meta)
return err
}
const slackHTTPTimeoutSeconds = 10 const slackHTTPTimeoutSeconds = 10
var slackHTTPClient = &http.Client{ //nolint:gochecknoglobals var slackHTTPClient = &http.Client{ //nolint:gochecknoglobals

View File

@@ -2,6 +2,7 @@ package notifications
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -9,6 +10,18 @@ import (
"time" "time"
) )
// TelegramNotifier sends alert notifications via Telegram.
type TelegramNotifier struct {
BotToken string
ChatID string
}
// Send implements Notifier for Telegram.
func (t *TelegramNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendTelegramNotification(t.BotToken, t.ChatID, message, meta)
return err
}
const telegramHTTPTimeoutSeconds = 10 const telegramHTTPTimeoutSeconds = 10
var telegramHTTPClient = &http.Client{ //nolint:gochecknoglobals var telegramHTTPClient = &http.Client{ //nolint:gochecknoglobals

View File

@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log"
"math/big" "math/big"
"net/http" "net/http"
"strings" "strings"
@@ -13,7 +15,11 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
) )
const rpcTimeoutMS = 30000 const (
rpcTimeoutMS = 30000
rpcMaxRetries = 3
rpcRetryBaseMS = 1000
)
type JsonRpcEthereum struct { type JsonRpcEthereum struct {
rpcURL string rpcURL string
@@ -66,6 +72,44 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
return nil, fmt.Errorf("marshal RPC request: %w", err) return nil, fmt.Errorf("marshal RPC request: %w", err)
} }
return j.callWithRetry(ctx, method, body)
}
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
// It retries on network errors, HTTP 429, and HTTP 5xx. It does NOT retry on RPC-level
// errors or other 4xx responses (those are permanent failures).
func (j *JsonRpcEthereum) callWithRetry(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
var lastErr error
for attempt := range rpcMaxRetries {
if attempt > 0 {
wait := time.Duration(rpcRetryBaseMS*(1<<(attempt-1))) * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
log.Printf("Retrying RPC call [%s] (attempt %d/%d)", method, attempt+1, rpcMaxRetries)
}
result, err := j.doRPCCall(ctx, method, body)
if err == nil {
return result, nil
}
lastErr = err
// Permanent errors: do not retry
if isPermanentRPCError(err) {
return nil, err
}
log.Printf("Transient RPC error [%s] (attempt %d/%d): %v", method, attempt+1, rpcMaxRetries, err)
}
return nil, lastErr
}
func (j *JsonRpcEthereum) doRPCCall(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, fmt.Errorf("create RPC request: %w", err) return nil, fmt.Errorf("create RPC request: %w", err)
@@ -78,8 +122,13 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
} }
defer resp.Body.Close() defer resp.Body.Close()
// 429 and 5xx are transient; other non-200 are permanent.
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method) err := fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { //nolint:mnd
return nil, err // transient — will be retried
}
return nil, &permanentRPCError{err}
} }
var rpcResp rpcResponse var rpcResp rpcResponse
@@ -88,12 +137,25 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
} }
if rpcResp.Error != nil { if rpcResp.Error != nil {
return nil, fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code) // RPC-level errors are permanent (bad params, unsupported method, etc.)
return nil, &permanentRPCError{
fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code),
}
} }
return rpcResp.Result, nil return rpcResp.Result, nil
} }
type permanentRPCError struct{ cause error }
func (e *permanentRPCError) Error() string { return e.cause.Error() }
func (e *permanentRPCError) Unwrap() error { return e.cause }
func isPermanentRPCError(err error) bool {
var p *permanentRPCError
return errors.As(err, &p)
}
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) { func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
result, err := j.callRPC(ctx, "eth_blockNumber") result, err := j.callRPC(ctx, "eth_blockNumber")
if err != nil { if err != nil {
@@ -180,6 +242,132 @@ func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (strin
return hexToDecimalString(hexBalance), nil return hexToDecimalString(hexBalance), nil
} }
const erc20TransferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
const minTransferTopics = 3
type rpcLog struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
BlockNumber string `json:"blockNumber"`
TxHash string `json:"transactionHash"`
}
// GetTokenTransfers fetches ERC-20 Transfer events for a given address
// across a block range, covering both incoming and outgoing transfers.
func (j *JsonRpcEthereum) GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) {
paddedAddr := padAddress(address)
hexFrom := fmt.Sprintf("0x%x", fromBlock)
hexTo := fmt.Sprintf("0x%x", toBlock)
incomingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, "", paddedAddr)
if err != nil {
return nil, fmt.Errorf("fetch incoming token transfers: %w", err)
}
outgoingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, paddedAddr, "")
if err != nil {
return nil, fmt.Errorf("fetch outgoing token transfers: %w", err)
}
seen := make(map[string]bool)
var txs []domain.NormalizedTx
for _, entry := range append(incomingLogs, outgoingLogs...) {
key := entry.TxHash + "|" + entry.Address + "|" + entry.Data
if seen[key] {
continue
}
seen[key] = true
tx, parseErr := parseTransferLog(entry)
if parseErr != nil {
log.Printf("Skipping unparseable transfer log in tx %s: %v", entry.TxHash, parseErr)
continue
}
txs = append(txs, tx)
}
return txs, nil
}
func (j *JsonRpcEthereum) getTransferLogs(ctx context.Context, fromBlock, toBlock, fromAddr, toAddr string) ([]rpcLog, error) {
topics := make([]interface{}, minTransferTopics)
topics[0] = erc20TransferTopic
if fromAddr != "" {
topics[1] = fromAddr
}
if toAddr != "" {
topics[2] = toAddr
}
filter := map[string]interface{}{
"fromBlock": fromBlock,
"toBlock": toBlock,
"topics": topics,
}
result, err := j.callRPC(ctx, "eth_getLogs", filter)
if err != nil {
return nil, err
}
var logs []rpcLog
if err := json.Unmarshal(result, &logs); err != nil {
return nil, fmt.Errorf("unmarshal logs: %w", err)
}
return logs, nil
}
func parseTransferLog(entry rpcLog) (domain.NormalizedTx, error) {
if len(entry.Topics) < minTransferTopics {
return domain.NormalizedTx{}, fmt.Errorf("transfer log has %d topics, expected >= 3", len(entry.Topics))
}
from := topicToAddress(entry.Topics[1])
to := topicToAddress(entry.Topics[2])
tokenValue := hexToDecimalString(entry.Data)
blockNumber, _ := hexToInt(entry.BlockNumber)
contractAddr := strings.ToLower(entry.Address)
tokenInfo, known := LookupToken(contractAddr)
tx := domain.NormalizedTx{
Hash: entry.TxHash,
From: from,
To: &to,
Value: "0",
BlockNumber: blockNumber,
TokenContract: &contractAddr,
TokenValue: &tokenValue,
}
if known {
tx.TokenSymbol = &tokenInfo.Symbol
tx.TokenDecimals = &tokenInfo.Decimals
}
return tx, nil
}
func padAddress(addr string) string {
clean := strings.TrimPrefix(strings.ToLower(addr), "0x")
const addressHexLen = 64
return "0x" + strings.Repeat("0", addressHexLen-len(clean)) + clean
}
func topicToAddress(topic string) string {
clean := strings.TrimPrefix(topic, "0x")
const ethAddrLen = 40
if len(clean) > ethAddrLen {
clean = clean[len(clean)-ethAddrLen:]
}
return "0x" + strings.ToLower(clean)
}
func hexToInt(hex string) (int, error) { func hexToInt(hex string) (int, error) {
hex = strings.TrimPrefix(hex, "0x") hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16) n, ok := new(big.Int).SetString(hex, 16)

View File

@@ -12,4 +12,5 @@ type EthereumObserver interface {
GetLatestBlockNumber(ctx context.Context) (int, error) GetLatestBlockNumber(ctx context.Context) (int, error)
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
GetBalance(ctx context.Context, address string) (string, error) GetBalance(ctx context.Context, address string) (string, error)
GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error)
} }

View File

@@ -0,0 +1,34 @@
package ethereum
import "strings"
// TokenInfo holds metadata for a known ERC-20 token contract.
type TokenInfo struct {
Symbol string
Decimals int
}
//nolint:gochecknoglobals
var wellKnownTokens = map[string]TokenInfo{
"0xdac17f958d2ee523a2206206994597c13d831ec7": {Symbol: "USDT", Decimals: 6},
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {Symbol: "USDC", Decimals: 6},
"0x6b175474e89094c44da98b954eedeac495271d0f": {Symbol: "DAI", Decimals: 18},
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {Symbol: "WETH", Decimals: 18},
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {Symbol: "WBTC", Decimals: 8},
"0x514910771af9ca656af840dff83e8264ecf986ca": {Symbol: "LINK", Decimals: 18},
"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {Symbol: "UNI", Decimals: 18},
"0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9": {Symbol: "AAVE", Decimals: 18},
"0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce": {Symbol: "SHIB", Decimals: 18},
"0x6982508145454ce325ddbe47a25d4ec3d2311933": {Symbol: "PEPE", Decimals: 18},
"0xb8c77482e45f1f44de1745f52c74426c631bdd52": {Symbol: "BNB", Decimals: 18},
"0x4fabb145d64652a948d72533023f6e7a623c7c53": {Symbol: "BUSD", Decimals: 18},
"0x75231f58b43240c9718dd58b4967c5114342a86c": {Symbol: "OKB", Decimals: 18},
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1": {Symbol: "TON", Decimals: 9},
"0x4d224452801aced8b2f0aebe155379bb5d594381": {Symbol: "APE", Decimals: 18},
}
// LookupToken returns metadata for a known token contract, if found.
func LookupToken(contractAddress string) (TokenInfo, bool) {
info, ok := wellKnownTokens[strings.ToLower(contractAddress)]
return info, ok
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
@@ -12,6 +13,12 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/wei" "github.com/kjannette/koin-ping/backend-go/internal/wei"
) )
const (
notificationTimeout = 30 * time.Second
notificationMaxRetries = 3
notificationRetryBase = time.Second
)
type EvaluatorService struct { type EvaluatorService struct {
eth ethereum.EthereumObserver eth ethereum.EthereumObserver
alertRules *models.AlertRuleModel alertRules *models.AlertRuleModel
@@ -167,25 +174,85 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
message := s.buildMessage(rule, obs) message := s.buildMessage(rule, obs)
txHash := &obs.Hash txHash := &obs.Hash
_, err = s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash) event, err := s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
if err != nil { if err != nil {
return err 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) log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
// Send Discord notification (non-fatal on failure)
if addr != nil { if addr != nil {
userID := addr.UserID
address := addr.Address
go func() { go func() {
s.sendNotification( notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address, defer cancel()
) s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
}() }()
} }
return nil return nil
} }
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) { 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) notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
if err != nil { if err != nil {
@@ -204,49 +271,20 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
Address: address, Address: address,
} }
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" { for _, n := range s.buildNotifiers(notifConfig) {
sent, sendErr := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta) if err := sendWithRetry(ctx, n, message, meta); err != nil {
if sendErr != nil || !sent { log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
log.Printf("Discord notification failed for user %s: %v", userID, sendErr)
} else { } else {
log.Printf("Discord notification sent to user %s", userID) log.Printf("Notification sent to user %s via %T", userID, n)
}
}
if notifConfig.TelegramBotToken != nil && *notifConfig.TelegramBotToken != "" &&
notifConfig.TelegramChatID != nil && *notifConfig.TelegramChatID != "" {
sent, sendErr := notifications.SendTelegramNotification(
*notifConfig.TelegramBotToken, *notifConfig.TelegramChatID, message, meta,
)
if sendErr != nil || !sent {
log.Printf("Telegram notification failed for user %s: %v", userID, sendErr)
} else {
log.Printf("Telegram notification sent to user %s", userID)
}
}
if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" {
sent, sendErr := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
if sendErr != nil || !sent {
log.Printf("Slack notification failed for user %s: %v", userID, sendErr)
} else {
log.Printf("Slack notification sent to user %s", userID)
}
}
if notifConfig.Email != nil && *notifConfig.Email != "" {
sent, sendErr := notifications.SendEmailNotification(
s.resendAPIKey, s.emailFrom, *notifConfig.Email, message, meta,
)
if sendErr != nil || !sent {
log.Printf("Email notification failed for user %s: %v", userID, sendErr)
} else {
log.Printf("Email notification sent to user %s", userID)
} }
} }
} }
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string { func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
if obs.IsTokenTransfer() {
return s.buildTokenMessage(rule, obs)
}
switch rule.Type { switch rule.Type {
case domain.AlertIncomingTx: case domain.AlertIncomingTx:
ethStr, _ := wei.FormatAsEth(obs.Value, 4) ethStr, _ := wei.FormatAsEth(obs.Value, 4)
@@ -271,3 +309,36 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ
return "Alert triggered" 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

@@ -80,6 +80,15 @@ func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Addres
} }
} }
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 { if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
return nil, err return nil, err
} }

View File

@@ -98,3 +98,54 @@ func FormatAsEth(weiString string, decimals int) (string, error) {
} }
return fmt.Sprintf("%.*f ETH", decimals, eth), nil return fmt.Sprintf("%.*f ETH", decimals, eth), nil
} }
// FormatTokenAmount formats a raw token amount using the token's decimal places.
// For example, 1000000 USDT (6 decimals) becomes "1".
func FormatTokenAmount(rawValue string, tokenDecimals int) string {
if rawValue == "" || rawValue == "0" {
return "0"
}
n, ok := new(big.Int).SetString(rawValue, 10)
if !ok {
return "0"
}
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(tokenDecimals)), nil) //nolint:mnd
whole := new(big.Int).Div(n, divisor)
remainder := new(big.Int).Mod(n, divisor)
if remainder.Sign() == 0 {
return addThousandsSeparators(whole.String())
}
fracStr := fmt.Sprintf("%0*s", tokenDecimals, remainder.String())
fracStr = strings.TrimRight(fracStr, "0")
const maxDisplayDecimals = 4
if len(fracStr) > maxDisplayDecimals {
fracStr = fracStr[:maxDisplayDecimals]
}
return addThousandsSeparators(whole.String()) + "." + fracStr
}
func addThousandsSeparators(s string) string {
if len(s) <= 3 { //nolint:mnd
return s
}
var result strings.Builder
offset := len(s) % 3 //nolint:mnd
if offset > 0 {
result.WriteString(s[:offset])
}
for i := offset; i < len(s); i += 3 { //nolint:mnd
if result.Len() > 0 {
result.WriteByte(',')
}
result.WriteString(s[i : i+3]) //nolint:mnd
}
return result.String()
}

BIN
frontend/public/ping.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -3,6 +3,7 @@ import { useAuth } from "./contexts/AuthContext";
import Navbar from "./components/Navbar"; import Navbar from "./components/Navbar";
import Login from "./pages/Login"; import Login from "./pages/Login";
import Signup from "./pages/Signup"; import Signup from "./pages/Signup";
import Onboarding from "./pages/Onboarding";
import Addresses from "./pages/Addresses"; import Addresses from "./pages/Addresses";
import Alerts from "./pages/Alerts"; import Alerts from "./pages/Alerts";
import AlertHistory from "./pages/AlertHistory"; import AlertHistory from "./pages/AlertHistory";
@@ -15,6 +16,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} /> <Route path="/signup" element={<Signup />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="*" element={<Navigate to="/login" />} /> <Route path="*" element={<Navigate to="/login" />} />
</Routes> </Routes>
); );
@@ -27,7 +29,8 @@ export default function App() {
<Route path="/" element={<Addresses />} /> <Route path="/" element={<Addresses />} />
<Route path="/addresses" element={<Addresses />} /> <Route path="/addresses" element={<Addresses />} />
<Route path="/alerts" element={<Alerts />} /> <Route path="/alerts" element={<Alerts />} />
<Route path="/history" element={<AlertHistory />} /> <Route path="/alertevents" element={<AlertHistory />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="*" element={<Navigate to="/addresses" />} /> <Route path="*" element={<Navigate to="/addresses" />} />
</Routes> </Routes>
</div> </div>

View File

@@ -80,6 +80,41 @@ export async function getAddresses() {
} }
} }
/**
* Update an address (e.g. change its label)
* @param {number} addressId - Address ID to update
* @param {Object} data - Fields to update (e.g. { label: "New Label" })
* @returns {Promise<Object>} Updated address
*/
export async function updateAddress(addressId, data) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/addresses/${addressId}`, {
method: "PATCH",
headers: headers,
body: JSON.stringify(data),
});
if (!response.ok) {
let errorMessage = "Failed to update address";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
if (error.message.includes("fetch")) {
throw new Error("Cannot connect to server. Is the backend running?");
}
throw error;
}
}
/** /**
* Delete a tracked address * Delete a tracked address
* @param {number} addressId - Address ID to delete * @param {number} addressId - Address ID to delete

View File

@@ -4,7 +4,7 @@ import { useAuth } from "../contexts/AuthContext";
const navLinks = [ const navLinks = [
{ to: "/addresses", label: "Addresses" }, { to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" }, { to: "/alerts", label: "Configure Alerts" },
{ to: "/history", label: "Alert History" }, { to: "/alertevents", label: "Alert Events" },
]; ];
export default function Navbar() { export default function Navbar() {
@@ -26,8 +26,8 @@ export default function Navbar() {
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ <span style={{
fontWeight: 700, fontWeight: 700,
fontSize: "1.1rem", fontSize: "1.4rem",
color: "#fff", color: "#e62525",
marginRight: "2rem", marginRight: "2rem",
letterSpacing: "0.5px", letterSpacing: "0.5px",
}}> }}>

View File

@@ -1,11 +1,13 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import AddressForm from "../components/AddressForm"; import AddressForm from "../components/AddressForm";
import { getAddresses, createAddress } from "../api/addresses"; import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses";
export default function Addresses() { export default function Addresses() {
const [addresses, setAddresses] = useState([]); const [addresses, setAddresses] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [editingId, setEditingId] = useState(null);
const [editLabel, setEditLabel] = useState("");
// Load addresses on mount // Load addresses on mount
useEffect(() => { useEffect(() => {
@@ -29,15 +31,52 @@ export default function Addresses() {
async function handleAddressSubmit(data) { async function handleAddressSubmit(data) {
try { try {
const newAddress = await createAddress(data); const newAddress = await createAddress(data);
// Append new address to state
setAddresses((prev) => [...prev, newAddress]); setAddresses((prev) => [...prev, newAddress]);
setError(null); // Clear any previous errors setError(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to create address:", err); console.error("Failed to create address:", err);
} }
} }
async function handleDelete(id, label) {
const displayName = label || "this address";
if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
return;
}
try {
await deleteAddress(id);
setAddresses((prev) => prev.filter((a) => a.id !== id));
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to delete address:", err);
}
}
function handleEditStart(addr) {
setEditingId(addr.id);
setEditLabel(addr.label ?? "");
}
async function handleEditSave(id) {
try {
const updated = await updateAddress(id, { label: editLabel || null });
setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
setEditingId(null);
setEditLabel("");
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to update address:", err);
}
}
function handleEditCancel() {
setEditingId(null);
setEditLabel("");
}
return ( return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}> <div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Tracked Addresses</h1> <h1>Tracked Addresses</h1>
@@ -68,22 +107,88 @@ export default function Addresses() {
backgroundColor: "#333", backgroundColor: "#333",
}} }}
> >
<div <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
style={{ <div style={{ flex: 1 }}>
fontWeight: "bold", {editingId === addr.id ? (
marginBottom: "0.25rem", <div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "0.25rem" }}>
}} <input
> value={editLabel}
{addr.label || "Unlabeled"} onChange={(e) => setEditLabel(e.target.value)}
</div> placeholder="Label (optional)"
<div style={{
style={{ background: "#444",
fontFamily: "monospace", border: "1px solid #666",
fontSize: "1.035rem", borderRadius: "3px",
color: "#b3b3b3", color: "#fff",
}} padding: "0.25rem 0.5rem",
> fontSize: "0.9rem",
{addr.address} }}
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave(addr.id);
if (e.key === "Escape") handleEditCancel();
}}
autoFocus
/>
<button
onClick={() => handleEditSave(addr.id)}
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem" }}
>
Save
</button>
<button
onClick={handleEditCancel}
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem", background: "transparent", color: "#aaa", border: "1px solid #555" }}
>
Cancel
</button>
</div>
) : (
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.25rem" }}>
<span style={{ fontWeight: "bold" }}>
{addr.label || "Unlabeled"}
</span>
<button
onClick={() => handleEditStart(addr)}
style={{
cursor: "pointer",
background: "transparent",
border: "none",
color: "#6699cc",
fontSize: "0.8rem",
padding: "0",
textDecoration: "underline",
}}
>
Edit
</button>
</div>
)}
<div
style={{
fontFamily: "monospace",
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
{addr.address}
</div>
</div>
<button
onClick={() => handleDelete(addr.id, addr.label)}
style={{
cursor: "pointer",
background: "transparent",
border: "1px solid #884444",
color: "#cc6666",
borderRadius: "3px",
padding: "0.3rem 0.7rem",
fontSize: "0.85rem",
marginLeft: "1rem",
flexShrink: 0,
}}
>
Remove
</button>
</div> </div>
</li> </li>
))} ))}

View File

@@ -36,7 +36,7 @@ export default function AlertHistory() {
return ( return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}> <div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Recent Alerts</h1> <h1>Recent Alert Events</h1>
{alertEvents.length === 0 ? ( {alertEvents.length === 0 ? (
<p style={{ color: "#808080" }}>No alerts yet</p> <p style={{ color: "#808080" }}>No alerts yet</p>

View File

@@ -65,6 +65,7 @@ export default function Alerts() {
const [testingChannels, setTestingChannels] = useState(false); const [testingChannels, setTestingChannels] = useState(false);
const [settingUpEmail, setSettingUpEmail] = useState(false); const [settingUpEmail, setSettingUpEmail] = useState(false);
const [sendingDigest, setSendingDigest] = useState(false); const [sendingDigest, setSendingDigest] = useState(false);
const [hasExistingConfig, setHasExistingConfig] = useState(false);
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
@@ -86,6 +87,14 @@ export default function Alerts() {
setTelegramChatId(configData.telegram_chat_id || ""); setTelegramChatId(configData.telegram_chat_id || "");
setEmail(configData.email || ""); setEmail(configData.email || "");
setSlackWebhookUrl(configData.slack_webhook_url || ""); setSlackWebhookUrl(configData.slack_webhook_url || "");
const hasSaved =
!!configData.discord_webhook_url ||
!!configData.telegram_bot_token ||
!!configData.telegram_chat_id ||
!!configData.email ||
!!configData.slack_webhook_url;
setHasExistingConfig(hasSaved);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to fetch data:", err); console.error("Failed to fetch data:", err);
@@ -155,6 +164,13 @@ export default function Alerts() {
} }
async function handleSaveNotificationConfig() { async function handleSaveNotificationConfig() {
if (hasExistingConfig) {
const confirmed = window.confirm(
"This will overwrite your previously saved notification settings. Continue?",
);
if (!confirmed) return;
}
try { try {
setNotificationLoading(true); setNotificationLoading(true);
setNotificationError(null); setNotificationError(null);
@@ -170,6 +186,7 @@ export default function Alerts() {
}; };
await updateNotificationConfig(config); await updateNotificationConfig(config);
setHasExistingConfig(true);
setNotificationSuccess("Notification settings saved!"); setNotificationSuccess("Notification settings saved!");
setTimeout(() => setNotificationSuccess(null), 3000); setTimeout(() => setNotificationSuccess(null), 3000);
} catch (err) { } catch (err) {

View File

@@ -0,0 +1,88 @@
.login-page {
min-height: 100vh;
background-image: url(/ping.png);
background-size: 67%;
background-position: center;
background-repeat: no-repeat;
}
.login-card {
max-width: 400px;
margin: 20px auto;
padding: 2rem;
padding-top: 8rem;
border: 1px solid #333;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.75);
}
.login-heading {
margin-bottom: 2rem;
text-align: center;
}
.login-brand {
color: #e62525;
}
.login-error {
padding: 0.75rem;
margin-bottom: 1rem;
background-color: #ff000020;
border: 1px solid #ff0000;
border-radius: 4px;
color: #ff6666;
}
.login-field {
margin-bottom: 1rem;
}
.login-field-last {
margin-bottom: 1.5rem;
}
.login-label {
display: block;
margin-bottom: 0.5rem;
}
.login-input {
width: 100%;
padding: 0.5rem;
font-size: 1.2rem;
background-color: #242424;
border: 1px solid #444;
border-radius: 4px;
color: white;
}
.login-button {
width: 100%;
padding: 0.75rem;
font-size: 1.2rem;
background-color: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.login-button:disabled {
background-color: #333;
cursor: not-allowed;
}
.login-footer {
margin-top: 1.5rem;
text-align: center;
}
.login-footer-text {
color: #b3b3b3;
}
.login-signup-link {
color: #0066cc;
text-decoration: none;
}

View File

@@ -1,146 +1,107 @@
/**
* Login Page
*
* Allows existing users to sign in with email and password
*/
import { useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import "./Login.css";
export default function Login() { export default function Login() {
const [email, setEmail] = useState(""); const [isVisible, setIsVisible] = useState(false);
const [password, setPassword] = useState(""); const [email, setEmail] = useState("");
const [error, setError] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth(); const { login } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
// Validation // Validation
if (!email || !password) { if (!email || !password) {
setError("Please fill in all fields"); setError("Please fill in all fields");
return; return;
}
try {
setError("");
setLoading(true);
await login(email, password);
navigate("/addresses"); // Redirect to main app
} catch (err) {
setError("Failed to log in: " + err.message);
} finally {
setLoading(false);
}
} }
return ( try {
<div setError("");
style={{ setLoading(true);
maxWidth: "400px", await login(email, password);
margin: "4rem auto", navigate("/addresses"); // Redirect to main app
padding: "2rem", } catch (err) {
border: "1px solid #333", setError("Failed to log in: " + err.message);
borderRadius: "8px", } finally {
}} setLoading(false);
> }
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}> }
Koin Ping - Login
</h1>
{error && ( return (
<div <div className="login-page">
style={{ <div
padding: "0.75rem", className="login-card"
marginBottom: "1rem", onMouseEnter={() => setIsVisible(true)}
backgroundColor: "#ff000020", >
border: "1px solid #ff0000", <h1 className="login-heading">
borderRadius: "4px", <span className="login-brand">Koin Ping</span> - Login
color: "#ff6666", </h1>
}}
>
{error}
</div>
)}
<form onSubmit={handleSubmit}> <div style={{ visibility: isVisible ? 'visible' : 'hidden' }}>
<div style={{ marginBottom: "1rem" }}> {error && (
<label style={{ display: "block", marginBottom: "0.5rem" }}> <div className="login-error">
Email {error}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem",
fontSize: "1rem",
backgroundColor: loading ? "#333" : "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
{loading ? "Logging in..." : "Log In"}
</button>
</form>
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
<p style={{ color: "#b3b3b3" }}>
Don't have an account?{" "}
<Link
to="/signup"
style={{ color: "#0066cc", textDecoration: "none" }}
>
Sign up here
</Link>
</p>
</div> </div>
)}
<form onSubmit={handleSubmit}>
<div className="login-field">
<label className="login-label">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
className="login-input"
required
/>
</div>
<div className="login-field-last">
<label className="login-label">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
className="login-input"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="login-button"
>
{loading ? "Logging in..." : "Log In"}
</button>
</form>
<div className="login-footer">
<p className="login-footer-text">
Don't have an account?{" "}
<Link
to="/signup"
className="login-signup-link"
>
Sign up here
</Link>
</p>
</div>
</div> </div>
); </div>
</div>
);
} }

View File

@@ -0,0 +1,834 @@
/**
* Onboarding Wizard
*
* 5-step guided flow: Create Account → Add Wallet → Alert Rules → Notifications → Done
*/
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { createAddress, getAddresses } from "../api/addresses";
import { createAlert } from "../api/alerts";
import {
updateNotificationConfig,
testNotificationChannels,
} from "../api/notificationConfig";
const STEPS = [
"Create Account",
"Add Wallet",
"Alert Rules",
"Notifications",
"Done",
];
const inputStyle = {
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#2a2a2a",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
boxSizing: "border-box",
};
const labelStyle = {
display: "block",
marginBottom: "0.4rem",
color: "#ccc",
fontSize: "0.9rem",
};
export default function Onboarding() {
const { currentUser, signup } = useAuth();
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [skipWarning, setSkipWarning] = useState("");
const [testResults, setTestResults] = useState(null);
const [testLoading, setTestLoading] = useState(false);
// Wizard state
const [data, setData] = useState({
email: "",
password: "",
confirmPassword: "",
walletAddress: "",
walletLabel: "",
createdAddressId: null,
alertIncomingTx: false,
alertOutgoingTx: false,
alertLargeTransfer: false,
largeTransferThreshold: "",
alertBalanceBelow: false,
balanceBelowThreshold: "",
discordWebhookUrl: "",
slackWebhookUrl: "",
notificationEmail: "",
// summary
alertsCreated: [],
notificationConfigured: false,
});
function set(field, value) {
setData((prev) => ({ ...prev, [field]: value }));
}
// On mount: if already fully onboarded, redirect away
useEffect(() => {
if (!currentUser) return;
getAddresses()
.then((addresses) => {
if (addresses.length > 0) {
navigate("/addresses", { replace: true });
}
})
.catch(() => {}); // ignore errors (e.g. mid-signup)
}, [currentUser, navigate]);
// ── Step handlers ─────────────────────────────────────────────────────────
async function handleStep1() {
setError("");
if (!data.email || !data.password || !data.confirmPassword) {
setError("Please fill in all fields");
return;
}
if (data.password !== data.confirmPassword) {
setError("Passwords do not match");
return;
}
if (data.password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
// If user already exists (browser-close-mid-wizard), skip signup
if (!currentUser) {
try {
setLoading(true);
await signup(data.email, data.password);
} catch (err) {
if (err.code === "auth/email-already-in-use") {
setError("Email already in use. Try logging in instead.");
} else if (err.code === "auth/invalid-email") {
setError("Invalid email address");
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
} else {
setError("Failed to create account: " + err.message);
}
setLoading(false);
return;
} finally {
setLoading(false);
}
}
setStep(2);
}
async function handleStep2() {
setError("");
if (!data.walletAddress) {
setError("Please enter a wallet address");
return;
}
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
return;
}
try {
setLoading(true);
const created = await createAddress({
address: data.walletAddress,
label: data.walletLabel || undefined,
});
set("createdAddressId", created.id);
setStep(3);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep3() {
setError("");
const rules = [];
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
if (data.alertLargeTransfer) {
if (!data.largeTransferThreshold) {
setError("Please enter a threshold for large transfers");
return;
}
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
}
if (data.alertBalanceBelow) {
if (!data.balanceBelowThreshold) {
setError("Please enter a threshold for balance below");
return;
}
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
}
if (rules.length === 0) {
setStep(4);
return;
}
try {
setLoading(true);
const created = [];
for (const rule of rules) {
const result = await createAlert(data.createdAddressId, rule);
created.push(result);
}
set("alertsCreated", created);
setStep(4);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep4() {
setError("");
const hasAny =
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
if (!hasAny) {
setStep(5);
return;
}
try {
setLoading(true);
await updateNotificationConfig({
notification_enabled: true,
discord_webhook_url: data.discordWebhookUrl || undefined,
slack_webhook_url: data.slackWebhookUrl || undefined,
email: data.notificationEmail || undefined,
});
set("notificationConfigured", true);
setStep(5);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleTestChannels() {
setTestLoading(true);
setTestResults(null);
try {
const results = await testNotificationChannels();
setTestResults(results);
} catch (err) {
setTestResults({ error: err.message });
} finally {
setTestLoading(false);
}
}
// ── Progress bar ──────────────────────────────────────────────────────────
function ProgressBar() {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "2rem",
}}
>
{STEPS.map((label, i) => {
const stepNum = i + 1;
const done = step > stepNum;
const active = step === stepNum;
return (
<div
key={label}
style={{ display: "flex", alignItems: "center" }}
>
{i > 0 && (
<div
style={{
width: "40px",
height: "2px",
backgroundColor: done || active ? "#0066cc" : "#444",
margin: "0 4px",
}}
/>
)}
<div style={{ textAlign: "center" }}>
<div
style={{
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor:
done ? "#0066cc" : active ? "#0066cc" : "#333",
border: active ? "2px solid #4499ff" : "2px solid transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: "bold",
fontSize: "0.85rem",
color: "white",
margin: "0 auto 4px",
}}
>
{done ? "✓" : stepNum}
</div>
<div
style={{
fontSize: "0.7rem",
color: active ? "white" : "#888",
whiteSpace: "nowrap",
}}
>
{label}
</div>
</div>
</div>
);
})}
</div>
);
}
// ── Step content ──────────────────────────────────────────────────────────
function Step1() {
return (
<>
<h2 style={{ marginBottom: "1.5rem" }}>Create your account</h2>
<div style={{ marginBottom: "1rem" }}>
<label style={labelStyle}>Email</label>
<input
type="email"
value={data.email}
onChange={(e) => set("email", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="you@example.com"
/>
</div>
<div style={{ marginBottom: "1rem" }}>
<label style={labelStyle}>Password</label>
<input
type="password"
value={data.password}
onChange={(e) => set("password", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="At least 6 characters"
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={labelStyle}>Confirm Password</label>
<input
type="password"
value={data.confirmPassword}
onChange={(e) => set("confirmPassword", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="Repeat your password"
/>
</div>
</>
);
}
function Step2() {
return (
<>
<h2 style={{ marginBottom: "0.5rem" }}>Add a wallet address</h2>
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
Enter the Ethereum address you want to monitor.
</p>
<div style={{ marginBottom: "1rem" }}>
<label style={labelStyle}>ETH Address</label>
<input
type="text"
value={data.walletAddress}
onChange={(e) => set("walletAddress", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="0x..."
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={labelStyle}>Label (optional)</label>
<input
type="text"
value={data.walletLabel}
onChange={(e) => set("walletLabel", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="e.g. My main wallet"
/>
</div>
</>
);
}
function Step3() {
return (
<>
<h2 style={{ marginBottom: "0.5rem" }}>Configure alert rules</h2>
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
Choose which events trigger notifications. You can change these later.
</p>
<CheckboxRow
checked={data.alertIncomingTx}
onChange={(v) => set("alertIncomingTx", v)}
label="Incoming transaction"
/>
<CheckboxRow
checked={data.alertOutgoingTx}
onChange={(v) => set("alertOutgoingTx", v)}
label="Outgoing transaction"
/>
<CheckboxRow
checked={data.alertLargeTransfer}
onChange={(v) => set("alertLargeTransfer", v)}
label="Large transfer"
>
{data.alertLargeTransfer && (
<div style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}>
<input
type="number"
value={data.largeTransferThreshold}
onChange={(e) => set("largeTransferThreshold", e.target.value)}
style={{ ...inputStyle, width: "160px" }}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
<CheckboxRow
checked={data.alertBalanceBelow}
onChange={(v) => set("alertBalanceBelow", v)}
label="Balance below"
>
{data.alertBalanceBelow && (
<div style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}>
<input
type="number"
value={data.balanceBelowThreshold}
onChange={(e) => set("balanceBelowThreshold", e.target.value)}
style={{ ...inputStyle, width: "160px" }}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
</>
);
}
function Step4() {
return (
<>
<h2 style={{ marginBottom: "0.5rem" }}>Set up notifications</h2>
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
Add at least one channel so you receive alerts. All fields are optional.
</p>
<div style={{ marginBottom: "1.25rem" }}>
<label style={labelStyle}>
Discord Webhook URL{" "}
<a
href="https://support.discord.com/hc/en-us/articles/228383668"
target="_blank"
rel="noreferrer"
style={{ color: "#4499ff", fontSize: "0.8rem" }}
>
(how to get one)
</a>
</label>
<input
type="url"
value={data.discordWebhookUrl}
onChange={(e) => set("discordWebhookUrl", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div style={{ marginBottom: "1.25rem" }}>
<label style={labelStyle}>
Slack Webhook URL{" "}
<a
href="https://api.slack.com/messaging/webhooks"
target="_blank"
rel="noreferrer"
style={{ color: "#4499ff", fontSize: "0.8rem" }}
>
(how to get one)
</a>
</label>
<input
type="url"
value={data.slackWebhookUrl}
onChange={(e) => set("slackWebhookUrl", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="https://hooks.slack.com/services/..."
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={labelStyle}>Email address for alerts</label>
<input
type="email"
value={data.notificationEmail}
onChange={(e) => set("notificationEmail", e.target.value)}
disabled={loading}
style={inputStyle}
placeholder="you@example.com"
/>
</div>
</>
);
}
function Step5() {
const alertCount = data.alertsCreated.length;
const hasNotif = data.notificationConfigured;
return (
<>
<h2 style={{ marginBottom: "1rem" }}>You're all set!</h2>
<div
style={{
backgroundColor: "#1e2e1e",
border: "1px solid #2d5a2d",
borderRadius: "6px",
padding: "1rem 1.25rem",
marginBottom: "1.5rem",
}}
>
<p style={{ margin: "0 0 0.5rem", color: "#90ee90", fontWeight: "bold" }}>
Summary
</p>
<ul style={{ margin: 0, paddingLeft: "1.25rem", color: "#ccc", lineHeight: "1.8" }}>
<li>
Wallet address added:{" "}
<span style={{ color: "white", fontFamily: "monospace", fontSize: "0.85rem" }}>
{data.walletAddress}
</span>
{data.walletLabel && ` (${data.walletLabel})`}
</li>
<li>
Alert rules configured:{" "}
<span style={{ color: "white" }}>
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
</span>
</li>
<li>
Notification channels:{" "}
<span style={{ color: "white" }}>
{hasNotif ? "Configured" : "Not set up (skipped)"}
</span>
</li>
</ul>
</div>
{hasNotif && (
<div style={{ marginBottom: "1.5rem" }}>
<button
onClick={handleTestChannels}
disabled={testLoading}
style={{
padding: "0.6rem 1.25rem",
backgroundColor: testLoading ? "#333" : "#1a4d80",
color: "white",
border: "1px solid #0066cc",
borderRadius: "4px",
cursor: testLoading ? "not-allowed" : "pointer",
fontSize: "0.9rem",
}}
>
{testLoading ? "Testing..." : "Test All Channels"}
</button>
{testResults && (
<div style={{ marginTop: "0.75rem" }}>
{testResults.error ? (
<p style={{ color: "#ff6666" }}>{testResults.error}</p>
) : (
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{Object.entries(testResults).map(([channel, result]) => (
<li
key={channel}
style={{
color: result.success ? "#90ee90" : "#ff6666",
fontSize: "0.9rem",
marginBottom: "0.25rem",
}}
>
{result.success ? "✓" : "✗"} {channel}:{" "}
{result.message || (result.success ? "OK" : "Failed")}
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
<button
onClick={() => navigate("/addresses")}
style={{
padding: "0.75rem 2rem",
backgroundColor: "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "1rem",
fontWeight: "bold",
}}
>
Go to Dashboard
</button>
</>
);
}
// ── Shared helpers ────────────────────────────────────────────────────────
function CheckboxRow({ checked, onChange, label, children }) {
return (
<div style={{ marginBottom: "1rem" }}>
<label
style={{
display: "flex",
alignItems: "center",
gap: "0.6rem",
cursor: "pointer",
color: "#ddd",
}}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
style={{ width: "16px", height: "16px", accentColor: "#0066cc" }}
/>
{label}
</label>
{children}
</div>
);
}
// ── Footer navigation ─────────────────────────────────────────────────────
function Footer() {
if (step === 5) return null;
const canSkip = step === 3 || step === 4;
const canBack = step > 1;
async function handleNext() {
setSkipWarning("");
if (step === 1) await handleStep1();
else if (step === 2) await handleStep2();
else if (step === 3) await handleStep3();
else if (step === 4) await handleStep4();
}
function handleSkip() {
setError("");
setSkipWarning("");
setStep((s) => s + 1);
}
function handleBack() {
setError("");
setSkipWarning("");
setStep((s) => s - 1);
}
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: "1.5rem",
paddingTop: "1rem",
borderTop: "1px solid #333",
}}
>
<div>
{canBack && (
<button
onClick={handleBack}
disabled={loading}
style={{
padding: "0.5rem 1rem",
backgroundColor: "transparent",
color: "#aaa",
border: "1px solid #444",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
Back
</button>
)}
</div>
<div style={{ display: "flex", gap: "0.75rem" }}>
{canSkip && (
<button
onClick={handleSkip}
disabled={loading}
style={{
padding: "0.5rem 1rem",
backgroundColor: "transparent",
color: "#aaa",
border: "1px solid #444",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
Skip for now
</button>
)}
<button
onClick={handleNext}
disabled={loading}
style={{
padding: "0.5rem 1.25rem",
backgroundColor: loading ? "#333" : "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
fontWeight: "bold",
}}
>
{loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"}
</button>
</div>
</div>
);
}
// ── Render ────────────────────────────────────────────────────────────────
const stepContent = {
1: <Step1 />,
2: <Step2 />,
3: <Step3 />,
4: <Step4 />,
5: <Step5 />,
};
return (
<div
style={{
minHeight: "100vh",
backgroundColor: "#1a1a1a",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-start",
paddingTop: "3rem",
paddingBottom: "3rem",
}}
>
<div style={{ width: "100%", maxWidth: "540px", padding: "0 1rem" }}>
<h1
style={{
textAlign: "center",
marginBottom: "2rem",
color: "#0066cc",
letterSpacing: "0.5px",
}}
>
Koin Ping
</h1>
<ProgressBar />
{error && (
<div
style={{
padding: "0.75rem 1rem",
marginBottom: "1rem",
backgroundColor: "#3a1a1a",
border: "1px solid #cc3333",
borderRadius: "4px",
color: "#ff6666",
fontSize: "0.9rem",
}}
>
{error}
</div>
)}
{skipWarning && (
<div
style={{
padding: "0.75rem 1rem",
marginBottom: "1rem",
backgroundColor: "#3a2e00",
border: "1px solid #aa7700",
borderRadius: "4px",
color: "#ffcc44",
fontSize: "0.9rem",
}}
>
{skipWarning}
</div>
)}
<div
style={{
backgroundColor: "#242424",
border: "1px solid #333",
borderRadius: "8px",
padding: "2rem",
}}
>
{stepContent[step]}
<Footer />
</div>
{step === 1 && (
<p
style={{
textAlign: "center",
marginTop: "1.25rem",
color: "#888",
fontSize: "0.9rem",
}}
>
Already have an account?{" "}
<a href="/login" style={{ color: "#0066cc" }}>
Log in here
</a>
</p>
)}
</div>
</div>
);
}

View File

@@ -1,188 +1,5 @@
/** import { Navigate } from "react-router-dom";
* Signup Page
*
* Allows new users to create an account with email and password
*/
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
export default function Signup() { export default function Signup() {
const [email, setEmail] = useState(""); return <Navigate to="/onboarding" replace />;
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { signup } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
// Validation
if (!email || !password || !confirmPassword) {
setError("Please fill in all fields");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
try {
setError("");
setLoading(true);
await signup(email, password);
navigate("/addresses"); // Auto-login and redirect
} catch (err) {
// Firebase-specific error messages
if (err.code === "auth/email-already-in-use") {
setError("Email already in use. Try logging in instead.");
} else if (err.code === "auth/invalid-email") {
setError("Invalid email address");
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
} else {
setError("Failed to create account: " + err.message);
}
} finally {
setLoading(false);
}
}
return (
<div
style={{
maxWidth: "400px",
margin: "4rem auto",
padding: "2rem",
border: "1px solid #333",
borderRadius: "8px",
}}
>
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
Koin Ping - Sign Up
</h1>
{error && (
<div
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#ff000020",
border: "1px solid #ff0000",
borderRadius: "4px",
color: "#ff6666",
}}
>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem",
fontSize: "1rem",
backgroundColor: loading ? "#333" : "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
{loading ? "Creating account..." : "Sign Up"}
</button>
</form>
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
<p style={{ color: "#b3b3b3" }}>
Already have an account?{" "}
<Link
to="/login"
style={{ color: "#0066cc", textDecoration: "none" }}
>
Log in here
</Link>
</p>
</div>
</div>
);
} }