Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions esp32/include/eth_crypto.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 19 additions & 0 deletions go/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand Down
47 changes: 46 additions & 1 deletion go/cmd/esp-mock-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@ package main

import (
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"strings"
"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
Expand All @@ -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{
Expand All @@ -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)

Expand All @@ -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{
Expand All @@ -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 {
Expand Down Expand Up @@ -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"}`)
}
11 changes: 11 additions & 0 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
88 changes: 87 additions & 1 deletion go/internal/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 31 additions & 1 deletion go/internal/database/sql/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading