From 2a40e61b13171834ebec50b1ec9df7b9f12ecb1d Mon Sep 17 00:00:00 2001 From: warku123 Date: Fri, 26 Dec 2025 17:07:42 +0800 Subject: [PATCH 01/15] Add go SR check --- tools/slack_sr_monitor/.env.example | 7 + tools/slack_sr_monitor/.gitignore | 3 + tools/slack_sr_monitor/go.mod | 5 + tools/slack_sr_monitor/go.sum | 2 + tools/slack_sr_monitor/main.go | 257 ++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+) create mode 100644 tools/slack_sr_monitor/.env.example create mode 100644 tools/slack_sr_monitor/.gitignore create mode 100644 tools/slack_sr_monitor/go.mod create mode 100644 tools/slack_sr_monitor/go.sum create mode 100644 tools/slack_sr_monitor/main.go diff --git a/tools/slack_sr_monitor/.env.example b/tools/slack_sr_monitor/.env.example new file mode 100644 index 00000000..a0b9daf6 --- /dev/null +++ b/tools/slack_sr_monitor/.env.example @@ -0,0 +1,7 @@ +# Slack SR Monitor Configuration + +# The Slack Webhook URL for sending notifications +SLACK_WEBHOOK=your_slack_webhook_url_here + +# The Tron node API endpoint +TRON_NODE=https://api.trongrid.io diff --git a/tools/slack_sr_monitor/.gitignore b/tools/slack_sr_monitor/.gitignore new file mode 100644 index 00000000..7b8733a7 --- /dev/null +++ b/tools/slack_sr_monitor/.gitignore @@ -0,0 +1,3 @@ +.env +slack_sr_monitor + diff --git a/tools/slack_sr_monitor/go.mod b/tools/slack_sr_monitor/go.mod new file mode 100644 index 00000000..f2cf959b --- /dev/null +++ b/tools/slack_sr_monitor/go.mod @@ -0,0 +1,5 @@ +module github.com/tronprotocol/tron-docker/tools/slack_sr_monitor + +go 1.25.5 + +require github.com/joho/godotenv v1.5.1 diff --git a/tools/slack_sr_monitor/go.sum b/tools/slack_sr_monitor/go.sum new file mode 100644 index 00000000..d61b19e1 --- /dev/null +++ b/tools/slack_sr_monitor/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/tools/slack_sr_monitor/main.go b/tools/slack_sr_monitor/main.go new file mode 100644 index 00000000..37280c2a --- /dev/null +++ b/tools/slack_sr_monitor/main.go @@ -0,0 +1,257 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/joho/godotenv" +) + +const ( + DefaultTronNode = "https://api.trongrid.io" +) + +// Witness represents the structure of a witness returned by the Tron API +type Witness struct { + Address string `json:"address"` + VoteCount int64 `json:"voteCount"` + PubKey string `json:"pubKey"` + URL string `json:"url"` + TotalProduced int64 `json:"totalProduced"` + TotalMissed int64 `json:"totalMissed"` + LatestBlock int64 `json:"latestBlockNum"` + IsJobs bool `json:"isJobs"` +} + +// WitnessListResponse is the wrapper for the witness list API response +type WitnessListResponse struct { + Witnesses []Witness `json:"witnesses"` +} + +// NextMaintenanceResponse is the wrapper for the maintenance time API response +type NextMaintenanceResponse struct { + Num int64 `json:"num"` +} + +func getNextMaintenanceTime(nodeURL string) (time.Time, error) { + url := fmt.Sprintf("%s/wallet/getnextmaintenancetime", nodeURL) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Post(url, "application/json", nil) + if err != nil { + return time.Time{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return time.Time{}, fmt.Errorf("status code %d", resp.StatusCode) + } + + var result NextMaintenanceResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return time.Time{}, err + } + + // Result is in milliseconds + return time.Unix(result.Num/1000, (result.Num%1000)*1000000), nil +} + +func getWitnessList(nodeURL string) ([]Witness, error) { + url := fmt.Sprintf("%s/wallet/getpaginatednowwitnesslist", nodeURL) + // Fetch top 28 SRs + payload := []byte(`{"offset": 0, "limit": 28}`) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Post(url, "application/json", bytes.NewBuffer(payload)) + if err != nil { + return nil, fmt.Errorf("failed to call Tron API: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("tron API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + var result WitnessListResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %v (body: %s)", err, string(body)) + } + + return result.Witnesses, nil +} + +func formatComma(n int64) string { + in := fmt.Sprintf("%d", n) + var out strings.Builder + if n < 0 { + out.WriteByte('-') + in = in[1:] + } + l := len(in) + for i, c := range in { + if i > 0 && (l-i)%3 == 0 { + out.WriteByte(',') + } + out.WriteRune(c) + } + return out.String() +} + +func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]int64) error { + var buffer bytes.Buffer + buffer.WriteString("*TRON SR Status Update (Maintenance Period)*\n") + buffer.WriteString(fmt.Sprintf("Time: %s (UTC)\n\n", time.Now().UTC().Format(time.RFC1123))) + + buffer.WriteString("```\n") + buffer.WriteString(fmt.Sprintf("%-3s | %-30s | %-15s | %-15s | %-8s\n", "#", "SR Name/URL", "Current Votes", "Prev Votes", "Change")) + buffer.WriteString("-----------------------------------------------------------------------------------------------------\n") + + for i, w := range witnesses { + name := w.URL + if len(name) > 30 { + name = name[:27] + "..." + } + if name == "" { + name = w.Address + } + + prev := prevVotes[w.Address] + diff := w.VoteCount - prev + + diffStr := formatComma(diff) + if diff >= 0 { + diffStr = "+" + diffStr + } + + if prev == 0 { + diffStr = "-" + } + + prevStr := formatComma(prev) + if prev == 0 { + prevStr = "-" + } + + buffer.WriteString(fmt.Sprintf("%-3d | %-30s | %-15s | %-15s | %-8s\n", + i+1, name, formatComma(w.VoteCount), prevStr, diffStr)) + } + buffer.WriteString("```\n") + + payload := map[string]string{ + "text": buffer.String(), + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal slack payload: %v", err) + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Post(webhookURL, "application/json", bytes.NewBuffer(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to send to slack: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("slack returned status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +func main() { + // Load .env file + if err := godotenv.Load(); err != nil { + fmt.Println("Warning: No .env file found, using system environment variables") + } + + tronNode := os.Getenv("TRON_NODE") + if tronNode == "" { + tronNode = DefaultTronNode + } + + slackWebhook := os.Getenv("SLACK_WEBHOOK") + if slackWebhook == "" { + fmt.Println("Error: SLACK_WEBHOOK environment variable is not set") + fmt.Println("Usage: SLACK_WEBHOOK=https://hooks.slack.com/... [TRON_NODE=...] go run main.go") + os.Exit(1) + } + + fmt.Printf("Starting SR monitor.\nNode: %s\nSlack Webhook: %s\n", tronNode, "[REDACTED]") + + // Map to track votes: Address -> VoteCount + lastVotes := make(map[string]int64) + + updateLastVotes := func(witnesses []Witness) { + for _, w := range witnesses { + lastVotes[w.Address] = w.VoteCount + } + } + + // Initial check + fmt.Println("Performing initial check...") + witnesses, err := getWitnessList(tronNode) + if err != nil { + fmt.Printf("Initial check failed: %v\n", err) + } else { + fmt.Printf("Successfully fetched %d witnesses. Sending to Slack...\n", len(witnesses)) + if err := sendToSlack(slackWebhook, witnesses, lastVotes); err != nil { + fmt.Printf("Failed to send initial update to Slack: %v\n", err) + } else { + fmt.Println("Initial update sent successfully.") + updateLastVotes(witnesses) + } + } + + fmt.Println("Monitoring for maintenance periods via getnextmaintenancetime...") + + for { + nextTime, err := getNextMaintenanceTime(tronNode) + if err != nil { + fmt.Printf("Error fetching next maintenance time: %v, retrying in 1 minute...\n", err) + time.Sleep(1 * time.Minute) + continue + } + + // Calculate trigger time: next maintenance time + 1 minute + triggerTime := nextTime.Add(1 * time.Minute) + now := time.Now().UTC() + waitDuration := triggerTime.Sub(now) + + if waitDuration > 0 { + fmt.Printf("Next maintenance time: %s (UTC). Waiting %v until %s...\n", + nextTime.Format(time.RFC1123), waitDuration.Truncate(time.Second), triggerTime.Format(time.RFC1123)) + time.Sleep(waitDuration) + } else { + fmt.Printf("Maintenance time %s has already passed. Checking now...\n", nextTime.Format(time.RFC1123)) + } + + fmt.Printf("[%s] Maintenance period reached (+1m). Fetching SR list...\n", time.Now().UTC().Format(time.RFC3339)) + witnesses, err := getWitnessList(tronNode) + if err != nil { + fmt.Printf("Error fetching witness list: %v\n", err) + } else { + if err := sendToSlack(slackWebhook, witnesses, lastVotes); err != nil { + fmt.Printf("Error sending to Slack: %v\n", err) + } else { + fmt.Println("Successfully sent SR list to Slack") + updateLastVotes(witnesses) + } + } + + // Wait a bit before checking for the NEXT maintenance time to avoid double-triggering + time.Sleep(2 * time.Minute) + } +} From 8bea3b46bc6a91685c66f4bc59eb8e05c474a1dc Mon Sep 17 00:00:00 2001 From: warku123 Date: Fri, 26 Dec 2025 17:49:04 +0800 Subject: [PATCH 02/15] add docker config --- tools/slack_sr_monitor/.env.example | 1 + tools/slack_sr_monitor/Dockerfile | 28 +++++++++++++++++++++++ tools/slack_sr_monitor/docker-compose.yml | 12 ++++++++++ 3 files changed, 41 insertions(+) create mode 100644 tools/slack_sr_monitor/Dockerfile create mode 100644 tools/slack_sr_monitor/docker-compose.yml diff --git a/tools/slack_sr_monitor/.env.example b/tools/slack_sr_monitor/.env.example index a0b9daf6..46ce6add 100644 --- a/tools/slack_sr_monitor/.env.example +++ b/tools/slack_sr_monitor/.env.example @@ -4,4 +4,5 @@ SLACK_WEBHOOK=your_slack_webhook_url_here # The Tron node API endpoint +# Default: https://api.trongrid.io TRON_NODE=https://api.trongrid.io diff --git a/tools/slack_sr_monitor/Dockerfile b/tools/slack_sr_monitor/Dockerfile new file mode 100644 index 00000000..c0c05e13 --- /dev/null +++ b/tools/slack_sr_monitor/Dockerfile @@ -0,0 +1,28 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -o slack_sr_monitor main.go + +# Final stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /root/ + +# Copy the binary from builder +COPY --from=builder /app/slack_sr_monitor . + +# Command to run +CMD ["./slack_sr_monitor"] + diff --git a/tools/slack_sr_monitor/docker-compose.yml b/tools/slack_sr_monitor/docker-compose.yml new file mode 100644 index 00000000..e9869308 --- /dev/null +++ b/tools/slack_sr_monitor/docker-compose.yml @@ -0,0 +1,12 @@ +services: + slack-sr-monitor: + build: . + container_name: slack-sr-monitor + restart: always + environment: + - SLACK_WEBHOOK=${SLACK_WEBHOOK} + - TRON_NODE=${TRON_NODE:-https://api.trongrid.io} + # Optional: ensure UTC timezone + # environment: + # - TZ=UTC + From e1adb27cdda0022b31e265a900c17645facd5215 Mon Sep 17 00:00:00 2001 From: warku123 Date: Fri, 26 Dec 2025 17:50:24 +0800 Subject: [PATCH 03/15] change go version --- tools/slack_sr_monitor/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/slack_sr_monitor/Dockerfile b/tools/slack_sr_monitor/Dockerfile index c0c05e13..1192ac27 100644 --- a/tools/slack_sr_monitor/Dockerfile +++ b/tools/slack_sr_monitor/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app From 27203f84c4760dac6fe5554329c3e4512dc1921e Mon Sep 17 00:00:00 2001 From: warku123 Date: Mon, 29 Dec 2025 16:27:43 +0800 Subject: [PATCH 04/15] Update URL to account name --- tools/slack_sr_monitor/main.go | 75 ++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/tools/slack_sr_monitor/main.go b/tools/slack_sr_monitor/main.go index 37280c2a..98371433 100644 --- a/tools/slack_sr_monitor/main.go +++ b/tools/slack_sr_monitor/main.go @@ -2,12 +2,14 @@ package main import ( "bytes" + "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "strings" + "sync" "time" "github.com/joho/godotenv" @@ -27,6 +29,42 @@ type Witness struct { TotalMissed int64 `json:"totalMissed"` LatestBlock int64 `json:"latestBlockNum"` IsJobs bool `json:"isJobs"` + DisplayName string `json:"-"` +} + +func getAccountName(nodeURL string, address string) string { + url := fmt.Sprintf("%s/wallet/getaccount", nodeURL) + payload := map[string]interface{}{ + "address": address, + "visible": true, + } + jsonPayload, _ := json.Marshal(payload) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonPayload)) + if err != nil { + return "" + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return "" + } + + if val, ok := data["account_name"]; ok { + if str, ok := val.(string); ok { + return str + } + return fmt.Sprintf("%v", val) + } + + return "" } // WitnessListResponse is the wrapper for the witness list API response @@ -63,8 +101,8 @@ func getNextMaintenanceTime(nodeURL string) (time.Time, error) { func getWitnessList(nodeURL string) ([]Witness, error) { url := fmt.Sprintf("%s/wallet/getpaginatednowwitnesslist", nodeURL) - // Fetch top 28 SRs - payload := []byte(`{"offset": 0, "limit": 28}`) + // Fetch top 28 SRs with visible=true to get Base58 addresses directly + payload := []byte(`{"offset": 0, "limit": 28, "visible": true}`) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Post(url, "application/json", bytes.NewBuffer(payload)) @@ -87,6 +125,29 @@ func getWitnessList(nodeURL string) ([]Witness, error) { return nil, fmt.Errorf("failed to unmarshal JSON: %v (body: %s)", err, string(body)) } + // For each witness, try to get the account name in parallel + fmt.Printf("Fetching account names for %d witnesses in parallel...\n", len(result.Witnesses)) + var wg sync.WaitGroup + for i := range result.Witnesses { + wg.Add(1) + go func(idx int) { + defer wg.Done() + w := &result.Witnesses[idx] + accName := getAccountName(nodeURL, w.Address) + if accName != "" { + if decoded, err := hex.DecodeString(accName); err == nil { + w.DisplayName = string(decoded) + } else { + w.DisplayName = accName + } + } + if w.DisplayName == "" { + w.DisplayName = w.URL + } + }(i) + } + wg.Wait() + return result.Witnesses, nil } @@ -113,13 +174,13 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in buffer.WriteString(fmt.Sprintf("Time: %s (UTC)\n\n", time.Now().UTC().Format(time.RFC1123))) buffer.WriteString("```\n") - buffer.WriteString(fmt.Sprintf("%-3s | %-30s | %-15s | %-15s | %-8s\n", "#", "SR Name/URL", "Current Votes", "Prev Votes", "Change")) + buffer.WriteString(fmt.Sprintf("%-3s | %-25s | %-15s | %-15s | %-8s\n", "#", "SR Name", "Current Votes", "Prev Votes", "Change")) buffer.WriteString("-----------------------------------------------------------------------------------------------------\n") for i, w := range witnesses { - name := w.URL - if len(name) > 30 { - name = name[:27] + "..." + name := w.DisplayName + if len(name) > 25 { + name = name[:22] + "..." } if name == "" { name = w.Address @@ -142,7 +203,7 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in prevStr = "-" } - buffer.WriteString(fmt.Sprintf("%-3d | %-30s | %-15s | %-15s | %-8s\n", + buffer.WriteString(fmt.Sprintf("%-3d | %-25s | %-15s | %-15s | %-8s\n", i+1, name, formatComma(w.VoteCount), prevStr, diffStr)) } buffer.WriteString("```\n") From f4d8bb1e0a50f4fde51d0f74186d09d217634e62 Mon Sep 17 00:00:00 2001 From: warku123 Date: Mon, 29 Dec 2025 17:32:20 +0800 Subject: [PATCH 05/15] Change output format --- tools/slack_sr_monitor/main.go | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/tools/slack_sr_monitor/main.go b/tools/slack_sr_monitor/main.go index 98371433..379cf569 100644 --- a/tools/slack_sr_monitor/main.go +++ b/tools/slack_sr_monitor/main.go @@ -173,15 +173,8 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in buffer.WriteString("*TRON SR Status Update (Maintenance Period)*\n") buffer.WriteString(fmt.Sprintf("Time: %s (UTC)\n\n", time.Now().UTC().Format(time.RFC1123))) - buffer.WriteString("```\n") - buffer.WriteString(fmt.Sprintf("%-3s | %-25s | %-15s | %-15s | %-8s\n", "#", "SR Name", "Current Votes", "Prev Votes", "Change")) - buffer.WriteString("-----------------------------------------------------------------------------------------------------\n") - for i, w := range witnesses { name := w.DisplayName - if len(name) > 25 { - name = name[:22] + "..." - } if name == "" { name = w.Address } @@ -193,7 +186,6 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in if diff >= 0 { diffStr = "+" + diffStr } - if prev == 0 { diffStr = "-" } @@ -203,10 +195,10 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in prevStr = "-" } - buffer.WriteString(fmt.Sprintf("%-3d | %-25s | %-15s | %-15s | %-8s\n", - i+1, name, formatComma(w.VoteCount), prevStr, diffStr)) + buffer.WriteString(fmt.Sprintf("*%d. %s*\n", i+1, name)) + buffer.WriteString(fmt.Sprintf("Current: `%s` Prev: `%s` Change: `%s` \n\n", + formatComma(w.VoteCount), prevStr, diffStr)) } - buffer.WriteString("```\n") payload := map[string]string{ "text": buffer.String(), From 0bb37bf717cf4c055b6692c6fbe439f69c32c1a1 Mon Sep 17 00:00:00 2001 From: warku123 Date: Tue, 30 Dec 2025 10:53:57 +0800 Subject: [PATCH 06/15] Delete previous vote output --- tools/slack_sr_monitor/main.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tools/slack_sr_monitor/main.go b/tools/slack_sr_monitor/main.go index 379cf569..ae1ba35b 100644 --- a/tools/slack_sr_monitor/main.go +++ b/tools/slack_sr_monitor/main.go @@ -190,14 +190,9 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in diffStr = "-" } - prevStr := formatComma(prev) - if prev == 0 { - prevStr = "-" - } - buffer.WriteString(fmt.Sprintf("*%d. %s*\n", i+1, name)) - buffer.WriteString(fmt.Sprintf("Current: `%s` Prev: `%s` Change: `%s` \n\n", - formatComma(w.VoteCount), prevStr, diffStr)) + buffer.WriteString(fmt.Sprintf("Current: `%s` Change: `%s` \n\n", + formatComma(w.VoteCount), diffStr)) } payload := map[string]string{ From 9ef9e7662841e6b5dfdc1a7a82754e0f50167112 Mon Sep 17 00:00:00 2001 From: warku123 Date: Tue, 30 Dec 2025 11:44:34 +0800 Subject: [PATCH 07/15] Add sr change check --- tools/slack_sr_monitor/main.go | 92 +++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 13 deletions(-) diff --git a/tools/slack_sr_monitor/main.go b/tools/slack_sr_monitor/main.go index ae1ba35b..dd8b3889 100644 --- a/tools/slack_sr_monitor/main.go +++ b/tools/slack_sr_monitor/main.go @@ -101,7 +101,7 @@ func getNextMaintenanceTime(nodeURL string) (time.Time, error) { func getWitnessList(nodeURL string) ([]Witness, error) { url := fmt.Sprintf("%s/wallet/getpaginatednowwitnesslist", nodeURL) - // Fetch top 28 SRs with visible=true to get Base58 addresses directly + payload := []byte(`{"offset": 0, "limit": 28, "visible": true}`) client := &http.Client{Timeout: 10 * time.Second} @@ -168,7 +168,7 @@ func formatComma(n int64) string { return out.String() } -func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]int64) error { +func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]int64, prevSRs []string) error { var buffer bytes.Buffer buffer.WriteString("*TRON SR Status Update (Maintenance Period)*\n") buffer.WriteString(fmt.Sprintf("Time: %s (UTC)\n\n", time.Now().UTC().Format(time.RFC1123))) @@ -195,6 +195,67 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in formatComma(w.VoteCount), diffStr)) } + // Check for SR changes in the top 27 + if len(prevSRs) > 0 { + currentTop27 := make(map[string]bool) + for i := 0; i < 27 && i < len(witnesses); i++ { + currentTop27[witnesses[i].Address] = true + } + + prevTop27 := make(map[string]bool) + for _, addr := range prevSRs { + prevTop27[addr] = true + } + + var entered []string + var left []string + + // Who enter + for i := 0; i < 27 && i < len(witnesses); i++ { + w := witnesses[i] + if !prevTop27[w.Address] { + name := w.DisplayName + if name == "" { + name = w.Address + } + entered = append(entered, name) + } + } + + // Who left + for _, addr := range prevSRs { + if !currentTop27[addr] { + name := addr + // Try to find name in current full list + for _, w := range witnesses { + if w.Address == addr { + name = w.DisplayName + if name == "" { + name = w.Address + } + break + } + } + left = append(left, name) + } + } + + if len(entered) > 0 || len(left) > 0 { + buffer.WriteString("*SR Replacement Detected:*\n") + if len(entered) > 0 { + buffer.WriteString(fmt.Sprintf(">:inbox_tray: *Entered:* %s\n", strings.Join(entered, ", "))) + } + if len(left) > 0 { + buffer.WriteString(fmt.Sprintf(">:outbox_tray: *Left:* %s\n", strings.Join(left, ", "))) + } + buffer.WriteString("\n") + } else { + buffer.WriteString("*Top 27 SRs remain unchanged.*\n\n") + } + } else { + buffer.WriteString("*First check, initializing SR list*\n\n") + } + payload := map[string]string{ "text": buffer.String(), } @@ -219,6 +280,17 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in return nil } +func updateLastStatus(witnesses []Witness, lastVotes map[string]int64) []string { + var top27 []string + for i, w := range witnesses { + lastVotes[w.Address] = w.VoteCount + if i < 27 { + top27 = append(top27, w.Address) + } + } + return top27 +} + func main() { // Load .env file if err := godotenv.Load(); err != nil { @@ -241,12 +313,7 @@ func main() { // Map to track votes: Address -> VoteCount lastVotes := make(map[string]int64) - - updateLastVotes := func(witnesses []Witness) { - for _, w := range witnesses { - lastVotes[w.Address] = w.VoteCount - } - } + var lastTop27 []string // Initial check fmt.Println("Performing initial check...") @@ -255,11 +322,10 @@ func main() { fmt.Printf("Initial check failed: %v\n", err) } else { fmt.Printf("Successfully fetched %d witnesses. Sending to Slack...\n", len(witnesses)) - if err := sendToSlack(slackWebhook, witnesses, lastVotes); err != nil { + if err := sendToSlack(slackWebhook, witnesses, lastVotes, lastTop27); err != nil { fmt.Printf("Failed to send initial update to Slack: %v\n", err) } else { - fmt.Println("Initial update sent successfully.") - updateLastVotes(witnesses) + lastTop27 = updateLastStatus(witnesses, lastVotes) } } @@ -291,11 +357,11 @@ func main() { if err != nil { fmt.Printf("Error fetching witness list: %v\n", err) } else { - if err := sendToSlack(slackWebhook, witnesses, lastVotes); err != nil { + if err := sendToSlack(slackWebhook, witnesses, lastVotes, lastTop27); err != nil { fmt.Printf("Error sending to Slack: %v\n", err) } else { fmt.Println("Successfully sent SR list to Slack") - updateLastVotes(witnesses) + lastTop27 = updateLastStatus(witnesses, lastVotes) } } From 2cc320360a62015ec51f708ca4bf3537bc652e82 Mon Sep 17 00:00:00 2001 From: warku123 Date: Sun, 4 Jan 2026 12:16:15 +0800 Subject: [PATCH 08/15] Add the readme --- README.md | 1 + tools/slack_sr_monitor/README.md | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tools/slack_sr_monitor/README.md diff --git a/README.md b/README.md index d29fa807..bb39d219 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ To start all available features, or you want more customized operations, navigat - **Gradle Docker**: Automate Docker image builds and testing. Check the [gradle docker](./tools/docker/README.md) documentation. - **Toolkit**: Perform a set of database related operations. Follow the [Toolkit guidance](./tools/toolkit/README.md). - **Stress Test**: Execute the stress test. Follow the [stress test guidance](./tools/stress_test/README.md). + - **Slack SR Monitor**: Monitor Super Representatives and notify a Slack channel after every maintenance period. Follow the [Slack SR Monitor guidance](./tools/slack_sr_monitor/README.md). ## Troubleshooting If you encounter any difficulties, please refer to the [Issue Work Flow](https://tronprotocol.github.io/documentation-en/developers/issue-workflow/#issue-work-flow), then raise an issue on [GitHub](https://github.com/tronprotocol/tron-docker/issues). For general questions, please use [Discord](https://discord.gg/cGKSsRVCGm) or [Telegram](https://t.me/TronOfficialDevelopersGroupEn). diff --git a/tools/slack_sr_monitor/README.md b/tools/slack_sr_monitor/README.md new file mode 100644 index 00000000..70aaef31 --- /dev/null +++ b/tools/slack_sr_monitor/README.md @@ -0,0 +1,63 @@ +## Slack SR Monitor Tool +The Slack SR Monitor tool is designed to monitor TRON Super Representatives (SRs) and notify a Slack channel after every maintenance period. +It automatically tracks vote changes and detects replacements in the top 27 SR positions, providing a clear and formatted report. + +### Build and Run the monitor +To run the monitor tool, you can choose between native Go execution or Docker deployment. + +#### Native Go Execution +Make sure you have Go 1.25+ installed. +```shell +# enter the directory +cd tools/slack_sr_monitor +# install dependencies +go mod tidy +# run the tool +go run main.go +``` + +#### Docker Deployment +We provide a Docker-based deployment for easier management in production environments. +```shell +# build and start the container +docker-compose up -d --build +# check logs +docker logs -f slack-sr-monitor +``` + +### Configuration +All configurations are managed via environment variables or a `.env` file in the project root. Please refer to [.env.example](./.env.example) as an example. + +- `SLACK_WEBHOOK`: The Slack Incoming Webhook URL used to send notifications. +- `TRON_NODE`: The TRON node HTTP API endpoint (e.g., `http://https://api.trongrid.io`). Default is Trongrid. + +### Key Features + +#### SR vote monitor +Use `/wallet/getpaginatednowwitnesslist` to get the top **28** real-time votes, also the SR address and URL. + +#### Dynamic Scheduling +Instead of a fixed interval, the tool queries `/wallet/getnextmaintenancetime` to calculate the exact wait time. It triggers the report **1 minute** after each maintenance period begins to ensure data consistency. + +#### Parallel Data Acquisition +The tool uses Go routines to fetch `account_name` for all 28 witnesses in parallel from the `/wallet/getaccount` interface, significantly reducing the collection time. + +#### Vote Change Tracking +The tool maintains an in-memory snapshot of the previous period's votes. It calculates the `Change` for each SR: +```text +*1. Poloniex* +Current: `3,228,089,488` Change: `+89,488` +``` + +#### Top 27 Replacement Detection +After each report, it compares the current Top 27 list with the previous one and highlights any changes: +```text +SR Replacement Detected: +>:inbox_tray: *Entered:* New_SR_Name +>:outbox_tray: *Left:* Old_SR_Name +``` +If no changes occur, it displays `Top 27 SRs remain unchanged.` + +### Notifications + +This monitor only support java-tron node v4.8.1+, because of the API it used. From 730864d39588cb7e94175f46f6615e5063bce72a Mon Sep 17 00:00:00 2001 From: warku123 Date: Sun, 4 Jan 2026 15:35:29 +0800 Subject: [PATCH 09/15] Add log directory and also log file --- tools/slack_sr_monitor/.gitignore | 1 + tools/slack_sr_monitor/Dockerfile | 3 ++ tools/slack_sr_monitor/docker-compose.yml | 5 +- tools/slack_sr_monitor/main.go | 56 ++++++++++++++++------- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/tools/slack_sr_monitor/.gitignore b/tools/slack_sr_monitor/.gitignore index 7b8733a7..d0269a07 100644 --- a/tools/slack_sr_monitor/.gitignore +++ b/tools/slack_sr_monitor/.gitignore @@ -1,3 +1,4 @@ .env slack_sr_monitor +logs/ diff --git a/tools/slack_sr_monitor/Dockerfile b/tools/slack_sr_monitor/Dockerfile index 1192ac27..40d30e1d 100644 --- a/tools/slack_sr_monitor/Dockerfile +++ b/tools/slack_sr_monitor/Dockerfile @@ -20,6 +20,9 @@ RUN apk --no-cache add ca-certificates tzdata WORKDIR /root/ +# Create logs directory +RUN mkdir -p logs + # Copy the binary from builder COPY --from=builder /app/slack_sr_monitor . diff --git a/tools/slack_sr_monitor/docker-compose.yml b/tools/slack_sr_monitor/docker-compose.yml index e9869308..d687f013 100644 --- a/tools/slack_sr_monitor/docker-compose.yml +++ b/tools/slack_sr_monitor/docker-compose.yml @@ -6,7 +6,6 @@ services: environment: - SLACK_WEBHOOK=${SLACK_WEBHOOK} - TRON_NODE=${TRON_NODE:-https://api.trongrid.io} - # Optional: ensure UTC timezone - # environment: - # - TZ=UTC + volumes: + - ./logs:/root/logs diff --git a/tools/slack_sr_monitor/main.go b/tools/slack_sr_monitor/main.go index dd8b3889..317f3af1 100644 --- a/tools/slack_sr_monitor/main.go +++ b/tools/slack_sr_monitor/main.go @@ -6,8 +6,10 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "os" + "path/filepath" "strings" "sync" "time" @@ -43,17 +45,20 @@ func getAccountName(nodeURL string, address string) string { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonPayload)) if err != nil { + log.Printf("Error: failed to request account name for %s: %v", address, err) return "" } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { + log.Printf("Error: failed to read account response body for %s: %v", address, err) return "" } var data map[string]interface{} if err := json.Unmarshal(body, &data); err != nil { + log.Printf("Error: failed to unmarshal account JSON for %s: %v", address, err) return "" } @@ -126,7 +131,7 @@ func getWitnessList(nodeURL string) ([]Witness, error) { } // For each witness, try to get the account name in parallel - fmt.Printf("Fetching account names for %d witnesses in parallel...\n", len(result.Witnesses)) + log.Printf("Fetching account names for %d witnesses in parallel...\n", len(result.Witnesses)) var wg sync.WaitGroup for i := range result.Witnesses { wg.Add(1) @@ -292,9 +297,26 @@ func updateLastStatus(witnesses []Witness, lastVotes map[string]int64) []string } func main() { + // Ensure logs directory exists + logDir := "logs" + if _, err := os.Stat(logDir); os.IsNotExist(err) { + _ = os.Mkdir(logDir, 0755) + } + + // Setup logging to both file and stdout + logPath := filepath.Join(logDir, "sr_monitor.log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + fmt.Printf("Error opening log file: %v, falling back to stdout only\n", err) + } else { + mw := io.MultiWriter(os.Stdout, logFile) + log.SetOutput(mw) + } + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + // Load .env file if err := godotenv.Load(); err != nil { - fmt.Println("Warning: No .env file found, using system environment variables") + log.Println("Warning: No .env file found, using system environment variables") } tronNode := os.Getenv("TRON_NODE") @@ -304,37 +326,37 @@ func main() { slackWebhook := os.Getenv("SLACK_WEBHOOK") if slackWebhook == "" { - fmt.Println("Error: SLACK_WEBHOOK environment variable is not set") - fmt.Println("Usage: SLACK_WEBHOOK=https://hooks.slack.com/... [TRON_NODE=...] go run main.go") + log.Println("Error: SLACK_WEBHOOK environment variable is not set") + log.Println("Usage: SLACK_WEBHOOK=https://hooks.slack.com/... [TRON_NODE=...] go run main.go") os.Exit(1) } - fmt.Printf("Starting SR monitor.\nNode: %s\nSlack Webhook: %s\n", tronNode, "[REDACTED]") + log.Printf("Starting SR monitor.\nNode: %s\nSlack Webhook: %s\n", tronNode, slackWebhook) // Map to track votes: Address -> VoteCount lastVotes := make(map[string]int64) var lastTop27 []string // Initial check - fmt.Println("Performing initial check...") + log.Println("Performing initial check...") witnesses, err := getWitnessList(tronNode) if err != nil { - fmt.Printf("Initial check failed: %v\n", err) + log.Printf("Initial check failed: %v\n", err) } else { - fmt.Printf("Successfully fetched %d witnesses. Sending to Slack...\n", len(witnesses)) + log.Printf("Successfully fetched %d witnesses. Sending to Slack...\n", len(witnesses)) if err := sendToSlack(slackWebhook, witnesses, lastVotes, lastTop27); err != nil { - fmt.Printf("Failed to send initial update to Slack: %v\n", err) + log.Printf("Failed to send initial update to Slack: %v\n", err) } else { lastTop27 = updateLastStatus(witnesses, lastVotes) } } - fmt.Println("Monitoring for maintenance periods via getnextmaintenancetime...") + log.Println("Monitoring for maintenance periods via getnextmaintenancetime...") for { nextTime, err := getNextMaintenanceTime(tronNode) if err != nil { - fmt.Printf("Error fetching next maintenance time: %v, retrying in 1 minute...\n", err) + log.Printf("Error fetching next maintenance time: %v, retrying in 1 minute...\n", err) time.Sleep(1 * time.Minute) continue } @@ -345,22 +367,22 @@ func main() { waitDuration := triggerTime.Sub(now) if waitDuration > 0 { - fmt.Printf("Next maintenance time: %s (UTC). Waiting %v until %s...\n", + log.Printf("Next maintenance time: %s (UTC). Waiting %v until %s...\n", nextTime.Format(time.RFC1123), waitDuration.Truncate(time.Second), triggerTime.Format(time.RFC1123)) time.Sleep(waitDuration) } else { - fmt.Printf("Maintenance time %s has already passed. Checking now...\n", nextTime.Format(time.RFC1123)) + log.Printf("Maintenance time %s has already passed. Checking now...\n", nextTime.Format(time.RFC1123)) } - fmt.Printf("[%s] Maintenance period reached (+1m). Fetching SR list...\n", time.Now().UTC().Format(time.RFC3339)) + log.Printf("[%s] Maintenance period reached (+1m). Fetching SR list...\n", time.Now().UTC().Format(time.RFC3339)) witnesses, err := getWitnessList(tronNode) if err != nil { - fmt.Printf("Error fetching witness list: %v\n", err) + log.Printf("Error fetching witness list: %v\n", err) } else { if err := sendToSlack(slackWebhook, witnesses, lastVotes, lastTop27); err != nil { - fmt.Printf("Error sending to Slack: %v\n", err) + log.Printf("Error sending to Slack: %v\n", err) } else { - fmt.Println("Successfully sent SR list to Slack") + log.Println("Successfully sent SR list to Slack") lastTop27 = updateLastStatus(witnesses, lastVotes) } } From 1083b5d278298ac8cdd5e1e7a9ab7430f912ba40 Mon Sep 17 00:00:00 2001 From: warku123 Date: Wed, 14 Jan 2026 11:47:38 +0800 Subject: [PATCH 10/15] Modify print format --- tools/slack_sr_monitor/main.go | 87 ++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 35 deletions(-) diff --git a/tools/slack_sr_monitor/main.go b/tools/slack_sr_monitor/main.go index 317f3af1..b2e9086a 100644 --- a/tools/slack_sr_monitor/main.go +++ b/tools/slack_sr_monitor/main.go @@ -174,33 +174,11 @@ func formatComma(n int64) string { } func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]int64, prevSRs []string) error { - var buffer bytes.Buffer - buffer.WriteString("*TRON SR Status Update (Maintenance Period)*\n") - buffer.WriteString(fmt.Sprintf("Time: %s (UTC)\n\n", time.Now().UTC().Format(time.RFC1123))) + var summary strings.Builder + summary.WriteString("*TRON SR Status Update (Maintenance Period)*\n") + summary.WriteString(fmt.Sprintf("Time: %s (UTC)\n\n", time.Now().UTC().Format(time.RFC1123))) - for i, w := range witnesses { - name := w.DisplayName - if name == "" { - name = w.Address - } - - prev := prevVotes[w.Address] - diff := w.VoteCount - prev - - diffStr := formatComma(diff) - if diff >= 0 { - diffStr = "+" + diffStr - } - if prev == 0 { - diffStr = "-" - } - - buffer.WriteString(fmt.Sprintf("*%d. %s*\n", i+1, name)) - buffer.WriteString(fmt.Sprintf("Current: `%s` Change: `%s` \n\n", - formatComma(w.VoteCount), diffStr)) - } - - // Check for SR changes in the top 27 + // 1. Calculate SR changes for the summary if len(prevSRs) > 0 { currentTop27 := make(map[string]bool) for i := 0; i < 27 && i < len(witnesses); i++ { @@ -231,7 +209,6 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in for _, addr := range prevSRs { if !currentTop27[addr] { name := addr - // Try to find name in current full list for _, w := range witnesses { if w.Address == addr { name = w.DisplayName @@ -246,23 +223,63 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in } if len(entered) > 0 || len(left) > 0 { - buffer.WriteString("*SR Replacement Detected:*\n") + summary.WriteString("*SR Replacement Detected:*\n") if len(entered) > 0 { - buffer.WriteString(fmt.Sprintf(">:inbox_tray: *Entered:* %s\n", strings.Join(entered, ", "))) + summary.WriteString(fmt.Sprintf(">:inbox_tray: *Entered:* %s\n", strings.Join(entered, ", "))) } if len(left) > 0 { - buffer.WriteString(fmt.Sprintf(">:outbox_tray: *Left:* %s\n", strings.Join(left, ", "))) + summary.WriteString(fmt.Sprintf(">:outbox_tray: *Left:* %s\n", strings.Join(left, ", "))) } - buffer.WriteString("\n") } else { - buffer.WriteString("*Top 27 SRs remain unchanged.*\n\n") + summary.WriteString("*Top 27 SRs remain unchanged.*") } } else { - buffer.WriteString("*First check, initializing SR list*\n\n") + summary.WriteString("*First check, initializing SR list*") + } + + // Calculate gap between 27th and 28th SR (always show this if we have enough witnesses) + if len(witnesses) >= 28 { + v27 := witnesses[26].VoteCount + v28 := witnesses[27].VoteCount + gap := v27 - v28 + summary.WriteString(fmt.Sprintf("\n*Gap (27 vs 28):* `%s` votes", formatComma(gap))) } - payload := map[string]string{ - "text": buffer.String(), + // 2. Build detailed list for attachment + var details strings.Builder + details.WriteString("```\n") + for i, w := range witnesses { + name := w.DisplayName + if name == "" { + name = w.Address + } + + prev := prevVotes[w.Address] + diff := w.VoteCount - prev + + diffStr := formatComma(diff) + if diff >= 0 { + diffStr = "+" + diffStr + } + if prev == 0 { + diffStr = "-" + } + + details.WriteString(fmt.Sprintf("%2d. %-25s Current: %15s Change: %10s\n", + i+1, name, formatComma(w.VoteCount), diffStr)) + } + details.WriteString("```") + + // 3. Construct Payload with Attachments + payload := map[string]interface{}{ + "text": summary.String(), + "attachments": []map[string]interface{}{ + { + "title": "Full Witness List Details", + "text": details.String(), + "color": "#36a64f", + }, + }, } jsonPayload, err := json.Marshal(payload) From e5bba78da09a5a6c0f53b2a8e3da73c63d686a44 Mon Sep 17 00:00:00 2001 From: warku123 Date: Wed, 14 Jan 2026 15:14:13 +0800 Subject: [PATCH 11/15] Delete unnecessary message --- tools/slack_sr_monitor/.gitignore | 2 +- tools/slack_sr_monitor/main.go | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/tools/slack_sr_monitor/.gitignore b/tools/slack_sr_monitor/.gitignore index d0269a07..7d1000f9 100644 --- a/tools/slack_sr_monitor/.gitignore +++ b/tools/slack_sr_monitor/.gitignore @@ -1,4 +1,4 @@ -.env slack_sr_monitor logs/ +.env diff --git a/tools/slack_sr_monitor/main.go b/tools/slack_sr_monitor/main.go index b2e9086a..eb1b0022 100644 --- a/tools/slack_sr_monitor/main.go +++ b/tools/slack_sr_monitor/main.go @@ -175,8 +175,8 @@ func formatComma(n int64) string { func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]int64, prevSRs []string) error { var summary strings.Builder - summary.WriteString("*TRON SR Status Update (Maintenance Period)*\n") - summary.WriteString(fmt.Sprintf("Time: %s (UTC)\n\n", time.Now().UTC().Format(time.RFC1123))) + summary.WriteString("*TRON SR Status Update (Maintenance Period)*") + summary.WriteString(fmt.Sprintf(" Time: %s\n", time.Now().UTC().Format(time.RFC1123))) // 1. Calculate SR changes for the summary if len(prevSRs) > 0 { @@ -231,10 +231,10 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in summary.WriteString(fmt.Sprintf(">:outbox_tray: *Left:* %s\n", strings.Join(left, ", "))) } } else { - summary.WriteString("*Top 27 SRs remain unchanged.*") + summary.WriteString("*Top 27 SRs remain unchanged.*\n") } } else { - summary.WriteString("*First check, initializing SR list*") + summary.WriteString("*First check, initializing SR list*\n") } // Calculate gap between 27th and 28th SR (always show this if we have enough witnesses) @@ -242,7 +242,7 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in v27 := witnesses[26].VoteCount v28 := witnesses[27].VoteCount gap := v27 - v28 - summary.WriteString(fmt.Sprintf("\n*Gap (27 vs 28):* `%s` votes", formatComma(gap))) + summary.WriteString(fmt.Sprintf("*Gap (27 vs 28):* `%s` votes", formatComma(gap))) } // 2. Build detailed list for attachment @@ -275,7 +275,6 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in "text": summary.String(), "attachments": []map[string]interface{}{ { - "title": "Full Witness List Details", "text": details.String(), "color": "#36a64f", }, From 7bf1968dd76b2794d09d3c7065b6540a8a9a26d3 Mon Sep 17 00:00:00 2001 From: warku123 Date: Tue, 17 Mar 2026 17:04:15 +0800 Subject: [PATCH 12/15] Add rank changes detect in top27 SR --- tools/slack_sr_monitor/main.go | 39 ++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/tools/slack_sr_monitor/main.go b/tools/slack_sr_monitor/main.go index eb1b0022..bc1d43fb 100644 --- a/tools/slack_sr_monitor/main.go +++ b/tools/slack_sr_monitor/main.go @@ -222,14 +222,49 @@ func sendToSlack(webhookURL string, witnesses []Witness, prevVotes map[string]in } } - if len(entered) > 0 || len(left) > 0 { - summary.WriteString("*SR Replacement Detected:*\n") + // Build rank map for previous SRs + prevRankMap := make(map[string]int) + for i, addr := range prevSRs { + prevRankMap[addr] = i + 1 // Rank is 1-based + } + + // Detect rank changes within top 27 + var rankChanges []string + for i := 0; i < 27 && i < len(witnesses); i++ { + w := witnesses[i] + if prevRank, ok := prevRankMap[w.Address]; ok { + change := prevRank - (i + 1) // Positive = rank up, negative = rank down + if change != 0 { + direction := "↑" + if change < 0 { + direction = "↓" + change = -change + } + name := w.DisplayName + if name == "" { + name = w.Address + } + rankChanges = append(rankChanges, + fmt.Sprintf("%s: %d → %d (%s%d)", name, prevRank, i+1, direction, change)) + } + } + } + + // Output all changes (entering, leaving, and rank changes) in one block + if len(entered) > 0 || len(left) > 0 || len(rankChanges) > 0 { + summary.WriteString("*SR Changes Detected:*\n") if len(entered) > 0 { summary.WriteString(fmt.Sprintf(">:inbox_tray: *Entered:* %s\n", strings.Join(entered, ", "))) } if len(left) > 0 { summary.WriteString(fmt.Sprintf(">:outbox_tray: *Left:* %s\n", strings.Join(left, ", "))) } + if len(rankChanges) > 0 { + summary.WriteString(">*Rank Changes:*\n") + for _, rc := range rankChanges { + summary.WriteString(fmt.Sprintf("> `%s`\n", rc)) + } + } } else { summary.WriteString("*Top 27 SRs remain unchanged.*\n") } From f330c1a689faee1f626fdc0687159a94a670d0c2 Mon Sep 17 00:00:00 2001 From: warku123 Date: Thu, 14 May 2026 16:33:54 +0800 Subject: [PATCH 13/15] Address PR review feedback for slack_sr_monitor - Handle json.Marshal error in getAccountName instead of discarding it - Pin base image digests (golang:1.25-alpine, alpine:3.20) for reproducible builds - Run container as non-root user 'monitor' under /home/monitor - Tighten log file permissions from 0666 to 0600 - Mask Slack webhook token in startup log - Switch docker-compose restart policy to unless-stopped --- tools/slack_sr_monitor/Dockerfile | 17 ++++++++++------- tools/slack_sr_monitor/docker-compose.yml | 4 ++-- tools/slack_sr_monitor/main.go | 19 ++++++++++++++++--- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/tools/slack_sr_monitor/Dockerfile b/tools/slack_sr_monitor/Dockerfile index 40d30e1d..643f3c23 100644 --- a/tools/slack_sr_monitor/Dockerfile +++ b/tools/slack_sr_monitor/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.25-alpine AS builder +FROM golang:1.25-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS builder WORKDIR /app @@ -14,17 +14,20 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o slack_sr_monitor main.go # Final stage -FROM alpine:latest +FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc -RUN apk --no-cache add ca-certificates tzdata +RUN apk --no-cache add ca-certificates tzdata \ + && addgroup -S monitor && adduser -S -G monitor monitor -WORKDIR /root/ +WORKDIR /home/monitor -# Create logs directory -RUN mkdir -p logs +# Create logs directory owned by the non-root user +RUN mkdir -p logs && chown -R monitor:monitor /home/monitor # Copy the binary from builder -COPY --from=builder /app/slack_sr_monitor . +COPY --from=builder --chown=monitor:monitor /app/slack_sr_monitor . + +USER monitor # Command to run CMD ["./slack_sr_monitor"] diff --git a/tools/slack_sr_monitor/docker-compose.yml b/tools/slack_sr_monitor/docker-compose.yml index d687f013..08efab19 100644 --- a/tools/slack_sr_monitor/docker-compose.yml +++ b/tools/slack_sr_monitor/docker-compose.yml @@ -2,10 +2,10 @@ services: slack-sr-monitor: build: . container_name: slack-sr-monitor - restart: always + restart: unless-stopped environment: - SLACK_WEBHOOK=${SLACK_WEBHOOK} - TRON_NODE=${TRON_NODE:-https://api.trongrid.io} volumes: - - ./logs:/root/logs + - ./logs:/home/monitor/logs diff --git a/tools/slack_sr_monitor/main.go b/tools/slack_sr_monitor/main.go index bc1d43fb..4e112d57 100644 --- a/tools/slack_sr_monitor/main.go +++ b/tools/slack_sr_monitor/main.go @@ -40,7 +40,11 @@ func getAccountName(nodeURL string, address string) string { "address": address, "visible": true, } - jsonPayload, _ := json.Marshal(payload) + jsonPayload, err := json.Marshal(payload) + if err != nil { + log.Printf("Error: failed to marshal account name payload for %s: %v", address, err) + return "" + } client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonPayload)) @@ -347,6 +351,15 @@ func updateLastStatus(witnesses []Witness, lastVotes map[string]int64) []string return top27 } +// The final path segment of a Slack webhook URL is the secret token; mask it before logging. +func maskWebhook(url string) string { + idx := strings.LastIndex(url, "/") + if idx < 0 || idx == len(url)-1 { + return "***" + } + return url[:idx+1] + "***" +} + func main() { // Ensure logs directory exists logDir := "logs" @@ -356,7 +369,7 @@ func main() { // Setup logging to both file and stdout logPath := filepath.Join(logDir, "sr_monitor.log") - logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { fmt.Printf("Error opening log file: %v, falling back to stdout only\n", err) } else { @@ -382,7 +395,7 @@ func main() { os.Exit(1) } - log.Printf("Starting SR monitor.\nNode: %s\nSlack Webhook: %s\n", tronNode, slackWebhook) + log.Printf("Starting SR monitor.\nNode: %s\nSlack Webhook: %s\n", tronNode, maskWebhook(slackWebhook)) // Map to track votes: Address -> VoteCount lastVotes := make(map[string]int64) From 837d86d1f63c94a27198430513bffc69d61cee32 Mon Sep 17 00:00:00 2001 From: warku123 Date: Thu, 14 May 2026 17:32:41 +0800 Subject: [PATCH 14/15] Strip extra trailing newline to satisfy end-of-file-fixer lint --- tools/slack_sr_monitor/.gitignore | 1 - tools/slack_sr_monitor/Dockerfile | 1 - tools/slack_sr_monitor/docker-compose.yml | 1 - 3 files changed, 3 deletions(-) diff --git a/tools/slack_sr_monitor/.gitignore b/tools/slack_sr_monitor/.gitignore index 7d1000f9..d775c44f 100644 --- a/tools/slack_sr_monitor/.gitignore +++ b/tools/slack_sr_monitor/.gitignore @@ -1,4 +1,3 @@ slack_sr_monitor logs/ .env - diff --git a/tools/slack_sr_monitor/Dockerfile b/tools/slack_sr_monitor/Dockerfile index 643f3c23..1b8d4668 100644 --- a/tools/slack_sr_monitor/Dockerfile +++ b/tools/slack_sr_monitor/Dockerfile @@ -31,4 +31,3 @@ USER monitor # Command to run CMD ["./slack_sr_monitor"] - diff --git a/tools/slack_sr_monitor/docker-compose.yml b/tools/slack_sr_monitor/docker-compose.yml index 08efab19..ff727028 100644 --- a/tools/slack_sr_monitor/docker-compose.yml +++ b/tools/slack_sr_monitor/docker-compose.yml @@ -8,4 +8,3 @@ services: - TRON_NODE=${TRON_NODE:-https://api.trongrid.io} volumes: - ./logs:/home/monitor/logs - From 8a85b2b79e30c50794abf9a364767672f51fa4b1 Mon Sep 17 00:00:00 2001 From: warku123 Date: Fri, 15 May 2026 14:55:07 +0800 Subject: [PATCH 15/15] Skip Slack push when the TRON node is still catching up The loop polls getnextmaintenancetime in a 2-minute cycle. When the node is lagging behind the chain head (e.g. after a connection outage), the API returns a past maintenance time over and over, causing the monitor to send a duplicate Slack message every 2 minutes until the node recovers. Before triggering a push, check the timestamp of the latest block on the node and skip the cycle if it is more than 30 seconds old. Once the node catches up, getnextmaintenancetime returns the next future maintenance time and the normal wait path resumes. --- tools/slack_sr_monitor/main.go | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tools/slack_sr_monitor/main.go b/tools/slack_sr_monitor/main.go index 4e112d57..089b4b85 100644 --- a/tools/slack_sr_monitor/main.go +++ b/tools/slack_sr_monitor/main.go @@ -108,6 +108,42 @@ func getNextMaintenanceTime(nodeURL string) (time.Time, error) { return time.Unix(result.Num/1000, (result.Num%1000)*1000000), nil } +// NowBlockResponse captures the timestamp of the node's latest block. +type NowBlockResponse struct { + BlockHeader struct { + RawData struct { + Number int64 `json:"number"` + Timestamp int64 `json:"timestamp"` + } `json:"raw_data"` + } `json:"block_header"` +} + +// A fresh TRON node should always be within a few block intervals (block time is 3s). +// Anything beyond this threshold means the node is still catching up to the chain head. +const maxBlockAge = 30 * time.Second + +func getLatestBlockTime(nodeURL string) (time.Time, error) { + url := fmt.Sprintf("%s/wallet/getnowblock", nodeURL) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Post(url, "application/json", nil) + if err != nil { + return time.Time{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return time.Time{}, fmt.Errorf("status code %d", resp.StatusCode) + } + + var result NowBlockResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return time.Time{}, err + } + + ts := result.BlockHeader.RawData.Timestamp + return time.Unix(ts/1000, (ts%1000)*1000000), nil +} + func getWitnessList(nodeURL string) ([]Witness, error) { url := fmt.Sprintf("%s/wallet/getpaginatednowwitnesslist", nodeURL) @@ -425,6 +461,21 @@ func main() { continue } + // A node that is still catching up will report a past maintenance time and would + // otherwise cause this loop to send duplicate Slack messages every 2 minutes. + blockTime, err := getLatestBlockTime(tronNode) + if err != nil { + log.Printf("Error fetching latest block: %v, retrying in 1 minute...\n", err) + time.Sleep(1 * time.Minute) + continue + } + if age := time.Since(blockTime); age > maxBlockAge { + log.Printf("Node is %s behind chain head (latest block at %s), waiting 2 minutes...\n", + age.Truncate(time.Second), blockTime.UTC().Format(time.RFC3339)) + time.Sleep(2 * time.Minute) + continue + } + // Calculate trigger time: next maintenance time + 1 minute triggerTime := nextTime.Add(1 * time.Minute) now := time.Now().UTC()