Migrate postgresql improvements to sqlite

This commit is contained in:
Ben
2026-06-05 10:48:42 -04:00
parent b971e9ce4c
commit 95565eeec4
32 changed files with 6491 additions and 13481 deletions
+1
View File
@@ -19,6 +19,7 @@ BITCOIN_RPC_COOKIEFILE=
API_PORT=3334
STRATUM_PORT=3333
STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000
#optional telegram bot
#TELEGRAM_BOT_TOKEN=
+1
View File
@@ -0,0 +1 @@
omit=optional
+2 -2
View File
@@ -2,7 +2,7 @@
# Docker build environment #
############################
FROM node:22.11.0-bookworm-slim AS build
FROM node:24.16.0-bookworm-slim AS build
# Upgrade all packages and install dependencies
RUN apt-get update \
@@ -26,7 +26,7 @@ RUN npm i && npm run build
# Docker final environment #
############################
FROM node:22.11.0-bookworm-slim
FROM node:24.16.0-bookworm-slim
# Expose ports for Stratum and Bitcoin RPC
EXPOSE 3333 3334 8332
+14
View File
@@ -45,6 +45,20 @@ Install pm2 (https://pm2.keymetrics.io/)
$ pm2 start dist/main.js
```
When running the worker app in PM2 cluster mode, start the PM2 daemon with OS-level
connection scheduling. The environment variable must be present when the PM2 daemon
starts, not only in the worker configuration.
```bash
$ NODE_CLUSTER_SCHED_POLICY=none pm2 start ecosystem.config.js
```
Cluster-mode connection dropping requires Node.js `22.12.0` or newer.
`STRATUM_MAX_CONNECTIONS_PER_LISTENER` is enforced per worker and Stratum port.
Size it using the busiest port: `worker count * limit`. For example, 28 workers
with the default limit of `10000` allow up to `280000` connections on one port.
## Docker
Build container:
+4627 -13121
View File
File diff suppressed because it is too large Load Diff
+43 -41
View File
@@ -5,6 +5,9 @@
"author": "",
"private": true,
"license": "UNLICENSED",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
@@ -21,60 +24,59 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^3.0.0",
"@nestjs/cache-manager": "^2.0.1",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.2",
"@nestjs/core": "^9.0.0",
"@nestjs/platform-fastify": "^9.4.2",
"@nestjs/schedule": "^3.0.1",
"@nestjs/typeorm": "^10.0.0",
"axios": "^1.4.0",
"big.js": "^6.2.1",
"bitcoin-address-validation": "^2.2.1",
"bitcoinjs-lib": "^6.1.3",
"bitcoinjs-message": "^2.2.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/cache-manager": "^3.1.2",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/typeorm": "^11.0.1",
"axios": "^1.16.0",
"bitcoin-address-validation": "^2.2.3",
"bitcoinjs-lib": "^6.1.7",
"bs58": "^5.0.0",
"cache-manager": "^5.2.3",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"discord.js": "^14.11.0",
"class-validator": "^0.14.4",
"discord.js": "^14.26.4",
"merkle-lib": "^2.0.10",
"node-telegram-bot-api": "^0.61.0",
"reflect-metadata": "^0.1.13",
"rpc-bitcoin": "^2.0.0",
"rxjs": "^7.2.0",
"sqlite3": "^5.1.6",
"tiny-secp256k1": "^2.2.3",
"typeorm": "^0.3.17",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"tiny-secp256k1": "^2.2.4",
"typeorm": "^0.3.28",
"zeromq": "6.0.0-beta.19"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@types/big.js": "^6.1.6",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/cron": "^2.0.1",
"@types/express": "^4.17.13",
"@types/jest": "29.5.1",
"@types/node": "^18.16.12",
"@types/node-telegram-bot-api": "^0.61.6",
"@types/supertest": "^2.0.11",
"@types/express": "^4.17.25",
"@types/jest": "^29.5.14",
"@types/node": "^18.19.130",
"@types/supertest": "^7.2.0",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "29.5.0",
"patch-package": "8.0.0",
"prettier": "^2.3.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"jest": "^29.7.0",
"patch-package": "^8.0.1",
"prettier": "^3.8.3",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "29.1.0",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9",
"ts-loader": "^9.5.7",
"ts-node": "^10.9.2",
"tsconfig-paths": "4.2.0",
"typescript": "^5.0.0"
"typescript": "^5.9.3"
},
"overrides": {
"@fastify/middie": "^9.3.2",
"fastify": "^5.8.5",
"path-to-regexp": "^8.4.2"
},
"jest": {
"moduleFileExtensions": [
-26
View File
@@ -1,26 +0,0 @@
diff --git a/node_modules/rpc-bitcoin/build/src/rpc.d.ts b/node_modules/rpc-bitcoin/build/src/rpc.d.ts
index d25d732..ce4eb22 100644
--- a/node_modules/rpc-bitcoin/build/src/rpc.d.ts
+++ b/node_modules/rpc-bitcoin/build/src/rpc.d.ts
@@ -6,7 +6,7 @@ export declare type RPCIniOptions = RESTIniOptions & {
fullResponse?: boolean;
};
export declare type JSONRPC = {
- jsonrpc?: string | number;
+ jsonrpc?: string;
id?: string | number;
method: string;
params?: object;
diff --git a/node_modules/rpc-bitcoin/build/src/rpc.js b/node_modules/rpc-bitcoin/build/src/rpc.js
index 7fec7aa..3bee5fc 100644
--- a/node_modules/rpc-bitcoin/build/src/rpc.js
+++ b/node_modules/rpc-bitcoin/build/src/rpc.js
@@ -12,7 +12,7 @@ class RPCClient extends rest_1.RESTClient {
}
async rpc(method, params = {}, wallet) {
const uri = typeof wallet === "undefined" ? "/" : "wallet/" + wallet;
- const body = { method, params, jsonrpc: 1.0, id: "rpc-bitcoin" };
+ const body = { method, params, jsonrpc: "1.0", id: "rpc-bitcoin" };
try {
const response = await this.batch(body, uri);
return this.fullResponse ? response : response.result;
@@ -31,6 +31,24 @@ export class AddressSettingsService {
return await this.addressSettingsRepository.update({ address }, { bestDifficulty, bestDifficultyUserAgent });
}
public async updateBestDifficultyIfHigher(address: string, bestDifficulty: number, bestDifficultyUserAgent: string) {
await this.addressSettingsRepository
.createQueryBuilder()
.insert()
.into(AddressSettingsEntity)
.values({ address })
.orIgnore()
.execute();
return await this.addressSettingsRepository
.createQueryBuilder()
.update(AddressSettingsEntity)
.set({ bestDifficulty, bestDifficultyUserAgent })
.where('address = :address', { address })
.andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty })
.execute();
}
public async getHighScores() {
return await this.addressSettingsRepository.createQueryBuilder()
.select('"updatedAt", "bestDifficulty", "bestDifficultyUserAgent"')
@@ -54,9 +72,13 @@ export class AddressSettingsService {
}
public async resetBestDifficultyAndShares() {
return await this.addressSettingsRepository.update({}, {
return await this.addressSettingsRepository
.createQueryBuilder()
.update(AddressSettingsEntity)
.set({
shares: 0,
bestDifficulty: 0
});
})
.execute();
}
}
@@ -252,6 +252,6 @@ export class ClientStatisticsService {
}
public async deleteAll() {
return await this.clientStatisticsRepository.delete({})
return await this.clientStatisticsRepository.clear()
}
}
+28 -4
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { firstValueFrom, Subject } from 'rxjs';
import { ObjectLiteral, Repository } from 'typeorm';
import { ClientEntity } from './client.entity';
@@ -12,7 +12,7 @@ import { ClientEntity } from './client.entity';
export class ClientService {
public insertQueue: { result: BehaviorSubject<ObjectLiteral | null>, partialClient: Partial<ClientEntity> }[] = [];
public insertQueue: { result: Subject<ObjectLiteral>, partialClient: Partial<ClientEntity> }[] = [];
constructor(
@@ -27,11 +27,21 @@ export class ClientService {
const queueCopy = [...this.insertQueue];
this.insertQueue = [];
if (queueCopy.length === 0) {
return;
}
try {
const results = await this.clientRepository.insert(queueCopy.map(c => c.partialClient));
queueCopy.forEach((c, index) => {
c.result.next(results.generatedMaps[index]);
c.result.complete();
});
} catch (e) {
queueCopy.forEach(c => c.result.error(e));
throw e;
}
}
public async killDeadClients() {
@@ -56,7 +66,7 @@ export class ClientService {
public async insert(partialClient: Partial<ClientEntity>): Promise<ClientEntity> {
const result = new BehaviorSubject(null);
const result = new Subject<ObjectLiteral>();
this.insertQueue.push({ result, partialClient });
@@ -93,6 +103,17 @@ export class ClientService {
public async updateBestDifficulty(sessionId: string, bestDifficulty: number) {
return await this.clientRepository.update({ sessionId }, { bestDifficulty });
}
public async updateBestDifficultyIfHigher(sessionId: string, bestDifficulty: number) {
return await this.clientRepository
.createQueryBuilder()
.update(ClientEntity)
.set({ bestDifficulty })
.where('sessionId = :sessionId', { sessionId })
.andWhere('"bestDifficulty" < :bestDifficulty', { bestDifficulty })
.execute();
}
public async connectedClientCount(): Promise<number> {
return await this.clientRepository.count();
}
@@ -126,7 +147,10 @@ export class ClientService {
}
public async deleteAll() {
return await this.clientRepository.softDelete({})
return await this.clientRepository
.createQueryBuilder()
.softDelete()
.execute();
}
public async getUserAgents() {
+1 -12
View File
@@ -1,5 +1,4 @@
import { Controller, Patch } from '@nestjs/common';
import * as bitcoinMessage from 'bitcoinjs-message';
@Controller('address')
@@ -7,16 +6,6 @@ export class AddressController {
@Patch('settings')
async settings() {
const publicKey = '...'; // Public key corresponding to the private key used for signing
const message = '...'; // The message that was signed
const signature = '...'; // The signature of the message
const isValid: boolean = bitcoinMessage.verify(message, publicKey, signature);
if (isValid) {
console.log('Signature is valid!');
} else {
console.log('Signature is not valid!');
}
return;
}
}
@@ -14,7 +14,7 @@ describe('ClientController', () => {
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: './DB/public-pool.test.sqlite',
database: ':memory:',
synchronize: true,
autoLoadEntities: true,
cache: true,
+127 -13
View File
@@ -1,9 +1,12 @@
import { ConfigService } from "@nestjs/config";
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import * as bitcoinjs from 'bitcoinjs-lib';
import { Test, TestingModule } from "@nestjs/testing";
import { MiningJob } from "./MiningJob";
import { IJobTemplate } from "../services/stratum-v1-jobs.service";
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { IMiningInfo } from './bitcoin-rpc/IMiningInfo';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { MiningJob } from './MiningJob';
function hexToAscii(hex: string): string {
let ascii = '';
@@ -46,12 +49,7 @@ describe('MiningJob', () => {
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
switch (key) {
// Configure mock responses for ConfigService
}
return null;
})
get: jest.fn(() => null)
}
}
],
@@ -59,11 +57,11 @@ describe('MiningJob', () => {
configService = moduleRef.get<ConfigService>(ConfigService);
});
describe('constructor', () => {
beforeEach(() => {
console.warn = jest.fn((message: string) => console.log('WARN:', message));
configService.get = jest.fn(() => null);
payoutInformation = [
{
address: 'tb1qr2ylpdgp9ejpt6v2uxlqrn9penp82rzz2grnns',
@@ -83,8 +81,10 @@ describe('MiningJob', () => {
jobTemplate = {
block: block,
merkle_branch: [],
blockData: {
id: '1',
creation: Date.now(),
coinbasevalue: 0,
networkDifficulty: 0,
height: 0,
@@ -92,6 +92,11 @@ describe('MiningJob', () => {
}
} as IJobTemplate;
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should create a new MiningJob if POOL_IDENTIFIER is not set and use the default', () => {
const expectedMiningIdentifier = 'Public-Pool';
expect(jobTemplate.block).toBeDefined();
@@ -144,7 +149,7 @@ describe('MiningJob', () => {
it('should use the POOL_IDENTIFIER if it doesn\'t make the script size too big with identifier abcabc', () => {
jobTemplate.block.transactions = []; // remove transactions because we only want to test the script size
const expectedMiningIdentifier = 'A'.repeat(88); // 88 chars is the maximum size validated against bitcoin core in regtest
const expectedMiningIdentifier = 'A'.repeat(84); // 84 chars fits after reserving 12 bytes for extranonce space
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return expectedMiningIdentifier;
@@ -165,7 +170,7 @@ describe('MiningJob', () => {
jobTemplate.block.transactions = []; // remove transactions because we only want to test the script size
configService.get = jest.fn((key: string) => {
switch (key) {
case 'POOL_IDENTIFIER': return 'A'.repeat(89); // 88 chars is the maximum size validated against bitcoin core in regtest
case 'POOL_IDENTIFIER': return 'A'.repeat(85);
}
return null;
});
@@ -178,4 +183,113 @@ describe('MiningJob', () => {
expect(miningIdentifier).toBe(expectedMiningIdentifier);
});
});
describe('block updates', () => {
let job: MiningJob;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
configService.get = jest.fn(() => null);
const miningInfo$ = new BehaviorSubject<IMiningInfo>({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
const bitcoinRpcService = {
newBlock$: miningInfo$.asObservable(),
getBlockTemplate: jest.fn().mockResolvedValue(MockRecording1.BLOCK_TEMPLATE)
};
jest.spyOn(console, 'log').mockImplementation(() => undefined);
const jobsService = new StratumV1JobsService(bitcoinRpcService as any);
jobTemplate = await firstValueFrom(jobsService.newMiningJob$);
job = new MiningJob(
configService,
bitcoinjs.networks.testnet,
'1',
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
it('should split coinbase around 12 bytes of extranonce space', () => {
const notify = JSON.parse(job.response(jobTemplate));
const coinbasePart1 = notify.params[2];
const coinbasePart2 = notify.params[3];
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`);
expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12);
expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`);
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
});
it('should update block nonce, timestamp, version mask, and coinbase script', () => {
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const timestamp = parseInt(MockRecording1.TIME, 16);
const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot);
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
parseInt('00002000', 16),
parseInt('ed460d91', 16),
extraNonce1,
extraNonce2,
timestamp
);
expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16));
expect(updatedBlock.timestamp).toBe(timestamp);
expect(updatedBlock.version).toBe(jobTemplate.block.version ^ parseInt('00002000', 16));
expect(updatedBlock.transactions[0].ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false);
});
it('should leave block version unchanged without a version mask', () => {
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
0,
parseInt('ed460d91', 16),
'57a6f098',
'c708000000000000',
parseInt(MockRecording1.TIME, 16)
);
expect(updatedBlock.version).toBe(jobTemplate.block.version);
});
it('should build the same header as the full block update path', () => {
const versionMask = parseInt('00002000', 16);
const nonce = parseInt('ed460d91', 16);
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
const timestamp = parseInt(MockRecording1.TIME, 16);
const updatedBlock = job.copyAndUpdateBlock(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
const fastHeader = job.buildHeaderBuffer(
jobTemplate,
versionMask,
nonce,
extraNonce1,
extraNonce2,
timestamp
);
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
});
});
});
+43 -7
View File
@@ -5,6 +5,7 @@ import { IJobTemplate } from '../services/stratum-v1-jobs.service';
import { eResponseMethod } from './enums/eResponseMethod';
import { IMiningNotify } from './stratum-messages/IMiningNotify';
import { ConfigService } from '@nestjs/config';
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
const MAX_BLOCK_WEIGHT = 4000000;
const MAX_SCRIPT_SIZE = 100; // https://github.com/bitcoin/bitcoin/blob/ffdc3d6060f6e65e69cf115a13b83e6eb4a0a0a8/src/consensus/tx_check.cpp#L49
@@ -17,6 +18,9 @@ export class MiningJob {
private coinbaseTransaction: bitcoinjs.Transaction;
private coinbasePart1: string;
private coinbasePart2: string;
private coinbasePart1Buffer: Buffer;
private coinbasePart2Buffer: Buffer;
private merkleBranchBuffers: Buffer[];
public jobTemplateId: string;
public networkDifficulty: number;
@@ -32,6 +36,7 @@ export class MiningJob {
this.creation = new Date().getTime();
this.jobTemplateId = jobTemplate.blockData.id;
this.merkleBranchBuffers = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex'));
this.coinbaseTransaction = this.createCoinbaseTransaction(payoutInformation, jobTemplate.blockData.coinbasevalue);
@@ -55,7 +60,7 @@ export class MiningJob {
const blockHeightLengthByte = Buffer.from([blockHeightEncoded.length]);
// Generate padding and take length of encode blockHeight into account
const padding = Buffer.alloc(8 + (3 - blockHeightEncoded.length), 0)
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0)
// Build the script
let script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding]);
@@ -83,12 +88,43 @@ export class MiningJob {
const partOneIndex = serializedCoinbaseTx.indexOf(inputScript) + inputScript.length;
this.coinbasePart1 = serializedCoinbaseTx.slice(0, partOneIndex - 16);
this.coinbasePart1 = serializedCoinbaseTx.slice(0, partOneIndex - (TOTAL_EXTRANONCE_SIZE_BYTES * 2));
this.coinbasePart2 = serializedCoinbaseTx.slice(partOneIndex);
this.coinbasePart1Buffer = Buffer.from(this.coinbasePart1, 'hex');
this.coinbasePart2Buffer = Buffer.from(this.coinbasePart2, 'hex');
}
public cloneCoinbaseTransaction(): bitcoinjs.Transaction {
return bitcoinjs.Transaction.fromBuffer(this.coinbaseTransaction.toBuffer());
}
public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer {
const coinbaseBuffer = Buffer.concat([
this.coinbasePart1Buffer,
Buffer.from(`${extraNonce}${extraNonce2}`, 'hex'),
this.coinbasePart2Buffer,
]);
const coinbaseHash = bitcoinjs.crypto.hash256(coinbaseBuffer);
const merkleRoot = this.calculateMerkleRootHash(coinbaseHash, this.merkleBranchBuffers);
let version = jobTemplate.block.version;
if (versionMask !== undefined && versionMask != 0) {
version = version ^ versionMask;
}
const header = Buffer.alloc(80);
header.writeInt32LE(version, 0);
jobTemplate.block.prevHash.copy(header, 4);
merkleRoot.copy(header, 36);
header.writeUInt32LE(timestamp, 68);
header.writeUInt32LE(jobTemplate.block.bits, 72);
header.writeUInt32LE(nonce, 76);
return header;
}
public copyAndUpdateBlock(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): bitcoinjs.Block {
const testBlock = Object.assign(new bitcoinjs.Block(), jobTemplate.block);
@@ -96,7 +132,7 @@ export class MiningJob {
return Object.assign(new bitcoinjs.Transaction(), tx);
});
testBlock.transactions[0] = this.coinbaseTransaction;
testBlock.transactions[0] = this.cloneCoinbaseTransaction();
testBlock.nonce = nonce;
@@ -108,10 +144,10 @@ export class MiningJob {
// set the nonces
const nonceScript = testBlock.transactions[0].ins[0].script.toString('hex');
testBlock.transactions[0].ins[0].script = Buffer.from(`${nonceScript.substring(0, nonceScript.length - 16)}${extraNonce}${extraNonce2}`, 'hex');
testBlock.transactions[0].ins[0].script = Buffer.from(`${nonceScript.substring(0, nonceScript.length - (TOTAL_EXTRANONCE_SIZE_BYTES * 2))}${extraNonce}${extraNonce2}`, 'hex');
//recompute the root since we updated the coinbase script with the nonces
testBlock.merkleRoot = this.calculateMerkleRootHash(testBlock.transactions[0].getHash(false), jobTemplate.merkle_branch);
testBlock.merkleRoot = this.calculateMerkleRootHash(testBlock.transactions[0].getHash(false), this.merkleBranchBuffers);
testBlock.timestamp = timestamp;
@@ -120,14 +156,14 @@ export class MiningJob {
}
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: string[]): Buffer {
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: Buffer[]): Buffer {
const bothMerkles = Buffer.alloc(64);
bothMerkles.set(newRoot);
for (let i = 0; i < merkleBranches.length; i++) {
bothMerkles.set(Buffer.from(merkleBranches[i], 'hex'), 32);
bothMerkles.set(merkleBranches[i], 32);
newRoot = bitcoinjs.crypto.hash256(bothMerkles);
bothMerkles.set(newRoot);
}
+386 -29
View File
@@ -8,6 +8,7 @@ import { DataSource } from 'typeorm';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { AddressSettingsModule } from '../ORM/address-settings/address-settings.module';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
import { BlocksEntity } from '../ORM/blocks/blocks.entity';
import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientStatisticsEntity } from '../ORM/client-statistics/client-statistics.entity';
import { ClientStatisticsModule } from '../ORM/client-statistics/client-statistics.module';
@@ -18,8 +19,11 @@ 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 { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate';
import { IMiningInfo } from './bitcoin-rpc/IMiningInfo';
import { MiningJob } from './MiningJob';
import { StratumV1Client } from './StratumV1Client';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
@@ -45,13 +49,20 @@ describe('StratumV1Client', () => {
let clientStatisticsService: ClientStatisticsService;
let notificationService: NotificationService;
let blocksService: BlocksService;
let externalSharesService;
let configService: ConfigService;
let client: StratumV1Client;
let socketEmitter: (...args: any[]) => void;
const emitMessage = (message: string) => socketEmitter(Buffer.from(`${message}\n`));
let consoleLogSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
let newBlockEmitter: BehaviorSubject<IMiningInfo> = new BehaviorSubject(null);
let newBlockEmitter: BehaviorSubject<IMiningInfo> = new BehaviorSubject({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
let moduleRef: TestingModule;
@@ -60,11 +71,12 @@ describe('StratumV1Client', () => {
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: './DB/public-pool.test.sqlite',
database: ':memory:',
synchronize: true,
autoLoadEntities: true,
cache: true,
logging: false
logging: false,
entities: [ClientEntity, ClientStatisticsEntity, BlocksEntity]
}),
ClientModule,
ClientStatisticsModule,
@@ -94,20 +106,36 @@ describe('StratumV1Client', () => {
beforeEach(async () => {
console.log('NEW TEST')
jest.useFakeTimers({ advanceTimers: true })
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
clientService = moduleRef.get<ClientService>(ClientService);
clientService.insertQueue = [];
const dataSource = moduleRef.get<DataSource>(DataSource);
dataSource.getRepository(ClientEntity).delete({});
dataSource.getRepository(ClientStatisticsEntity).delete({});
await dataSource.getRepository(ClientStatisticsEntity).clear();
await dataSource.getRepository(ClientEntity).clear();
await dataSource.getRepository(BlocksEntity).clear();
clientStatisticsService = moduleRef.get<ClientStatisticsService>(ClientStatisticsService);
configService = moduleRef.get<ConfigService>(ConfigService);
(configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) {
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
});
(StratumV1Client as any).blockedUserAgentLogState.clear();
(StratumV1Client as any).validationErrorLogState.clear();
bitcoinRpcService = new MockBitcoinRpcService(configService,null);
jest.spyOn(bitcoinRpcService, 'getBlockTemplate').mockReturnValue(Promise.resolve(MockRecording1.BLOCK_TEMPLATE));
@@ -127,8 +155,18 @@ describe('StratumV1Client', () => {
});
socket.end = jest.fn();
jest.spyOn(socket, 'destroy').mockImplementation(() => socket);
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
notificationService = {
notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined)
} as any;
blocksService = {
save: jest.fn().mockResolvedValue(undefined)
} as any;
externalSharesService = {
submitShare: jest.fn().mockResolvedValue(undefined)
};
client = new StratumV1Client(
@@ -140,16 +178,20 @@ describe('StratumV1Client', () => {
notificationService,
blocksService,
configService,
addressSettings
addressSettings,
externalSharesService
);
client.extraNonceAndSessionId = MockRecording1.EXTRA_NONCE;
jest.spyOn(client as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE);
jest.useFakeTimers({ advanceTimers: true })
});
afterEach(async () => {
client.destroy();
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
jest.useRealTimers();
})
@@ -159,7 +201,7 @@ describe('StratumV1Client', () => {
});
it('should close socket on invalid JSON', () => {
socketEmitter(Buffer.from('INVALID'));
emitMessage('INVALID');
jest.spyOn(socket, 'destroy');
expect(socket.on).toHaveBeenCalled();
});
@@ -168,21 +210,89 @@ describe('StratumV1Client', () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(`{"id":1,"error":null,"result":[[["mining.notify","${client.extraNonceAndSessionId}"]],"${client.extraNonceAndSessionId}",4]}\n`, expect.any(Function));
expect(socket.write).toHaveBeenCalledWith(`{"id":1,"error":null,"result":[[["mining.notify","${client.extraNonceAndSessionId}"]],"${client.extraNonceAndSessionId}",8]}\n`, expect.any(Function));
});
it('should block non-compliant user agents on subscribe without allocating a session', async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) {
case 'NON_COMPLIANT_USER_AGENTS':
return 'NMMiner';
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
});
jest.spyOn(socket, 'write').mockImplementation((data) => true);
emitMessage(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}`);
await new Promise((r) => setTimeout(r, 1));
expect(socket.destroy).toHaveBeenCalled();
expect(socket.write).not.toHaveBeenCalled();
expect((client as any).statistics).toBeUndefined();
expect(consoleLogSpy).toHaveBeenCalledWith('Blocked non-compliant connection from userAgent: NMMiner');
});
it('should throttle repeated non-compliant user agent logs', async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) {
case 'NON_COMPLIANT_USER_AGENTS':
return 'NMMiner';
case 'DEV_FEE_ADDRESS':
return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
case 'NETWORK':
return 'testnet';
}
return null;
});
jest.spyOn(socket, 'write').mockImplementation((data) => true);
emitMessage(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}`);
await new Promise((r) => setTimeout(r, 1));
const secondSocket = new Socket();
jest.spyOn(secondSocket, 'on').mockImplementation((event: string, listener: (...args: any[]) => void) => {
socketEmitter = listener;
return secondSocket;
});
secondSocket.end = jest.fn();
jest.spyOn(secondSocket, 'destroy').mockImplementation(() => secondSocket);
const secondClient = new StratumV1Client(
secondSocket,
stratumV1JobsService,
bitcoinRpcService,
clientService,
clientStatisticsService,
notificationService,
blocksService,
configService,
moduleRef.get<AddressSettingsService>(AddressSettingsService),
externalSharesService
);
socketEmitter(Buffer.from(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}\n`));
await new Promise((r) => setTimeout(r, 1));
expect(secondSocket.destroy).toHaveBeenCalled();
expect(consoleLogSpy.mock.calls.filter(call => call[0]?.startsWith('Blocked non-compliant connection'))).toHaveLength(1);
await secondClient.destroy();
});
it('should respond to mining.configure', async () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_CONFIGURE));
emitMessage(MockRecording1.MINING_CONFIGURE);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(`{"id":2,"error":null,"result":{"version-rolling":true,"version-rolling.mask":"1fffe000"}}\n`, expect.any(Function));
});
@@ -192,7 +302,7 @@ describe('StratumV1Client', () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith('{"id":3,"error":null,"result":true}\n', expect.any(Function));
});
@@ -201,7 +311,7 @@ describe('StratumV1Client', () => {
jest.spyOn(socket, 'write').mockImplementation((data) => true);
expect(socket.on).toHaveBeenCalled();
socketEmitter(Buffer.from(MockRecording1.MINING_SUGGEST_DIFFICULTY));
emitMessage(MockRecording1.MINING_SUGGEST_DIFFICULTY);
await new Promise((r) => setTimeout(r, 1));
expect(socket.write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[512]}\n`, expect.any(Function));
});
@@ -209,20 +319,24 @@ describe('StratumV1Client', () => {
it('should set difficulty', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
console.log('should set difficulty')
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[16384]}\n`);
expect((client as any).write).toHaveBeenCalledWith(`{"id":null,"method":"mining.set_difficulty","params":[100000]}\n`);
});
it('should save client', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
await new Promise((r) => setTimeout(r, 100));
const clientCount = await clientService.connectedClientCount();
@@ -245,21 +359,27 @@ describe('StratumV1Client', () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
socketEmitter(Buffer.from(MockRecording1.MINING_SUBSCRIBE));
socketEmitter(Buffer.from(MockRecording1.MINING_SUGGEST_DIFFICULTY));
socketEmitter(Buffer.from(MockRecording1.MINING_AUTHORIZE));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
const notifyCall = (client as any).write.mock.calls
.map(call => call[0])
.find((message: string) => message.includes('"method":"mining.notify"'));
const notify = JSON.parse(notifyCall);
expect(notify.params[0]).toBe('1');
expect(notify.params[5]).toBe('20000000');
expect(notify.params[6]).toBe('192495f8');
expect(notify.params[7]).toBe(MockRecording1.TIME);
expect(notify.params[8]).toBe(true);
expect((client as any).write).lastCalledWith(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1903c943255c7075626c69632d706f6f6c5c","ffffffff037a90000000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc3b0f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",false]}\n`);
socketEmitter(Buffer.from(MockRecording1.MINING_SUBMIT));
emitMessage(MockRecording1.MINING_SUBMIT);
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
@@ -269,6 +389,243 @@ describe('StratumV1Client', () => {
});
it('should use the header-only fast path for non-block submissions', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
const buildHeaderSpy = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
const fullBlockSpy = jest.spyOn(MiningJob.prototype, 'copyAndUpdateBlock');
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
expect(buildHeaderSpy).toHaveBeenCalled();
expect(fullBlockSpy).not.toHaveBeenCalled();
});
it('should write accepted response before share accounting finishes', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
let finishAccounting: () => void;
const accountingPromise = new Promise<void>((resolve) => {
finishAccounting = resolve;
});
jest.spyOn((client as any).statistics, 'addShares').mockReturnValue(accountingPromise);
emitMessage(MockRecording1.MINING_SUBMIT);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect((client as any).write).toHaveBeenCalledWith(`{"id":5,"error":null,"result":true}\n`);
finishAccounting();
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 100));
});
it('should update address best difficulty through the atomic path', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: 1024,
submissionHash: 'share'
});
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
const getSettingsSpy = jest.spyOn(addressSettings, 'getSettings');
const updateIfHigherSpy = jest.spyOn(addressSettings as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 });
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
await clientService.insertClients();
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
expect(updateIfHigherSpy).toHaveBeenCalledWith(
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
1024,
expect.any(String)
);
expect(clientUpdateIfHigherSpy).toHaveBeenCalledWith(expect.any(String), 1024);
expect(getSettingsSpy).not.toHaveBeenCalled();
});
it('should reject duplicate submissions', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[22,"Duplicate share",""]}\n`);
});
it('should reject submissions for unknown jobs', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
const hashSpy = jest.spyOn(MiningSubmitMessage.prototype, 'hash');
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "ff", "c708000000000000", "64b3f3ec", "ed460d91", "00002000"]}`);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job not found",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(0);
expect(hashSpy).not.toHaveBeenCalled();
});
it('should reject submissions when the job template has expired', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
stratumV1JobsService.blocks = {};
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job Template not found",""]}\n`);
});
it('should reject low difficulty shares', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_SUGGEST_DIFFICULTY);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(0);
});
it('should reject submissions with short extranonce2 values', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}`);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(expect.stringContaining(`"error":[20,"Mining Submit validation error"`));
expect(socket.destroy).toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Mining Submit validation error: extraNonce2:isLength'));
});
it('should throttle repeated mining submit validation logs', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}`);
await new Promise((r) => setTimeout(r, 100));
const secondSocket = new Socket();
jest.spyOn(secondSocket, 'on').mockImplementation((event: string, listener: (...args: any[]) => void) => {
socketEmitter = listener;
return secondSocket;
});
secondSocket.end = jest.fn();
jest.spyOn(secondSocket, 'destroy').mockImplementation(() => secondSocket);
const secondClient = new StratumV1Client(
secondSocket,
stratumV1JobsService,
bitcoinRpcService,
clientService,
clientStatisticsService,
notificationService,
blocksService,
configService,
moduleRef.get<AddressSettingsService>(AddressSettingsService),
externalSharesService
);
jest.spyOn(secondClient as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(secondClient as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE);
socketEmitter(Buffer.from(`${MockRecording1.MINING_SUBSCRIBE}\n`));
socketEmitter(Buffer.from(`${MockRecording1.MINING_AUTHORIZE}\n`));
await new Promise((r) => setTimeout(r, 100));
socketEmitter(Buffer.from(`{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}\n`));
await new Promise((r) => setTimeout(r, 100));
expect(consoleWarnSpy.mock.calls.filter(call => call[0]?.startsWith('Mining Submit validation error'))).toHaveLength(1);
await secondClient.destroy();
});
it('should close socket when a submit arrives before stratum is initialized', async () => {
const endSpy = jest.spyOn(socket, 'end');
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect(endSpy).toHaveBeenCalled();
});
it('should submit and persist found blocks', 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: 'block-share'
});
const addressSettings = moduleRef.get<AddressSettingsService>(AddressSettingsService);
jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined);
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
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(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
expect(blocksService.save).toHaveBeenCalledWith(expect.objectContaining({
height: MockRecording1.BLOCK_TEMPLATE.height,
minerAddress: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
worker: 'bitaxe3',
sessionId: MockRecording1.EXTRA_NONCE,
blockData: expect.any(String)
}));
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled();
expect(addressSettings.resetBestDifficultyAndShares).toHaveBeenCalled();
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
});
});
+226 -55
View File
@@ -1,7 +1,7 @@
import { ConfigService } from '@nestjs/config';
import * as bitcoinjs from 'bitcoinjs-lib';
import { plainToInstance } from 'class-transformer';
import { validate, ValidatorOptions } from 'class-validator';
import { validate, ValidationError, ValidatorOptions } from 'class-validator';
import * as crypto from 'crypto';
import { Socket } from 'net';
import { firstValueFrom, Subscription } from 'rxjs';
@@ -24,25 +24,30 @@ import { ConfigurationMessage } from './stratum-messages/ConfigurationMessage';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
import { StratumErrorMessage } from './stratum-messages/StratumErrorMessage';
import { SubscriptionMessage } from './stratum-messages/SubscriptionMessage';
import { EXTRANONCE1_SIZE_BYTES } from './stratum.constants';
import { SuggestDifficulty } from './stratum-messages/SuggestDifficultyMessage';
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
import { ExternalSharesService } from '../services/external-shares.service';
import { DifficultyUtils } from '../utils/difficulty.utils';
const TRUE_DIFF_ONE = 2.695953529101131e67;
const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000;
const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000;
export class StratumV1Client {
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
private static validationErrorLogState = new Map<string, { nextLogAt: number, suppressed: number, sample: string }>();
private clientSubscription: SubscriptionMessage;
private clientConfiguration: ConfigurationMessage;
private clientAuthorization: AuthorizationMessage;
private clientSuggestedDifficulty: SuggestDifficulty;
private stratumSubscription: Subscription;
private backgroundWork: NodeJS.Timer[] = [];
private backgroundWork: NodeJS.Timeout[] = [];
private statistics: StratumV1ClientStatistics;
private stratumInitialized = false;
private usedSuggestedDifficulty = false;
private sessionDifficulty: number = 16384;
private sessionDifficulty: number = 100000;
private entity: ClientEntity;
private creatingEntity: Promise<void>;
@@ -53,6 +58,8 @@ export class StratumV1Client {
public hashRate: number = 0;
private buffer: string = '';
private connectionClosed = false;
private lastSentMiningJobTimestamp: number = null;
private miningSubmissionHashes = new Set<string>()
@@ -74,16 +81,19 @@ export class StratumV1Client {
let lines = this.buffer.split('\n');
this.buffer = lines.pop() || ''; // Save the last part of the data (incomplete line) to the buffer
lines
.filter(m => m.length > 0)
.forEach(async (m) => {
(async () => {
for (const m of lines.filter(l => l.length > 0)) {
if (this.connectionClosed || this.socket.destroyed || this.socket.writableEnded) {
break;
}
try {
await this.handleMessage(m);
} catch (e) {
await this.socket.end();
console.error(e);
}
});
}
})();
});
@@ -105,10 +115,8 @@ export class StratumV1Client {
}
private getRandomHexString() {
const randomBytes = crypto.randomBytes(4); // 4 bytes = 32 bits
const randomNumber = randomBytes.readUInt32BE(0); // Convert bytes to a 32-bit unsigned integer
const hexString = randomNumber.toString(16).padStart(8, '0'); // Convert to hex and pad with zeros
return hexString;
const randomBytes = crypto.randomBytes(EXTRANONCE1_SIZE_BYTES);
return randomBytes.toString('hex');
}
@@ -142,6 +150,11 @@ export class StratumV1Client {
const errors = await validate(subscriptionMessage, validatorOptions);
if (errors.length === 0) {
if (this.isBlockedUserAgent(subscriptionMessage.userAgent)) {
this.logBlockedUserAgent(subscriptionMessage.userAgent);
this.closeSocket();
return;
}
if (this.sessionStart == null) {
this.sessionStart = new Date();
@@ -225,6 +238,9 @@ export class StratumV1Client {
if (errors.length === 0) {
this.clientAuthorization = authorizationMessage;
if (this.clientSuggestedDifficulty == null && this.clientAuthorization.startingDiff != null && this.clientAuthorization.startingDiff > this.sessionDifficulty) {
this.sessionDifficulty = this.clientAuthorization.startingDiff;
}
const success = await this.write(JSON.stringify(this.clientAuthorization.response()) + '\n');
if (!success) {
return;
@@ -318,17 +334,18 @@ export class StratumV1Client {
} else {
console.log('Mining Submit validation error');
this.logValidationError('Mining Submit validation error', errors);
const err = new StratumErrorMessage(
miningSubmitMessage.id,
eStratumErrorCode.OtherUnknown,
'Mining Submit validation error',
errors).response();
console.error(err);
const success = await this.write(err);
if (!success) {
return;
}
this.closeSocket();
return;
}
break;
}
@@ -353,6 +370,12 @@ export class StratumV1Client {
private async initStratum() {
this.stratumInitialized = true;
if (this.isBlockedUserAgent(this.clientSubscription.userAgent)) {
this.logBlockedUserAgent(this.clientSubscription.userAgent);
this.closeSocket();
return;
}
switch (this.clientSubscription.userAgent) {
case 'cpuminer': {
this.sessionDifficulty = 0.1;
@@ -438,6 +461,7 @@ export class StratumV1Client {
if (!success) {
return;
}
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
//console.log(`Sent new job to ${this.clientAuthorization.worker}.${this.extraNonceAndSessionId}. (clearJobs: ${jobTemplate.blockData.clearJobs}, fee?: ${!this.noFee})`)
@@ -445,12 +469,13 @@ export class StratumV1Client {
}
private async handleMiningSubmission(submission: MiningSubmitMessage) {
private async ensureClientEntity() {
if (this.entity != null) {
return;
}
if (this.entity == null) {
if (this.creatingEntity == null) {
this.creatingEntity = new Promise(async (resolve, reject) => {
try {
this.creatingEntity = (async () => {
this.entity = await this.clientService.insert({
sessionId: this.extraNonceAndSessionId,
address: this.clientAuthorization.address,
@@ -459,33 +484,14 @@ export class StratumV1Client {
startTime: new Date(),
bestDifficulty: 0
});
} catch (e) {
reject(e);
}
resolve();
});
await this.creatingEntity;
} else {
await this.creatingEntity;
}
})();
}
const submissionHash = submission.hash();
if(this.miningSubmissionHashes.has(submissionHash)){
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.DuplicateShare,
'Duplicate share').response();
const success = await this.write(err);
if (!success) {
return false;
}
return false;
}else{
this.miningSubmissionHashes.add(submissionHash);
await this.creatingEntity;
}
private async handleMiningSubmission(submission: MiningSubmitMessage) {
const job = this.stratumV1JobsService.getJobById(submission.jobId);
// a miner may submit a job that doesn't exist anymore if it was removed by a new block notification (or expired, 5 min)
@@ -503,24 +509,73 @@ export class StratumV1Client {
}
const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId);
const updatedJobBlock = job.copyAndUpdateBlock(
if (jobTemplate == null) {
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.JobNotFound,
'Job Template not found').response();
//console.log(err);
const success = await this.write(err);
if (!success) {
return false;
}
return false;
}
const submissionHash = [
submission.jobId,
submission.extraNonce2,
submission.ntime,
submission.nonce,
submission.versionMask ?? ''
].join(':');
if (this.miningSubmissionHashes.has(submissionHash)) {
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.DuplicateShare,
'Duplicate share').response();
const success = await this.write(err);
if (!success) {
return false;
}
return false;
} else {
this.miningSubmissionHashes.add(submissionHash);
}
const versionMask = parseInt(submission.versionMask, 16);
const nonce = parseInt(submission.nonce, 16);
const timestamp = parseInt(submission.ntime, 16);
const header = job.buildHeaderBuffer(
jobTemplate,
parseInt(submission.versionMask, 16),
parseInt(submission.nonce, 16),
versionMask,
nonce,
this.extraNonceAndSessionId,
submission.extraNonce2,
parseInt(submission.ntime, 16)
timestamp
);
const header = updatedJobBlock.toBuffer(true);
const { submissionDifficulty } = DifficultyUtils.calculateDifficulty(header);
const { submissionDifficulty } = this.calculateDifficulty(header);
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
if (submissionDifficulty >= this.sessionDifficulty) {
const success = await this.write(JSON.stringify(submission.response()) + '\n');
if (!success) {
return false;
}
if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) {
console.log('!!! BLOCK FOUND !!!');
const updatedJobBlock = job.copyAndUpdateBlock(
jobTemplate,
versionMask,
nonce,
this.extraNonceAndSessionId,
submission.extraNonce2,
timestamp
);
const blockHex = updatedJobBlock.toHex(false);
const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
await this.blocksService.save({
@@ -537,6 +592,7 @@ export class StratumV1Client {
await this.addressSettingsService.resetBestDifficultyAndShares();
}
}
await this.ensureClientEntity();
try {
await this.statistics.addShares(this.entity, this.sessionDifficulty);
const now = new Date();
@@ -551,11 +607,9 @@ export class StratumV1Client {
}
if (submissionDifficulty > this.entity.bestDifficulty) {
await this.clientService.updateBestDifficulty(this.extraNonceAndSessionId, submissionDifficulty);
await this.clientService.updateBestDifficultyIfHigher(this.extraNonceAndSessionId, submissionDifficulty);
this.entity.bestDifficulty = submissionDifficulty;
if (submissionDifficulty > (await this.addressSettingsService.getSettings(this.clientAuthorization.address, true)).bestDifficulty) {
await this.addressSettingsService.updateBestDifficulty(this.clientAuthorization.address, submissionDifficulty, this.entity.userAgent);
}
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.entity.userAgent);
}
@@ -587,7 +641,7 @@ export class StratumV1Client {
}
//await this.checkDifficulty();
return true;
return false;
}
@@ -611,13 +665,130 @@ export class StratumV1Client {
await this.socket.write(data);
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
// we need to clear the jobs so that the difficulty set takes effect. Otherwise the different miner implementations can cause issues
jobTemplate.blockData.clearJobs = true;
await this.sendNewMiningJob(jobTemplate);
const nextTimestamp = Math.max(
jobTemplate.block.timestamp,
Math.floor(Date.now() / 1000),
(this.lastSentMiningJobTimestamp ?? 0) + 1
);
// We need to clear jobs so the difficulty takes effect, but avoid mutating or
// re-sending the shared cached template with byte-identical work.
const refreshedJobTemplate: IJobTemplate = {
...jobTemplate,
block: Object.assign(new bitcoinjs.Block(), jobTemplate.block, {
timestamp: nextTimestamp
}),
blockData: { ...jobTemplate.blockData, clearJobs: true }
};
await this.sendNewMiningJob(refreshedJobTemplate);
}
}
private calculateDifficulty(header: Buffer): { submissionDifficulty: number, submissionHash: string } {
const hashResult = bitcoinjs.crypto.hash256(header);
const target = this.le256todouble(hashResult);
const submissionDifficulty = target === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE / target;
return { submissionDifficulty, submissionHash: hashResult.toString('hex') };
}
private le256todouble(target: Buffer): number {
let number = 0;
for (let i = target.length - 1; i >= 0; i--) {
number = number * 256 + target[i];
}
return number;
}
private isBlockedUserAgent(userAgent: string): boolean {
const blockedUserAgents = this.configService.get<string>('NON_COMPLIANT_USER_AGENTS')
|| this.configService.get<string>('BLOCKED_USER_AGENTS')
|| this.configService.get<string>('COMPLIANT_HEADERS');
if (!blockedUserAgents || blockedUserAgents.trim() === '') {
return false;
}
const blockedList = blockedUserAgents.split(',').map(ua => ua.trim().toLowerCase());
const userAgentLower = userAgent.toLowerCase();
return blockedList.some(blocked => blocked.length > 0 && userAgentLower.includes(blocked));
}
private logBlockedUserAgent(userAgent: string) {
const now = Date.now();
const logState = StratumV1Client.blockedUserAgentLogState.get(userAgent);
if (logState != null && now < logState.nextLogAt) {
logState.suppressed += 1;
return;
}
const suppressed = logState?.suppressed ?? 0;
const suffix = suppressed > 0 ? ` (${suppressed} similar connections suppressed)` : '';
console.log(`Blocked non-compliant connection from userAgent: ${userAgent}${suffix}`);
StratumV1Client.blockedUserAgentLogState.set(userAgent, {
nextLogAt: now + BLOCKED_USER_AGENT_LOG_INTERVAL_MS,
suppressed: 0
});
}
private logValidationError(label: string, errors: ValidationError[]) {
const now = Date.now();
const signature = this.getValidationErrorSignature(errors);
const sample = this.getValidationErrorSample(errors);
const key = `${label}:${signature}`;
const logState = StratumV1Client.validationErrorLogState.get(key);
if (logState != null && now < logState.nextLogAt) {
logState.suppressed += 1;
return;
}
const suppressed = logState?.suppressed ?? 0;
const suffix = suppressed > 0 ? ` (${suppressed} similar validation errors suppressed)` : '';
console.warn(`${label}: ${signature}${sample}${suffix}`);
StratumV1Client.validationErrorLogState.set(key, {
nextLogAt: now + VALIDATION_ERROR_LOG_INTERVAL_MS,
suppressed: 0,
sample
});
}
private getValidationErrorSignature(errors: ValidationError[]): string {
if (errors.length === 0) {
return 'unknown';
}
return errors.map(error => {
const constraints = Object.keys(error.constraints ?? {}).sort().join('|') || 'invalid';
return `${error.property}:${constraints}`;
}).join(';');
}
private getValidationErrorSample(errors: ValidationError[]): string {
const values = errors
.map(error => error.value)
.filter(value => value != null)
.map(value => String(value).replace(/[\r\n]/g, '').slice(0, 64));
if (values.length === 0) {
return '';
}
return ` sample=${values.join(',')}`;
}
private closeSocket() {
this.connectionClosed = true;
if (!this.socket.destroyed) {
this.socket.destroy();
}
}
private async write(message: string): Promise<boolean> {
try {
if (!this.socket.destroyed && !this.socket.writableEnded) {
@@ -0,0 +1,110 @@
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
describe('StratumV1ClientStatistics', () => {
const client = {
id: 'client-id',
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'bitaxe3',
sessionId: '57a6f098'
} as any;
let clientStatisticsService: {
insert: jest.Mock,
update: jest.Mock
};
let statistics: StratumV1ClientStatistics;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-06T12:00:00Z'));
clientStatisticsService = {
insert: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined)
};
statistics = new StratumV1ClientStatistics(clientStatisticsService as any);
});
afterEach(() => {
jest.useRealTimers();
});
it('should insert the first share bucket', async () => {
await statistics.addShares(client, 64);
expect(clientStatisticsService.insert).toHaveBeenCalledWith({
time: new Date('2026-05-06T12:00:00Z').getTime(),
shares: 64,
acceptedCount: 1,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
});
it('should update the current share bucket for additional shares', async () => {
await statistics.addShares(client, 64);
jest.setSystemTime(new Date('2026-05-06T12:01:01Z'));
await statistics.addShares(client, 32);
expect(clientStatisticsService.update).toHaveBeenCalledWith({
time: new Date('2026-05-06T12:00:00Z').getTime(),
shares: 96,
acceptedCount: 2,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
});
it('should create a new share bucket when the time slot changes', async () => {
await statistics.addShares(client, 64);
jest.setSystemTime(new Date('2026-05-06T12:10:00Z'));
await statistics.addShares(client, 32);
expect(clientStatisticsService.update).toHaveBeenCalledWith({
time: new Date('2026-05-06T12:00:00Z').getTime(),
shares: 64,
acceptedCount: 1,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
expect(clientStatisticsService.insert).toHaveBeenLastCalledWith({
time: new Date('2026-05-06T12:10:00Z').getTime(),
shares: 32,
acceptedCount: 1,
address: client.address,
clientName: client.clientName,
sessionId: client.sessionId
});
});
it('should not suggest a difficulty change before enough time or shares have passed', () => {
expect(statistics.getSuggestedDifficulty(64)).toBeNull();
});
it('should lower difficulty when a miner has not submitted shares for several minutes', () => {
jest.setSystemTime(new Date('2026-05-06T12:06:00Z'));
expect(statistics.getSuggestedDifficulty(64)).toBe(8);
});
it('should increase difficulty for rapid submissions', async () => {
for (let i = 0; i < 5; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 1000)));
await statistics.addShares(client, 64);
}
expect(statistics.getSuggestedDifficulty(64)).toBe(512);
});
it('should decrease difficulty for slow submissions', async () => {
for (let i = 0; i < 5; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 150000)));
await statistics.addShares(client, 64);
}
expect(statistics.getSuggestedDifficulty(128)).toBe(4);
});
});
@@ -0,0 +1,49 @@
import { plainToInstance } from 'class-transformer';
import { AuthorizationMessage } from './AuthorizationMessage';
describe('AuthorizationMessage', () => {
it('should parse address, worker, and starting difficulty', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x,d=2048"]}')
);
expect(message.address).toBe('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4');
expect(message.worker).toBe('worker1');
expect(message.password).toBe('x,d=2048');
expect(message.startingDiff).toBe(2048);
});
it('should default worker name when one is not provided', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4","x"]}')
);
expect(message.worker).toBe('worker');
expect(message.startingDiff).toBeNull();
});
it('should ignore malformed starting difficulty hints', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x,d=abc"]}')
);
expect(message.startingDiff).toBeNull();
});
it('should build successful authorization responses', () => {
const message = plainToInstance(
AuthorizationMessage,
JSON.parse('{"id":3,"method":"mining.authorize","params":["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker1","x"]}')
);
expect(message.response()).toEqual({
id: 3,
error: null,
result: true
});
});
});
@@ -1,5 +1,5 @@
import { Expose, Transform } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsOptional, IsString, MaxLength } from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
import { IsBitcoinAddress } from '../validators/bitcoin-address.validator';
@@ -8,7 +8,7 @@ import { StratumBaseMessage } from './StratumBaseMessage';
export class AuthorizationMessage extends StratumBaseMessage {
@IsArray()
@ArrayMinSize(2)
@ArrayMinSize(1)
@ArrayMaxSize(2)
params: string[];
@@ -28,6 +28,19 @@ export class AuthorizationMessage extends StratumBaseMessage {
})
public worker: string;
@Expose()
@IsNumber()
@Transform(({ value, key, obj, type }) => {
const password: string | null = obj.params[1];
const difficultyMatch = password?.match(/(?:^|,)d=(\d+)(?:,|$)/);
if (difficultyMatch != null) {
const startingDiff = Number.parseInt(difficultyMatch[1], 10);
return Number.isFinite(startingDiff) ? startingDiff : null;
}
return null;
})
@IsOptional()
public startingDiff?: number;
@Expose()
@IsString()
@@ -1,4 +1,5 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { MiningSubmitMessage } from './MiningSubmitMessage';
@@ -13,7 +14,7 @@ describe('MiningSubmitMessage', () => {
describe('test message parsing', () => {
const MINING_SUBMIT_MESSAGE = ' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "99020000", "64b1f10f", "2402812d", "00006000"]}'
const MINING_SUBMIT_MESSAGE = ' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "9902000000000000", "64b1f10f", "2402812d", "00006000"]}'
const message = plainToInstance(
MiningSubmitMessage,
@@ -24,11 +25,39 @@ describe('MiningSubmitMessage', () => {
expect(message.id).toEqual(5);
expect(message.userId).toEqual('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3');
expect(message.jobId).toEqual('1');
expect(message.extraNonce2).toEqual('99020000');
expect(message.extraNonce2).toEqual('9902000000000000');
expect(message.ntime).toEqual('64b1f10f');
expect(message.nonce).toEqual('2402812d');
expect(message.versionMask).toEqual('00006000');
});
it('should validate 8-byte extranonce2 submissions', async () => {
const errors = await validate(message);
expect(errors).toEqual([]);
});
it('should reject short extranonce2 submissions', async () => {
const shortMessage = plainToInstance(
MiningSubmitMessage,
JSON.parse(' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "99020000", "64b1f10f", "2402812d", "00006000"]}'),
);
const errors = await validate(shortMessage);
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
});
it('should reject long extranonce2 submissions', async () => {
const longMessage = plainToInstance(
MiningSubmitMessage,
JSON.parse(' {"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "990200000000000000", "64b1f10f", "2402812d", "00006000"]}'),
);
const errors = await validate(longMessage);
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
});
});
@@ -1,7 +1,8 @@
import { Expose, Transform } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, Length } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
import { StratumBaseMessage } from './StratumBaseMessage';
import * as bitcoinjs from 'bitcoinjs-lib';
@@ -27,6 +28,7 @@ export class MiningSubmitMessage extends StratumBaseMessage {
public jobId: string;
@Expose()
@IsString()
@Length(EXTRANONCE2_SIZE_BYTES * 2, EXTRANONCE2_SIZE_BYTES * 2)
@Transform(({ value, key, obj, type }) => {
return obj.params[2];
})
@@ -0,0 +1,45 @@
import { plainToInstance } from 'class-transformer';
import { SubscriptionMessage } from './SubscriptionMessage';
describe('SubscriptionMessage', () => {
it('should parse and refine known user agents', () => {
const bosminer = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":["bosminer/23.08"]}')
);
const cpuminer = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":["cpuminer-opt/1.0"]}')
);
expect(bosminer.userAgent).toBe('Braiins OS');
expect(cpuminer.userAgent).toBe('cpuminer');
});
it('should default missing user agents to unknown', () => {
const message = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":[]}')
);
expect(message.userAgent).toBe('unknown');
});
it('should respond with extranonce2 size of 8 bytes', () => {
const message = plainToInstance(
SubscriptionMessage,
JSON.parse('{"id":1,"method":"mining.subscribe","params":["bitaxe v2.2"]}')
);
expect(message.response('57a6f098')).toEqual({
id: 1,
error: null,
result: [
[['mining.notify', '57a6f098']],
'57a6f098',
8
]
});
});
});
@@ -2,6 +2,7 @@ import { Expose, Transform } from 'class-transformer';
import { IsArray, IsString, MaxLength } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
import { StratumBaseMessage } from './StratumBaseMessage';
export class SubscriptionMessage extends StratumBaseMessage {
@@ -32,7 +33,7 @@ export class SubscriptionMessage extends StratumBaseMessage {
['mining.notify', clientId]
],
clientId, //Extranonce1 - Hex-encoded, per-connection unique string which will be used for coinbase serialization later. Keep it safe!
4 //Extranonce2_size - Represents expected length of extranonce2 which will be generated by the miner.
EXTRANONCE2_SIZE_BYTES //Extranonce2_size - Represents expected length of extranonce2 which will be generated by the miner.
]
}
+3
View File
@@ -0,0 +1,3 @@
export const EXTRANONCE1_SIZE_BYTES = 4;
export const EXTRANONCE2_SIZE_BYTES = 8;
export const TOTAL_EXTRANONCE_SIZE_BYTES = EXTRANONCE1_SIZE_BYTES + EXTRANONCE2_SIZE_BYTES;
+47 -17
View File
@@ -1,19 +1,20 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RPCClient } from 'rpc-bitcoin';
import axios, { AxiosInstance } from 'axios';
import * as fs from 'node:fs';
import { BehaviorSubject, filter, shareReplay } from 'rxjs';
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
import * as zmq from 'zeromq';
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
import * as fs from 'node:fs';
@Injectable()
export class BitcoinRpcService implements OnModuleInit {
private blockHeight = 0;
private client: RPCClient;
private client: AxiosInstance;
private rpcRequestId = 0;
private _newBlock$: BehaviorSubject<IMiningInfo> = new BehaviorSubject(undefined);
public newBlock$ = this._newBlock$.pipe(filter(block => block != null), shareReplay({ refCount: true, bufferSize: 1 }));
@@ -30,18 +31,26 @@ export class BitcoinRpcService implements OnModuleInit {
const port = parseInt(this.configService.get('BITCOIN_RPC_PORT'));
const timeout = parseInt(this.configService.get('BITCOIN_RPC_TIMEOUT'));
const cookiefile = this.configService.get('BITCOIN_RPC_COOKIEFILE')
const cookiefile = this.configService.get('BITCOIN_RPC_COOKIEFILE');
if (cookiefile != undefined && cookiefile != '') {
const cookie = fs.readFileSync(cookiefile).toString().split(':')
const cookie = fs.readFileSync(cookiefile).toString().trim().split(':');
user = cookie[0]
pass = cookie[1]
user = cookie[0];
pass = cookie[1];
}
this.client = new RPCClient({ url, port, timeout, user, pass });
const baseURL = this.buildRpcUrl(url, port);
this.client = axios.create({
baseURL,
timeout,
auth: {
username: user,
password: pass
}
});
this.client.getrpcinfo().then((res) => {
this.callRpc('getrpcinfo').then(() => {
console.log('Bitcoin RPC connected');
}, () => {
console.error('Could not reach RPC host');
@@ -138,13 +147,13 @@ export class BitcoinRpcService implements OnModuleInit {
let blockTemplate: IBlockTemplate;
while (blockTemplate == null) {
blockTemplate = await this.client.getblocktemplate({
template_request: {
blockTemplate = await this.callRpc<IBlockTemplate>('getblocktemplate', [
{
rules: ['segwit'],
mode: 'template',
capabilities: ['serverlist', 'proposal']
}
});
]);
}
@@ -155,7 +164,7 @@ export class BitcoinRpcService implements OnModuleInit {
public async getMiningInfo(): Promise<IMiningInfo> {
try {
return await this.client.getmininginfo();
return await this.callRpc<IMiningInfo>('getmininginfo');
} catch (e) {
console.error('Error getmininginfo', e.message);
return null;
@@ -166,9 +175,7 @@ export class BitcoinRpcService implements OnModuleInit {
public async SUBMIT_BLOCK(hexdata: string): Promise<string> {
let response: string = 'unknown';
try {
response = await this.client.submitblock({
hexdata
});
response = await this.callRpc<string>('submitblock', [hexdata]);
if (response == null) {
response = 'SUCCESS!';
}
@@ -182,5 +189,28 @@ export class BitcoinRpcService implements OnModuleInit {
return response;
}
private async callRpc<T>(method: string, params: unknown[] = []): Promise<T> {
const response = await this.client.post('', {
jsonrpc: '1.0',
id: ++this.rpcRequestId,
method,
params
});
if (response.data.error != null) {
throw response.data.error;
}
return response.data.result;
}
private buildRpcUrl(url: string, port: number): string {
const normalizedUrl = /^https?:\/\//i.test(url) ? url : `http://${url}`;
const rpcUrl = new URL(normalizedUrl);
if (Number.isFinite(port) && port > 0) {
rpcUrl.port = port.toString();
}
return rpcUrl.toString();
}
}
@@ -0,0 +1,138 @@
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
describe('StratumV1JobsService', () => {
let miningInfo$: BehaviorSubject<IMiningInfo>;
let bitcoinRpcService: { newBlock$: any, getBlockTemplate: jest.Mock };
let service: StratumV1JobsService;
let consoleLogSpy: jest.SpyInstance;
const createTemplate = (height = MockRecording1.BLOCK_TEMPLATE.height): IBlockTemplate => ({
...MockRecording1.BLOCK_TEMPLATE,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
height
});
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
miningInfo$ = new BehaviorSubject({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
bitcoinRpcService = {
newBlock$: miningInfo$.asObservable(),
getBlockTemplate: jest.fn((height: number) => Promise.resolve(createTemplate(height)))
};
service = new StratumV1JobsService(bitcoinRpcService as any);
});
afterEach(() => {
consoleLogSpy.mockRestore();
jest.useRealTimers();
});
it('should create job templates from block templates', async () => {
const jobTemplate = await firstValueFrom(service.newMiningJob$);
expect(jobTemplate.blockData).toEqual(expect.objectContaining({
id: '1',
height: MockRecording1.BLOCK_TEMPLATE.height,
clearJobs: true,
coinbasevalue: MockRecording1.BLOCK_TEMPLATE.coinbasevalue
}));
expect(jobTemplate.merkle_branch.length).toBeGreaterThan(0);
expect(jobTemplate.block.transactions[0].ins[0].witness[0]).toHaveLength(32);
expect(service.getJobTemplateById('1')).toBe(jobTemplate);
});
it('should clear jobs when the block height changes', async () => {
const firstTemplate = await firstValueFrom(service.newMiningJob$);
service.addJob({ jobId: 'old-job', creation: Date.now() } as any);
const nextTemplate = firstValueFrom(service.newMiningJob$);
miningInfo$.next({
blocks: MockRecording1.BLOCK_TEMPLATE.height + 1
} as IMiningInfo);
const jobTemplate = await nextTemplate;
expect(jobTemplate.blockData.clearJobs).toBe(true);
expect(service.getJobById('old-job')).toBeUndefined();
expect(service.getJobTemplateById(firstTemplate.blockData.id)).toBeUndefined();
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
});
it('should skip identical non-clean template refreshes', async () => {
await firstValueFrom(service.newMiningJob$);
miningInfo$.next({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
expect(service.latestJobTemplateId).toBe(2);
expect(Object.keys(service.blocks)).toHaveLength(1);
});
it('should emit when transaction identity changes without changing transaction count', async () => {
await firstValueFrom(service.newMiningJob$);
const emissions = [];
const subscription = service.newMiningJob$.subscribe(jobTemplate => {
emissions.push(jobTemplate);
});
await Promise.resolve();
const changedTemplate = createTemplate();
changedTemplate.transactions[0] = {
...changedTemplate.transactions[0],
hash: `${changedTemplate.transactions[0].hash}-changed`
};
bitcoinRpcService.getBlockTemplate.mockResolvedValueOnce(changedTemplate);
miningInfo$.next({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
await Promise.resolve();
await Promise.resolve();
subscription.unsubscribe();
const jobTemplate = emissions[emissions.length - 1];
expect(jobTemplate.blockData.clearJobs).toBe(false);
expect(jobTemplate.blockData.id).toBe('2');
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
});
it('should age old jobs and templates after five minutes', async () => {
await firstValueFrom(service.newMiningJob$);
const oldCreation = Date.now() - (1000 * 60 * 11);
service.jobs['old-job'] = { jobId: 'old-job', creation: oldCreation } as any;
service.blocks['old-template'] = {
blockData: { creation: oldCreation }
} as any;
jest.setSystemTime(new Date(Date.now() + (1000 * 60 * 11)));
const nextTemplate = firstValueFrom(service.newMiningJob$);
miningInfo$.next({
blocks: MockRecording1.BLOCK_TEMPLATE.height
} as IMiningInfo);
const jobTemplate = await nextTemplate;
expect(jobTemplate.blockData.clearJobs).toBe(false);
expect(service.getJobById('old-job')).toBeUndefined();
expect(service.getJobTemplateById('old-template')).toBeUndefined();
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
});
it('should increment job ids when jobs are added', () => {
expect(service.getNextId()).toBe('1');
service.addJob({ jobId: '1', creation: Date.now() } as any);
expect(service.getNextId()).toBe('2');
expect(service.getJobById('1')).toEqual(expect.objectContaining({ jobId: '1' }));
});
});
+31 -17
View File
@@ -24,8 +24,6 @@ export interface IJobTemplate {
@Injectable()
export class StratumV1JobsService {
private lastIntervalCount: number;
private skipNext: boolean = false;
public newMiningJob$: Observable<IJobTemplate>;
public latestJobId: number = 1;
@@ -37,44 +35,61 @@ export class StratumV1JobsService {
// offset the interval so that all the cluster processes don't try and refresh at the same time.
private delay = process.env.NODE_APP_INSTANCE == null ? 0 : parseInt(process.env.NODE_APP_INSTANCE) * 5000;
private lastBlockHeight = 0;
private lastWorkSignature: string;
constructor(
private readonly bitcoinRpcService: BitcoinRpcService
) {
this.newMiningJob$ = combineLatest([this.bitcoinRpcService.newBlock$, interval(60000).pipe(delay(this.delay), startWith(-1))]).pipe(
const refreshInterval$ = this.delay > 0
? interval(60000).pipe(delay(this.delay), startWith(-1))
: interval(60000).pipe(startWith(-1));
this.newMiningJob$ = combineLatest([this.bitcoinRpcService.newBlock$, refreshInterval$]).pipe(
switchMap(([miningInfo, interval]) => {
return from(this.bitcoinRpcService.getBlockTemplate(miningInfo.blocks)).pipe(map((blockTemplate) => {
return {
blockTemplate,
interval
miningInfo
}
}))
}),
map(({ blockTemplate, interval }) => {
map(({ blockTemplate, miningInfo }) => {
let clearJobs = false;
if (this.lastIntervalCount === interval) {
const currentBlockHeight = miningInfo.blocks;
if (this.lastBlockHeight == 0 || this.lastBlockHeight != currentBlockHeight) {
clearJobs = true;
this.skipNext = true;
console.log('new block')
this.lastBlockHeight = currentBlockHeight;
console.log('new block');
}
if (this.skipNext == true && clearJobs == false) {
this.skipNext = false;
return null;
}
this.lastIntervalCount = interval;
const currentTime = Math.floor(new Date().getTime() / 1000);
const timestamp = blockTemplate.mintime > currentTime ? blockTemplate.mintime : currentTime;
const workSignature = [
blockTemplate.previousblockhash,
blockTemplate.version,
blockTemplate.bits,
timestamp,
blockTemplate.height,
blockTemplate.coinbasevalue,
...blockTemplate.transactions.map(tx => tx.hash ?? tx.txid ?? tx.data)
].join('|');
if (!clearJobs && workSignature === this.lastWorkSignature) {
return null;
}
this.lastWorkSignature = workSignature;
return {
version: blockTemplate.version,
bits: parseInt(blockTemplate.bits, 16),
prevHash: this.convertToLittleEndian(blockTemplate.previousblockhash),
transactions: blockTemplate.transactions.map(t => bitcoinjs.Transaction.fromHex(t.data)),
coinbasevalue: blockTemplate.coinbasevalue,
timestamp: blockTemplate.mintime > currentTime ? blockTemplate.mintime : currentTime,
timestamp,
networkDifficulty: this.calculateNetworkDifficulty(parseInt(blockTemplate.bits, 16)),
clearJobs,
height: blockTemplate.height
@@ -186,5 +201,4 @@ export class StratumV1JobsService {
return this.latestJobId.toString(16);
}
}
+137
View File
@@ -0,0 +1,137 @@
import { StratumV1Service } from './stratum-v1.service';
describe('StratumV1Service', () => {
const originalMaster = process.env.MASTER;
const originalNodeAppInstance = process.env.NODE_APP_INSTANCE;
const originalStratumPort = process.env.STRATUM_PORT;
const originalBackpressureEnabled = process.env.STRATUM_BACKPRESSURE_ENABLED;
const originalMaxConnectionsPerListener = process.env.STRATUM_MAX_CONNECTIONS_PER_LISTENER;
let service: StratumV1Service;
let clientService;
let consoleLogSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers();
clientService = {
deleteAll: jest.fn().mockResolvedValue(undefined)
};
service = new StratumV1Service(
{} as any,
clientService,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any
);
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
restoreEnv('MASTER', originalMaster);
restoreEnv('NODE_APP_INSTANCE', originalNodeAppInstance);
restoreEnv('STRATUM_PORT', originalStratumPort);
restoreEnv('STRATUM_BACKPRESSURE_ENABLED', originalBackpressureEnabled);
restoreEnv('STRATUM_MAX_CONNECTIONS_PER_LISTENER', originalMaxConnectionsPerListener);
consoleLogSpy.mockRestore();
consoleWarnSpy.mockRestore();
jest.useRealTimers();
});
it('should skip Stratum listeners in the master process', async () => {
process.env.MASTER = 'true';
const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer');
await service.onModuleInit();
jest.runOnlyPendingTimers();
expect(clientService.deleteAll).toHaveBeenCalled();
expect(startSocketServerSpy).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith('Master process skipping Stratum socket listeners');
});
it('should start the Stratum listener in worker processes', async () => {
process.env.MASTER = 'false';
process.env.STRATUM_PORT = '3334';
const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer').mockImplementation(() => undefined);
await service.onModuleInit();
jest.advanceTimersByTime(10000);
expect(clientService.deleteAll).not.toHaveBeenCalled();
expect(startSocketServerSpy).toHaveBeenCalledWith(3334);
});
it('should pause listeners when worker backpressure is high', () => {
const close = jest.fn((callback?: (error?: Error) => void) => callback?.());
(service as any).listeners.push({
port: 3333,
server: { close },
paused: false
});
jest.spyOn(service as any, 'getEventLoopP95Ms').mockReturnValue(5000);
jest.spyOn(service as any, 'getBackpressureEventLoopP95Ms').mockReturnValue(2000);
(service as any).checkBackpressure();
expect(close).toHaveBeenCalled();
expect((service as any).listeners[0].paused).toBe(true);
expect((service as any).listeners[0].server).toBeNull();
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Pausing Stratum accepts'));
});
it('should resume listeners after consecutive healthy backpressure checks', () => {
(service as any).listeners.push({
port: 3333,
server: null,
paused: true
});
jest.spyOn(service as any, 'getEventLoopP95Ms').mockReturnValue(50);
jest.spyOn(service as any, 'getBackpressureEventLoopP95Ms').mockReturnValue(2000);
jest.spyOn(service as any, 'getBackpressureResumeEventLoopP95Ms').mockReturnValue(250);
jest.spyOn(service as any, 'getBackpressureResumeRssMb').mockReturnValue(Number.MAX_SAFE_INTEGER);
jest.spyOn(service as any, 'getBackpressureHealthyChecks').mockReturnValue(2);
const listenSpy = jest.spyOn(service as any, 'listen').mockImplementation((listener: any) => {
listener.server = {};
listener.paused = false;
});
(service as any).checkBackpressure();
expect(listenSpy).not.toHaveBeenCalled();
(service as any).checkBackpressure();
expect(listenSpy).toHaveBeenCalledWith((service as any).listeners[0]);
expect((service as any).listeners[0].paused).toBe(false);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Resuming Stratum accepts'));
});
it('should cap listener connections and drop excess cluster connections', () => {
process.env.STRATUM_MAX_CONNECTIONS_PER_LISTENER = '250';
const server = {} as any;
(service as any).configureConnectionLimit(server);
expect(server.maxConnections).toBe(250);
expect(server.dropMaxConnection).toBe(true);
});
it('should allow high-volume pools by default', () => {
delete process.env.STRATUM_MAX_CONNECTIONS_PER_LISTENER;
expect((service as any).getMaxConnectionsPerListener()).toBe(10000);
});
function restoreEnv(key: string, value: string | undefined) {
if (value == null) {
delete process.env[key];
return;
}
process.env[key] = value;
}
});
+193 -7
View File
@@ -1,6 +1,7 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Server, Socket } from 'net';
import { monitorEventLoopDelay } from 'perf_hooks';
import { StratumV1Client } from '../models/StratumV1Client';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
@@ -8,14 +9,33 @@ import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientService } from '../ORM/client/client.service';
import { BitcoinRpcService } from './bitcoin-rpc.service';
import { ExternalSharesService } from './external-shares.service';
import { NotificationService } from './notification.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { ExternalSharesService } from './external-shares.service';
interface StratumListenerState {
port: number;
server: Server | null;
paused: boolean;
}
const DEFAULT_BACKPRESSURE_CHECK_INTERVAL_MS = 5000;
const DEFAULT_BACKPRESSURE_EVENT_LOOP_P95_MS = 2000;
const DEFAULT_BACKPRESSURE_EVENT_LOOP_RESUME_P95_MS = 250;
const DEFAULT_BACKPRESSURE_RSS_MB = 2500;
const DEFAULT_BACKPRESSURE_RESUME_RSS_MB = 2000;
const DEFAULT_BACKPRESSURE_HEALTHY_CHECKS = 3;
const DEFAULT_MAX_CONNECTIONS_PER_LISTENER = 10000;
@Injectable()
export class StratumV1Service implements OnModuleInit {
private readonly listeners: StratumListenerState[] = [];
private readonly eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
private backpressureMonitor: NodeJS.Timeout | null = null;
private healthyBackpressureChecks = 0;
constructor(
private readonly bitcoinRpcService: BitcoinRpcService,
private readonly clientService: ClientService,
@@ -32,16 +52,34 @@ export class StratumV1Service implements OnModuleInit {
async onModuleInit(): Promise<void> {
if (process.env.NODE_APP_INSTANCE == '0') {
if (process.env.NODE_APP_INSTANCE == '0' || process.env.MASTER == 'true') {
await this.clientService.deleteAll();
}
if (process.env.MASTER == 'true') {
console.log('Master process skipping Stratum socket listeners');
return;
}
setTimeout(() => {
this.startSocketServer();
}, 1000 * 10)
this.startSocketServer(this.getStratumPort());
}, 1000 * 10);
this.startBackpressureMonitor();
}
private startSocketServer() {
private startSocketServer(port: number) {
const listener: StratumListenerState = {
port,
server: null,
paused: false
};
this.listeners.push(listener);
this.listen(listener);
}
private createSocketServer(): Server {
const server = new Server(async (socket: Socket) => {
//5 min
@@ -82,9 +120,157 @@ export class StratumV1Service implements OnModuleInit {
});
server.listen(process.env.STRATUM_PORT, () => {
console.log(`Stratum server is listening on port ${process.env.STRATUM_PORT}`);
server.on('error', (err) => {
console.error(`Server error: ${err.message}`);
});
this.configureConnectionLimit(server);
return server;
}
private listen(listener: StratumListenerState) {
if (listener.server != null) {
return;
}
const server = this.createSocketServer();
listener.server = server;
listener.paused = false;
server.listen(listener.port, () => {
console.log(`Stratum server is listening on port ${listener.port}`);
});
}
private startBackpressureMonitor() {
if (this.isBackpressureDisabled() || this.backpressureMonitor != null) {
return;
}
this.eventLoopDelay.enable();
this.backpressureMonitor = setInterval(() => {
this.checkBackpressure();
}, this.getBackpressureCheckIntervalMs());
}
private checkBackpressure() {
const eventLoopP95Ms = this.getEventLoopP95Ms();
const rssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
const overloaded = eventLoopP95Ms >= this.getBackpressureEventLoopP95Ms()
|| rssMb >= this.getBackpressureRssMb();
const paused = this.listeners.some(listener => listener.paused);
if (overloaded) {
this.healthyBackpressureChecks = 0;
if (!paused) {
this.pauseAccepting(eventLoopP95Ms, rssMb);
}
this.eventLoopDelay.reset();
return;
}
if (!paused) {
this.eventLoopDelay.reset();
return;
}
const healthy = eventLoopP95Ms <= this.getBackpressureResumeEventLoopP95Ms()
&& rssMb <= this.getBackpressureResumeRssMb();
if (!healthy) {
this.healthyBackpressureChecks = 0;
this.eventLoopDelay.reset();
return;
}
this.healthyBackpressureChecks++;
if (this.healthyBackpressureChecks >= this.getBackpressureHealthyChecks()) {
this.resumeAccepting(eventLoopP95Ms, rssMb);
this.healthyBackpressureChecks = 0;
}
this.eventLoopDelay.reset();
}
private pauseAccepting(eventLoopP95Ms: number, rssMb: number) {
console.warn(`Pausing Stratum accepts: eventLoopP95Ms=${eventLoopP95Ms}, rssMb=${rssMb}`);
for (const listener of this.listeners) {
if (listener.paused || listener.server == null) {
continue;
}
const server = listener.server;
listener.server = null;
listener.paused = true;
server.close((error) => {
if (error != null) {
console.error(`Error while pausing Stratum listener on port ${listener.port}: ${error.message}`);
}
});
}
}
private resumeAccepting(eventLoopP95Ms: number, rssMb: number) {
console.warn(`Resuming Stratum accepts: eventLoopP95Ms=${eventLoopP95Ms}, rssMb=${rssMb}`);
for (const listener of this.listeners) {
if (!listener.paused || listener.server != null) {
continue;
}
this.listen(listener);
}
}
private configureConnectionLimit(server: Server) {
server.maxConnections = this.getMaxConnectionsPerListener();
(server as Server & { dropMaxConnection: boolean }).dropMaxConnection = true;
}
private getEventLoopP95Ms() {
return Math.round(this.eventLoopDelay.percentile(95) / 1e6);
}
private isBackpressureDisabled() {
return process.env.STRATUM_BACKPRESSURE_ENABLED?.toLowerCase() === 'false';
}
private getBackpressureCheckIntervalMs() {
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_CHECK_INTERVAL_MS', DEFAULT_BACKPRESSURE_CHECK_INTERVAL_MS);
}
private getBackpressureEventLoopP95Ms() {
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_EVENT_LOOP_P95_MS', DEFAULT_BACKPRESSURE_EVENT_LOOP_P95_MS);
}
private getBackpressureResumeEventLoopP95Ms() {
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_EVENT_LOOP_RESUME_P95_MS', DEFAULT_BACKPRESSURE_EVENT_LOOP_RESUME_P95_MS);
}
private getBackpressureRssMb() {
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_RSS_MB', DEFAULT_BACKPRESSURE_RSS_MB);
}
private getBackpressureResumeRssMb() {
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_RESUME_RSS_MB', DEFAULT_BACKPRESSURE_RESUME_RSS_MB);
}
private getBackpressureHealthyChecks() {
return this.getPositiveIntegerEnv('STRATUM_BACKPRESSURE_HEALTHY_CHECKS', DEFAULT_BACKPRESSURE_HEALTHY_CHECKS);
}
private getMaxConnectionsPerListener() {
return this.getPositiveIntegerEnv('STRATUM_MAX_CONNECTIONS_PER_LISTENER', DEFAULT_MAX_CONNECTIONS_PER_LISTENER);
}
private getStratumPort() {
return this.getPositiveIntegerEnv('STRATUM_PORT', 3333);
}
private getPositiveIntegerEnv(key: string, fallback: number) {
const configured = parseInt(process.env[key], 10);
if (Number.isFinite(configured) && configured > 0) {
return configured;
}
return fallback;
}
+63 -22
View File
@@ -1,8 +1,8 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import { validate } from 'bitcoin-address-validation';
import { Block } from 'bitcoinjs-lib';
import * as TelegramBot from 'node-telegram-bot-api';
import { TelegramSubscriptionsService } from '../ORM/telegram-subscriptions/telegram-subscriptions.service';
@@ -10,7 +10,9 @@ import { TelegramSubscriptionsService } from '../ORM/telegram-subscriptions/tele
@Injectable()
export class TelegramService implements OnModuleInit {
private bot: TelegramBot;
private bot: AxiosInstance;
private updateOffset = 0;
private pollingTimer: NodeJS.Timeout;
constructor(
private readonly configService: ConfigService,
@@ -20,7 +22,10 @@ export class TelegramService implements OnModuleInit {
if (token == null || token.length < 1) {
return;
}
this.bot = new TelegramBot(token, { polling: true });
this.bot = axios.create({
baseURL: `https://api.telegram.org/bot${token}/`,
timeout: 10000
});
console.log('Telegram bot init');
@@ -32,23 +37,10 @@ export class TelegramService implements OnModuleInit {
return;
}
this.bot.onText(/\/subscribe/, async (msg) => {
const address = msg.text.split('/subscribe ')[1];
if (validate(address) == false) {
this.bot.sendMessage(msg.chat.id, "Invalid address.");
return;
}
await this.telegramSubscriptionsService.saveSubscription(msg.chat.id, address);
this.bot.sendMessage(msg.chat.id, "Subscribed!");
});
this.bot.onText(/\/start/, (msg) => {
this.bot.sendMessage(msg.chat.id, "Welcome to the public-pool bot. /subscribe <address> to get notified.");
});
this.bot.on('message', (msg) => {
console.log(msg);
});
await this.pollUpdates();
this.pollingTimer = setInterval(async () => {
await this.pollUpdates();
}, 2000);
}
public async notifySubscribersBlockFound(address: string, height: number, block: Block, message: string) {
@@ -57,8 +49,57 @@ export class TelegramService implements OnModuleInit {
}
const subscribers = await this.telegramSubscriptionsService.getSubscriptions(address);
subscribers.forEach(subscriber => {
this.bot.sendMessage(subscriber.telegramChatId, `Block Found! Result: ${message}, Height: ${height}`);
await Promise.all(subscribers.map(subscriber => {
return this.sendMessage(subscriber.telegramChatId, `Block Found! Result: ${message}, Height: ${height}`);
}));
}
private async pollUpdates() {
try {
const response = await this.bot.get('getUpdates', {
params: {
offset: this.updateOffset,
timeout: 0
}
});
for (const update of response.data.result ?? []) {
this.updateOffset = update.update_id + 1;
await this.handleMessage(update.message);
}
} catch (e) {
console.error('Telegram polling failed', e.message);
}
}
private async handleMessage(msg: any) {
if (msg?.text == null) {
return;
}
if (msg.text.startsWith('/subscribe')) {
const address = msg.text.split('/subscribe ')[1];
if (validate(address) == false) {
await this.sendMessage(msg.chat.id, 'Invalid address.');
return;
}
await this.telegramSubscriptionsService.saveSubscription(msg.chat.id, address);
await this.sendMessage(msg.chat.id, 'Subscribed!');
return;
}
if (msg.text.startsWith('/start')) {
await this.sendMessage(msg.chat.id, 'Welcome to the public-pool bot. /subscribe <address> to get notified.');
return;
}
console.log(msg);
}
private async sendMessage(chatId: number | string, text: string) {
await this.bot.post('sendMessage', {
chat_id: chatId,
text
});
}
}
+10 -9
View File
@@ -1,23 +1,24 @@
import Big from 'big.js';
import * as bitcoinjs from 'bitcoinjs-lib';
const TRUE_DIFF_ONE = 2.695953529101131e67;
export class DifficultyUtils {
static calculateDifficulty(header: Buffer): { submissionDifficulty: number; submissionHash: string } {
const hashResult = bitcoinjs.crypto.hash256(Buffer.isBuffer(header) ? header : Buffer.from(header, 'hex'));
const s64 = DifficultyUtils.le256todouble(hashResult);
const truediffone = Big('26959535291011309493156476344723991336010898738574164086137773096960');
const difficulty = truediffone.div(s64.toString());
const target = DifficultyUtils.le256todouble(hashResult);
const difficulty = target === 0 ? Number.POSITIVE_INFINITY : TRUE_DIFF_ONE / target;
return {
submissionDifficulty: difficulty.toNumber(),
submissionDifficulty: difficulty,
submissionHash: hashResult.toString('hex')
};
}
private static le256todouble(target: Buffer): bigint {
const number = target.reduceRight((acc, byte) => {
return (acc << BigInt(8)) | BigInt(byte);
}, BigInt(0));
private static le256todouble(target: Buffer): number {
let number = 0;
for (let i = target.length - 1; i >= 0; i--) {
number = number * 256 + target[i];
}
return number;
}
}
+1 -1
View File
@@ -7,6 +7,6 @@ export class MockRecording1 {
public static MINING_AUTHORIZE = `{"id": 3, "method": "mining.authorize", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "x"]}\n`;
public static MINING_SUGGEST_DIFFICULTY = `{"id": 4, "method": "mining.suggest_difficulty", "params": [512]}\n`;
public static BLOCK_TEMPLATE: IBlockTemplate = { "capabilities": ["proposal"], "version": 536870912, "rules": ["csv", "!segwit", "taproot"], "vbavailable": {}, "vbrequired": 0, "previousblockhash": "00000000000000022246451e9af7ac4f8bff2527d223f6e623740e92171592f2", "transactions": [{ "data": "02000000000101c3efa41c94ee24dd6c6aaccab0f27e9d2baf92e5112bff15cc463b600e4e3a3c0100000000fdffffff024ea319000000000017a914a5b8d32a6388f02204a1445bd623150ee3b851d18765d67b000000000017a914a316e3a4eaeee9cbee9c254c3292830281a8c9c38702473044022065fbcd2918e8b52c3af8c4ae21b3646d7a12f66ddcaa73a68273f5806527c0da0220291f555dd120bb9eadb144ccf4203db659b78d79bffcd37a29016da068f3abb9012102baab4d464b57771f5108f40ffc31b7242c5b430a61d4bfead024d715c441f3f3c7432500", "txid": "a174b99a87c899e66854ba6f101799ac852ef588ec69299846875e9d64355317", "hash": "748f4db5e45dab6a9903f50e7e760470ce80ac219c637d61418667a0ccb6c536", "depends": [], "fee": 14300, "sigops": 1, "weight": 569 }, { "data": "01000000018985719caa78472c8fad7efa88fc109c6faf6df3dc3a690656479e53c6b10fe9030000006b483045022100e7abb2f2f7e6cfad680efe10a7fc218d685a9cce929b1a4e62fcb726228ac9b50220538f3d2eb36d277bcadf00f3ac2abbaf80395c389e1f8048dc95fdf6204cf1840121037435c194e9b01b3d7f7a2802d6684a3af68d05bbf4ec8f17021980d777691f1dfdffffff040000000000000000536a4c5054325b0b15aaff5c59aab47ce39829055c3007922e2934d3b7c9f51238f8d4a98718da4f9c99a6692adacd0e324b7775eebe151c4f3ad37fcc5f9303eed491a02638b8002543c70002002543a7000a4c10270000000000001976a914000000000000000000000000000000000000000088ac10270000000000001976a914000000000000000000000000000000000000000088aca99f6412000000001976a914ba27f99e007c7f605a8305e318c1abde3cd220ac88ac00000000", "txid": "c34ac3f822a0b49f298be1a25fc9e9dd375c23985ed2656f7cf7d87b67fee1c0", "hash": "c34ac3f822a0b49f298be1a25fc9e9dd375c23985ed2656f7cf7d87b67fee1c0", "depends": [], "fee": 9153, "sigops": 12, "weight": 1408 }, { "data": "020000000001013f8e5ebf5620f2c62c9c4e4f77bb4eb903699620da372ad44f666a552e5b9fc801000000171600148442ee1fe3742153a6b40623d363a22ceed677c3ffffffff02bc0200000000000017a91470b93cf7ead31ef24b71159388c2e8e147f755b987aa0415000000000017a9145506e665bcf63e1225100d7c8613a640699fd5d087024730440220215cd74141ebcde3aee383f232dfd2c6b011645012f35714781d28d4a6959829022017ea40a15f4116bbf0a6f86dc0af08758d9a74673498a61164ed13e9d1c712e501210294184e8093958861740c4ce32e724a24b7df7eeb04702cae274652e86b01f83a00000000", "txid": "5aaf2fa707da25c24374b87d89c420c9e4c51022a4bbed4cc91614831f359366", "hash": "2c19268e65cc223c5fac9666883ecc1d10e680c54a4fdbbb3e4775f886b0feec", "depends": [], "fee": 491, "sigops": 1, "weight": 661 }, { "data": "020000000001012c53f5b0a12cadb57602466e703e60272183b75f3366073f4841dac8e616f2740000000000feffffff02e80300000000000016001496e381bd3fe4bae631adc5a5a420fb9124e5480af0a8100000000000160014cd06fc96fea44c6c5e074ba16dd20b222a0a9b32024730440220440ba2252ea6f70f1b8106897984330a997ef504f7f0dbb89679e44309026f2b02207074ad7dbd9a2bd37a41352527b7f3e68612956fe2085a0eb0b41f97f6a3599d012103b3ecf0f44126019a94a818245e35ecff1582c2900a38487a306fcd91bdb28003c8432500", "txid": "69770cf296b64329e784a5666fb0eab034fbb1780bf067f4e1606874c214f01d", "hash": "fadcaa8de64274e63c093592ed6d8c6b7584bb92c0e8d9c49f02a4df11f6f69d", "depends": [], "fee": 141, "sigops": 1, "weight": 561 }, { "data": "01000000019e03cd78895f6876932501b66eab1e44611d48b4c5029d250b7d2d6073b44b68000000006b48304502210088dfbeb842d54893987e5afb87b70fe70e7b91ebad740782515c44464b19a74202205a6bc47ad6c925f85aa29b2be1a174efcc30a4ad8ed6cfe6236b66d7a4f8d96f01210259530ee281106e237ac3aa801d1f7699ada05501186401da78b7d88849bbda8bffffffff0204ce3900000000001976a91419803f0d83a1b58348a4617c743638bbf9ec235588ac44ce5c01000000001976a9149e38aebe02be7cecacdb521f514cffe445c5533788ac00000000", "txid": "2137ca428d6233e680cea25c428b984391314d872a1a0f5f7b8dc8cfc4474c44", "hash": "2137ca428d6233e680cea25c428b984391314d872a1a0f5f7b8dc8cfc4474c44", "depends": [], "fee": 226, "sigops": 8, "weight": 904 }], "coinbaseaux": {}, "coinbasevalue": 2465717, "longpollid": "00000000000000022246451e9af7ac4f8bff2527d223f6e623740e92171592f29370", "target": "000000000000002495f800000000000000000000000000000000000000000000", "mintime": 1689512405, "mutable": ["time", "transactions", "prevblock"], "noncerange": "00000000ffffffff", "sigoplimit": 80000, "sizelimit": 4000000, "weightlimit": 4000000, "curtime": 1689514989, "bits": "192495f8", "height": 2442185, "default_witness_commitment": "6a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c" };
public static MINING_SUBMIT = `{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c7080000", "64b3f3ec", "ed460d91", "00002000"]}\n`;
public static MINING_SUBMIT = `{"id": 5, "method": "mining.submit", "params": ["tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.bitaxe3", "1", "c708000000000000", "64b3f3ec", "ed460d91", "00002000"]}`;
public static TIME = '64b3f3ec';
}