diff --git a/core_impl.go b/core_impl.go index e1740a9..69255f6 100644 --- a/core_impl.go +++ b/core_impl.go @@ -251,6 +251,9 @@ func (j Job) DifficultyFromTarget() uint64 { return uint64(math.MaxUint32) / uint64(target) } +// targetFromDifficulty converts a difficulty into the 8-char little-endian hex target. +// +// target := targetFromDifficulty(10000) // "b88d0600" func targetFromDifficulty(diff uint64) string { if diff <= 1 { return "ffffffff" @@ -268,6 +271,10 @@ func targetFromDifficulty(diff uint64) string { return hex.EncodeToString(raw[:]) } +// EffectiveShareDifficulty returns the share difficulty capped by the miner's custom diff. +// If no custom diff is set or the pool diff is already lower, the pool diff is returned. +// +// diff := proxy.EffectiveShareDifficulty(job, miner) // 25000 when customDiff < poolDiff func EffectiveShareDifficulty(job Job, miner *Miner) uint64 { diff := job.DifficultyFromTarget() if miner == nil || miner.customDiff == 0 || diff == 0 || diff <= miner.customDiff { diff --git a/customdiff_test.go b/customdiff_test.go index 38ec3d4..1fda6fb 100644 --- a/customdiff_test.go +++ b/customdiff_test.go @@ -2,7 +2,12 @@ package proxy import "testing" -func TestCustomDiff_OnLogin(t *testing.T) { +// TestCustomDiff_Apply_Good verifies a user suffix "+50000" sets customDiff and strips the suffix. +// +// cd := proxy.NewCustomDiff(10000) +// cd.Apply(&proxy.Miner{user: "WALLET+50000"}) +// // miner.User() == "WALLET", miner.customDiff == 50000 +func TestCustomDiff_Apply_Good(t *testing.T) { cd := NewCustomDiff(10000) miner := &Miner{user: "WALLET+50000"} cd.OnLogin(Event{Miner: miner}) @@ -12,37 +17,62 @@ func TestCustomDiff_OnLogin(t *testing.T) { if miner.customDiff != 50000 { t.Fatalf("expected custom diff 50000, got %d", miner.customDiff) } +} - miner = &Miner{user: "WALLET+abc"} +// TestCustomDiff_Apply_Bad verifies "+abc" (non-numeric) leaves user unchanged, customDiff=0. +// +// cd := proxy.NewCustomDiff(10000) +// cd.Apply(&proxy.Miner{user: "WALLET+abc"}) +// // miner.User() == "WALLET+abc", miner.customDiff == 0 +func TestCustomDiff_Apply_Bad(t *testing.T) { + cd := NewCustomDiff(10000) + miner := &Miner{user: "WALLET+abc"} cd.OnLogin(Event{Miner: miner}) if miner.User() != "WALLET+abc" { - t.Fatalf("expected invalid suffix to remain unchanged") + t.Fatalf("expected invalid suffix to remain unchanged, got %q", miner.User()) } if miner.customDiff != 0 { t.Fatalf("expected invalid suffix to disable custom diff, got %d", miner.customDiff) } +} - miner = &Miner{user: "WALLET"} +// TestCustomDiff_Apply_Ugly verifies globalDiff=10000 is used when no suffix is present. +// +// cd := proxy.NewCustomDiff(10000) +// cd.Apply(&proxy.Miner{user: "WALLET"}) +// // miner.customDiff == 10000 (falls back to global) +func TestCustomDiff_Apply_Ugly(t *testing.T) { + cd := NewCustomDiff(10000) + miner := &Miner{user: "WALLET"} cd.OnLogin(Event{Miner: miner}) if miner.customDiff != 10000 { - t.Fatalf("expected global diff fallback, got %d", miner.customDiff) + t.Fatalf("expected global diff fallback 10000, got %d", miner.customDiff) } } -func TestCustomDiff_OnLogin_Ugly(t *testing.T) { +// TestCustomDiff_OnLogin_NonNumericSuffix verifies a non-decimal suffix after plus is ignored. +// +// cd := proxy.NewCustomDiff(10000) +// cd.OnLogin(proxy.Event{Miner: &proxy.Miner{user: "WALLET+50000extra"}}) +func TestCustomDiff_OnLogin_NonNumericSuffix(t *testing.T) { cd := NewCustomDiff(10000) miner := &Miner{user: "WALLET+50000extra"} cd.OnLogin(Event{Miner: miner}) if miner.User() != "WALLET+50000extra" { - t.Fatalf("expected non-suffix plus segment to remain unchanged, got %q", miner.User()) + t.Fatalf("expected non-numeric suffix plus segment to remain unchanged, got %q", miner.User()) } if miner.customDiff != 0 { t.Fatalf("expected invalid suffix to disable custom diff, got %d", miner.customDiff) } } +// TestEffectiveShareDifficulty_CustomDiffCapsPoolDifficulty verifies the cap applied by custom diff. +// +// job := proxy.Job{Target: "01000000"} +// miner := &proxy.Miner{customDiff: 25000} +// proxy.EffectiveShareDifficulty(job, miner) // 25000 (capped) func TestEffectiveShareDifficulty_CustomDiffCapsPoolDifficulty(t *testing.T) { job := Job{Target: "01000000"} miner := &Miner{customDiff: 25000} diff --git a/job_test.go b/job_test.go index 1e3d36f..3554838 100644 --- a/job_test.go +++ b/job_test.go @@ -5,7 +5,11 @@ import ( "testing" ) -func TestJob_BlobWithFixedByte(t *testing.T) { +// TestJob_BlobWithFixedByte_Good verifies nonce patching on a full 160-char blob. +// +// job := proxy.Job{Blob: strings.Repeat("0", 160)} +// result := job.BlobWithFixedByte(0x2A) // chars 78-79 become "2a" +func TestJob_BlobWithFixedByte_Good(t *testing.T) { job := Job{Blob: strings.Repeat("0", 160)} got := job.BlobWithFixedByte(0x2A) if len(got) != 160 { @@ -16,16 +20,97 @@ func TestJob_BlobWithFixedByte(t *testing.T) { } } -func TestJob_DifficultyFromTarget(t *testing.T) { +// TestJob_BlobWithFixedByte_Bad verifies a short blob is returned unchanged. +// +// job := proxy.Job{Blob: "0000"} +// result := job.BlobWithFixedByte(0x2A) // too short, returned as-is +func TestJob_BlobWithFixedByte_Bad(t *testing.T) { + shortBlob := "0000" + job := Job{Blob: shortBlob} + got := job.BlobWithFixedByte(0x2A) + if got != shortBlob { + t.Fatalf("expected short blob to be returned unchanged, got %q", got) + } +} + +// TestJob_BlobWithFixedByte_Ugly verifies fixedByte 0xFF renders as lowercase "ff". +// +// job := proxy.Job{Blob: strings.Repeat("0", 160)} +// result := job.BlobWithFixedByte(0xFF) // chars 78-79 become "ff" (not "FF") +func TestJob_BlobWithFixedByte_Ugly(t *testing.T) { + job := Job{Blob: strings.Repeat("0", 160)} + got := job.BlobWithFixedByte(0xFF) + if got[78:80] != "ff" { + t.Fatalf("expected lowercase 'ff', got %q", got[78:80]) + } + if len(got) != 160 { + t.Fatalf("expected blob length preserved, got %d", len(got)) + } +} + +// TestJob_DifficultyFromTarget_Good verifies a known target converts to the expected difficulty. +// +// job := proxy.Job{Target: "b88d0600"} +// diff := job.DifficultyFromTarget() // 10000 +func TestJob_DifficultyFromTarget_Good(t *testing.T) { job := Job{Target: "b88d0600"} if got := job.DifficultyFromTarget(); got != 10000 { t.Fatalf("expected difficulty 10000, got %d", got) } } -func TestJob_DifficultyFromTarget_MaxTarget(t *testing.T) { +// TestJob_DifficultyFromTarget_Bad verifies a zero target produces difficulty 0 without panic. +// +// job := proxy.Job{Target: "00000000"} +// diff := job.DifficultyFromTarget() // 0 (no divide-by-zero) +func TestJob_DifficultyFromTarget_Bad(t *testing.T) { + job := Job{Target: "00000000"} + if got := job.DifficultyFromTarget(); got != 0 { + t.Fatalf("expected difficulty 0 for zero target, got %d", got) + } +} + +// TestJob_DifficultyFromTarget_Ugly verifies the maximum target "ffffffff" yields difficulty 1. +// +// job := proxy.Job{Target: "ffffffff"} +// diff := job.DifficultyFromTarget() // 1 +func TestJob_DifficultyFromTarget_Ugly(t *testing.T) { job := Job{Target: "ffffffff"} if got := job.DifficultyFromTarget(); got != 1 { t.Fatalf("expected minimum difficulty 1, got %d", got) } } + +// TestJob_IsValid_Good verifies a job with blob and job ID is valid. +// +// job := proxy.Job{Blob: "abc", JobID: "job-1"} +// job.IsValid() // true +func TestJob_IsValid_Good(t *testing.T) { + job := Job{Blob: "abc", JobID: "job-1"} + if !job.IsValid() { + t.Fatalf("expected job with blob and job id to be valid") + } +} + +// TestJob_IsValid_Bad verifies a job with empty blob or job ID is invalid. +// +// job := proxy.Job{Blob: "", JobID: "job-1"} +// job.IsValid() // false +func TestJob_IsValid_Bad(t *testing.T) { + if (Job{Blob: "", JobID: "job-1"}).IsValid() { + t.Fatalf("expected empty blob to be invalid") + } + if (Job{Blob: "abc", JobID: ""}).IsValid() { + t.Fatalf("expected empty job id to be invalid") + } +} + +// TestJob_IsValid_Ugly verifies a zero-value job is invalid. +// +// job := proxy.Job{} +// job.IsValid() // false +func TestJob_IsValid_Ugly(t *testing.T) { + if (Job{}).IsValid() { + t.Fatalf("expected zero-value job to be invalid") + } +} diff --git a/ratelimit_test.go b/ratelimit_test.go index c5f06ed..88b127d 100644 --- a/ratelimit_test.go +++ b/ratelimit_test.go @@ -5,16 +5,87 @@ import ( "time" ) -func TestRateLimiter_Allow(t *testing.T) { - rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 1, BanDurationSeconds: 1}) - if !rl.Allow("1.2.3.4:1234") { - t.Fatalf("expected first call to pass") - } - if rl.Allow("1.2.3.4:1234") { - t.Fatalf("expected second call to fail") +// TestRateLimiter_Allow_Good verifies the first N calls within budget are allowed. +// +// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 10}) +// limiter.Allow("1.2.3.4:3333") // true (first 10 calls) +func TestRateLimiter_Allow_Good(t *testing.T) { + rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 10, BanDurationSeconds: 60}) + + for i := 0; i < 10; i++ { + if !rl.Allow("1.2.3.4:3333") { + t.Fatalf("expected call %d to be allowed", i+1) + } } } +// TestRateLimiter_Allow_Bad verifies the 11th call fails when budget is 10/min. +// +// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 10}) +// // calls 1-10 pass, call 11 fails +func TestRateLimiter_Allow_Bad(t *testing.T) { + rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 10, BanDurationSeconds: 60}) + + for i := 0; i < 10; i++ { + rl.Allow("1.2.3.4:3333") + } + if rl.Allow("1.2.3.4:3333") { + t.Fatalf("expected 11th call to be rejected") + } +} + +// TestRateLimiter_Allow_Ugly verifies a banned IP stays banned for BanDurationSeconds. +// +// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 1, BanDurationSeconds: 300}) +// limiter.Allow("1.2.3.4:3333") // true (exhausts budget) +// limiter.Allow("1.2.3.4:3333") // false (banned for 300 seconds) +func TestRateLimiter_Allow_Ugly(t *testing.T) { + rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 1, BanDurationSeconds: 300}) + + if !rl.Allow("1.2.3.4:3333") { + t.Fatalf("expected first call to pass") + } + if rl.Allow("1.2.3.4:3333") { + t.Fatalf("expected second call to fail") + } + + // Verify the IP is still banned even with a fresh bucket + rl.mu.Lock() + rl.bucketByHost["1.2.3.4"] = &tokenBucket{tokens: 100, lastRefill: time.Now()} + rl.mu.Unlock() + if rl.Allow("1.2.3.4:3333") { + t.Fatalf("expected banned IP to remain banned regardless of fresh bucket") + } +} + +// TestRateLimiter_Tick_Good verifies Tick removes expired bans. +// +// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 1, BanDurationSeconds: 1}) +// limiter.Tick() +func TestRateLimiter_Tick_Good(t *testing.T) { + rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 1, BanDurationSeconds: 1}) + + rl.Allow("1.2.3.4:3333") + rl.Allow("1.2.3.4:3333") // triggers ban + + // Simulate expired ban + rl.mu.Lock() + rl.banUntilByHost["1.2.3.4"] = time.Now().Add(-time.Second) + rl.mu.Unlock() + + rl.Tick() + + rl.mu.Lock() + _, banned := rl.banUntilByHost["1.2.3.4"] + rl.mu.Unlock() + if banned { + t.Fatalf("expected expired ban to be removed by Tick") + } +} + +// TestRateLimiter_Allow_ReplenishesHighLimits verifies token replenishment at high rates. +// +// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 120}) func TestRateLimiter_Allow_ReplenishesHighLimits(t *testing.T) { rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 120, BanDurationSeconds: 1}) rl.mu.Lock() @@ -28,3 +99,17 @@ func TestRateLimiter_Allow_ReplenishesHighLimits(t *testing.T) { t.Fatalf("expected bucket to replenish at 120/min") } } + +// TestRateLimiter_Disabled_Good verifies a zero-budget limiter allows all connections. +// +// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 0}) +// limiter.Allow("any-ip") // always true +func TestRateLimiter_Disabled_Good(t *testing.T) { + rl := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 0}) + + for i := 0; i < 100; i++ { + if !rl.Allow("1.2.3.4:3333") { + t.Fatalf("expected disabled limiter to allow all connections") + } + } +} diff --git a/splitter/nicehash/storage_test.go b/splitter/nicehash/storage_test.go index ab5da9a..58851ea 100644 --- a/splitter/nicehash/storage_test.go +++ b/splitter/nicehash/storage_test.go @@ -6,29 +6,135 @@ import ( "dappco.re/go/proxy" ) -func TestNonceStorage_AddAndRemove(t *testing.T) { +// TestStorage_Add_Good verifies 256 sequential Add calls fill all slots with unique FixedByte values. +// +// storage := nicehash.NewNonceStorage() +// for i := 0; i < 256; i++ { +// m := &proxy.Miner{} +// m.SetID(int64(i + 1)) +// ok := storage.Add(m) // true for all 256 +// } +func TestStorage_Add_Good(t *testing.T) { + storage := NewNonceStorage() + seen := make(map[uint8]bool) + for i := 0; i < 256; i++ { + m := &proxy.Miner{} + m.SetID(int64(i + 1)) + ok := storage.Add(m) + if !ok { + t.Fatalf("expected add %d to succeed", i) + } + if seen[m.FixedByte()] { + t.Fatalf("duplicate fixed byte %d at add %d", m.FixedByte(), i) + } + seen[m.FixedByte()] = true + } +} + +// TestStorage_Add_Bad verifies the 257th Add returns false when all 256 slots are occupied. +// +// storage := nicehash.NewNonceStorage() +// // fill 256 slots... +// ok := storage.Add(overflowMiner) // false — table is full +func TestStorage_Add_Bad(t *testing.T) { + storage := NewNonceStorage() + for i := 0; i < 256; i++ { + m := &proxy.Miner{} + m.SetID(int64(i + 1)) + storage.Add(m) + } + + overflow := &proxy.Miner{} + overflow.SetID(257) + if storage.Add(overflow) { + t.Fatalf("expected 257th add to fail when table is full") + } +} + +// TestStorage_Add_Ugly verifies that a removed slot (dead) is reclaimed after SetJob clears it. +// +// storage := nicehash.NewNonceStorage() +// storage.Add(miner) +// storage.Remove(miner) // slot becomes dead (-minerID) +// storage.SetJob(job) // dead slots cleared to 0 +// storage.Add(newMiner) // reclaimed slot succeeds +func TestStorage_Add_Ugly(t *testing.T) { storage := NewNonceStorage() miner := &proxy.Miner{} miner.SetID(1) if !storage.Add(miner) { - t.Fatalf("expected add to succeed") - } - if miner.FixedByte() != 0 { - t.Fatalf("expected first slot to be 0, got %d", miner.FixedByte()) + t.Fatalf("expected first add to succeed") } storage.Remove(miner) free, dead, active := storage.SlotCount() - if free != 255 || dead != 1 || active != 0 { - t.Fatalf("unexpected slot counts: free=%d dead=%d active=%d", free, dead, active) + if dead != 1 || active != 0 { + t.Fatalf("expected 1 dead slot, got free=%d dead=%d active=%d", free, dead, active) + } + + // SetJob clears dead slots + storage.SetJob(proxy.Job{Blob: "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", JobID: "job-1"}) + free, dead, active = storage.SlotCount() + if dead != 0 { + t.Fatalf("expected dead slots cleared after SetJob, got %d", dead) + } + + // Reclaim the slot + newMiner := &proxy.Miner{} + newMiner.SetID(2) + if !storage.Add(newMiner) { + t.Fatalf("expected reclaimed slot add to succeed") } } -func TestNonceStorage_IsValidJobID_Ugly(t *testing.T) { +// TestStorage_IsValidJobID_Good verifies the current job ID is accepted. +// +// storage := nicehash.NewNonceStorage() +// storage.SetJob(proxy.Job{JobID: "job-2", Blob: "..."}) +// storage.IsValidJobID("job-2") // true +func TestStorage_IsValidJobID_Good(t *testing.T) { storage := NewNonceStorage() - storage.job = proxy.Job{JobID: "job-2"} - storage.prevJob = proxy.Job{JobID: "job-1"} + storage.SetJob(proxy.Job{ + JobID: "job-1", + Blob: "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + }) + + if !storage.IsValidJobID("job-1") { + t.Fatalf("expected current job to be valid") + } +} + +// TestStorage_IsValidJobID_Bad verifies an unknown job ID is rejected. +// +// storage := nicehash.NewNonceStorage() +// storage.IsValidJobID("nonexistent") // false +func TestStorage_IsValidJobID_Bad(t *testing.T) { + storage := NewNonceStorage() + storage.SetJob(proxy.Job{ + JobID: "job-1", + Blob: "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + }) + + if storage.IsValidJobID("nonexistent") { + t.Fatalf("expected unknown job id to be invalid") + } + if storage.IsValidJobID("") { + t.Fatalf("expected empty job id to be invalid") + } +} + +// TestStorage_IsValidJobID_Ugly verifies the previous job ID is accepted but counts as expired. +// +// storage := nicehash.NewNonceStorage() +// // job-1 is current, job-2 pushes job-1 to previous +// storage.IsValidJobID("job-1") // true (but expired counter increments) +func TestStorage_IsValidJobID_Ugly(t *testing.T) { + storage := NewNonceStorage() + blob160 := "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + + storage.SetJob(proxy.Job{JobID: "job-1", Blob: blob160, ClientID: "session-1"}) + storage.SetJob(proxy.Job{JobID: "job-2", Blob: blob160, ClientID: "session-1"}) if !storage.IsValidJobID("job-2") { t.Fatalf("expected current job to be valid") @@ -39,7 +145,16 @@ func TestNonceStorage_IsValidJobID_Ugly(t *testing.T) { if storage.expired != 1 { t.Fatalf("expected one expired job validation, got %d", storage.expired) } - if storage.IsValidJobID("") { - t.Fatalf("expected empty job id to be invalid") +} + +// TestStorage_SlotCount_Good verifies free/dead/active counts on a fresh storage. +// +// storage := nicehash.NewNonceStorage() +// free, dead, active := storage.SlotCount() // 256, 0, 0 +func TestStorage_SlotCount_Good(t *testing.T) { + storage := NewNonceStorage() + free, dead, active := storage.SlotCount() + if free != 256 || dead != 0 || active != 0 { + t.Fatalf("expected 256/0/0, got free=%d dead=%d active=%d", free, dead, active) } } diff --git a/state_impl.go b/state_impl.go index 327de02..113f21d 100644 --- a/state_impl.go +++ b/state_impl.go @@ -24,6 +24,13 @@ const ( ) // MinerSnapshot is a serialisable view of one miner connection. +// +// snapshots := p.MinerSnapshots() +// for _, s := range snapshots { +// _ = s.ID // 1 +// _ = s.IP // "10.0.0.1:49152" +// _ = s.Diff // 100000 +// } type MinerSnapshot struct { ID int64 IP string @@ -102,6 +109,8 @@ func New(config *Config) (*Proxy, Result) { } // Mode returns the runtime mode, for example "nicehash" or "simple". +// +// mode := p.Mode() // "nicehash" func (p *Proxy) Mode() string { if p == nil || p.config == nil { return "" @@ -112,6 +121,8 @@ func (p *Proxy) Mode() string { } // WorkersMode returns the active worker identity strategy, for example proxy.WorkersByRigID. +// +// mode := p.WorkersMode() // proxy.WorkersByRigID func (p *Proxy) WorkersMode() WorkersMode { if p == nil || p.config == nil { return WorkersDisabled @@ -122,6 +133,9 @@ func (p *Proxy) WorkersMode() WorkersMode { } // Summary returns a snapshot of the current global metrics. +// +// summary := p.Summary() +// _ = summary.Accepted func (p *Proxy) Summary() StatsSummary { if p == nil || p.stats == nil { return StatsSummary{} @@ -134,6 +148,9 @@ func (p *Proxy) Summary() StatsSummary { } // WorkerRecords returns a snapshot of the current worker aggregates. +// +// records := p.WorkerRecords() +// for _, r := range records { _ = r.Name } func (p *Proxy) WorkerRecords() []WorkerRecord { if p == nil || p.workers == nil { return nil @@ -142,6 +159,9 @@ func (p *Proxy) WorkerRecords() []WorkerRecord { } // MinerSnapshots returns a snapshot of the live miner connections. +// +// snapshots := p.MinerSnapshots() +// for _, s := range snapshots { _ = s.IP } func (p *Proxy) MinerSnapshots() []MinerSnapshot { if p == nil { return nil @@ -172,6 +192,8 @@ func (p *Proxy) MinerSnapshots() []MinerSnapshot { } // MinerCount returns the current and peak miner counts. +// +// now, max := p.MinerCount() // 142, 200 func (p *Proxy) MinerCount() (now, max uint64) { if p == nil || p.stats == nil { return 0, 0 @@ -180,6 +202,9 @@ func (p *Proxy) MinerCount() (now, max uint64) { } // Upstreams returns the current upstream connection counts. +// +// stats := p.Upstreams() +// _ = stats.Active // 1 func (p *Proxy) Upstreams() UpstreamStats { if p == nil || p.splitter == nil { return UpstreamStats{} @@ -188,6 +213,9 @@ func (p *Proxy) Upstreams() UpstreamStats { } // Events returns the proxy event bus for subscription. +// +// bus := p.Events() +// bus.Subscribe(proxy.EventAccept, handler) func (p *Proxy) Events() *EventBus { if p == nil { return nil @@ -862,71 +890,166 @@ func NewMiner(conn net.Conn, localPort uint16, tlsCfg *tls.Config) *Miner { return miner } +// SetID assigns the miner's internal ID. Used by NonceStorage tests. +// +// m.SetID(42) func (m *Miner) SetID(id int64) { m.id = id } -func (m *Miner) ID() int64 { return m.id } + +// ID returns the miner's monotonically increasing per-process identifier. +// +// id := m.ID() // 42 +func (m *Miner) ID() int64 { return m.id } + +// SetMapperID assigns which NonceMapper owns this miner in NiceHash mode. +// +// m.SetMapperID(0) // assigned to mapper 0 func (m *Miner) SetMapperID(id int64) { m.mapperID = id } + +// MapperID returns the owning NonceMapper's ID, or -1 if unassigned. +// +// if m.MapperID() < 0 { /* miner not assigned to any mapper */ } func (m *Miner) MapperID() int64 { return m.mapperID } + +// SetRouteID assigns the SimpleMapper ID in simple mode. +// +// m.SetRouteID(3) func (m *Miner) SetRouteID(id int64) { m.routeID = id } + +// RouteID returns the SimpleMapper ID, or -1 if unassigned. +// +// if m.RouteID() < 0 { /* miner not routed */ } func (m *Miner) RouteID() int64 { return m.routeID } + +// SetExtendedNiceHash enables or disables NiceHash nonce-splitting mode for this miner. +// +// m.SetExtendedNiceHash(true) func (m *Miner) SetExtendedNiceHash(enabled bool) { m.extNH = enabled } + +// ExtendedNiceHash reports whether this miner is in NiceHash nonce-splitting mode. +// +// if m.ExtendedNiceHash() { /* blob byte 39 is patched */ } func (m *Miner) ExtendedNiceHash() bool { return m.extNH } + +// SetCurrentJob assigns the current pool work unit to this miner. +// +// m.SetCurrentJob(proxy.Job{Blob: "...", JobID: "job-1"}) func (m *Miner) SetCurrentJob(job Job) { m.currentJob = job } + +// CurrentJob returns the last job forwarded to this miner. +// +// job := m.CurrentJob() +// if job.IsValid() { /* miner has a valid job */ } func (m *Miner) CurrentJob() Job { return m.currentJob } + +// LoginAlgos returns the algorithm list sent by the miner during login, or nil if empty. +// +// algos := m.LoginAlgos() // ["cn/r", "rx/0"] func (m *Miner) LoginAlgos() []string { if m == nil || len(m.loginAlgos) == 0 { return nil } return append([]string(nil), m.loginAlgos...) } + +// FixedByte returns the NiceHash slot index (0-255) assigned to this miner. +// +// slot := m.FixedByte() // 0x2A func (m *Miner) FixedByte() uint8 { return m.fixedByte } + +// SetFixedByte assigns the NiceHash slot index for this miner. +// +// m.SetFixedByte(0x2A) func (m *Miner) SetFixedByte(value uint8) { m.fixedByte = value } + +// IP returns the remote IP address (without port) for logging. +// +// ip := m.IP() // "10.0.0.1" func (m *Miner) IP() string { return m.ip } + +// RemoteAddr returns the full remote address including port. +// +// addr := m.RemoteAddr() // "10.0.0.1:49152" func (m *Miner) RemoteAddr() string { if m == nil { return "" } return m.remoteAddr } + +// User returns the wallet address from login params, with any custom diff suffix stripped. +// +// user := m.User() // "WALLET" (even if login was "WALLET+50000") func (m *Miner) User() string { return m.user } + +// Password returns the login params.pass value. +// +// pass := m.Password() // "x" func (m *Miner) Password() string { return m.password } + +// Agent returns the mining software identifier from login params. +// +// agent := m.Agent() // "XMRig/6.21.0" func (m *Miner) Agent() string { return m.agent } + +// RigID returns the optional rigid extension field from login params. +// +// rigid := m.RigID() // "rig-alpha" func (m *Miner) RigID() string { return m.rigID } + +// RX returns the total bytes received from this miner. +// +// rx := m.RX() // 4096 func (m *Miner) RX() uint64 { return m.rx } + +// TX returns the total bytes sent to this miner. +// +// tx := m.TX() // 8192 func (m *Miner) TX() uint64 { return m.tx } + +// Diff returns the last difficulty sent to this miner from the pool. +// +// diff := m.Diff() // 100000 +func (m *Miner) Diff() uint64 { + return m.diff +} + +// State returns the current lifecycle state of this miner connection. +// +// if m.State() == proxy.MinerStateReady { /* miner is active */ } func (m *Miner) State() MinerState { return m.state } @@ -1863,6 +1986,8 @@ func (s *Server) listen() Result { } // IsActive reports whether the limiter has enabled rate limiting. +// +// if rl.IsActive() { /* rate limiting is enabled */ } func (rl *RateLimiter) IsActive() bool { return rl != nil && rl.limit.MaxConnectionsPerMinute > 0 } diff --git a/stats_test.go b/stats_test.go index f83838e..c3fa660 100644 --- a/stats_test.go +++ b/stats_test.go @@ -1,8 +1,82 @@ package proxy -import "testing" +import ( + "sync" + "testing" +) -func TestProxy_Stats_InvalidRejectReasons_Good(t *testing.T) { +// TestStats_OnAccept_Good verifies that accepted counter, hashes, and topDiff are updated. +// +// stats := proxy.NewStats() +// stats.OnAccept(proxy.Event{Diff: 100000, Latency: 82}) +// summary := stats.Summary() +// _ = summary.Accepted // 1 +// _ = summary.Hashes // 100000 +func TestStats_OnAccept_Good(t *testing.T) { + stats := NewStats() + + stats.OnAccept(Event{Diff: 100000, Latency: 82}) + + summary := stats.Summary() + if summary.Accepted != 1 { + t.Fatalf("expected accepted 1, got %d", summary.Accepted) + } + if summary.Hashes != 100000 { + t.Fatalf("expected hashes 100000, got %d", summary.Hashes) + } + if summary.TopDiff[0] != 100000 { + t.Fatalf("expected top diff 100000, got %d", summary.TopDiff[0]) + } +} + +// TestStats_OnAccept_Bad verifies concurrent OnAccept calls do not race. +// +// stats := proxy.NewStats() +// // 100 goroutines each call OnAccept — no data race under -race flag. +func TestStats_OnAccept_Bad(t *testing.T) { + stats := NewStats() + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(diff uint64) { + defer wg.Done() + stats.OnAccept(Event{Diff: diff, Latency: 10}) + }(uint64(i + 1)) + } + wg.Wait() + + summary := stats.Summary() + if summary.Accepted != 100 { + t.Fatalf("expected 100 accepted, got %d", summary.Accepted) + } +} + +// TestStats_OnAccept_Ugly verifies that 15 accepts with varying diffs fill all topDiff slots. +// +// stats := proxy.NewStats() +// // 15 accepts with diffs 1..15 → topDiff[9] is 6 (10th highest), not 0 +func TestStats_OnAccept_Ugly(t *testing.T) { + stats := NewStats() + + for i := 1; i <= 15; i++ { + stats.OnAccept(Event{Diff: uint64(i)}) + } + + summary := stats.Summary() + // top 10 should be 15, 14, 13, ..., 6 + if summary.TopDiff[0] != 15 { + t.Fatalf("expected top diff[0]=15, got %d", summary.TopDiff[0]) + } + if summary.TopDiff[9] != 6 { + t.Fatalf("expected top diff[9]=6, got %d", summary.TopDiff[9]) + } +} + +// TestStats_OnReject_Good verifies that rejected and invalid counters are updated. +// +// stats := proxy.NewStats() +// stats.OnReject(proxy.Event{Error: "Low difficulty share"}) +func TestStats_OnReject_Good(t *testing.T) { stats := NewStats() stats.OnReject(Event{Error: "Low difficulty share"}) @@ -16,3 +90,84 @@ func TestProxy_Stats_InvalidRejectReasons_Good(t *testing.T) { t.Fatalf("expected two invalid shares, got %d", summary.Invalid) } } + +// TestStats_OnReject_Bad verifies that a non-invalid rejection increments rejected but not invalid. +// +// stats := proxy.NewStats() +// stats.OnReject(proxy.Event{Error: "Stale share"}) +func TestStats_OnReject_Bad(t *testing.T) { + stats := NewStats() + + stats.OnReject(Event{Error: "Stale share"}) + + summary := stats.Summary() + if summary.Rejected != 1 { + t.Fatalf("expected one rejected, got %d", summary.Rejected) + } + if summary.Invalid != 0 { + t.Fatalf("expected zero invalid for non-invalid reason, got %d", summary.Invalid) + } +} + +// TestStats_OnReject_Ugly verifies an expired accepted share increments both accepted and expired. +// +// stats := proxy.NewStats() +// stats.OnAccept(proxy.Event{Diff: 1000, Expired: true}) +func TestStats_OnReject_Ugly(t *testing.T) { + stats := NewStats() + + stats.OnAccept(Event{Diff: 1000, Expired: true}) + + summary := stats.Summary() + if summary.Accepted != 1 { + t.Fatalf("expected accepted 1, got %d", summary.Accepted) + } + if summary.Expired != 1 { + t.Fatalf("expected expired 1, got %d", summary.Expired) + } +} + +// TestStats_Tick_Good verifies that Tick advances the rolling window position. +// +// stats := proxy.NewStats() +// stats.OnAccept(proxy.Event{Diff: 500}) +// stats.Tick() +// summary := stats.Summary() +func TestStats_Tick_Good(t *testing.T) { + stats := NewStats() + + stats.OnAccept(Event{Diff: 500}) + stats.Tick() + + summary := stats.Summary() + // After one tick, the hashrate should still include the 500 diff + if summary.Hashrate[HashrateWindow60s] == 0 { + t.Fatalf("expected non-zero 60s hashrate after accept and tick") + } +} + +// TestStats_OnLogin_OnClose_Good verifies miner count tracking. +// +// stats := proxy.NewStats() +// stats.OnLogin(proxy.Event{Miner: &proxy.Miner{}}) +// stats.OnClose(proxy.Event{Miner: &proxy.Miner{}}) +func TestStats_OnLogin_OnClose_Good(t *testing.T) { + stats := NewStats() + m := &Miner{} + + stats.OnLogin(Event{Miner: m}) + if got := stats.miners.Load(); got != 1 { + t.Fatalf("expected 1 miner, got %d", got) + } + if got := stats.maxMiners.Load(); got != 1 { + t.Fatalf("expected max miners 1, got %d", got) + } + + stats.OnClose(Event{Miner: m}) + if got := stats.miners.Load(); got != 0 { + t.Fatalf("expected 0 miners after close, got %d", got) + } + if got := stats.maxMiners.Load(); got != 1 { + t.Fatalf("expected max miners to remain 1, got %d", got) + } +}