go-proxy/pool/client.go
Virgil f16c9033e3 refactor(proxy): use clearer runtime names
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 14:16:33 +00:00

275 lines
6.7 KiB
Go

// Package pool implements the outbound stratum pool client and failover strategy.
//
// client := pool.NewStratumClient(poolCfg, listener)
// client.Connect()
package pool
import (
"bufio"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"net"
"strings"
"sync"
"sync/atomic"
"time"
"dappco.re/go/core/proxy"
)
// StratumClient is one outbound stratum TCP (optionally TLS) connection to a pool.
// The proxy presents itself to the pool as a standard stratum miner using the
// wallet address and password from PoolConfig.
//
// client := pool.NewStratumClient(poolCfg, listener)
// client.Connect()
type StratumClient struct {
config proxy.PoolConfig
listener StratumListener
conn net.Conn
tlsConn *tls.Conn // nil if plain TCP
sessionID string // pool-assigned session id from login reply
sequence int64 // atomic JSON-RPC request id counter
active bool // true once first job received
disconnectOnce sync.Once
sendMu sync.Mutex
}
// StratumListener receives events from the pool connection.
type StratumListener interface {
// OnJob is called when the pool pushes a new job notification or the login reply contains a job.
OnJob(job proxy.Job)
// OnResultAccepted is called when the pool accepts or rejects a submitted share.
// sequence matches the value returned by Submit(). errorMessage is "" on accept.
OnResultAccepted(sequence int64, accepted bool, errorMessage string)
// OnDisconnect is called when the pool TCP connection closes for any reason.
OnDisconnect()
}
type jsonRPCRequest struct {
ID int64 `json:"id"`
Method string `json:"method"`
Params interface{} `json:"params,omitempty"`
}
type jsonRPCResponse struct {
ID int64 `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Error *jsonRPCErrorBody `json:"error"`
}
type jsonRPCErrorBody struct {
Code int `json:"code"`
Message string `json:"message"`
}
// NewStratumClient stores the pool config and listener.
//
// client := pool.NewStratumClient(poolCfg, listener)
func NewStratumClient(cfg proxy.PoolConfig, listener StratumListener) *StratumClient {
return &StratumClient{
config: cfg,
listener: listener,
}
}
// Connect dials the pool. Applies TLS if cfg.TLS is true.
//
// errorValue := client.Connect()
func (c *StratumClient) Connect() error {
var connection net.Conn
var errorValue error
dialer := net.Dialer{}
if c.config.Keepalive {
dialer.KeepAlive = 30 * time.Second
}
if c.config.TLS {
connection, errorValue = dialer.Dial("tcp", c.config.URL)
if errorValue != nil {
return errorValue
}
serverName := c.config.URL
if host, _, splitError := net.SplitHostPort(c.config.URL); splitError == nil && host != "" {
serverName = host
}
tlsConnection := tls.Client(connection, &tls.Config{MinVersion: tls.VersionTLS12, ServerName: serverName})
errorValue = tlsConnection.Handshake()
if errorValue != nil {
_ = connection.Close()
return errorValue
}
if c.config.TLSFingerprint != "" {
state := tlsConnection.ConnectionState()
if len(state.PeerCertificates) == 0 {
_ = connection.Close()
return errors.New("missing peer certificate")
}
fingerprint := sha256.Sum256(state.PeerCertificates[0].Raw)
if hex.EncodeToString(fingerprint[:]) != strings.ToLower(c.config.TLSFingerprint) {
_ = connection.Close()
return errors.New("pool fingerprint mismatch")
}
}
connection = tlsConnection
c.tlsConn = tlsConnection
} else {
connection, errorValue = dialer.Dial("tcp", c.config.URL)
if errorValue != nil {
return errorValue
}
}
c.conn = connection
c.disconnectOnce = sync.Once{}
go c.readLoop()
return nil
}
// Login sends the stratum login request using cfg.User and cfg.Pass.
//
// client.Login()
func (c *StratumClient) Login() {
params := map[string]interface{}{
"login": c.config.User,
"pass": c.config.Pass,
"rigid": c.config.RigID,
}
if c.config.Algo != "" {
params["algo"] = []string{c.config.Algo}
}
_ = c.writeJSON(jsonRPCRequest{
ID: 1,
Method: "login",
Params: params,
})
}
// Submit sends a share submission. Returns the sequence number for result correlation.
//
// seq := client.Submit(jobID, "deadbeef", "HASH64HEX", "cn/r")
func (c *StratumClient) Submit(jobID string, nonce string, result string, algo string) int64 {
sequence := atomic.AddInt64(&c.sequence, 1)
params := map[string]string{
"id": c.sessionID,
"job_id": jobID,
"nonce": nonce,
"result": result,
}
if algo != "" {
params["algo"] = algo
}
_ = c.writeJSON(jsonRPCRequest{
ID: sequence,
Method: "submit",
Params: params,
})
return sequence
}
// Disconnect closes the connection cleanly. Triggers OnDisconnect on the listener.
//
// client.Disconnect()
func (c *StratumClient) Disconnect() {
if c.conn != nil {
_ = c.conn.Close()
}
c.notifyDisconnect()
}
func (c *StratumClient) writeJSON(value interface{}) error {
if c.conn == nil {
return nil
}
data, errorValue := json.Marshal(value)
if errorValue != nil {
return errorValue
}
c.sendMu.Lock()
defer c.sendMu.Unlock()
_, errorValue = c.conn.Write(append(data, '\n'))
return errorValue
}
func (c *StratumClient) readLoop() {
reader := bufio.NewReaderSize(c.conn, 16384)
for {
line, isPrefix, errorValue := reader.ReadLine()
if errorValue != nil {
c.notifyDisconnect()
return
}
if isPrefix {
c.notifyDisconnect()
return
}
response := jsonRPCResponse{}
if errorValue = json.Unmarshal(line, &response); errorValue != nil {
continue
}
c.handleMessage(response)
}
}
func (c *StratumClient) notifyDisconnect() {
c.disconnectOnce.Do(func() {
if c.listener != nil {
c.listener.OnDisconnect()
}
})
}
func (c *StratumClient) handleMessage(response jsonRPCResponse) {
if len(response.Result) > 0 {
var loginResult struct {
ID string `json:"id"`
Job proxy.Job `json:"job"`
}
if json.Unmarshal(response.Result, &loginResult) == nil && loginResult.ID != "" {
c.sessionID = loginResult.ID
if loginResult.Job.IsValid() {
loginResult.Job.ClientID = c.sessionID
c.active = true
if c.listener != nil {
c.listener.OnJob(loginResult.Job)
}
}
return
}
if c.listener != nil {
accepted := response.Error == nil
errorMessage := ""
if response.Error != nil {
errorMessage = response.Error.Message
}
c.listener.OnResultAccepted(response.ID, accepted, errorMessage)
}
return
}
if response.Method == "job" {
var payload proxy.Job
if json.Unmarshal(response.Params, &payload) == nil && payload.IsValid() {
payload.ClientID = c.sessionID
c.active = true
if c.listener != nil {
c.listener.OnJob(payload)
}
}
}
}