diff --git a/README.md b/README.md index d29fa80..bb39d21 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/.env.example b/tools/slack_sr_monitor/.env.example new file mode 100644 index 0000000..46ce6ad --- /dev/null +++ b/tools/slack_sr_monitor/.env.example @@ -0,0 +1,8 @@ +# Slack SR Monitor Configuration + +# The Slack Webhook URL for sending notifications +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/.gitignore b/tools/slack_sr_monitor/.gitignore new file mode 100644 index 0000000..d0269a0 --- /dev/null +++ b/tools/slack_sr_monitor/.gitignore @@ -0,0 +1,4 @@ +.env +slack_sr_monitor +logs/ + diff --git a/tools/slack_sr_monitor/Dockerfile b/tools/slack_sr_monitor/Dockerfile new file mode 100644 index 0000000..40d30e1 --- /dev/null +++ b/tools/slack_sr_monitor/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM golang:1.25-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/ + +# Create logs directory +RUN mkdir -p logs + +# 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/README.md b/tools/slack_sr_monitor/README.md new file mode 100644 index 0000000..70aaef3 --- /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. diff --git a/tools/slack_sr_monitor/docker-compose.yml b/tools/slack_sr_monitor/docker-compose.yml new file mode 100644 index 0000000..d687f01 --- /dev/null +++ b/tools/slack_sr_monitor/docker-compose.yml @@ -0,0 +1,11 @@ +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} + volumes: + - ./logs:/root/logs + diff --git a/tools/slack_sr_monitor/go.mod b/tools/slack_sr_monitor/go.mod new file mode 100644 index 0000000..f2cf959 --- /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 0000000..d61b19e --- /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 0000000..317f3af --- /dev/null +++ b/tools/slack_sr_monitor/main.go @@ -0,0 +1,393 @@ +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "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"` + 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 { + 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 "" + } + + 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 +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) + + 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)) + 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)) + } + + // For each witness, try to get the account name in parallel + 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) + 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 +} + +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, 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))) + + 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 + 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(), + } + + 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 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() { + // 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 { + log.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 == "" { + 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) + } + + 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 + log.Println("Performing initial check...") + witnesses, err := getWitnessList(tronNode) + if err != nil { + log.Printf("Initial check failed: %v\n", err) + } else { + log.Printf("Successfully fetched %d witnesses. Sending to Slack...\n", len(witnesses)) + if err := sendToSlack(slackWebhook, witnesses, lastVotes, lastTop27); err != nil { + log.Printf("Failed to send initial update to Slack: %v\n", err) + } else { + lastTop27 = updateLastStatus(witnesses, lastVotes) + } + } + + log.Println("Monitoring for maintenance periods via getnextmaintenancetime...") + + for { + nextTime, err := getNextMaintenanceTime(tronNode) + if err != nil { + log.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 { + 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 { + log.Printf("Maintenance time %s has already passed. Checking now...\n", nextTime.Format(time.RFC1123)) + } + + log.Printf("[%s] Maintenance period reached (+1m). Fetching SR list...\n", time.Now().UTC().Format(time.RFC3339)) + witnesses, err := getWitnessList(tronNode) + if err != nil { + log.Printf("Error fetching witness list: %v\n", err) + } else { + if err := sendToSlack(slackWebhook, witnesses, lastVotes, lastTop27); err != nil { + log.Printf("Error sending to Slack: %v\n", err) + } else { + log.Println("Successfully sent SR list to Slack") + lastTop27 = updateLastStatus(witnesses, lastVotes) + } + } + + // Wait a bit before checking for the NEXT maintenance time to avoid double-triggering + time.Sleep(2 * time.Minute) + } +}