From 1c5a3d9a94bcd59a2ea05d6e0ba381e6a741e16d Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 9 Jun 2026 17:13:27 -0400 Subject: [PATCH] performance and optimization --- .gitignore | 1 + src/models/MiningJob.ts | 5 +- src/models/StratumV1Client.spec.ts | 28 ++++- src/models/StratumV1Client.ts | 24 +++-- src/models/StratumV2Client.spec.ts | 101 +++++++++++++++++- src/models/StratumV2Client.ts | 18 +++- .../MiningSubmitMessage.spec.ts | 4 + .../stratum-messages/MiningSubmitMessage.ts | 4 +- src/services/stratum-v1-jobs.service.ts | 3 +- src/utils/difficulty.utils.spec.ts | 62 +++++++++++ src/utils/difficulty.utils.ts | 61 ++++++----- src/utils/hash.utils.spec.ts | 15 +++ src/utils/hash.utils.ts | 6 ++ 13 files changed, 284 insertions(+), 48 deletions(-) create mode 100644 src/utils/difficulty.utils.spec.ts create mode 100644 src/utils/hash.utils.spec.ts create mode 100644 src/utils/hash.utils.ts diff --git a/.gitignore b/.gitignore index 6bb1c3c..6985701 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ lerna-debug.log* /.nyc_output /.baseline-*.json /.test-artifacts +/.profiles # IDEs and editors /.idea diff --git a/src/models/MiningJob.ts b/src/models/MiningJob.ts index 5024b0e..05b3b95 100644 --- a/src/models/MiningJob.ts +++ b/src/models/MiningJob.ts @@ -2,6 +2,7 @@ import { AddressType, getAddressInfo } from 'bitcoin-address-validation'; import * as bitcoinjs from 'bitcoinjs-lib'; import { IJobTemplate } from '../services/stratum-v1-jobs.service'; +import { hash256 } from '../utils/hash.utils'; import { eResponseMethod } from './enums/eResponseMethod'; import { IMiningNotify } from './stratum-messages/IMiningNotify'; import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants'; @@ -96,7 +97,7 @@ export class MiningJob { Buffer.from(`${extraNonce}${extraNonce2}`, 'hex'), this.coinbasePart2Buffer, ]); - const coinbaseHash = bitcoinjs.crypto.hash256(coinbaseBuffer); + const coinbaseHash = hash256(coinbaseBuffer); const merkleRoot = this.calculateMerkleRootHash(coinbaseHash, this.merkleBranchBuffers); let version = jobTemplate.block.version; @@ -154,7 +155,7 @@ export class MiningJob { for (let i = 0; i < merkleBranches.length; i++) { bothMerkles.set(merkleBranches[i], 32); - newRoot = bitcoinjs.crypto.hash256(bothMerkles); + newRoot = hash256(bothMerkles); bothMerkles.set(newRoot); } diff --git a/src/models/StratumV1Client.spec.ts b/src/models/StratumV1Client.spec.ts index 1720cd6..940b6c0 100644 --- a/src/models/StratumV1Client.spec.ts +++ b/src/models/StratumV1Client.spec.ts @@ -9,6 +9,7 @@ import { ClientService } from '../ORM/client/client.service'; import { BitcoinRpcService as MockBitcoinRpcService } from '../services/bitcoin-rpc.service'; import { NotificationService } from '../services/notification.service'; import { StratumV1JobsService } from '../services/stratum-v1-jobs.service'; +import { DifficultyUtils } from '../utils/difficulty.utils'; import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate'; import { MiningJob } from './MiningJob'; import { StratumV1Client } from './StratumV1Client'; @@ -471,7 +472,8 @@ describe('StratumV1Client', () => { jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({ submissionDifficulty: 1024, - submissionHash: 'share' + submissionHash: 'share', + hashBuffer: DifficultyUtils.difficultyToTarget(1024), }); const getSettingsSpy = jest.spyOn(addressSettings, 'getSettings'); const updateIfHigherSpy = jest.spyOn(addressSettings as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 }); @@ -495,6 +497,27 @@ describe('StratumV1Client', () => { expect(getSettingsSpy).not.toHaveBeenCalled(); }); + it('should reject shares by exact target even when reported difficulty is huge', async () => { + jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); + jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({ + submissionDifficulty: Number.MAX_SAFE_INTEGER, + submissionHash: 'too-easy', + hashBuffer: Buffer.alloc(32, 0xff), + }); + + emitMessage(MockRecording1.MINING_SUBSCRIBE); + emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [1024]}`); + emitMessage(MockRecording1.MINING_AUTHORIZE); + await new Promise((r) => setTimeout(r, 100)); + + emitMessage(MockRecording1.MINING_SUBMIT); + jest.useRealTimers(); + await new Promise((r) => setTimeout(r, 1000)); + + expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`); + expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled(); + }); + it('should reject duplicate submissions', async () => { jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); @@ -626,7 +649,8 @@ describe('StratumV1Client', () => { jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({ submissionDifficulty: Number.MAX_SAFE_INTEGER, - submissionHash: 'block-share' + submissionHash: 'block-share', + hashBuffer: Buffer.alloc(32), }); jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined); diff --git a/src/models/StratumV1Client.ts b/src/models/StratumV1Client.ts index a131fe2..aa2beb0 100644 --- a/src/models/StratumV1Client.ts +++ b/src/models/StratumV1Client.ts @@ -16,6 +16,8 @@ import { BitcoinRpcService } from '../services/bitcoin-rpc.service'; import { NotificationService } from '../services/notification.service'; import { RedisMessagingService } from '../services/redis-messaging.service'; import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service'; +import { DifficultyUtils } from '../utils/difficulty.utils'; +import { hash256 } from '../utils/hash.utils'; import { eRequestMethod } from './enums/eRequestMethod'; import { eResponseMethod } from './enums/eResponseMethod'; import { eStratumErrorCode } from './enums/eStratumErrorCode'; @@ -51,6 +53,7 @@ export class StratumV1Client { private stratumInitialized = false; private usedSuggestedDifficulty = false; private sessionDifficulty: number = 100000; + private sessionDifficultyTarget: Buffer = DifficultyUtils.difficultyToTarget(this.sessionDifficulty); private clientEntity: ClientEntity; private creatingEntity: Promise; @@ -268,6 +271,7 @@ export class StratumV1Client { this.clientAuthorization = authorizationMessage; if (this.clientSuggestedDifficulty == null && this.clientAuthorization.startingDiff != null && this.clientAuthorization.startingDiff > this.sessionDifficulty) { this.sessionDifficulty = this.clientAuthorization.startingDiff; + this.sessionDifficultyTarget = DifficultyUtils.difficultyToTarget(this.sessionDifficulty); } const success = await this.write(JSON.stringify(this.clientAuthorization.response()) + '\n'); if (!success) { @@ -309,6 +313,7 @@ export class StratumV1Client { this.clientSuggestedDifficulty = suggestDifficultyMessage; this.sessionDifficulty = this.clampDifficulty(suggestDifficultyMessage.suggestedDifficulty); + this.sessionDifficultyTarget = DifficultyUtils.difficultyToTarget(this.sessionDifficulty); const success = await this.write(JSON.stringify(this.clientSuggestedDifficulty.response(this.sessionDifficulty)) + '\n'); if (!success) { return; @@ -610,19 +615,23 @@ export class StratumV1Client { submission.extraNonce2, timestamp ); - const { submissionDifficulty } = this.calculateDifficulty(header); + const { submissionDifficulty, hashBuffer } = this.calculateDifficulty(header); //console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`); - if (submissionDifficulty >= this.sessionDifficulty) { + if (DifficultyUtils.meetsTarget(hashBuffer, this.sessionDifficultyTarget)) { const success = await this.write(JSON.stringify(submission.response()) + '\n'); if (!success) { return false; } let blockSubmissionResult: string = null; - if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) { + const isBlockCandidate = DifficultyUtils.meetsTarget( + hashBuffer, + DifficultyUtils.difficultyToTarget(jobTemplate.blockData.networkDifficulty), + ); + if (isBlockCandidate) { console.log('!!! BLOCK FOUND !!!'); const updatedJobBlock = job.copyAndUpdateBlock( jobTemplate, @@ -668,7 +677,7 @@ export class StratumV1Client { ? (jobTemplate.block.version ^ versionMask).toString(16) : jobTemplate.block.version.toString(16), extraNonce2: submission.extraNonce2, - isBlockCandidate: submissionDifficulty >= jobTemplate.blockData.networkDifficulty, + isBlockCandidate, blockSubmissionResult, }); await this.statistics.addShares(this.clientEntity, this.sessionDifficulty); @@ -718,6 +727,7 @@ export class StratumV1Client { if (targetDiff != this.sessionDifficulty) { //console.log(`Adjusting ${this.extraNonceAndSessionId} difficulty from ${this.sessionDifficulty} to ${targetDiff}`); this.sessionDifficulty = targetDiff; + this.sessionDifficultyTarget = DifficultyUtils.difficultyToTarget(this.sessionDifficulty); const data = JSON.stringify({ id: null, @@ -748,13 +758,13 @@ export class StratumV1Client { } } - private calculateDifficulty(header: Buffer): { submissionDifficulty: number, submissionHash: string } { + private calculateDifficulty(header: Buffer): { submissionDifficulty: number, submissionHash: string, hashBuffer: Buffer } { - const hashResult = bitcoinjs.crypto.hash256(header); + const hashResult = hash256(header); const target = this.le256todouble(hashResult); const submissionDifficulty = target === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE / target; - return { submissionDifficulty, submissionHash: hashResult.toString('hex') }; + return { submissionDifficulty, submissionHash: hashResult.toString('hex'), hashBuffer: hashResult }; } diff --git a/src/models/StratumV2Client.spec.ts b/src/models/StratumV2Client.spec.ts index 37b2dbd..3585b78 100644 --- a/src/models/StratumV2Client.spec.ts +++ b/src/models/StratumV2Client.spec.ts @@ -1,4 +1,5 @@ import { ConfigService } from '@nestjs/config'; +import * as bitcoinjs from 'bitcoinjs-lib'; import { Socket } from 'net'; import { BehaviorSubject, firstValueFrom } from 'rxjs'; @@ -14,6 +15,7 @@ import { serializeSubmitSharesExtended, } from './sv2/sv2-extended-messages'; import { deserializeSetNewPrevHash, deserializeSubmitSharesError } from './sv2/sv2-messages'; +import { MiningJob } from './MiningJob'; import { StratumV2Client } from './StratumV2Client'; describe('StratumV2Client extended channels', () => { @@ -145,9 +147,87 @@ describe('StratumV2Client extended channels', () => { expect(postAccountingPresenceUpdates.length).toBeGreaterThan(0); }); + it('does not submit a block when only the reported SV2 difficulty is huge', async () => { + const { client, shareAccountingService, bitcoinRpcService, blocksService, jobTemplate } = await createClient(); + (client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'; + (client as any).workerName = 'worker'; + (client as any).sessionId = 'sv2-session'; + (client as any).userAgent = 'test/sv2'; + + const job = new MiningJob( + bitcoinjs.networks.testnet, + '1', + [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }], + jobTemplate, + ); + + await (client as any).handleAcceptedShare( + { + nonce: 123, + ntime: parseInt(MockRecording1.TIME, 16), + version: jobTemplate.block.version, + }, + { extranoncePrefix: Buffer.from(MockRecording1.EXTRA_NONCE, 'hex') }, + job, + jobTemplate, + Number.MAX_SAFE_INTEGER, + 1024, + Buffer.alloc(32, 0xff), + ); + + expect(bitcoinRpcService.SUBMIT_BLOCK).not.toHaveBeenCalled(); + expect(blocksService.save).not.toHaveBeenCalled(); + expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({ + submissionDifficulty: Number.MAX_SAFE_INTEGER, + isBlockCandidate: false, + })); + }); + + it('submits a block when the SV2 hash target exactly meets network target', async () => { + const { client, bitcoinRpcService, blocksService, notificationService, jobTemplate } = await createClient(); + (client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'; + (client as any).workerName = 'worker'; + (client as any).sessionId = 'sv2-session'; + (client as any).userAgent = 'test/sv2'; + + const job = new MiningJob( + bitcoinjs.networks.testnet, + '1', + [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }], + jobTemplate, + ); + + await (client as any).handleAcceptedShare( + { + nonce: 123, + ntime: parseInt(MockRecording1.TIME, 16), + version: jobTemplate.block.version, + }, + { extranoncePrefix: Buffer.from(MockRecording1.EXTRA_NONCE, 'hex') }, + job, + jobTemplate, + 1, + 1024, + Buffer.alloc(32), + ); + + expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String)); + expect(blocksService.save).toHaveBeenCalledWith(expect.objectContaining({ + height: MockRecording1.BLOCK_TEMPLATE.height, + minerAddress: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + worker: 'worker', + sessionId: 'sv2-session', + blockData: expect.any(String), + })); + expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled(); + }); + async function createClient(): Promise<{ client: StratumV2Client; sentFrames: any[]; + bitcoinRpcService: { SUBMIT_BLOCK: jest.Mock }; + blocksService: { save: jest.Mock }; + notificationService: { notifySubscribersBlockFound: jest.Mock }; shareAccountingService: { recordAcceptedShare: jest.Mock }; redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock }; jobTemplate: any; @@ -196,6 +276,12 @@ describe('StratumV2Client extended channels', () => { const shareAccountingService = { recordAcceptedShare: jest.fn().mockResolvedValue(undefined), }; + const notificationService = { + notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined), + }; + const blocksService = { + save: jest.fn().mockResolvedValue(undefined), + }; const redisMessagingService = { setClientPresence: jest.fn().mockResolvedValue(undefined), removeClientPresence: jest.fn().mockResolvedValue(undefined), @@ -225,8 +311,8 @@ describe('StratumV2Client extended channels', () => { stratumV1JobsService, bitcoinRpcService as any, clientService as any, - { notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined) } as any, - { save: jest.fn().mockResolvedValue(undefined) } as any, + notificationService as any, + blocksService as any, { get: jest.fn((key: string) => { switch (key) { @@ -251,6 +337,15 @@ describe('StratumV2Client extended channels', () => { return Promise.resolve(); }); - return { client, sentFrames, shareAccountingService, redisMessagingService, jobTemplate }; + return { + client, + sentFrames, + bitcoinRpcService, + blocksService, + notificationService, + shareAccountingService, + redisMessagingService, + jobTemplate, + }; } }); diff --git a/src/models/StratumV2Client.ts b/src/models/StratumV2Client.ts index de1a907..ec62238 100644 --- a/src/models/StratumV2Client.ts +++ b/src/models/StratumV2Client.ts @@ -17,6 +17,7 @@ import { StratumV2Service } from '../services/stratum-v2.service'; import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service'; import { patchCoinbasePrefixVarint } from '../utils/coinbase-prefix.utils'; import { DifficultyUtils } from '../utils/difficulty.utils'; +import { hash256 } from '../utils/hash.utils'; import { MiningJob } from './MiningJob'; import { StratumV1ClientStatistics } from './StratumV1ClientStatistics'; import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants'; @@ -560,7 +561,7 @@ export class StratumV2Client { ); channel.acceptedShareCount++; - await this.handleAcceptedShare(submission, channel, job, jobTemplate, submissionDifficulty, jobDifficulty); + await this.handleAcceptedShare(submission, channel, job, jobTemplate, submissionDifficulty, jobDifficulty, hashBuffer); } private async handleSubmitSharesExtended(payload: Buffer): Promise { @@ -608,12 +609,12 @@ export class StratumV2Client { submission.extranonce, extendedJob.coinbaseSuffix, ]); - let merkleRoot = bitcoinjs.crypto.hash256(coinbaseTxBytes); + let merkleRoot = hash256(coinbaseTxBytes); const merklePair = Buffer.alloc(64); for (const sibling of extendedJob.merklePath) { merklePair.set(merkleRoot, 0); merklePair.set(sibling, 32); - merkleRoot = bitcoinjs.crypto.hash256(merklePair); + merkleRoot = hash256(merklePair); } const header = this.buildHeader( @@ -650,7 +651,10 @@ export class StratumV2Client { channel.acceptedShareCount++; let updatedJobBlock: bitcoinjs.Block = null; - if (submissionDifficulty >= extendedJob.jobTemplate.blockData.networkDifficulty) { + if (DifficultyUtils.meetsTarget( + hashBuffer, + DifficultyUtils.difficultyToTarget(extendedJob.jobTemplate.blockData.networkDifficulty), + )) { updatedJobBlock = this.reconstructExtendedBlock(extendedJob, submission, merkleRoot, channel.extranoncePrefix); } await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, extendedJob.jobTemplate, updatedJobBlock, { @@ -669,9 +673,13 @@ export class StratumV2Client { jobTemplate: IJobTemplate, submissionDifficulty: number, jobDifficulty: number, + hashBuffer: Buffer, ): Promise { let updatedJobBlock: bitcoinjs.Block = null; - if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) { + if (DifficultyUtils.meetsTarget( + hashBuffer, + DifficultyUtils.difficultyToTarget(jobTemplate.blockData.networkDifficulty), + )) { const versionMask = submission.version ^ jobTemplate.block.version; updatedJobBlock = job.copyAndUpdateBlock( jobTemplate, diff --git a/src/models/stratum-messages/MiningSubmitMessage.spec.ts b/src/models/stratum-messages/MiningSubmitMessage.spec.ts index 01fa3e1..e5f9fd9 100644 --- a/src/models/stratum-messages/MiningSubmitMessage.spec.ts +++ b/src/models/stratum-messages/MiningSubmitMessage.spec.ts @@ -37,6 +37,10 @@ describe('MiningSubmitMessage', () => { expect(errors).toEqual([]); }); + it('should hash submissions deterministically', () => { + expect(message.hash()).toBe('t2bFzhZ6mketRxa5nOoKrNxG3RkFIZuOwY1WewFtv9k='); + }); + it('should reject short extranonce2 submissions', async () => { const shortMessage = plainToInstance( MiningSubmitMessage, diff --git a/src/models/stratum-messages/MiningSubmitMessage.ts b/src/models/stratum-messages/MiningSubmitMessage.ts index 8fbd121..28791e7 100644 --- a/src/models/stratum-messages/MiningSubmitMessage.ts +++ b/src/models/stratum-messages/MiningSubmitMessage.ts @@ -4,7 +4,7 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, Length } from 'class-val import { eRequestMethod } from '../enums/eRequestMethod'; import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants'; import { StratumBaseMessage } from './StratumBaseMessage'; -import * as bitcoinjs from 'bitcoinjs-lib'; +import { hash256 } from '../../utils/hash.utils'; export class MiningSubmitMessage extends StratumBaseMessage { @@ -69,7 +69,7 @@ export class MiningSubmitMessage extends StratumBaseMessage { public hash(): string{ const buffer = Buffer.from(this.versionMask + this.nonce + this.extraNonce2 + this.ntime + this.jobId); - return bitcoinjs.crypto.hash256(buffer).toString('base64'); + return hash256(buffer).toString('base64'); } diff --git a/src/services/stratum-v1-jobs.service.ts b/src/services/stratum-v1-jobs.service.ts index 83d89cd..9247be2 100644 --- a/src/services/stratum-v1-jobs.service.ts +++ b/src/services/stratum-v1-jobs.service.ts @@ -5,6 +5,7 @@ import * as merkleProof from 'merkle-lib/proof'; import { combineLatest, delay, filter, from, interval, map, Observable, shareReplay, startWith, switchMap, tap } from 'rxjs'; import { MiningJob } from '../models/MiningJob'; +import { hash256 } from '../utils/hash.utils'; import { BitcoinRpcService } from './bitcoin-rpc.service'; export interface IJobTemplate { @@ -95,7 +96,7 @@ export class StratumV1JobsService { const transactionBuffers = transactions.map(tx => tx.getHash(false)); - const merkleTree = merkle(transactionBuffers, bitcoinjs.crypto.hash256); + const merkleTree = merkle(transactionBuffers, hash256); const merkleBranches: Buffer[] = merkleProof(merkleTree, transactionBuffers[0]).filter(h => h != null); block.merkleRoot = merkleBranches.pop(); diff --git a/src/utils/difficulty.utils.spec.ts b/src/utils/difficulty.utils.spec.ts new file mode 100644 index 0000000..4251c74 --- /dev/null +++ b/src/utils/difficulty.utils.spec.ts @@ -0,0 +1,62 @@ +import { DifficultyUtils } from './difficulty.utils'; + +describe('DifficultyUtils', () => { + function incrementLe256(target: Buffer): Buffer { + const next = Buffer.from(target); + for (let i = 0; i < next.length; i++) { + next[i]++; + if (next[i] !== 0) { + break; + } + } + return next; + } + + it('round-trips common difficulty values through compact targets', () => { + for (const difficulty of [0.001, 1, 128, 512, 1000, 1_000_000, 1_000_000_000, 1e30]) { + const target = DifficultyUtils.difficultyToTarget(difficulty); + const roundTrip = DifficultyUtils.targetToDifficulty(target); + + expect(Math.abs(roundTrip - difficulty) / difficulty).toBeLessThan(1e-12); + } + }); + + it('compares little-endian targets without converting to BigInt', () => { + const target = Buffer.alloc(32); + target[0] = 0x80; + target[30] = 0x01; + const easier = Buffer.from(target); + const harder = Buffer.from(target); + + easier[31] += 1; + harder[0] -= 1; + + expect(DifficultyUtils.meetsTarget(target, target)).toBe(true); + expect(DifficultyUtils.meetsTarget(harder, target)).toBe(true); + expect(DifficultyUtils.meetsTarget(easier, target)).toBe(false); + }); + + it('calculates difficulty consistently with the returned hash buffer', () => { + const header = Buffer.alloc(80, 1); + const result = DifficultyUtils.calculateDifficulty(header); + + expect(result.submissionDifficulty).toBeCloseTo( + DifficultyUtils.targetToDifficulty(result.hashBuffer), + 10, + ); + expect(result.submissionHash).toBe(result.hashBuffer.toString('hex')); + }); + + it('keeps huge-difficulty boundary checks exact with target bytes', () => { + const target = DifficultyUtils.difficultyToTarget(1e30); + const justTooEasy = incrementLe256(target); + + expect(DifficultyUtils.targetToDifficulty(target)).toBeGreaterThan(1e29); + expect(DifficultyUtils.meetsTarget(target, target)).toBe(true); + expect(DifficultyUtils.meetsTarget(justTooEasy, target)).toBe(false); + }); + + it('treats a zero hash target as infinite reported difficulty', () => { + expect(DifficultyUtils.targetToDifficulty(Buffer.alloc(32))).toBe(Number.POSITIVE_INFINITY); + }); +}); diff --git a/src/utils/difficulty.utils.ts b/src/utils/difficulty.utils.ts index 3fc31cc..61da11f 100644 --- a/src/utils/difficulty.utils.ts +++ b/src/utils/difficulty.utils.ts @@ -1,35 +1,25 @@ -import * as bitcoinjs from 'bitcoinjs-lib'; +import { hash256 } from './hash.utils'; const TRUE_DIFF_ONE_BIGINT = BigInt( '26959535291011309493156476344723991336010898738574164086137773096960', ); +const TRUE_DIFF_ONE_NUMBER = 2.695953529101131e67; const TWO_TO_256 = 1n << 256n; -const FRACTION_SCALE = 1_000_000_000_000_000n; -const FRACTION_SCALE_NUM = 1e15; - -function bigIntRatioToDifficulty(divisor: bigint): number { - if (divisor === 0n) { - return Number.POSITIVE_INFINITY; - } - - const scaled = (TRUE_DIFF_ONE_BIGINT * FRACTION_SCALE) / divisor; - return Number(scaled) / FRACTION_SCALE_NUM; -} export class DifficultyUtils { public static calculateDifficulty(header: Buffer): { submissionDifficulty: number; submissionHash: string; hashBuffer: Buffer } { - const hashResult = bitcoinjs.crypto.hash256(header); - const target = DifficultyUtils.le256ToBigInt(hashResult); + const hashResult = hash256(header); + const target = DifficultyUtils.le256ToDouble(hashResult); return { - submissionDifficulty: bigIntRatioToDifficulty(target), + submissionDifficulty: target === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE_NUMBER / target, submissionHash: hashResult.toString('hex'), hashBuffer: hashResult, }; } public static meetsTarget(hashBuffer: Buffer, target: Buffer): boolean { - return DifficultyUtils.le256ToBigInt(hashBuffer) <= DifficultyUtils.le256ToBigInt(target); + return DifficultyUtils.compareLe256(hashBuffer, target) <= 0; } public static difficultyToTarget(difficulty: number): Buffer { @@ -51,8 +41,8 @@ export class DifficultyUtils { throw new Error('Target must be 32 bytes'); } - const targetBigInt = DifficultyUtils.le256ToBigInt(target); - return bigIntRatioToDifficulty(targetBigInt); + const targetNumber = DifficultyUtils.le256ToDouble(target); + return targetNumber === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE_NUMBER / targetNumber; } public static hashRateToDifficulty(hashRate: number, sharesPerMinute: number): number { @@ -65,15 +55,11 @@ export class DifficultyUtils { return difficulty; } - const maxTargetBigInt = DifficultyUtils.le256ToBigInt(maxTarget); - if (maxTargetBigInt === 0n) { + if (DifficultyUtils.isZeroTarget(maxTarget)) { return difficulty; } - const computedTargetBigInt = DifficultyUtils.le256ToBigInt( - DifficultyUtils.difficultyToTarget(difficulty), - ); - if (computedTargetBigInt > maxTargetBigInt) { + if (DifficultyUtils.compareLe256(DifficultyUtils.difficultyToTarget(difficulty), maxTarget) > 0) { const clamped = DifficultyUtils.targetToDifficulty(maxTarget); return Number.isFinite(clamped) && clamped > 0 ? clamped : difficulty; } @@ -111,7 +97,30 @@ export class DifficultyUtils { return buf; } - private static le256ToBigInt(target: Buffer): bigint { - return target.reduceRight((acc, byte) => (acc << 8n) | BigInt(byte), 0n); + private static le256ToDouble(target: Buffer): number { + let value = 0; + for (let i = target.length - 1; i >= 0; i--) { + value = value * 256 + target[i]; + } + return value; + } + + private static compareLe256(left: Buffer, right: Buffer): number { + for (let i = 31; i >= 0; i--) { + const diff = left[i] - right[i]; + if (diff !== 0) { + return diff; + } + } + return 0; + } + + private static isZeroTarget(target: Buffer): boolean { + for (let i = 0; i < target.length; i++) { + if (target[i] !== 0) { + return false; + } + } + return true; } } diff --git a/src/utils/hash.utils.spec.ts b/src/utils/hash.utils.spec.ts new file mode 100644 index 0000000..f0c466a --- /dev/null +++ b/src/utils/hash.utils.spec.ts @@ -0,0 +1,15 @@ +import * as crypto from 'crypto'; + +import { hash256 } from './hash.utils'; + +describe('hash256', () => { + it('matches native double sha256 for known input', () => { + const data = Buffer.from('public-pool-share-validation', 'utf8'); + const expected = crypto + .createHash('sha256') + .update(crypto.createHash('sha256').update(data).digest()) + .digest('hex'); + + expect(hash256(data).toString('hex')).toBe(expected); + }); +}); diff --git a/src/utils/hash.utils.ts b/src/utils/hash.utils.ts new file mode 100644 index 0000000..d8bda06 --- /dev/null +++ b/src/utils/hash.utils.ts @@ -0,0 +1,6 @@ +import * as crypto from 'crypto'; + +export function hash256(data: Buffer): Buffer { + const first = crypto.createHash('sha256').update(data).digest(); + return crypto.createHash('sha256').update(first).digest(); +}