diff --git a/core_impl.go b/core_impl.go index d42ada8..f1d5c14 100644 --- a/core_impl.go +++ b/core_impl.go @@ -1,6 +1,7 @@ package proxy import ( + "encoding/binary" "crypto/rand" "crypto/sha256" "encoding/hex" @@ -171,6 +172,23 @@ func (j Job) DifficultyFromTarget() uint64 { return uint64(math.MaxUint32 / uint64(target)) } +func targetFromDifficulty(diff uint64) string { + if diff <= 1 { + return "ffffffff" + } + maxTarget := uint64(math.MaxUint32) + target := (maxTarget + diff - 1) / diff + if target == 0 { + target = 1 + } + if target > maxTarget { + target = maxTarget + } + var raw [4]byte + binary.LittleEndian.PutUint32(raw[:], uint32(target)) + return hex.EncodeToString(raw[:]) +} + // NewCustomDiff creates a login-time custom difficulty resolver. func NewCustomDiff(globalDiff uint64) *CustomDiff { return &CustomDiff{globalDiff: globalDiff} diff --git a/miner_login_test.go b/miner_login_test.go index 6a92311..75a0862 100644 --- a/miner_login_test.go +++ b/miner_login_test.go @@ -6,6 +6,7 @@ import ( "net" "strings" "testing" + "time" ) func TestMiner_HandleLogin_Good(t *testing.T) { @@ -169,3 +170,148 @@ func TestMiner_HandleLogin_Ugly(t *testing.T) { t.Fatalf("expected rejected miner to remain unassigned, got mapper %d", miner.MapperID()) } } + +func TestMiner_HandleLogin_CustomDiffCap_Good(t *testing.T) { + minerConn, clientConn := net.Pipe() + defer minerConn.Close() + defer clientConn.Close() + + miner := NewMiner(minerConn, 3333, nil) + miner.onLogin = func(m *Miner) { + m.SetRouteID(1) + m.customDiff = 50000 + } + miner.currentJob = Job{ + Blob: strings.Repeat("0", 160), + JobID: "job-1", + Target: targetFromDifficulty(100000), + } + + params, err := json.Marshal(loginParams{ + Login: "wallet", + Pass: "x", + }) + if err != nil { + t.Fatalf("marshal login params: %v", err) + } + + go miner.handleLogin(stratumRequest{ID: 3, Method: "login", Params: params}) + + line, err := bufio.NewReader(clientConn).ReadBytes('\n') + if err != nil { + t.Fatalf("read login response: %v", err) + } + + var payload struct { + Result struct { + Job struct { + Target string `json:"target"` + } `json:"job"` + } `json:"result"` + } + if err := json.Unmarshal(line, &payload); err != nil { + t.Fatalf("unmarshal login response: %v", err) + } + + originalDiff := miner.currentJob.DifficultyFromTarget() + cappedDiff := Job{Target: payload.Result.Job.Target}.DifficultyFromTarget() + if cappedDiff == 0 || cappedDiff > 50000 { + t.Fatalf("expected capped difficulty at or below 50000, got %d", cappedDiff) + } + if cappedDiff >= originalDiff { + t.Fatalf("expected lowered target difficulty below %d, got %d", originalDiff, cappedDiff) + } + if miner.diff != cappedDiff { + t.Fatalf("expected miner diff %d, got %d", cappedDiff, miner.diff) + } +} + +func TestMiner_ReadLoop_RFCLineLimit_Good(t *testing.T) { + minerConn, clientConn := net.Pipe() + defer minerConn.Close() + defer clientConn.Close() + + miner := NewMiner(minerConn, 3333, nil) + miner.onLogin = func(m *Miner) { + m.SetRouteID(1) + } + miner.Start() + + params, err := json.Marshal(loginParams{ + Login: "wallet", + Pass: "x", + Agent: strings.Repeat("a", 5000), + }) + if err != nil { + t.Fatalf("marshal login params: %v", err) + } + request, err := json.Marshal(stratumRequest{ID: 4, Method: "login", Params: params}) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + if len(request) >= maxStratumLineLength { + t.Fatalf("expected test request below RFC limit, got %d bytes", len(request)) + } + + if _, err := clientConn.Write(append(request, '\n')); err != nil { + t.Fatalf("write login request: %v", err) + } + _ = clientConn.SetReadDeadline(time.Now().Add(time.Second)) + line, err := bufio.NewReader(clientConn).ReadBytes('\n') + if err != nil { + t.Fatalf("read login response: %v", err) + } + if len(line) == 0 { + t.Fatal("expected login response for request under RFC limit") + } +} + +func TestMiner_ReadLoop_RFCLineLimit_Ugly(t *testing.T) { + minerConn, clientConn := net.Pipe() + defer minerConn.Close() + defer clientConn.Close() + + miner := NewMiner(minerConn, 3333, nil) + miner.Start() + + params, err := json.Marshal(loginParams{ + Login: "wallet", + Pass: "x", + Agent: strings.Repeat("b", maxStratumLineLength), + }) + if err != nil { + t.Fatalf("marshal login params: %v", err) + } + request, err := json.Marshal(stratumRequest{ID: 5, Method: "login", Params: params}) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + if len(request) <= maxStratumLineLength { + t.Fatalf("expected test request above RFC limit, got %d bytes", len(request)) + } + + writeDone := make(chan error, 1) + go func() { + _, writeErr := clientConn.Write(append(request, '\n')) + writeDone <- writeErr + }() + + var writeErr error + select { + case writeErr = <-writeDone: + case <-time.After(time.Second): + t.Fatal("timed out writing oversized request") + } + if writeErr == nil { + _ = clientConn.SetReadDeadline(time.Now().Add(time.Second)) + line, err := bufio.NewReader(clientConn).ReadBytes('\n') + if err == nil || len(line) > 0 { + t.Fatalf("expected oversized request to close the connection, got line=%q err=%v", string(line), err) + } + return + } + + if !strings.Contains(writeErr.Error(), "closed pipe") { + t.Fatalf("expected oversized request to close the connection, got write error %v", writeErr) + } +} diff --git a/state_impl.go b/state_impl.go index b20af1e..0b42cec 100644 --- a/state_impl.go +++ b/state_impl.go @@ -15,6 +15,8 @@ import ( "time" ) +const maxStratumLineLength = 16384 + // MinerSnapshot is a serialisable view of one miner connection. type MinerSnapshot struct { ID int64 @@ -826,7 +828,7 @@ func (m *Miner) readLoop() { } }() - reader := bufio.NewReader(m.conn) + reader := bufio.NewReaderSize(m.conn, maxStratumLineLength+1) for { if m.state == MinerStateClosing { return @@ -839,7 +841,7 @@ func (m *Miner) readLoop() { m.Close() return } - if isPrefix { + if isPrefix || len(line) > maxStratumLineLength { m.Close() return } @@ -1034,28 +1036,21 @@ func (m *Miner) ForwardJob(job Job, algo string) { return } m.currentJob = job - m.diff = job.DifficultyFromTarget() - if algo == "" { - algo = job.Algo - } - blob := job.Blob - if m.extNH { - blob = job.BlobWithFixedByte(m.fixedByte) - } + renderedJob, effectiveAlgo := m.renderJob(job, algo) payload := map[string]any{ "jsonrpc": "2.0", "method": "job", "params": map[string]any{ - "blob": blob, - "job_id": job.JobID, - "target": job.Target, + "blob": renderedJob.Blob, + "job_id": renderedJob.JobID, + "target": renderedJob.Target, "id": m.rpcID, - "height": job.Height, - "seed_hash": job.SeedHash, + "height": renderedJob.Height, + "seed_hash": renderedJob.SeedHash, }, } - if m.supportsAlgoExtension() && algo != "" { - payload["params"].(map[string]any)["algo"] = algo + if m.supportsAlgoExtension() && effectiveAlgo != "" { + payload["params"].(map[string]any)["algo"] = effectiveAlgo } _ = m.writeJSON(payload) m.touchActivity() @@ -1076,21 +1071,17 @@ func (m *Miner) replyLoginSuccess(id int64) { result["extensions"] = []string{"algo"} } if job := m.CurrentJob(); job.IsValid() { - blob := job.Blob - if m.extNH { - blob = job.BlobWithFixedByte(m.fixedByte) - } - m.diff = job.DifficultyFromTarget() + renderedJob, effectiveAlgo := m.renderJob(job, job.Algo) jobPayload := map[string]any{ - "blob": blob, - "job_id": job.JobID, - "target": job.Target, + "blob": renderedJob.Blob, + "job_id": renderedJob.JobID, + "target": renderedJob.Target, "id": m.rpcID, - "height": job.Height, - "seed_hash": job.SeedHash, + "height": renderedJob.Height, + "seed_hash": renderedJob.SeedHash, } - if m.supportsAlgoExtension() && job.Algo != "" { - jobPayload["algo"] = job.Algo + if m.supportsAlgoExtension() && effectiveAlgo != "" { + jobPayload["algo"] = effectiveAlgo } result["job"] = jobPayload m.touchActivity() @@ -1105,6 +1096,26 @@ func (m *Miner) replyLoginSuccess(id int64) { _ = m.writeJSON(payload) } +func (m *Miner) renderJob(job Job, algo string) (Job, string) { + if m == nil { + return job, algo + } + rendered := job + if algo == "" { + algo = job.Algo + } + if m.extNH { + rendered.Blob = job.BlobWithFixedByte(m.fixedByte) + } + effectiveDiff := job.DifficultyFromTarget() + if m.customDiff > 0 && effectiveDiff > 0 && effectiveDiff > m.customDiff { + rendered.Target = targetFromDifficulty(m.customDiff) + effectiveDiff = rendered.DifficultyFromTarget() + } + m.diff = effectiveDiff + return rendered, algo +} + func (m *Miner) ReplyWithError(id int64, message string) { if m == nil { return