From 2252ef82e8a664d9badc0b9a78df6a0f0e8f1a3a Mon Sep 17 00:00:00 2001 From: taserz <852984+taserz@users.noreply.github.com> Date: Wed, 13 May 2026 13:36:41 -0400 Subject: [PATCH 1/2] Add hashrate_split config for distributing miners across pool accounts Adds a new top-level config option hashrate_split, which accepts a list of sub-account names with percentage weights. When configured, incoming miners are randomly assigned to one of the split pool accounts on connect, with each account receiving roughly its target share of traffic. Example config: "hashrate_split": [ {"sub_account": "account_a", "percent": 70}, {"sub_account": "account_b", "percent": 30} ] All listed pool servers are used for connectivity; only the sub-account field in the pools array is ignored when splitting is active. Pool connections for every split account are established upfront at startup. The split is per-miner (not per-share), which is accurate at any meaningful number of miners and requires no per-share job tracking. Fixes #36. --- Config.go | 44 ++++++++++++++++++++++++++++++++++++++--- SessionManager.go | 16 +++++++++++---- UpSessionBTC.go | 2 +- UpSessionETH.go | 2 +- agent_conf.default.json | 1 + 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/Config.go b/Config.go index 1e58a93..a30073c 100644 --- a/Config.go +++ b/Config.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "io/ioutil" + "math/rand" "strings" "time" @@ -42,6 +43,12 @@ func (r *PoolInfo) MarshalJSON() ([]byte, error) { return json.Marshal([]interface{}{r.Host, r.Port, r.SubAccount}) } +// SplitAccount defines one destination account for hashrate splitting +type SplitAccount struct { + SubAccount string `json:"sub_account"` + Percent uint `json:"percent"` +} + type Seconds uint32 func (s Seconds) Get() time.Duration { @@ -64,7 +71,8 @@ type Config struct { DirectConnectWithProxy bool `json:"direct_connect_with_proxy"` DirectConnectAfterProxy bool `json:"direct_connect_after_proxy"` PoolUseTls bool `json:"pool_use_tls"` - Pools []PoolInfo `json:"pools"` + Pools []PoolInfo `json:"pools"` + HashrateSplit []SplitAccount `json:"hashrate_split"` HTTPDebug struct { Enable bool `json:"enable"` Listen string `json:"listen"` @@ -117,6 +125,25 @@ func NewConfig() (config *Config) { return } +// PickSplitAccount returns a sub-account chosen at random weighted by Percent values +func (conf *Config) PickSplitAccount() string { + total := 0 + for _, sa := range conf.HashrateSplit { + total += int(sa.Percent) + } + if total <= 0 { + return conf.HashrateSplit[0].SubAccount + } + r := rand.Intn(total) + for _, sa := range conf.HashrateSplit { + r -= int(sa.Percent) + if r < 0 { + return sa.SubAccount + } + } + return conf.HashrateSplit[len(conf.HashrateSplit)-1].SubAccount +} + // LoadFromFile 从文件载入配置 func (conf *Config) LoadFromFile(file string) (err error) { configJSON, err := ioutil.ReadFile(file) @@ -173,10 +200,21 @@ func (conf *Config) Init() { glog.Info("[OPTION] Connect to pool server with proxy ", conf.Proxy) } + if len(conf.HashrateSplit) > 0 { + totalPercent := uint(0) + for _, sa := range conf.HashrateSplit { + totalPercent += sa.Percent + glog.Info("[OPTION] Hashrate split: sub-account ", sa.SubAccount, ", ", sa.Percent, "%") + } + if totalPercent != 100 { + glog.Warning("[OPTION] Hashrate split percentages sum to ", totalPercent, "%, not 100%") + } + } + for i := range conf.Pools { pool := &conf.Pools[i] - if conf.MultiUserMode { - // 如果启用多用户模式,删除矿池设置中的子账户名 + if conf.MultiUserMode || len(conf.HashrateSplit) > 0 { + // sub-account comes from the miner's worker name or hashrate_split, not pool config pool.SubAccount = "" glog.Info("add pool: ", pool.Host, ":", pool.Port, ", multi user mode") } else { diff --git a/SessionManager.go b/SessionManager.go index 3197aec..1c2c247 100644 --- a/SessionManager.go +++ b/SessionManager.go @@ -47,8 +47,12 @@ func (manager *SessionManager) Run() { return } - // 为单用户模式连接矿池 - if !manager.config.MultiUserMode { + // Pre-create pool connections for hashrate split accounts or single-user mode + if len(manager.config.HashrateSplit) > 0 { + for _, sa := range manager.config.HashrateSplit { + manager.createUpSessionManager(sa.SubAccount) + } + } else if !manager.config.MultiUserMode { manager.createUpSessionManager("") } @@ -117,9 +121,13 @@ func (manager *SessionManager) createUpSessionManager(subAccount string) (upMana } func (manager *SessionManager) addDownSession(e EventAddDownSession) { - upManager, ok := manager.upSessionManagers[e.Session.SubAccountName()] + subAccount := e.Session.SubAccountName() + if len(manager.config.HashrateSplit) > 0 { + subAccount = manager.config.PickSplitAccount() + } + upManager, ok := manager.upSessionManagers[subAccount] if !ok { - upManager = manager.createUpSessionManager(e.Session.SubAccountName()) + upManager = manager.createUpSessionManager(subAccount) } upManager.SendEvent(e) } diff --git a/UpSessionBTC.go b/UpSessionBTC.go index 6838ed3..0a21cd2 100644 --- a/UpSessionBTC.go +++ b/UpSessionBTC.go @@ -64,7 +64,7 @@ func NewUpSessionBTC(manager *UpSessionManager, poolIndex int, slot int) (up *Up up.eventChannel = make(chan interface{}, manager.config.Advanced.MessageQueueSize.PoolSession) up.submitIDs = make(map[uint16]SubmitID) - if !up.config.MultiUserMode { + if !up.config.MultiUserMode && len(up.config.HashrateSplit) == 0 { up.subAccount = manager.config.Pools[poolIndex].SubAccount } diff --git a/UpSessionETH.go b/UpSessionETH.go index 956b8ba..1b1e520 100644 --- a/UpSessionETH.go +++ b/UpSessionETH.go @@ -60,7 +60,7 @@ func NewUpSessionETH(manager *UpSessionManager, poolIndex int, slot int) (up *Up up.eventChannel = make(chan interface{}, manager.config.Advanced.MessageQueueSize.PoolSession) up.submitIDs = make(map[uint16]SubmitID) - if !up.config.MultiUserMode { + if !up.config.MultiUserMode && len(up.config.HashrateSplit) == 0 { up.subAccount = manager.config.Pools[poolIndex].SubAccount } diff --git a/agent_conf.default.json b/agent_conf.default.json index 9ec8ff1..8eb9576 100644 --- a/agent_conf.default.json +++ b/agent_conf.default.json @@ -19,6 +19,7 @@ ["us.ss.btc.com", 443, "YourSubAccountName"], ["us.ss.btc.com", 3333, "YourSubAccountName"] ], + "hashrate_split": [], "http_debug": { "enable": false, "listen": "127.0.0.1:9999" From ac51fd61a3799e4ef1ef376a344acc92ded7303a Mon Sep 17 00:00:00 2001 From: taserz <852984+taserz@users.noreply.github.com> Date: Wed, 13 May 2026 18:05:59 -0400 Subject: [PATCH 2/2] Add tests for hashrate_split feature Unit tests verify PickSplitAccount distributes across two and three accounts within 2% of the configured percentages over 100k iterations, handles a single account, and works when weights don't sum to 100. The end-to-end test spins up a minimal mock stratum server, starts btcagent with a 70/30 split config, connects 200 mock miners, and confirms routing lands within 10% of the target percentages. A test hook (onSubAccountPick) on SessionManager captures routing decisions without requiring full share submission flow. --- HashrateSplit_test.go | 341 ++++++++++++++++++++++++++++++++++++++++++ SessionManager.go | 7 + 2 files changed, 348 insertions(+) create mode 100644 HashrateSplit_test.go diff --git a/HashrateSplit_test.go b/HashrateSplit_test.go new file mode 100644 index 0000000..4412417 --- /dev/null +++ b/HashrateSplit_test.go @@ -0,0 +1,341 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "math" + "net" + "strings" + "sync" + "testing" + "time" +) + +// --- Unit tests for PickSplitAccount --- + +func TestPickSplitAccountDistribution(t *testing.T) { + conf := NewConfig() + conf.HashrateSplit = []SplitAccount{ + {SubAccount: "account_a", Percent: 70}, + {SubAccount: "account_b", Percent: 30}, + } + + const iterations = 100000 + counts := map[string]int{} + for i := 0; i < iterations; i++ { + counts[conf.PickSplitAccount()]++ + } + + for _, sa := range conf.HashrateSplit { + got := float64(counts[sa.SubAccount]) / iterations * 100 + want := float64(sa.Percent) + // Allow ±2% tolerance + if math.Abs(got-want) > 2.0 { + t.Errorf("account %s: got %.2f%%, want %.2f%% (±2%%)", sa.SubAccount, got, want) + } + } +} + +func TestPickSplitAccountThreeWay(t *testing.T) { + conf := NewConfig() + conf.HashrateSplit = []SplitAccount{ + {SubAccount: "a", Percent: 50}, + {SubAccount: "b", Percent: 30}, + {SubAccount: "c", Percent: 20}, + } + + const iterations = 100000 + counts := map[string]int{} + for i := 0; i < iterations; i++ { + counts[conf.PickSplitAccount()]++ + } + + for _, sa := range conf.HashrateSplit { + got := float64(counts[sa.SubAccount]) / iterations * 100 + want := float64(sa.Percent) + if math.Abs(got-want) > 2.0 { + t.Errorf("account %s: got %.2f%%, want %.2f%% (±2%%)", sa.SubAccount, got, want) + } + } +} + +func TestPickSplitAccountSingle(t *testing.T) { + conf := NewConfig() + conf.HashrateSplit = []SplitAccount{ + {SubAccount: "only_one", Percent: 100}, + } + for i := 0; i < 1000; i++ { + if conf.PickSplitAccount() != "only_one" { + t.Fatal("single-account split should always return the one account") + } + } +} + +func TestPickSplitAccountUnequalSum(t *testing.T) { + // Percents don't have to sum to 100; selection is proportional + conf := NewConfig() + conf.HashrateSplit = []SplitAccount{ + {SubAccount: "x", Percent: 1}, + {SubAccount: "y", Percent: 3}, + } + + const iterations = 100000 + counts := map[string]int{} + for i := 0; i < iterations; i++ { + counts[conf.PickSplitAccount()]++ + } + + gotX := float64(counts["x"]) / iterations * 100 + gotY := float64(counts["y"]) / iterations * 100 + + if math.Abs(gotX-25.0) > 2.0 { + t.Errorf("x: got %.2f%%, want ~25%%", gotX) + } + if math.Abs(gotY-75.0) > 2.0 { + t.Errorf("y: got %.2f%%, want ~75%%", gotY) + } +} + +func TestConfigInitClearsPoolSubAccountsWhenSplitting(t *testing.T) { + conf := NewConfig() + conf.AgentType = "btc" + conf.Pools = []PoolInfo{ + {Host: "pool.example.com", Port: 3333, SubAccount: "should_be_cleared"}, + } + conf.HashrateSplit = []SplitAccount{ + {SubAccount: "account_a", Percent: 60}, + {SubAccount: "account_b", Percent: 40}, + } + + conf.Init() + + for _, pool := range conf.Pools { + if pool.SubAccount != "" { + t.Errorf("pool sub-account should be cleared when hashrate_split is active, got: %q", pool.SubAccount) + } + } +} + +// --- Integration test: mock stratum pool + real btcagent routing --- + +// mockStratumServer listens on a free port, performs minimal stratum handshakes, +// and records which sub-account each connection authorized with. +type mockStratumServer struct { + listener net.Listener + mu sync.Mutex + subAccounts []string +} + +func newMockStratumServer(t *testing.T) *mockStratumServer { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("mock server listen: %v", err) + } + s := &mockStratumServer{listener: ln} + go s.serve(t) + return s +} + +func (s *mockStratumServer) Addr() string { + return s.listener.Addr().String() +} + +func (s *mockStratumServer) SubAccounts() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, len(s.subAccounts)) + copy(out, s.subAccounts) + return out +} + +func (s *mockStratumServer) serve(t *testing.T) { + for { + conn, err := s.listener.Accept() + if err != nil { + return + } + go s.handleConn(t, conn) + } +} + +func (s *mockStratumServer) handleConn(t *testing.T, conn net.Conn) { + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + scanner := bufio.NewScanner(conn) + + send := func(msg string) { + conn.Write([]byte(msg + "\n")) + } + + for scanner.Scan() { + line := scanner.Text() + var req map[string]interface{} + if err := json.Unmarshal([]byte(line), &req); err != nil { + continue + } + + id := req["id"] + method, _ := req["method"].(string) + + switch method { + case "agent.get_capabilities": + resp := fmt.Sprintf(`{"id":%v,"result":{"capabilities":[]},"error":null}`, marshalID(id)) + send(resp) + + case "mining.configure": + resp := fmt.Sprintf(`{"id":%v,"result":{"version-rolling":false},"error":null}`, marshalID(id)) + send(resp) + + case "mining.subscribe": + // extraNonce2Size must be 8 to satisfy btcagent's protocol check + resp := fmt.Sprintf(`{"id":%v,"result":[[["mining.notify","00000000"]],"00000001",8],"error":null}`, marshalID(id)) + send(resp) + + case "mining.authorize": + params, _ := req["params"].([]interface{}) + subAccount := "" + if len(params) > 0 { + subAccount, _ = params[0].(string) + } + s.mu.Lock() + s.subAccounts = append(s.subAccounts, subAccount) + s.mu.Unlock() + + resp := fmt.Sprintf(`{"id":%v,"result":true,"error":null}`, marshalID(id)) + send(resp) + // Keep connection open so btcagent doesn't retry; drain any further messages + conn.SetDeadline(time.Now().Add(10 * time.Second)) + for scanner.Scan() { + // absorb any downstream share submissions or agent messages + } + return + } + } +} + +func marshalID(id interface{}) string { + b, _ := json.Marshal(id) + return string(b) +} + +// connectMockMiner connects to btcagent, does subscribe+authorize, then disconnects. +func connectMockMiner(t *testing.T, agentAddr, workerName string) { + t.Helper() + conn, err := net.DialTimeout("tcp", agentAddr, 3*time.Second) + if err != nil { + t.Logf("miner connect error (may be timing): %v", err) + return + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(5 * time.Second)) + + send := func(msg string) { + conn.Write([]byte(msg + "\n")) + } + scanner := bufio.NewScanner(conn) + + // subscribe + send(`{"id":1,"method":"mining.subscribe","params":["TestMiner/1.0"]}`) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, `"id":1`) { + break + } + } + + // authorize + send(fmt.Sprintf(`{"id":2,"method":"mining.authorize","params":[%q,""]}`, workerName)) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, `"id":2`) { + break + } + } +} + +func TestHashrateSplitEndToEnd(t *testing.T) { + // Start a mock pool server + pool := newMockStratumServer(t) + defer pool.listener.Close() + + host, portStr, _ := net.SplitHostPort(pool.Addr()) + var poolPort uint16 + fmt.Sscanf(portStr, "%d", &poolPort) + + // Find a free port for btcagent + agentLn, _ := net.Listen("tcp", "127.0.0.1:0") + _, agentPortStr, _ := net.SplitHostPort(agentLn.Addr().String()) + var agentPort uint16 + fmt.Sscanf(agentPortStr, "%d", &agentPort) + agentLn.Close() + + conf := NewConfig() + conf.AgentType = "btc" + conf.AgentListenIp = "127.0.0.1" + conf.AgentListenPort = agentPort + conf.UseProxy = false + conf.AlwaysKeepDownconn = true + conf.Pools = []PoolInfo{ + {Host: host, Port: poolPort, SubAccount: ""}, + } + conf.HashrateSplit = []SplitAccount{ + {SubAccount: "account_a", Percent: 70}, + {SubAccount: "account_b", Percent: 30}, + } + conf.Init() + + // Intercept sub-account routing decisions via the test hook + const numMiners = 200 + picked := make(chan string, numMiners) + + sm := NewSessionManager(conf) + sm.onSubAccountPick = func(sa string) { picked <- sa } + go sm.Run() + defer sm.Stop() + + // Give btcagent time to start and establish pool connections + time.Sleep(400 * time.Millisecond) + + var wg sync.WaitGroup + for i := 0; i < numMiners; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + connectMockMiner(t, fmt.Sprintf("127.0.0.1:%d", agentPort), fmt.Sprintf("worker%d", i)) + }(i) + time.Sleep(2 * time.Millisecond) + } + wg.Wait() + + // Drain routed sub-accounts with a deadline + deadline := time.After(3 * time.Second) + counts := map[string]int{} + for i := 0; i < numMiners; i++ { + select { + case sa := <-picked: + counts[sa]++ + case <-deadline: + t.Logf("timeout waiting for routing decisions; got %d of %d", i, numMiners) + goto done + } + } +done: + + total := counts["account_a"] + counts["account_b"] + if total == 0 { + t.Fatal("no miners were routed to any split account") + } + + t.Logf("Routed %d miners: account_a=%d, account_b=%d", total, counts["account_a"], counts["account_b"]) + + for _, sa := range conf.HashrateSplit { + got := float64(counts[sa.SubAccount]) / float64(total) * 100 + want := float64(sa.Percent) + // Allow ±10% tolerance at 200 miners + if math.Abs(got-want) > 10.0 { + t.Errorf("account %s: got %.1f%%, want %.1f%% (±10%%)", sa.SubAccount, got, want) + } + } +} diff --git a/SessionManager.go b/SessionManager.go index 1c2c247..4a36c6e 100644 --- a/SessionManager.go +++ b/SessionManager.go @@ -14,6 +14,10 @@ type SessionManager struct { upSessionManagers map[string]*UpSessionManager // map[子账户名]矿池会话管理器 exitChannel chan bool // 退出信号 eventChannel chan interface{} // 事件循环 + + // onSubAccountPick is called (if non-nil) each time a miner is assigned to a + // split sub-account. Used only in tests; nil in production. + onSubAccountPick func(subAccount string) } func NewSessionManager(config *Config) (manager *SessionManager) { @@ -124,6 +128,9 @@ func (manager *SessionManager) addDownSession(e EventAddDownSession) { subAccount := e.Session.SubAccountName() if len(manager.config.HashrateSplit) > 0 { subAccount = manager.config.PickSplitAccount() + if manager.onSubAccountPick != nil { + manager.onSubAccountPick(subAccount) + } } upManager, ok := manager.upSessionManagers[subAccount] if !ok {