diff --git a/README.md b/README.md index ec5cbf6..71ce50d 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,40 @@ eth-scanner/ │ ├── database/ # SQL schema and queries │ └── tasks/ # Task board (Backlog/Done) ├── go/ # Master API & PC Worker (Go) -│ ├── cmd/ # Entry points (master, worker-pc) -│ ├── internal/ # Core logic (database, config, server, jobs) +│ ├── cmd/ # Entry points (master, worker-pc, esp-mock-api) +│ ├── internal/ # Core logic (database, config, server, worker) │ └── Makefile # Development shortcuts -└── esp32/ # ESP32 firmware (C++/Arduino) - PLANNED +└── esp32/ # ESP32 firmware (C++/Arduino) ``` +## Key Derivation & Win Scenario + +To facilitate testing and verification, EthScanner uses a standardized key derivation strategy across all worker types (PC and ESP32). + +### Private Key Construction +A 32-byte Ethereum private key is constructed by combining a **28-byte prefix** (managed by the Master) and a **4-byte nonce** (assigned to the worker). + +- **Offset 0-27**: `prefix_28` (28 bytes) +- **Offset 28-31**: `nonce` (4 bytes, **Big-Endian**) + +**Why Big-Endian?** +Using Big-Endian for the nonce ensures that the 32-byte buffer, when interpreted as a large integer, increments naturally. For example, a prefix of all zeros and a nonce of `1` results in the private key `0x00...0001`. + +### The "Win" Scenario (Testing) +The project includes a mock API (`esp-mock-api`) specifically designed for integration testing without a full Master/Database setup. + +By running the mock server with the `-win` flag, you can trigger a "guaranteed find": + +```bash +# Start the mock server in 'win' mode +go run cmd/esp-mock-api/main.go -win -port 8080 +``` + +**Scenario Details:** +- **Prefix**: 28 bytes of zeros (`0x00...00`) +- **Target Address**: `0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf` +- **Result**: This address corresponds to the private key `0x00...0001`. A worker starting at nonce `0` will find the match at the second iteration (nonce `1`). + ## Database Architecture & Storage Optimization EthScanner uses a **multi-tier statistics architecture** to prevent unbounded database growth while preserving comprehensive performance data for monitoring dashboards. diff --git a/esp32/include/eth_crypto.h b/esp32/include/eth_crypto.h index 264d97b..7a040cb 100644 --- a/esp32/include/eth_crypto.h +++ b/esp32/include/eth_crypto.h @@ -32,10 +32,10 @@ void derive_eth_address(const uint8_t *priv_key, uint8_t *address); */ static inline void update_nonce_in_buffer(uint8_t *buffer, uint32_t nonce) { - buffer[28] = (uint8_t)(nonce & 0xFF); - buffer[29] = (uint8_t)((nonce >> 8) & 0xFF); - buffer[30] = (uint8_t)((nonce >> 16) & 0xFF); - buffer[31] = (uint8_t)((nonce >> 24) & 0xFF); + buffer[31] = (uint8_t)(nonce & 0xFF); + buffer[30] = (uint8_t)((nonce >> 8) & 0xFF); + buffer[29] = (uint8_t)((nonce >> 16) & 0xFF); + buffer[28] = (uint8_t)((nonce >> 24) & 0xFF); } #endif // ETH_CRYPTO_H diff --git a/go/Makefile b/go/Makefile index de107ea..a3953ab 100644 --- a/go/Makefile +++ b/go/Makefile @@ -132,6 +132,25 @@ run-master: MASTER_PORT=$(MASTER_PORT) \ go run ./cmd/master +# Run master API server testing the win scenario +run-master-win: + @echo "Starting Master API server..." + @MASTER_PORT=$(MASTER_PORT) \ + MASTER_DB_PATH=$(MASTER_DB_PATH) \ + MASTER_LOG_LEVEL=$(MASTER_LOG_LEVEL) \ + MASTER_SHUTDOWN_TIMEOUT=$(MASTER_SHUTDOWN_TIMEOUT) \ + MASTER_API_KEY=$(MASTER_API_KEY) \ + MASTER_TARGET_ADDRESSES="$(MASTER_TARGET_ADDRESSES)" \ + MASTER_STALE_JOB_THRESHOLD=$(MASTER_STALE_JOB_THRESHOLD) \ + MASTER_CLEANUP_INTERVAL=$(MASTER_CLEANUP_INTERVAL) \ + WORKER_HISTORY_LIMIT=$(WORKER_HISTORY_LIMIT) \ + WORKER_DAILY_STATS_LIMIT=$(WORKER_DAILY_STATS_LIMIT) \ + WORKER_MONTHLY_STATS_LIMIT=$(WORKER_MONTHLY_STATS_LIMIT) \ + DASHBOARD_PASSWORD=$(DASHBOARD_PASSWORD) \ + MASTER_PORT=$(MASTER_PORT) \ + MASTER_WIN_SCENARIO=true \ + go run ./cmd/master + # Run PC worker run-worker: @echo "Starting PC Worker..." diff --git a/go/cmd/esp-mock-api/main.go b/go/cmd/esp-mock-api/main.go index 1042ef4..654d062 100644 --- a/go/cmd/esp-mock-api/main.go +++ b/go/cmd/esp-mock-api/main.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "flag" "fmt" "log" "net/http" @@ -9,7 +10,15 @@ import ( "time" ) +var ( + winScenario bool + won bool +) + func main() { + flag.BoolVar(&winScenario, "win", false, "Always return a winning job scenario (Key 0x1)") + flag.Parse() + mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/lease", handleLease) mux.HandleFunc("/api/v1/jobs/", handleJobUpdate) // matches /checkpoint and /complete @@ -24,6 +33,9 @@ func main() { port := "8080" log.Printf("ESP32 Mock API starting on :%s (listening on all interfaces)", port) + if winScenario { + log.Printf("Win scenario active: returning nonce 1 as a winner.") + } // Use an http.Server with timeouts to satisfy security linters srv := &http.Server{ @@ -45,6 +57,17 @@ func handleLease(w http.ResponseWriter, r *http.Request) { //nolint:gosec // false positive: Log injection via taint analysis in mock server is not a security risk scenario := r.Header.Get("X-Test-Scenario") + + // If the global flag is set, override the default scenario to "win" + if winScenario && scenario == "" { + if won { + log.Printf("Win already achieved. Returning 404 No Jobs.") + http.Error(w, "no jobs available", http.StatusNotFound) + return + } + scenario = "win" + } + //nolint:gosec // false positive: Log injection via taint analysis in mock server is not a security risk log.Printf("Lease request received. Scenario: %q", scenario) @@ -56,6 +79,24 @@ func handleLease(w http.ResponseWriter, r *http.Request) { case "malformed": w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"job_id": 123, "nonce_start": "not-a-number"}`) // invalid type + case "win": + // Winning case: private key 0x1 (nonce 1 + prefix 28 zero bytes) + // which hashes to address 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf. + // A worker starting at nonce 0 will find it at the second iteration (nonce=1). + resp := map[string]any{ + "job_id": 777, + "prefix_28": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", // 28 bytes of zeros + "nonce_start": 0, + "nonce_end": 100, // Small range + "target_addresses": []string{"0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"}, + "expires_at": time.Now().Add(time.Hour).Format(time.RFC3339), + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("failed to encode winning lease response: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } default: // Success case resp := map[string]any{ @@ -66,6 +107,7 @@ func handleLease(w http.ResponseWriter, r *http.Request) { "target_addresses": []string{ "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", }, + "expires_at": time.Now().Add(time.Hour).Format(time.RFC3339), } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(resp); err != nil { @@ -120,7 +162,10 @@ func handleResults(w http.ResponseWriter, r *http.Request) { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - log.Printf("[MOCK] Result submitted successfully") + log.Printf("[MOCK] Result submitted successfully! STOPPING WIN SCENARIO.") + if winScenario { + won = true + } w.WriteHeader(http.StatusCreated) fmt.Fprintf(w, `{"status":"created"}`) } diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 9a1813d..9eb996f 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -54,6 +54,11 @@ type Config struct { // DashboardPassword is the password required to access the dashboard UI. // If empty, dashboard authentication is disabled. DashboardPassword string //nolint:gosec // false positive + + // WinScenario enables the "Win" debug scenario: instead of random prefixes, + // the master will always allocate a job with a 28-byte zero prefix and small + // nonce range containing nonce 1 (the winning key 0x1). + WinScenario bool } // Load reads configuration from environment variables, applies defaults and @@ -192,6 +197,12 @@ func Load() (*Config, error) { cfg.WorkerMonthlyStatsLimit = 1000 } + // Win Scenario (defaults to false) + cfg.WinScenario = strings.ToLower(strings.TrimSpace(os.Getenv("MASTER_WIN_SCENARIO"))) == "true" + if cfg.WinScenario { + log.Printf("WARNING: MASTER_WIN_SCENARIO is active. All workers will receive nonce 1 winning job.") + } + return cfg, nil } diff --git a/go/internal/database/queries.sql.go b/go/internal/database/queries.sql.go index 2fe7011..8ec91b2 100644 --- a/go/internal/database/queries.sql.go +++ b/go/internal/database/queries.sql.go @@ -512,6 +512,66 @@ func (q *Queries) GetBestMonthRecord(ctx context.Context) (GetBestMonthRecordRow return i, err } +const getDetailedResults = `-- name: GetDetailedResults :many +SELECT + r.id, + r.private_key, + r.address, + r.worker_id, + r.job_id, + r.nonce_found, + r.found_at, + j.prefix_28 +FROM results r +JOIN jobs j ON r.job_id = j.id +ORDER BY r.found_at DESC +LIMIT ? +` + +type GetDetailedResultsRow struct { + ID int64 `json:"id"` + PrivateKey string `json:"private_key"` + Address string `json:"address"` + WorkerID string `json:"worker_id"` + JobID int64 `json:"job_id"` + NonceFound int64 `json:"nonce_found"` + FoundAt time.Time `json:"found_at"` + Prefix28 []byte `json:"prefix_28"` +} + +// Get results with job details for dashboard display +func (q *Queries) GetDetailedResults(ctx context.Context, limit int64) ([]GetDetailedResultsRow, error) { + rows, err := q.db.QueryContext(ctx, getDetailedResults, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetDetailedResultsRow{} + for rows.Next() { + var i GetDetailedResultsRow + if err := rows.Scan( + &i.ID, + &i.PrivateKey, + &i.Address, + &i.WorkerID, + &i.JobID, + &i.NonceFound, + &i.FoundAt, + &i.Prefix28, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getGlobalDailyStats = `-- name: GetGlobalDailyStats :many SELECT stats_date, @@ -1558,6 +1618,8 @@ func (q *Queries) GetWorkersByType(ctx context.Context, workerType string) ([]Wo const insertResult = `-- name: InsertResult :one INSERT INTO results (private_key, address, worker_id, job_id, nonce_found) VALUES (?, ?, ?, ?, ?) +ON CONFLICT (private_key) DO UPDATE SET + found_at = results.found_at -- No change, just to satisfy the syntax and RETURNING RETURNING id, private_key, address, worker_id, job_id, nonce_found, found_at ` @@ -1569,7 +1631,8 @@ type InsertResultParams struct { NonceFound int64 `json:"nonce_found"` } -// Insert a new result (found key) +// Insert a new result (found key). +// Idempotent using ON CONFLICT to allow multiple workers to report the same key without error. func (q *Queries) InsertResult(ctx context.Context, arg InsertResultParams) (Result, error) { row := q.db.QueryRowContext(ctx, insertResult, arg.PrivateKey, @@ -1696,6 +1759,29 @@ func (q *Queries) RecordWorkerStats(ctx context.Context, arg RecordWorkerStatsPa return err } +const resetWinScenarioJob = `-- name: ResetWinScenarioJob :exec +UPDATE jobs +SET status = 'pending', current_nonce = NULL +WHERE prefix_28 = ? AND nonce_start = 0 +` + +// Reset win scenario: set status to pending for nonce_start = 0 +func (q *Queries) ResetWinScenarioJob(ctx context.Context, prefix28 []byte) error { + _, err := q.db.ExecContext(ctx, resetWinScenarioJob, prefix28) + return err +} + +const resetWinScenarioPrefix = `-- name: ResetWinScenarioPrefix :exec +DELETE FROM jobs +WHERE prefix_28 = ? AND nonce_start > 0 +` + +// Reset win scenario: delete nonces > 0 for a specific prefix +func (q *Queries) ResetWinScenarioPrefix(ctx context.Context, prefix28 []byte) error { + _, err := q.db.ExecContext(ctx, resetWinScenarioPrefix, prefix28) + return err +} + const updateCheckpoint = `-- name: UpdateCheckpoint :exec UPDATE jobs SET diff --git a/go/internal/database/sql/queries.sql b/go/internal/database/sql/queries.sql index 6ff43bd..4c3afe6 100644 --- a/go/internal/database/sql/queries.sql +++ b/go/internal/database/sql/queries.sql @@ -115,9 +115,12 @@ ORDER BY created_at DESC LIMIT ?; -- name: InsertResult :one --- Insert a new result (found key) +-- Insert a new result (found key). +-- Idempotent using ON CONFLICT to allow multiple workers to report the same key without error. INSERT INTO results (private_key, address, worker_id, job_id, nonce_found) VALUES (?, ?, ?, ?, ?) +ON CONFLICT (private_key) DO UPDATE SET + found_at = results.found_at -- No change, just to satisfy the syntax and RETURNING RETURNING *; -- name: GetResultByPrivateKey :one @@ -137,12 +140,39 @@ SELECT * FROM results WHERE worker_id = ? ORDER BY found_at DESC; +-- name: ResetWinScenarioPrefix :exec +-- Reset win scenario: delete nonces > 0 for a specific prefix +DELETE FROM jobs +WHERE prefix_28 = ? AND nonce_start > 0; + +-- name: ResetWinScenarioJob :exec +-- Reset win scenario: set status to pending for nonce_start = 0 +UPDATE jobs +SET status = 'pending', current_nonce = NULL +WHERE prefix_28 = ? AND nonce_start = 0; + -- name: GetAllResults :many -- Get all results (limited) SELECT * FROM results ORDER BY found_at DESC LIMIT ?; +-- name: GetDetailedResults :many +-- Get results with job details for dashboard display +SELECT + r.id, + r.private_key, + r.address, + r.worker_id, + r.job_id, + r.nonce_found, + r.found_at, + j.prefix_28 +FROM results r +JOIN jobs j ON r.job_id = j.id +ORDER BY r.found_at DESC +LIMIT ?; + -- name: GetWorkerLastPrefix :one -- Tracks the last prefix assigned to a worker to enable vertical exhaustion SELECT prefix_28, MAX(nonce_end) as highest_nonce diff --git a/go/internal/server/hub.go b/go/internal/server/hub.go index 515524d..2dc2273 100644 --- a/go/internal/server/hub.go +++ b/go/internal/server/hub.go @@ -195,6 +195,7 @@ func (s *Server) broadcastStats(ctx context.Context) { activeWorkers, _ := q.GetActiveWorkerDetails(ctx) prefixProgress, _ := q.GetPrefixProgress(ctx) + results, _ := q.GetDetailedResults(ctx, 10) // Normalize total keys scanned to int64 var totalKeys int64 @@ -232,6 +233,7 @@ func (s *Server) broadcastStats(ctx context.Context) { GlobalKeysPerSecond float64 ActiveWorkers []database.GetActiveWorkerDetailsRow PrefixProgress []database.GetPrefixProgressRow + Results []database.GetDetailedResultsRow NowTimestamp int64 }{ ActiveWorkerCount: stats.ActiveWorkers, @@ -243,6 +245,7 @@ func (s *Server) broadcastStats(ctx context.Context) { GlobalKeysPerSecond: globalThroughput, ActiveWorkers: activeWorkers, PrefixProgress: prefixProgress, + Results: results, NowTimestamp: time.Now().Unix(), } diff --git a/go/internal/server/jobs.go b/go/internal/server/jobs.go index 6446c35..5313ac8 100644 --- a/go/internal/server/jobs.go +++ b/go/internal/server/jobs.go @@ -58,16 +58,34 @@ func (s *Server) handleJobLease(w http.ResponseWriter, r *http.Request) { q := database.NewQueries(s.db) m := jobs.New(q) + var job *database.Job + var err error + + // If Win Scenario is active, we ensure the "win job" (zero prefix, nonce 1) + // exists and is available for this worker. This works by resetting any + // existing job for the zero prefix/nonce 0 range and clearing siblings. + if s.cfg.WinScenario { + log.Printf("[WIN-SCENARIO] Forcing Win job for worker %s", req.WorkerID) + zeroPrefix := make([]byte, 28) + // 1. Delete all other jobs for this prefix to avoid "running away" nonces. + if err := q.ResetWinScenarioPrefix(ctx, zeroPrefix); err != nil { + log.Printf("[WIN-SCENARIO] error resetting win prefix: %v", err) + } + // 2. Reset the main job [0, 99] to pending so it can be re-leased. + if err := q.ResetWinScenarioJob(ctx, zeroPrefix); err != nil { + log.Printf("[WIN-SCENARIO] error resetting win job: %v", err) + } + } + // Try to lease an existing available job first (pass worker type so the // database record can be annotated). - job, err := m.LeaseExistingJob(ctx, req.WorkerID, req.WorkerType) + job, err = m.LeaseExistingJob(ctx, req.WorkerID, req.WorkerType) if err != nil { http.Error(w, "failed to lease existing job", http.StatusInternalServerError) return } - // If none available, create and lease a new batch (extracted to helper to - // reduce nesting and cyclomatic complexity). + // If none available (or forced by win-scenario if first time), create and lease a new batch if job == nil { job, err = s.createAndLeaseBatch(ctx, m, q, req.WorkerID, req.WorkerType, req.Prefix28, req.RequestedBatchSize) if err != nil { @@ -97,6 +115,22 @@ func (s *Server) handleJobLease(w http.ResponseWriter, r *http.Request) { ExpiresAt *string `json:"expires_at,omitempty"` } + targets := s.cfg.TargetAddresses + if s.cfg.WinScenario { + // Ensure the winner address is in the targets list for this job + winAddr := "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf" + found := false + for _, a := range targets { + if strings.EqualFold(a, winAddr) { + found = true + break + } + } + if !found { + targets = append([]string{winAddr}, targets...) + } + } + var cur *int64 if job.CurrentNonce.Valid { v := job.CurrentNonce.Int64 @@ -113,7 +147,7 @@ func (s *Server) handleJobLease(w http.ResponseWriter, r *http.Request) { Prefix28: base64.StdEncoding.EncodeToString(job.Prefix28), NonceStart: job.NonceStart, NonceEnd: job.NonceEnd, - TargetAddresses: s.cfg.TargetAddresses, + TargetAddresses: targets, CurrentNonce: cur, ExpiresAt: exp, } @@ -130,8 +164,12 @@ func (s *Server) handleJobLease(w http.ResponseWriter, r *http.Request) { func (s *Server) createAndLeaseBatch(ctx context.Context, m *jobs.Manager, q *database.Queries, workerID, workerType string, prefixOpt *string, batchSize uint32) (*database.Job, error) { var prefix28 []byte - // If client provided a prefix, validate and use it. - if prefixOpt != nil { + // Win Scenario override: always use 28 bytes of zeros and small nonce range + if s.cfg.WinScenario { + prefix28 = make([]byte, 28) // zeros + batchSize = 100 // Ensure it doesn't take long to find (0-100 contains nonce 1) + log.Printf("[WIN-SCENARIO] Forcing zero-prefix and small batch for worker %s", workerID) + } else if prefixOpt != nil { decoded, err := base64.StdEncoding.DecodeString(*prefixOpt) if err != nil { return nil, fmt.Errorf("invalid base64 prefix_28: %w", err) @@ -144,6 +182,9 @@ func (s *Server) createAndLeaseBatch(ctx context.Context, m *jobs.Manager, q *da // Helper: attempt to find a worker-specific prefix with remaining nonces. getWorkerAvailablePrefix := func() []byte { + if s.cfg.WinScenario { + return nil // Don't use worker's last prefix in win mode + } last, err := q.GetWorkerLastPrefix(ctx, sql.NullString{String: workerID, Valid: true}) if err != nil || last.HighestNonce == nil { return nil diff --git a/go/internal/server/results.go b/go/internal/server/results.go index 74ced16..e48f710 100644 --- a/go/internal/server/results.go +++ b/go/internal/server/results.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/hex" "encoding/json" + "log" "net/http" "strings" @@ -72,6 +73,7 @@ func (s *Server) handleResultSubmit(w http.ResponseWriter, r *http.Request) { } res, err := q.InsertResult(ctx, params) if err != nil { + log.Printf("failed to insert result from worker %s: %v", req.WorkerID, err) http.Error(w, "failed to insert result", http.StatusInternalServerError) return } diff --git a/go/internal/server/ui/renderer.go b/go/internal/server/ui/renderer.go index 509a49b..dc05fda 100644 --- a/go/internal/server/ui/renderer.go +++ b/go/internal/server/ui/renderer.go @@ -81,14 +81,14 @@ func (r *TemplateRenderer) loadTemplates() error { } if entry.Name() == "base.html" { layoutFiles = append(layoutFiles, filepath.Join("templates", entry.Name())) - } else if entry.Name() == "fragments.html" || entry.Name() == "active_workers.html" { + } else if entry.Name() == "fragments.html" || entry.Name() == "active_workers.html" || entry.Name() == "found_results.html" { partialFiles = append(partialFiles, filepath.Join("templates", entry.Name())) } } // For each template file that isn't a shared one, parse it together with shared ones for _, entry := range entries { - if entry.IsDir() || entry.Name() == "base.html" || entry.Name() == "fragments.html" || entry.Name() == "active_workers.html" { + if entry.IsDir() || entry.Name() == "base.html" || entry.Name() == "fragments.html" || entry.Name() == "active_workers.html" || entry.Name() == "found_results.html" { // We still want to parse fragments and active_workers as their own sets so RenderFragment works if entry.Name() == "base.html" { continue diff --git a/go/internal/server/ui/templates/daily.html b/go/internal/server/ui/templates/daily.html index 1c17a53..6400751 100644 --- a/go/internal/server/ui/templates/daily.html +++ b/go/internal/server/ui/templates/daily.html @@ -76,7 +76,8 @@
{{printf "%.1f" .AvgThroughput7Days}} +
{{printf "%.1f" (float64 + .AvgThroughput7Days)}} k/s
| + Ethereum Address | ++ Prefix & Nonce | ++ Worker | ++ Found At (UTC) | ++ Details | +
|---|---|---|---|---|
|
+
+
+
+
+
+
+ {{.Address}}
+ Full
+ Match Discovered
+
+ |
+
+
+ {{truncateHex .Prefix28}}
+ Nonce: {{.NonceFound}}
+
+ |
+
+
+ {{.WorkerID}}
+
+ |
+ + {{.FoundAt.UTC.Format "2006-01-02 15:04:05"}} + | ++ + | +
|
+
+ Confidential
+ Private Key (HEX)
+
+
+
+ {{.PrivateKey}}
+
+ |
+ ||||
|
+
+
+
+ Searching + for the winning combination... + |
+ ||||
Keys Scanned (Fleet)
-{{ formatCount .TotalKeysScanned }}
+{{ formatCount (int .TotalKeysScanned) }}
{{ .TotalWorkers }}
{{ .ProcessingJobCount }}
-{{ printf "%.1f" .GlobalKeysPerSecond }} k/s
+{{ printf "%.1f" (float64 .GlobalKeysPerSecond) }} k/s
+ + +Real-time distributed Ethereum brute-force monitoring.
{{ printf "%.1f" - .GlobalKeysPerSecond }} k/s
+ (float64 .GlobalKeysPerSecond) }} k/s