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/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 3197aec..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) { @@ -47,8 +51,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 +125,16 @@ 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() + if manager.onSubAccountPick != nil { + manager.onSubAccountPick(subAccount) + } + } + 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"