From dade9b02bef2bd2892375a86de35591f7d3452f8 Mon Sep 17 00:00:00 2001 From: garnizeH Date: Sun, 22 Feb 2026 19:24:40 -0300 Subject: [PATCH 1/2] feat: Enhance result handling and UI for found keys - Added idempotent insert for results with ON CONFLICT handling in SQL. - Introduced new SQL queries for resetting win scenarios and fetching detailed results. - Updated server logic to handle win scenarios, ensuring proper job leasing and target address management. - Enhanced result submission with detailed logging for failures. - Created new UI templates for displaying found results and integrated them into existing dashboard. - Improved data handling in the dashboard to normalize key counts and throughput values. - Updated worker logic to correctly process and submit found results, including nonce handling. - Adjusted tests to reflect changes in result submission and processing logic. --- README.md | 34 ++++- esp32/include/eth_crypto.h | 8 +- go/Makefile | 19 +++ go/cmd/esp-mock-api/main.go | 47 +++++- go/internal/config/config.go | 11 ++ go/internal/database/queries.sql.go | 88 +++++++++++- go/internal/database/sql/queries.sql | 32 ++++- go/internal/server/hub.go | 3 + go/internal/server/jobs.go | 53 ++++++- go/internal/server/results.go | 2 + go/internal/server/ui/renderer.go | 4 +- go/internal/server/ui/templates/daily.html | 7 +- .../server/ui/templates/found_results.html | 135 ++++++++++++++++++ .../server/ui/templates/fragments.html | 31 +++- go/internal/server/ui/templates/index.html | 25 +++- .../server/ui/templates/leaderboard.html | 4 +- go/internal/server/ui_handlers.go | 27 +++- go/internal/worker/client.go | 23 +-- go/internal/worker/client_test.go | 19 +-- go/internal/worker/scanner.go | 4 +- go/internal/worker/scanner_test.go | 14 +- go/internal/worker/worker.go | 32 +++-- go/internal/worker/worker_test.go | 4 +- 23 files changed, 551 insertions(+), 75 deletions(-) create mode 100644 go/internal/server/ui/templates/found_results.html 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 @@

Total class="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex flex-col justify-between transition hover:shadow-md">

Avg Throughput (7d)

-

{{printf "%.1f" .AvgThroughput7Days}} +

{{printf "%.1f" (float64 + .AvgThroughput7Days)}} k/s

@@ -141,9 +142,9 @@

Daily Log {{end}} - {{printf "%.0f" .TotalKeysScanned}} + {{printf "%.0f" (float64 .TotalKeysScanned)}} - {{printf "%.0f" .TotalErrors}} + {{printf "%.0f" (float64 .TotalErrors)}} {{else}} diff --git a/go/internal/server/ui/templates/found_results.html b/go/internal/server/ui/templates/found_results.html new file mode 100644 index 0000000..9ac07c9 --- /dev/null +++ b/go/internal/server/ui/templates/found_results.html @@ -0,0 +1,135 @@ +{{define "found-results"}} +
+
+
+

Discovered Keys

+ WINNERS +
+ Verified On-Chain + Ready +
+ +
+
+ + + + + + + + + + + + {{range .Results}} + + + + + + + + + + + {{else}} + + + + {{end}} + +
+ Ethereum Address + Prefix & Nonce + Worker + Found At (UTC) + Details
+
+
+ + + +
+
+ {{.Address}} + Full + Match Discovered +
+
+
+
+ 0x{{printf "%x" + .Prefix28}} + Nonce: {{.NonceFound}} +
+
+
+ {{.WorkerID}} +
+
+ {{.FoundAt.UTC.Format "2006-01-02 15:04:05"}} + + +
+
+ + + +

Searching + for the winning combination...

+
+
+
+
+
+ + +{{end}} \ No newline at end of file diff --git a/go/internal/server/ui/templates/fragments.html b/go/internal/server/ui/templates/fragments.html index 933f7e5..004c35b 100644 --- a/go/internal/server/ui/templates/fragments.html +++ b/go/internal/server/ui/templates/fragments.html @@ -1,3 +1,16 @@ +{{define "found-badge"}} +{{if .Results}} +
+ + + + + Found Result! +
+{{end}} +{{end}} + {{define "fleet-stats-content"}}
@@ -22,7 +35,7 @@

Keys Scanned (Fleet)

-

{{ formatCount .TotalKeysScanned }}

+

{{ formatCount (int .TotalKeysScanned) }}

@@ -46,11 +59,21 @@

{{ .TotalWorkers }}

{{ .ProcessingJobCount }}

-

{{ printf "%.1f" .GlobalKeysPerSecond }} k/s

+

{{ printf "%.1f" (float64 .GlobalKeysPerSecond) }} k/s

+ + +
+ {{template "found-results" .}} +
+ + +
+ {{template "found-badge" .}} +
- + -
- - - - - Live Monitoring +
+
+ {{template "found-badge" .}} +
+
+ + + + + Live Monitoring +
@@ -72,7 +78,7 @@

Activ

Global Throughput

{{ printf "%.1f" - .GlobalKeysPerSecond }} k/s

+ (float64 .GlobalKeysPerSecond) }} k/s

@@ -82,6 +88,11 @@

Globa

+ +
+ {{template "found-results" .}} +
+
{{template "fleet-stats-content" .}} diff --git a/go/internal/server/ui/templates/leaderboard.html b/go/internal/server/ui/templates/leaderboard.html index b0bf3d4..2ae8b21 100644 --- a/go/internal/server/ui/templates/leaderboard.html +++ b/go/internal/server/ui/templates/leaderboard.html @@ -95,7 +95,7 @@

Lifetime {{.Label}} {{printf "%.1f" - .Pct}}% + (float64 .Pct)}}%

{{.Value}} keys @@ -160,7 +160,7 @@

Lifetime
- {{printf "%.1f" .KeysPerSecondBest}} k/s + {{printf "%.1f" (float64 .KeysPerSecondBest)}} k/s Average: {{printf "%.1f" .KeysPerSecondAvg.Float64}} k/s
diff --git a/go/internal/server/ui_handlers.go b/go/internal/server/ui_handlers.go index 941c84f..de0dae6 100644 --- a/go/internal/server/ui_handlers.go +++ b/go/internal/server/ui_handlers.go @@ -24,25 +24,48 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { activeWorkers, _ := q.GetActiveWorkerDetails(ctx) prefixProgress, _ := q.GetPrefixProgress(ctx) + // Normalize interface fields from database + var totalKeys int64 + if v, ok := stats.TotalKeysScanned.(int64); ok { + totalKeys = v + } else if v, ok := stats.TotalKeysScanned.(int); ok { + totalKeys = int64(v) + } else if v, ok := stats.TotalKeysScanned.(float64); ok { + totalKeys = int64(v) + } + + var globalThroughput float64 + if v, ok := stats.GlobalKeysPerSecond.(float64); ok { + globalThroughput = v + } else if v, ok := stats.GlobalKeysPerSecond.(int64); ok { + globalThroughput = float64(v) + } else if v, ok := stats.GlobalKeysPerSecond.(int); ok { + globalThroughput = float64(v) + } + // Fetch last 10 minutes of global history for initial chart state recentHistory, _ := q.GetRecentWorkerHistory(ctx, database.GetRecentWorkerHistoryParams{ Column1: sql.NullString{String: "600", Valid: true}, // 10 minutes Limit: 1000, }) + // Fetch found results + results, _ := q.GetDetailedResults(ctx, 10) + tmpl := "index.html" data := map[string]any{ "CurrentPath": path, "ActiveWorkers": activeWorkers, "PrefixProgress": prefixProgress, "RecentHistory": recentHistory, + "Results": results, "TotalWorkers": stats.TotalWorkers, "ActiveWorkerCount": stats.ActiveWorkers, "ActiveWorkersList": activeWorkers, - "TotalKeysScanned": stats.TotalKeysScanned, + "TotalKeysScanned": totalKeys, "CompletedJobCount": stats.CompletedBatches, "ProcessingJobCount": stats.ProcessingBatches, - "GlobalKeysPerSecond": stats.GlobalKeysPerSecond, + "GlobalKeysPerSecond": globalThroughput, "NowTimestamp": time.Now().UTC().Unix(), } diff --git a/go/internal/worker/client.go b/go/internal/worker/client.go index 3c8bd7b..57c1225 100644 --- a/go/internal/worker/client.go +++ b/go/internal/worker/client.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "path" + "strconv" "time" ) @@ -308,23 +309,27 @@ func (c *Client) CompleteBatch(ctx context.Context, jobID string, finalNonce uin // resultRequest is the payload sent to submit a found private key match. type resultRequest struct { - WorkerID string `json:"worker_id"` - PrivateKey string `json:"private_key"` //nolint:gosec // false positive - hex-encoded private key, not a hardcoded secret - EthereumAddress string `json:"ethereum_address"` // checksummed - FoundAt string `json:"found_at"` // RFC3339 UTC + WorkerID string `json:"worker_id"` + JobID int64 `json:"job_id"` + PrivateKey string `json:"private_key"` //nolint:gosec // false positive - hex-encoded private key, not a hardcoded secret + Address string `json:"address"` + Nonce int64 `json:"nonce"` } // SubmitResult submits a found private key result to the Master API. -func (c *Client) SubmitResult(ctx context.Context, privateKey []byte, address string) error { +func (c *Client) SubmitResult(ctx context.Context, jobID string, privateKey []byte, address string, nonce uint32) error { if len(privateKey) != 32 { return fmt.Errorf("invalid private key length: expected 32 bytes, got %d", len(privateKey)) } + jid, _ := strconv.ParseInt(jobID, 10, 64) + req := resultRequest{ - WorkerID: c.workerID, - PrivateKey: hex.EncodeToString(privateKey), - EthereumAddress: address, - FoundAt: time.Now().UTC().Format(time.RFC3339), + WorkerID: c.workerID, + JobID: jid, + PrivateKey: hex.EncodeToString(privateKey), + Address: address, + Nonce: int64(nonce), } if err := c.doRequestWithContext(ctx, http.MethodPost, "/api/v1/results", req, nil); err != nil { diff --git a/go/internal/worker/client_test.go b/go/internal/worker/client_test.go index 29e1d9e..8836c51 100644 --- a/go/internal/worker/client_test.go +++ b/go/internal/worker/client_test.go @@ -559,11 +559,14 @@ func TestSubmitResult_Success(t *testing.T) { if len(req.PrivateKey) != 64 { t.Fatalf("unexpected private key length: %d", len(req.PrivateKey)) } - if req.EthereumAddress == "" { - t.Fatalf("missing ethereum address") + if req.Address == "" { + t.Fatalf("missing address") } - if _, err := time.Parse(time.RFC3339, req.FoundAt); err != nil { - t.Fatalf("invalid found_at timestamp: %v", err) + if req.JobID != 123 { + t.Fatalf("expected job_id 123, got %d", req.JobID) + } + if req.Nonce != 456 { + t.Fatalf("expected nonce 456, got %d", req.Nonce) } w.WriteHeader(http.StatusCreated) })) @@ -571,14 +574,14 @@ func TestSubmitResult_Success(t *testing.T) { c := NewClient(&Config{APIURL: server.URL, WorkerID: "test-worker", APIKey: "test-key"}) privateKey := make([]byte, 32) - if err := c.SubmitResult(context.Background(), privateKey, "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"); err != nil { + if err := c.SubmitResult(context.Background(), "123", privateKey, "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", 456); err != nil { t.Fatalf("unexpected error: %v", err) } } func TestSubmitResult_InvalidPrivateKeyLength(t *testing.T) { c := NewClient(&Config{APIURL: "http://example.com", WorkerID: "w", APIKey: ""}) - err := c.SubmitResult(context.Background(), make([]byte, 16), "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb") + err := c.SubmitResult(context.Background(), "123", make([]byte, 16), "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", 456) if err == nil { t.Fatalf("expected error for invalid private key length") } @@ -599,7 +602,7 @@ func TestSubmitResult_UnauthorizedReturnsErrUnauthorized(t *testing.T) { cfg := &Config{APIURL: srv.URL, WorkerID: "w", APIKey: "bad"} c := NewClient(cfg) - err := c.SubmitResult(context.Background(), make([]byte, 32), "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb") + err := c.SubmitResult(context.Background(), "123", make([]byte, 32), "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", 456) if err == nil { t.Fatalf("expected ErrUnauthorized") } @@ -620,7 +623,7 @@ func TestSubmitResult_APIErrorWrapped(t *testing.T) { cfg := &Config{APIURL: srv.URL, WorkerID: "w", APIKey: ""} c := NewClient(cfg) - err := c.SubmitResult(context.Background(), make([]byte, 32), "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb") + err := c.SubmitResult(context.Background(), "123", make([]byte, 32), "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", 456) if err == nil { t.Fatalf("expected wrapped API error") } diff --git a/go/internal/worker/scanner.go b/go/internal/worker/scanner.go index c3e70ac..c46d828 100644 --- a/go/internal/worker/scanner.go +++ b/go/internal/worker/scanner.go @@ -69,7 +69,7 @@ func ScanRange(ctx context.Context, job Job, targetAddresses []common.Address) ( // Reuse key buffer copy(key[:28], job.Prefix28[:]) - binary.LittleEndian.PutUint32(key[28:], nonce) + binary.BigEndian.PutUint32(key[28:], nonce) // Use fast, allocation-free derivation path addr, err := DeriveEthereumAddressFast(key, hasher, &pubBuf, &hashBuf) @@ -230,6 +230,6 @@ func ScanRangeParallel(ctx context.Context, job Job, targetAddresses []common.Ad // Helper to extract nonce bytes if needed elsewhere. func nonceBytesFromUint32(n uint32) [4]byte { var b [4]byte - binary.LittleEndian.PutUint32(b[:], n) + binary.BigEndian.PutUint32(b[:], n) return b } diff --git a/go/internal/worker/scanner_test.go b/go/internal/worker/scanner_test.go index 9675f01..2e83057 100644 --- a/go/internal/worker/scanner_test.go +++ b/go/internal/worker/scanner_test.go @@ -49,7 +49,7 @@ func TestScanRange_FindAtNonce(t *testing.T) { var prefix [28]byte copy(prefix[:], privBytes[:28]) - nonce := binary.LittleEndian.Uint32(privBytes[28:32]) + nonce := binary.BigEndian.Uint32(privBytes[28:32]) job := Job{ ID: 2, @@ -91,7 +91,7 @@ func TestScanRange_InclusiveEnd(t *testing.T) { privBytes := crypto.FromECDSA(key) var prefix [28]byte copy(prefix[:], privBytes[:28]) - nonce := binary.LittleEndian.Uint32(privBytes[28:32]) + nonce := binary.BigEndian.Uint32(privBytes[28:32]) job := Job{ ID: 3, @@ -155,14 +155,14 @@ func TestScanRange_MultipleTargets(t *testing.T) { p1 := crypto.FromECDSA(k1) var prefix [28]byte copy(prefix[:], p1[:28]) - n1 := binary.LittleEndian.Uint32(p1[28:32]) + n1 := binary.BigEndian.Uint32(p1[28:32]) a1, _ := DeriveEthereumAddressFast([32]byte(p1), crypto.NewKeccakState(), &[64]byte{}, &[32]byte{}) // Target 2 (different nonce, same prefix) n2 := n1 + 10 var k2Full [32]byte copy(k2Full[:28], prefix[:]) - binary.LittleEndian.PutUint32(k2Full[28:], n2) + binary.BigEndian.PutUint32(k2Full[28:], n2) a2, _ := DeriveEthereumAddressFast(k2Full, crypto.NewKeccakState(), &[64]byte{}, &[32]byte{}) job := Job{ @@ -208,7 +208,7 @@ func TestScanRangeParallel_Match(t *testing.T) { var prefix [28]byte copy(prefix[:], privBytes[:28]) - nonce := binary.LittleEndian.Uint32(privBytes[28:32]) + nonce := binary.BigEndian.Uint32(privBytes[28:32]) // Narrow range that definitely includes the nonce job := Job{ @@ -284,9 +284,9 @@ func TestNonceBytesFromUint32(t *testing.T) { want [4]byte }{ {name: "zero", n: 0, want: [4]byte{0, 0, 0, 0}}, - {name: "one", n: 1, want: [4]byte{1, 0, 0, 0}}, + {name: "one", n: 1, want: [4]byte{0, 0, 0, 1}}, {name: "max", n: 0xFFFFFFFF, want: [4]byte{0xFF, 0xFF, 0xFF, 0xFF}}, - {name: "pattern", n: 0x12345678, want: [4]byte{0x78, 0x56, 0x34, 0x12}}, + {name: "pattern", n: 0x12345678, want: [4]byte{0x12, 0x34, 0x56, 0x78}}, } for _, tt := range tests { diff --git a/go/internal/worker/worker.go b/go/internal/worker/worker.go index 8a3124d..91ccbbe 100644 --- a/go/internal/worker/worker.go +++ b/go/internal/worker/worker.go @@ -164,7 +164,7 @@ func (w *Worker) Run(ctx context.Context) error { } log.Printf("worker: leased job %s prefix=%s targets=%v nonce=[%d,%d] expires=%s", lease.JobID, prefixHex, lease.TargetAddresses, lease.NonceStart, lease.NonceEnd, lease.ExpiresAt) - duration, keys, err := w.processBatch(ctx, lease) + duration, keys, found, err := w.processBatch(ctx, lease) if err != nil { // If unauthorized bubbled up, stop worker if errors.Is(err, ErrUnauthorized) { @@ -175,6 +175,13 @@ func (w *Worker) Run(ctx context.Context) error { continue } + if found { + log.Printf("worker: !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + log.Printf("worker: !! SCANNER STOPPED: Key found. Check the result submission above. !!") + log.Printf("worker: !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + return nil + } + if !w.config.LogSampling { log.Printf("worker: completed job %s (duration=%s keys=%d)", lease.JobID, duration.Round(time.Millisecond), keys) } @@ -198,7 +205,7 @@ func (w *Worker) Run(ctx context.Context) error { // and completing the job when done. The actual scanning (crypto) is delegated // to the scanner component (not implemented here); this function contains a // simple placeholder to simulate work and the checkpointing logic. -func (w *Worker) processBatch(ctx context.Context, lease *JobLease) (time.Duration, uint64, error) { +func (w *Worker) processBatch(ctx context.Context, lease *JobLease) (time.Duration, uint64, bool, error) { // Lease context tied to (expires_at - gracePeriod) so we stop scanning // slightly before the master-side lease expires to allow time for a final // checkpoint and graceful shutdown. @@ -401,7 +408,7 @@ func (w *Worker) processBatch(ctx context.Context, lease *JobLease) (time.Durati <-doneCh elapsed := time.Since(startTime) afterKeys := atomic.LoadUint64(&totalKeys) - return elapsed, afterKeys, fmt.Errorf("scan failed: %w", err) + return elapsed, afterKeys, false, fmt.Errorf("scan failed: %w", err) } // If a result was found, submit it @@ -411,18 +418,21 @@ func (w *Worker) processBatch(ctx context.Context, lease *JobLease) (time.Durati // Submit with per-call timeout sctx, scancel := context.WithTimeout(ctx, w.config.CheckpointTimeout) - if err := w.client.SubmitResult(sctx, res.PrivateKey[:], res.Address.Hex()); err != nil { + if err := w.client.SubmitResult(sctx, lease.JobID, res.PrivateKey[:], res.Address.Hex(), res.Nonce); err != nil { scancel() if errors.Is(err, ErrUnauthorized) { cancel() <-doneCh elapsed := time.Since(startTime) afterKeys := atomic.LoadUint64(&totalKeys) - return elapsed, afterKeys, ErrUnauthorized + return elapsed, afterKeys, false, ErrUnauthorized } log.Printf("worker: failed to submit result: %v", err) } else { scancel() + log.Printf("worker: !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + log.Printf("worker: !! SUCCESS !! MATCH FOUND: %s -> %s", res.Address.Hex(), hex.EncodeToString(res.PrivateKey[:])) + log.Printf("worker: !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") } foundResult = res } @@ -436,7 +446,7 @@ func (w *Worker) processBatch(ctx context.Context, lease *JobLease) (time.Durati <-doneCh elapsed := time.Since(startTime) currentTk := atomic.LoadUint64(&totalKeys) - return elapsed, currentTk, err + return elapsed, currentTk, false, err } lastCheckpointTime = time.Now() } @@ -464,7 +474,7 @@ func (w *Worker) processBatch(ctx context.Context, lease *JobLease) (time.Durati // If the checkpoint loop encountered an unauthorized error, propagate it // so the worker stops entirely. if atomic.LoadInt32(&unauthorizedFlag) == 1 { - return elapsed, tk, ErrUnauthorized + return elapsed, tk, false, ErrUnauthorized } // If we exited early due to lease expiry, the caller will handle re-request. @@ -474,16 +484,16 @@ func (w *Worker) processBatch(ctx context.Context, lease *JobLease) (time.Durati defer bgCancel() if err := w.client.CompleteBatch(bgCtx, lease.JobID, lease.NonceEnd, tk, startTime, elapsed.Milliseconds()); err != nil { if errors.Is(err, ErrUnauthorized) { - return elapsed, tk, ErrUnauthorized + return elapsed, tk, false, ErrUnauthorized } var apiErr *APIError if errors.As(err, &apiErr) && apiErr.StatusCode == 410 { - return elapsed, tk, ErrLeaseExpired + return elapsed, tk, false, ErrLeaseExpired } - return elapsed, tk, fmt.Errorf("failed to complete batch: %w", err) + return elapsed, tk, false, fmt.Errorf("failed to complete batch: %w", err) } - return elapsed, tk, nil + return elapsed, tk, foundResult != nil, nil } // sendChunkCheckpoint sends a checkpoint for a chunk and handles errors. diff --git a/go/internal/worker/worker_test.go b/go/internal/worker/worker_test.go index 426cf6e..3a1758b 100644 --- a/go/internal/worker/worker_test.go +++ b/go/internal/worker/worker_test.go @@ -132,7 +132,7 @@ func TestWorkerRun_LeaseExpiresBeforeCompletion(t *testing.T) { ExpiresAt: time.Now().Add(500 * time.Millisecond).UTC(), } - _, _, err := w.processBatch(context.Background(), lease) + _, _, _, err := w.processBatch(context.Background(), lease) if err != nil { t.Logf("processBatch returned: %v", err) } @@ -310,7 +310,7 @@ func TestProcessBatch_CompleteUnauthorizedReturnsErrUnauthorized(t *testing.T) { t.Fatalf("lease failed: %v", err) } - _, _, err = w.processBatch(context.Background(), lease) + _, _, _, err = w.processBatch(context.Background(), lease) if !errors.Is(err, ErrUnauthorized) { t.Fatalf("expected ErrUnauthorized, got %v", err) } From 1bb8f866f33f5d9126c955a24b53a50a7b1f1ed2 Mon Sep 17 00:00:00 2001 From: garnizeH Date: Sun, 22 Feb 2026 19:25:22 -0300 Subject: [PATCH 2/2] feat: Update key display with title attribute and hex truncation --- go/internal/server/ui/templates/found_results.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/internal/server/ui/templates/found_results.html b/go/internal/server/ui/templates/found_results.html index 9ac07c9..06b4d0f 100644 --- a/go/internal/server/ui/templates/found_results.html +++ b/go/internal/server/ui/templates/found_results.html @@ -55,8 +55,8 @@

Discovere
- 0x{{printf "%x" - .Prefix28}} + {{truncateHex .Prefix28}} Nonce: {{.NonceFound}}