diff --git a/src/models/StratumV1ClientStatistics.spec.ts b/src/models/StratumV1ClientStatistics.spec.ts index 8702c0d..cde6c89 100644 --- a/src/models/StratumV1ClientStatistics.spec.ts +++ b/src/models/StratumV1ClientStatistics.spec.ts @@ -32,7 +32,30 @@ describe('StratumV1ClientStatistics', () => { await statistics.addShares(client, 64); } - expect(statistics.hashRate).toBeGreaterThan(0); + expect(statistics.hashRate).toBeCloseTo((64 * 4294967296) / 62); + }); + + it('keeps exactly the configured number of share samples', async () => { + for (let i = 0; i < 31; i++) { + jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000))); + await statistics.addShares(client, 64); + } + + expect((statistics as any).submissionCache).toHaveLength(30); + expect((statistics as any).submissionCacheDifficultySum).toBe(30 * 64); + }); + + it('excludes pre-window work and corrects share-terminated sampling bias', async () => { + for (let i = 0; i < 30; i++) { + jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000))); + await statistics.addShares(client, 64); + } + + const elapsedSeconds = 29 * 31; + const expectedUnbiasedDifficulty = 28 * 64; + expect(statistics.hashRate).toBeCloseTo( + (expectedUnbiasedDifficulty * 4294967296) / elapsedSeconds, + ); }); it('should not suggest a difficulty change before enough time or shares have passed', () => { @@ -51,7 +74,7 @@ describe('StratumV1ClientStatistics', () => { await statistics.addShares(client, 64); } - expect(statistics.getSuggestedDifficulty(64)).toBe(2048); + expect(statistics.getSuggestedDifficulty(64)).toBe(1024); }); it('does not retarget when accepted shares have a zero-duration sample window', async () => { @@ -76,7 +99,7 @@ describe('StratumV1ClientStatistics', () => { await statistics.addShares(client, 64); } - expect(statistics.getSuggestedDifficulty(128)).toBe(16); + expect(statistics.getSuggestedDifficulty(128)).toBe(8); }); it('should not suggest a difficulty below the configured minimum', () => { diff --git a/src/models/StratumV1ClientStatistics.ts b/src/models/StratumV1ClientStatistics.ts index 8b1f751..3a94b43 100644 --- a/src/models/StratumV1ClientStatistics.ts +++ b/src/models/StratumV1ClientStatistics.ts @@ -16,9 +16,9 @@ export class StratumV1ClientStatistics { } public async addShares(_client: ClientEntity, targetDifficulty: number) { - var date = new Date(); + const date = new Date(); - if (this.submissionCache.length > CACHE_SIZE) { + if (this.submissionCache.length >= CACHE_SIZE) { this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty; this.submissionCache.shift(); } @@ -28,9 +28,12 @@ export class StratumV1ClientStatistics { }); this.submissionCacheDifficultySum += targetDifficulty; - const time = new Date().getTime() - this.submissionCache[0].time.getTime(); - if(time > 60000 && this.submissionCache.length > 2) { - this.hashRate = (this.submissionCacheDifficultySum * 4294967296) / (time / 1000); + const elapsedSeconds = (date.getTime() - this.submissionCache[0].time.getTime()) / 1000; + if (elapsedSeconds > 60) { + const difficultyPerSecond = this.getDifficultyPerSecond(elapsedSeconds); + if (difficultyPerSecond != null) { + this.hashRate = difficultyPerSecond * 4294967296; + } } } @@ -46,17 +49,12 @@ export class StratumV1ClientStatistics { } } - const sum = this.submissionCache.reduce((pre, cur) => { - pre += cur.difficulty; - return pre; - }, 0); const diffSeconds = (this.submissionCache[this.submissionCache.length - 1].time.getTime() - this.submissionCache[0].time.getTime()) / 1000; - if (!Number.isFinite(diffSeconds) || diffSeconds <= 0) { + const difficultyPerSecond = this.getDifficultyPerSecond(diffSeconds); + if (difficultyPerSecond == null) { return null; } - const difficultyPerSecond = sum / diffSeconds; - const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds; if (!Number.isFinite(targetDifficulty) || targetDifficulty <= 0) { return null; @@ -69,6 +67,32 @@ export class StratumV1ClientStatistics { return null; } + /** + * Estimate work rate from a share-terminated sample window. + * + * A cache of N shares contains N - 1 observed inter-share intervals. The + * first share's work predates the window and must not be counted. Because + * the window closes on a share arrival, the reciprocal elapsed time also + * has the usual finite-sample Poisson bias; multiplying by + * (intervalCount - 1) / intervalCount removes it. + */ + private getDifficultyPerSecond(elapsedSeconds: number): number | null { + const sampleCount = this.submissionCache.length; + if (sampleCount <= 2 || !Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) { + return null; + } + + const intervalCount = sampleCount - 1; + const observedDifficulty = this.submissionCacheDifficultySum + - this.submissionCache[0].difficulty; + const unbiasedDifficulty = observedDifficulty * (intervalCount - 1) / intervalCount; + const difficultyPerSecond = unbiasedDifficulty / elapsedSeconds; + + return Number.isFinite(difficultyPerSecond) && difficultyPerSecond > 0 + ? difficultyPerSecond + : null; + } + private nearestPowerOfTwo(val: number): number { if (!Number.isFinite(val) || val <= 0) { return null;