Files
public-pool/src/models/StratumV2Client.ts
T
2026-06-05 23:41:22 -04:00

1204 lines
46 KiB
TypeScript

import { ConfigService } from '@nestjs/config';
import { getAddressInfo } from 'bitcoin-address-validation';
import * as bitcoinjs from 'bitcoinjs-lib';
import * as crypto from 'crypto';
import { Socket } from 'net';
import { firstValueFrom, Subscription } from 'rxjs';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
import { ClientEntity } from '../ORM/client/client.entity';
import { ClientService } from '../ORM/client/client.service';
import { BitcoinRpcService } from '../services/bitcoin-rpc.service';
import { NotificationService } from '../services/notification.service';
import { StratumV2Service } from '../services/stratum-v2.service';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { patchCoinbasePrefixVarint } from '../utils/coinbase-prefix.utils';
import { DifficultyUtils } from '../utils/difficulty.utils';
import { MiningJob } from './MiningJob';
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
import { BufferReader } from './sv2/sv2-binary-codec';
import {
SV2_CHANNEL_MSG_FLAG,
SV2_NOISE_ACT1_SIZE,
Sv2MiningSetupFlags,
Sv2MiningSetupSuccessFlags,
Sv2MsgType,
Sv2Protocol,
} from './sv2/sv2-constants';
import { Sv2FrameReader, Sv2FrameWriter } from './sv2/sv2-frame';
import {
deserializeCloseChannel,
deserializeOpenStandardMiningChannel,
deserializeRequestExtensions,
deserializeSetupConnection,
deserializeSubmitSharesStandard,
deserializeUpdateChannel,
serializeOpenMiningChannelError,
serializeOpenStandardMiningChannelSuccess,
serializeNewMiningJob,
serializeRequestExtensionsSuccess,
serializeSetNewPrevHash,
serializeSetTarget,
serializeSetupConnectionError,
serializeSetupConnectionSuccess,
serializeSubmitSharesError,
serializeSubmitSharesSuccess,
} from './sv2/sv2-messages';
import {
deserializeOpenExtendedMiningChannel,
deserializeSubmitSharesExtended,
serializeNewExtendedMiningJob,
serializeOpenExtendedMiningChannelSuccess,
Sv2SubmitSharesExtended,
} from './sv2/sv2-extended-messages';
import { Sv2NoiseSession } from './sv2/sv2-noise';
const DEFAULT_START_DIFFICULTY = 100000;
const DEFAULT_TARGET_SHARES_PER_MINUTE = 2;
const DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS = 60 * 1000;
const FIXED_STANDARD_EXTRANONCE2 = '0000000000000000';
const RETIRED_EXTENDED_JOB_RETENTION_MS = 5 * 60 * 1000;
const SV2_AUTH_FAILURE_LOG_INTERVAL_MS = 60 * 1000;
interface ExtendedJobData {
coinbasePrefix: Buffer;
coinbaseSuffix: Buffer;
merklePath: Buffer[];
prevHash: Buffer;
nBits: number;
minNtime: number;
jobTemplate: IJobTemplate;
miningJob: MiningJob;
retiredAt?: number;
creation: number;
}
interface ChannelState {
channelId: number;
channelType: 'standard' | 'extended';
extranoncePrefix: Buffer;
extranonceSize: number;
sessionDifficulty: number;
declaredMaxTarget: Buffer;
jobIdToDifficulty: Map<number, number>;
jobIdToMerkleRoot: Map<number, Buffer>;
extendedJobs: Map<number, ExtendedJobData>;
latestExtendedPrevHash: Buffer;
latestExtendedNBits: number;
latestExtendedMinNtime: number;
miningSubmissionHashes: Set<string>;
acceptedShareCount: number;
}
export class StratumV2Client {
private static authFailureLogState = new Map<string, { nextLogAt: number; suppressed: number }>();
private readonly sessionId = crypto.randomBytes(4).toString('hex');
private readonly noiseSession: Sv2NoiseSession;
private readonly frameReader = new Sv2FrameReader(null);
private readonly frameWriter = new Sv2FrameWriter(null);
private readonly statistics: StratumV1ClientStatistics;
private readonly network: bitcoinjs.networks.Network;
private readonly targetSharesPerMinute: number;
private readonly difficultyCheckIntervalMs: number;
private handshakeBuffer = Buffer.alloc(0);
private handshakeComplete = false;
private processingHandshake = false;
private destroyed = false;
private stratumSubscription: Subscription = null;
private difficultyTimer: NodeJS.Timeout = null;
private channels = new Map<number, ChannelState>();
private primaryChannelId: number = null;
private address: string = null;
private workerName = 'default';
private userAgent = 'unknown/sv2';
private sessionDifficulty: number;
private clientEntity: ClientEntity = null;
private creatingEntity: Promise<void> = null;
private readonly firstChunkSummary: string;
constructor(
private readonly socket: Socket,
firstChunk: Buffer,
private readonly stratumV2Service: StratumV2Service,
private readonly stratumV1JobsService: StratumV1JobsService,
private readonly bitcoinRpcService: BitcoinRpcService,
private readonly clientService: ClientService,
private readonly clientStatisticsService: ClientStatisticsService,
private readonly notificationService: NotificationService,
private readonly blocksService: BlocksService,
private readonly configService: ConfigService,
private readonly addressSettingsService: AddressSettingsService,
) {
this.firstChunkSummary = this.describeChunk(firstChunk);
this.noiseSession = new Sv2NoiseSession(this.stratumV2Service.getNoiseConfig());
this.sessionDifficulty = this.getInitialDifficulty();
this.targetSharesPerMinute = this.getTargetSharesPerMinute();
this.difficultyCheckIntervalMs = this.getDifficultyCheckIntervalMs();
this.statistics = new StratumV1ClientStatistics(this.clientStatisticsService);
this.statistics.targetSubmitShareEveryNSeconds = 60 / this.targetSharesPerMinute;
this.network = this.getNetwork();
this.socket.on('data', (data: Buffer) => {
void this.handleSocketData(data);
});
void this.handleSocketData(firstChunk);
}
public async destroy(): Promise<void> {
if (this.destroyed) {
return;
}
this.destroyed = true;
if (this.stratumSubscription != null) {
this.stratumSubscription.unsubscribe();
this.stratumSubscription = null;
}
if (this.difficultyTimer != null) {
clearInterval(this.difficultyTimer);
this.difficultyTimer = null;
}
for (const channel of this.channels.values()) {
if (channel.channelType === 'extended') {
this.stratumV2Service.releaseExtendedExtranoncePrefix(channel.channelId);
}
}
this.channels.clear();
if (this.clientEntity?.id != null) {
await this.clientService.delete(this.clientEntity.id);
}
}
private async handleSocketData(data: Buffer): Promise<void> {
if (this.destroyed) {
return;
}
try {
if (!this.handshakeComplete) {
await this.handleHandshakeData(data);
} else {
await this.handleEncryptedData(data);
}
} catch (error) {
this.logProtocolError(error);
this.closeSocket();
}
}
private async handleHandshakeData(data: Buffer): Promise<void> {
this.handshakeBuffer = Buffer.concat([this.handshakeBuffer, data]);
if (this.processingHandshake || this.handshakeBuffer.length < SV2_NOISE_ACT1_SIZE) {
return;
}
this.processingHandshake = true;
const act1 = this.handshakeBuffer.subarray(0, SV2_NOISE_ACT1_SIZE);
const remainder = Buffer.from(this.handshakeBuffer.subarray(SV2_NOISE_ACT1_SIZE));
this.handshakeBuffer = Buffer.alloc(0);
const act2 = await this.noiseSession.processAct1(Buffer.from(act1));
await this.writeRaw(act2);
this.frameReader.setDecryptFn(ciphertext => this.noiseSession.decrypt(ciphertext));
this.frameWriter.setEncryptFn(plaintext => this.noiseSession.encrypt(plaintext));
this.handshakeComplete = true;
this.processingHandshake = false;
if (remainder.length > 0) {
await this.handleEncryptedData(remainder);
}
}
private async handleEncryptedData(data: Buffer): Promise<void> {
const frames = this.frameReader.feed(data);
for (const frame of frames) {
await this.handleFrame(frame.header.msgType, frame.header.extensionType, frame.payload);
}
}
private async handleFrame(msgType: number, extensionType: number, payload: Buffer): Promise<void> {
if ((extensionType & ~SV2_CHANNEL_MSG_FLAG) === 0x0001 && msgType === 0x00) {
const request = deserializeRequestExtensions(new BufferReader(payload));
await this.sendFrame(
0x01,
serializeRequestExtensionsSuccess({
requestId: request.requestId,
supportedExtensions: [],
}),
0x0001,
);
return;
}
switch (msgType) {
case Sv2MsgType.SETUP_CONNECTION:
await this.handleSetupConnection(payload);
break;
case Sv2MsgType.OPEN_STANDARD_MINING_CHANNEL:
await this.handleOpenStandardMiningChannel(payload);
break;
case Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL:
await this.handleOpenExtendedMiningChannel(payload);
break;
case Sv2MsgType.SUBMIT_SHARES_STANDARD:
await this.handleSubmitSharesStandard(payload);
break;
case Sv2MsgType.SUBMIT_SHARES_EXTENDED:
await this.handleSubmitSharesExtended(payload);
break;
case Sv2MsgType.UPDATE_CHANNEL:
await this.handleUpdateChannel(payload);
break;
case Sv2MsgType.CLOSE_CHANNEL:
this.handleCloseChannel(payload);
break;
default:
console.warn(`[SV2 ${this.sessionId}] Ignoring unsupported message type 0x${msgType.toString(16)}`);
break;
}
}
private async handleSetupConnection(payload: Buffer): Promise<void> {
const message = deserializeSetupConnection(new BufferReader(payload));
this.userAgent = `${message.vendor || 'unknown'}/sv2`;
if (message.protocol !== Sv2Protocol.MINING) {
await this.sendFrame(
Sv2MsgType.SETUP_CONNECTION_ERROR,
serializeSetupConnectionError({
flags: 0,
errorCode: 'unsupported-protocol',
}),
);
this.closeSocket();
return;
}
if (message.minVersion > 2 || message.maxVersion < 2) {
await this.sendFrame(
Sv2MsgType.SETUP_CONNECTION_ERROR,
serializeSetupConnectionError({
flags: 0,
errorCode: 'protocol-version-mismatch',
}),
);
this.closeSocket();
return;
}
const supportedFlags = Sv2MiningSetupFlags.REQUIRES_STANDARD_JOBS
| Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING;
const unsupportedFlags = message.flags & ~supportedFlags;
if (unsupportedFlags !== 0) {
await this.sendFrame(
Sv2MsgType.SETUP_CONNECTION_ERROR,
serializeSetupConnectionError({
flags: unsupportedFlags,
errorCode: 'unsupported-feature-flags',
}),
);
this.closeSocket();
return;
}
const versionRolling = (message.flags & Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING) !== 0;
const successFlags = versionRolling ? 0 : Sv2MiningSetupSuccessFlags.REQUIRES_FIXED_VERSION;
await this.sendFrame(
Sv2MsgType.SETUP_CONNECTION_SUCCESS,
serializeSetupConnectionSuccess({
usedVersion: 2,
flags: successFlags,
}),
);
}
private async handleOpenStandardMiningChannel(payload: Buffer): Promise<void> {
const message = deserializeOpenStandardMiningChannel(new BufferReader(payload));
const { address, workerName } = this.parseUserIdentity(message.user_identity);
if (!this.isValidAddress(address)) {
await this.sendOpenChannelError(message.requestId, 'unknown-user');
this.closeSocket();
return;
}
if (this.address != null && this.address !== address) {
await this.sendOpenChannelError(message.requestId, 'unknown-user');
return;
}
if (message.maxTarget.length !== 32 || message.maxTarget.every(byte => byte === 0)) {
await this.sendOpenChannelError(message.requestId, 'max-target-out-of-range');
return;
}
const isFirstChannel = this.channels.size === 0;
if (isFirstChannel) {
this.address = address;
this.workerName = workerName;
}
const channelId = this.stratumV2Service.getNextChannelId();
const extranoncePrefix = this.stratumV2Service.generateExtranoncePrefix();
let channelDifficulty = this.sessionDifficulty;
if (Number.isFinite(message.nominalHashRate) && message.nominalHashRate > 0) {
const calculatedDifficulty = DifficultyUtils.hashRateToDifficulty(
message.nominalHashRate,
this.targetSharesPerMinute,
);
if (Number.isFinite(calculatedDifficulty) && calculatedDifficulty > 0) {
channelDifficulty = calculatedDifficulty;
}
}
channelDifficulty = DifficultyUtils.clampDifficultyToMaxTarget(channelDifficulty, message.maxTarget);
this.sessionDifficulty = channelDifficulty;
const channel: ChannelState = {
channelId,
channelType: 'standard',
extranoncePrefix,
extranonceSize: Buffer.byteLength(FIXED_STANDARD_EXTRANONCE2, 'hex'),
sessionDifficulty: channelDifficulty,
declaredMaxTarget: Buffer.from(message.maxTarget),
jobIdToDifficulty: new Map(),
jobIdToMerkleRoot: new Map(),
extendedJobs: new Map(),
latestExtendedPrevHash: Buffer.alloc(32),
latestExtendedNBits: 0,
latestExtendedMinNtime: 0,
miningSubmissionHashes: new Set(),
acceptedShareCount: 0,
};
this.channels.set(channelId, channel);
if (this.primaryChannelId == null) {
this.primaryChannelId = channelId;
}
await this.ensureClientEntity();
await this.sendFrame(
Sv2MsgType.OPEN_STANDARD_MINING_CHANNEL_SUCCESS,
serializeOpenStandardMiningChannelSuccess({
requestId: message.requestId,
channelId,
target: DifficultyUtils.difficultyToTarget(channel.sessionDifficulty),
extranonce_prefix: channel.extranoncePrefix,
groupChannelId: 0,
}),
);
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
await this.sendNewMiningJob(channel, jobTemplate, true);
if (isFirstChannel) {
this.subscribeToJobs();
this.startDifficultyTimer();
}
}
private async handleOpenExtendedMiningChannel(payload: Buffer): Promise<void> {
const message = deserializeOpenExtendedMiningChannel(new BufferReader(payload));
const { address, workerName } = this.parseUserIdentity(message.userIdentity);
if (!this.isValidAddress(address)) {
await this.sendOpenChannelError(message.requestId, 'unknown-user');
this.closeSocket();
return;
}
if (this.address != null && this.address !== address) {
await this.sendOpenChannelError(message.requestId, 'unknown-user');
return;
}
if (message.maxTarget.length !== 32 || message.maxTarget.every(byte => byte === 0)) {
await this.sendOpenChannelError(message.requestId, 'max-target-out-of-range');
return;
}
const isFirstChannel = this.channels.size === 0;
if (isFirstChannel) {
this.address = address;
this.workerName = workerName;
}
const channelId = this.stratumV2Service.getNextChannelId();
const extranoncePrefix = this.stratumV2Service.allocateExtendedExtranoncePrefix(channelId);
const maxMinerExtranonceSize = Math.max(0, TOTAL_EXTRANONCE_SIZE_BYTES - extranoncePrefix.length);
const defaultMinerExtranonceSize = Math.min(
this.stratumV2Service.getExtendedMinerExtranonceSize(),
maxMinerExtranonceSize,
);
const extranonceSize = message.minExtranonceSize > 0
? Math.min(message.minExtranonceSize, maxMinerExtranonceSize)
: defaultMinerExtranonceSize;
let channelDifficulty = this.sessionDifficulty;
if (Number.isFinite(message.nominalHashRate) && message.nominalHashRate > 0) {
const calculatedDifficulty = DifficultyUtils.hashRateToDifficulty(
message.nominalHashRate,
this.targetSharesPerMinute,
);
if (Number.isFinite(calculatedDifficulty) && calculatedDifficulty > 0) {
channelDifficulty = calculatedDifficulty;
}
}
channelDifficulty = DifficultyUtils.clampDifficultyToMaxTarget(channelDifficulty, message.maxTarget);
this.sessionDifficulty = channelDifficulty;
const channel: ChannelState = {
channelId,
channelType: 'extended',
extranoncePrefix,
extranonceSize,
sessionDifficulty: channelDifficulty,
declaredMaxTarget: Buffer.from(message.maxTarget),
jobIdToDifficulty: new Map(),
jobIdToMerkleRoot: new Map(),
extendedJobs: new Map(),
latestExtendedPrevHash: Buffer.alloc(32),
latestExtendedNBits: 0,
latestExtendedMinNtime: 0,
miningSubmissionHashes: new Set(),
acceptedShareCount: 0,
};
this.channels.set(channelId, channel);
if (this.primaryChannelId == null) {
this.primaryChannelId = channelId;
}
await this.ensureClientEntity();
await this.sendFrame(
Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL_SUCCESS,
serializeOpenExtendedMiningChannelSuccess({
requestId: message.requestId,
channelId,
target: DifficultyUtils.difficultyToTarget(channel.sessionDifficulty),
extranonceSize,
extranoncePrefix,
groupChannelId: 0,
}),
);
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
await this.sendNewExtendedMiningJob(channel, jobTemplate, true);
if (isFirstChannel) {
this.subscribeToJobs();
this.startDifficultyTimer();
}
}
private async handleSubmitSharesStandard(payload: Buffer): Promise<void> {
const submission = deserializeSubmitSharesStandard(new BufferReader(payload));
const channel = this.channels.get(submission.channelId);
if (channel == null) {
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-channel-id');
return;
}
const submissionKey = [
submission.jobId,
submission.nonce,
submission.ntime,
submission.version,
].join(':');
if (channel.miningSubmissionHashes.has(submissionKey)) {
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share');
return;
}
channel.miningSubmissionHashes.add(submissionKey);
if (channel.miningSubmissionHashes.size > 10000) {
channel.miningSubmissionHashes.clear();
}
const jobIdHex = submission.jobId.toString(16);
const job = this.stratumV1JobsService.getJobById(jobIdHex);
const sentMerkleRoot = channel.jobIdToMerkleRoot.get(submission.jobId);
if (job == null || sentMerkleRoot == null) {
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-job-id');
return;
}
const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId);
if (jobTemplate == null) {
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-job-id');
return;
}
const header = this.buildHeader(jobTemplate, sentMerkleRoot, submission.version, submission.ntime, submission.nonce);
const { submissionDifficulty, hashBuffer } = DifficultyUtils.calculateDifficulty(header);
const jobDifficulty = channel.jobIdToDifficulty.get(submission.jobId) ?? channel.sessionDifficulty;
const target = DifficultyUtils.difficultyToTarget(jobDifficulty);
if (!DifficultyUtils.meetsTarget(hashBuffer, target)) {
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'difficulty-too-low');
return;
}
await this.sendFrame(
Sv2MsgType.SUBMIT_SHARES_SUCCESS,
serializeSubmitSharesSuccess({
channelId: submission.channelId,
lastSequenceNumber: submission.sequenceNumber,
newSubmitsAcceptedCount: 1,
newSharesSum: BigInt(Math.round(jobDifficulty)),
}),
SV2_CHANNEL_MSG_FLAG,
);
channel.acceptedShareCount++;
await this.handleAcceptedShare(submission, channel, job, jobTemplate, submissionDifficulty, jobDifficulty);
}
private async handleSubmitSharesExtended(payload: Buffer): Promise<void> {
const submission = deserializeSubmitSharesExtended(new BufferReader(payload));
const channel = this.channels.get(submission.channelId);
if (channel == null || channel.channelType !== 'extended') {
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-channel-id');
return;
}
const submissionKey = [
submission.jobId,
submission.nonce,
submission.ntime,
submission.version,
submission.extranonce.toString('hex'),
].join(':');
if (channel.miningSubmissionHashes.has(submissionKey)) {
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share');
return;
}
channel.miningSubmissionHashes.add(submissionKey);
if (channel.miningSubmissionHashes.size > 10000) {
channel.miningSubmissionHashes.clear();
}
const extendedJob = channel.extendedJobs.get(submission.jobId);
const jobDifficulty = channel.jobIdToDifficulty.get(submission.jobId) ?? channel.sessionDifficulty;
if (extendedJob == null) {
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-job-id');
return;
}
if (extendedJob.retiredAt != null) {
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share');
return;
}
if (submission.extranonce.length !== channel.extranonceSize) {
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-extranonce-size');
return;
}
const coinbaseTxBytes = Buffer.concat([
extendedJob.coinbasePrefix,
channel.extranoncePrefix,
submission.extranonce,
extendedJob.coinbaseSuffix,
]);
let merkleRoot = bitcoinjs.crypto.hash256(coinbaseTxBytes);
const merklePair = Buffer.alloc(64);
for (const sibling of extendedJob.merklePath) {
merklePair.set(merkleRoot, 0);
merklePair.set(sibling, 32);
merkleRoot = bitcoinjs.crypto.hash256(merklePair);
}
const header = this.buildHeader(
{
...extendedJob.jobTemplate,
block: Object.assign(new bitcoinjs.Block(), extendedJob.jobTemplate.block, {
prevHash: extendedJob.prevHash,
bits: extendedJob.nBits,
}),
},
merkleRoot,
submission.version,
submission.ntime,
submission.nonce,
);
const { submissionDifficulty, hashBuffer } = DifficultyUtils.calculateDifficulty(header);
const target = DifficultyUtils.difficultyToTarget(jobDifficulty);
if (!DifficultyUtils.meetsTarget(hashBuffer, target)) {
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'difficulty-too-low');
return;
}
await this.sendFrame(
Sv2MsgType.SUBMIT_SHARES_SUCCESS,
serializeSubmitSharesSuccess({
channelId: submission.channelId,
lastSequenceNumber: submission.sequenceNumber,
newSubmitsAcceptedCount: 1,
newSharesSum: BigInt(Math.round(jobDifficulty)),
}),
SV2_CHANNEL_MSG_FLAG,
);
channel.acceptedShareCount++;
let updatedJobBlock: bitcoinjs.Block = null;
if (submissionDifficulty >= extendedJob.jobTemplate.blockData.networkDifficulty) {
updatedJobBlock = this.reconstructExtendedBlock(extendedJob, submission, merkleRoot, channel.extranoncePrefix);
}
await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, extendedJob.jobTemplate, updatedJobBlock);
}
private async handleAcceptedShare(
submission: { nonce: number; ntime: number; version: number },
channel: ChannelState,
job: MiningJob,
jobTemplate: IJobTemplate,
submissionDifficulty: number,
jobDifficulty: number,
): Promise<void> {
let updatedJobBlock: bitcoinjs.Block = null;
if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) {
const versionMask = submission.version ^ jobTemplate.block.version;
updatedJobBlock = job.copyAndUpdateBlock(
jobTemplate,
versionMask,
submission.nonce,
channel.extranoncePrefix.toString('hex'),
FIXED_STANDARD_EXTRANONCE2,
submission.ntime,
);
}
await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, jobTemplate, updatedJobBlock);
}
private async recordAcceptedShare(
submissionDifficulty: number,
jobDifficulty: number,
jobTemplate: IJobTemplate,
updatedJobBlock: bitcoinjs.Block | null,
): Promise<void> {
await this.ensureClientEntity();
if (updatedJobBlock != null) {
console.log(`[SV2 ${this.sessionId}] BLOCK FOUND at height ${jobTemplate.blockData.height}`);
const blockHex = updatedJobBlock.toHex(false);
const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
await this.blocksService.save({
height: jobTemplate.blockData.height,
minerAddress: this.address,
worker: this.workerName,
sessionId: this.sessionId,
blockData: blockHex,
});
await this.notificationService.notifySubscribersBlockFound(
this.address,
jobTemplate.blockData.height,
updatedJobBlock,
result,
);
if (result == null) {
await this.addressSettingsService.resetBestDifficultyAndShares();
}
}
await this.statistics.addShares(this.clientEntity, jobDifficulty);
const now = new Date();
this.clientService.heartbeatBulkAsync(this.clientEntity.id, this.statistics.hashRate, now);
this.clientEntity.updatedAt = now;
if (submissionDifficulty > this.clientEntity.bestDifficulty) {
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
this.clientEntity.bestDifficulty = submissionDifficulty;
await this.addressSettingsService.updateBestDifficultyIfHigher(
this.address,
submissionDifficulty,
this.userAgent,
);
}
}
private reconstructExtendedBlock(
extendedJob: ExtendedJobData,
submission: Sv2SubmitSharesExtended,
merkleRoot: Buffer,
extranoncePrefix: Buffer,
): bitcoinjs.Block {
const jobTemplate = extendedJob.jobTemplate;
const testBlock = Object.assign(new bitcoinjs.Block(), jobTemplate.block);
testBlock.transactions = jobTemplate.block.transactions.map(tx => Object.assign(new bitcoinjs.Transaction(), tx));
const coinbaseTx = extendedJob.miningJob.cloneCoinbaseTransaction();
const script = coinbaseTx.ins[0].script;
coinbaseTx.ins[0].script = Buffer.concat([
script.subarray(0, script.length - TOTAL_EXTRANONCE_SIZE_BYTES),
extranoncePrefix,
submission.extranonce,
]);
testBlock.transactions[0] = coinbaseTx;
testBlock.version = submission.version;
testBlock.nonce = submission.nonce;
testBlock.timestamp = submission.ntime;
testBlock.merkleRoot = merkleRoot;
return testBlock;
}
private async handleUpdateChannel(payload: Buffer): Promise<void> {
const message = deserializeUpdateChannel(new BufferReader(payload));
const channel = this.channels.get(message.channelId);
if (channel == null) {
return;
}
channel.declaredMaxTarget = Buffer.from(message.maximumTarget);
if (Number.isFinite(message.nominalHashRate) && message.nominalHashRate > 0) {
const nextDifficulty = DifficultyUtils.hashRateToDifficulty(
message.nominalHashRate,
this.targetSharesPerMinute,
);
if (Number.isFinite(nextDifficulty) && nextDifficulty > 0) {
channel.sessionDifficulty = DifficultyUtils.clampDifficultyToMaxTarget(
nextDifficulty,
channel.declaredMaxTarget,
);
await this.sendSetTarget(channel);
}
}
}
private handleCloseChannel(payload: Buffer): void {
const message = deserializeCloseChannel(new BufferReader(payload));
const channel = this.channels.get(message.channelId);
if (channel?.channelType === 'extended') {
this.stratumV2Service.releaseExtendedExtranoncePrefix(message.channelId);
}
this.channels.delete(message.channelId);
if (this.primaryChannelId === message.channelId) {
this.primaryChannelId = this.channels.keys().next().value ?? null;
}
if (this.channels.size === 0) {
this.closeSocket();
}
}
private subscribeToJobs(): void {
if (this.stratumSubscription != null) {
return;
}
this.stratumSubscription = this.stratumV1JobsService.newMiningJob$.subscribe({
next: async (jobTemplate) => {
try {
if (jobTemplate.blockData.clearJobs) {
this.channels.forEach(channel => {
if (channel.channelType === 'extended') {
const now = Date.now();
channel.extendedJobs.forEach(job => {
if (job.retiredAt == null) {
job.retiredAt = now;
}
});
this.cleanupRetiredExtendedJobs(channel);
} else {
channel.jobIdToDifficulty.clear();
channel.jobIdToMerkleRoot.clear();
}
channel.miningSubmissionHashes.clear();
});
}
for (const channel of this.channels.values()) {
if (channel.channelType === 'extended') {
await this.sendNewExtendedMiningJob(channel, jobTemplate, jobTemplate.blockData.clearJobs);
} else {
await this.sendNewMiningJob(channel, jobTemplate, jobTemplate.blockData.clearJobs);
}
}
} catch (error) {
console.error(`[SV2 ${this.sessionId}] Failed to send mining job: ${error.message}`);
this.closeSocket();
}
},
error: error => {
console.error(`[SV2 ${this.sessionId}] Job subscription failed: ${error.message}`);
this.closeSocket();
},
});
}
private startDifficultyTimer(): void {
if (this.difficultyTimer != null) {
return;
}
this.difficultyTimer = setInterval(() => {
void this.checkDifficulty();
}, this.difficultyCheckIntervalMs);
}
private async checkDifficulty(): Promise<void> {
const targetDiff = this.statistics.getSuggestedDifficulty(this.sessionDifficulty);
if (targetDiff == null || targetDiff === this.sessionDifficulty || !Number.isFinite(targetDiff)) {
return;
}
this.sessionDifficulty = targetDiff;
for (const channel of this.channels.values()) {
channel.sessionDifficulty = DifficultyUtils.clampDifficultyToMaxTarget(
targetDiff,
channel.declaredMaxTarget,
);
await this.sendSetTarget(channel);
}
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
const refreshedTemplate: IJobTemplate = {
...jobTemplate,
block: Object.assign(new bitcoinjs.Block(), jobTemplate.block, {
timestamp: Math.max(jobTemplate.block.timestamp, Math.floor(Date.now() / 1000)),
}),
blockData: { ...jobTemplate.blockData, clearJobs: true },
};
for (const channel of this.channels.values()) {
if (channel.channelType === 'extended') {
await this.sendNewExtendedMiningJob(channel, refreshedTemplate, true);
} else {
await this.sendNewMiningJob(channel, refreshedTemplate, true);
}
}
}
private async sendNewMiningJob(channel: ChannelState, jobTemplate: IJobTemplate, sendPrevHash: boolean): Promise<void> {
if (this.address == null) {
return;
}
const jobIdHex = this.stratumV1JobsService.getNextId();
const jobId = parseInt(jobIdHex, 16);
const job = new MiningJob(
this.network,
jobIdHex,
[{ address: this.address, percent: 100 }],
jobTemplate,
);
this.stratumV1JobsService.addJob(job);
const jobSideBlock = job.copyAndUpdateBlock(
jobTemplate,
0,
0,
channel.extranoncePrefix.toString('hex'),
FIXED_STANDARD_EXTRANONCE2,
jobTemplate.block.timestamp,
);
const merkleRoot = Buffer.from(jobSideBlock.merkleRoot);
channel.jobIdToDifficulty.set(jobId, channel.sessionDifficulty);
channel.jobIdToMerkleRoot.set(jobId, merkleRoot);
await this.sendFrame(
Sv2MsgType.NEW_MINING_JOB,
serializeNewMiningJob({
channelId: channel.channelId,
jobId,
minNtime: sendPrevHash ? null : jobTemplate.block.timestamp,
version: jobTemplate.block.version,
merkleRoot,
}),
SV2_CHANNEL_MSG_FLAG,
);
if (sendPrevHash) {
await this.sendFrame(
Sv2MsgType.SET_NEW_PREV_HASH,
serializeSetNewPrevHash({
channelId: channel.channelId,
jobId,
prevHash: Buffer.from(jobTemplate.block.prevHash),
minNtime: jobTemplate.block.timestamp,
nBits: jobTemplate.block.bits,
}),
SV2_CHANNEL_MSG_FLAG,
);
}
}
private async sendNewExtendedMiningJob(channel: ChannelState, jobTemplate: IJobTemplate, sendPrevHash: boolean): Promise<void> {
if (this.address == null) {
return;
}
const jobIdHex = this.stratumV1JobsService.getNextId();
const jobId = parseInt(jobIdHex, 16);
const job = new MiningJob(
this.network,
jobIdHex,
[{ address: this.address, percent: 100 }],
jobTemplate,
);
this.stratumV1JobsService.addJob(job);
const merklePath = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex'));
const totalExtranonceSize = channel.extranoncePrefix.length + channel.extranonceSize;
const coinbasePrefix = patchCoinbasePrefixVarint(job.getCoinbasePrefixBuffer(), totalExtranonceSize);
const coinbaseSuffix = job.getCoinbaseSuffixBuffer();
await this.sendFrame(
Sv2MsgType.NEW_EXTENDED_MINING_JOB,
serializeNewExtendedMiningJob({
channelId: channel.channelId,
jobId,
minNtime: sendPrevHash ? null : jobTemplate.block.timestamp,
version: jobTemplate.block.version,
versionRollingAllowed: true,
merklePath,
coinbasePrefix,
coinbaseSuffix,
}),
SV2_CHANNEL_MSG_FLAG,
);
const prevHash = sendPrevHash
? Buffer.from(jobTemplate.block.prevHash)
: Buffer.from(channel.latestExtendedPrevHash);
const nBits = sendPrevHash ? jobTemplate.block.bits : channel.latestExtendedNBits;
const minNtime = sendPrevHash ? jobTemplate.block.timestamp : channel.latestExtendedMinNtime;
channel.jobIdToDifficulty.set(jobId, channel.sessionDifficulty);
channel.extendedJobs.set(jobId, {
coinbasePrefix,
coinbaseSuffix,
merklePath,
prevHash,
nBits,
minNtime,
jobTemplate,
miningJob: job,
creation: Date.now(),
});
if (sendPrevHash) {
channel.latestExtendedPrevHash = prevHash;
channel.latestExtendedNBits = nBits;
channel.latestExtendedMinNtime = minNtime;
await this.sendFrame(
Sv2MsgType.SET_NEW_PREV_HASH,
serializeSetNewPrevHash({
channelId: channel.channelId,
jobId,
prevHash,
minNtime,
nBits,
}),
SV2_CHANNEL_MSG_FLAG,
);
}
}
private cleanupRetiredExtendedJobs(channel: ChannelState): void {
const cutoff = Date.now() - RETIRED_EXTENDED_JOB_RETENTION_MS;
for (const [jobId, job] of channel.extendedJobs.entries()) {
if (job.retiredAt != null && job.retiredAt < cutoff) {
channel.extendedJobs.delete(jobId);
channel.jobIdToDifficulty.delete(jobId);
}
}
}
private buildHeader(
jobTemplate: IJobTemplate,
merkleRoot: Buffer,
version: number,
timestamp: number,
nonce: number,
): Buffer {
const header = Buffer.alloc(80);
header.writeInt32LE(version, 0);
jobTemplate.block.prevHash.copy(header, 4);
merkleRoot.copy(header, 36);
header.writeUInt32LE(timestamp >>> 0, 68);
header.writeUInt32LE(jobTemplate.block.bits >>> 0, 72);
header.writeUInt32LE(nonce >>> 0, 76);
return header;
}
private async sendSetTarget(channel: ChannelState): Promise<void> {
await this.sendFrame(
Sv2MsgType.SET_TARGET,
serializeSetTarget({
channelId: channel.channelId,
maxTarget: DifficultyUtils.difficultyToTarget(channel.sessionDifficulty),
}),
SV2_CHANNEL_MSG_FLAG,
);
}
private async sendOpenChannelError(requestId: number, errorCode: string): Promise<void> {
await this.sendFrame(
Sv2MsgType.OPEN_STANDARD_MINING_CHANNEL_ERROR,
serializeOpenMiningChannelError({ requestId, errorCode }),
);
}
private async sendShareError(channelId: number, sequenceNumber: number, errorCode: string): Promise<void> {
await this.sendFrame(
Sv2MsgType.SUBMIT_SHARES_ERROR,
serializeSubmitSharesError({
channelId,
sequenceNumber,
errorCode,
}),
SV2_CHANNEL_MSG_FLAG,
);
}
private async sendFrame(msgType: number, payload: Buffer, extensionType = 0): Promise<void> {
const data = this.frameWriter.writeFrame({
extensionType,
msgType,
msgLength: payload.length,
}, payload);
await this.writeRaw(data);
}
private async writeRaw(data: Buffer): Promise<void> {
if (this.socket.destroyed || this.socket.writableEnded) {
return;
}
await new Promise<void>((resolve, reject) => {
this.socket.write(data, error => error ? reject(error) : resolve());
});
}
private async ensureClientEntity(): Promise<void> {
if (this.clientEntity != null) {
return;
}
if (this.creatingEntity == null) {
this.creatingEntity = (async () => {
this.clientEntity = await this.clientService.insert({
sessionId: this.sessionId,
address: this.address,
clientName: this.workerName,
userAgent: this.userAgent,
startTime: new Date(),
bestDifficulty: 0,
});
})();
}
await this.creatingEntity;
}
private parseUserIdentity(userIdentity: string): { address: string; workerName: string } {
const parts = userIdentity.split('.');
const rawAddress = parts[0] ?? '';
return {
address: this.normalizeAddress(rawAddress),
workerName: parts.length > 1 ? parts.slice(1).join('.') : 'default',
};
}
private normalizeAddress(address: string): string {
if (
address.startsWith('bc1')
|| address.startsWith('BC1')
|| address.startsWith('tb1')
|| address.startsWith('TB1')
|| address.startsWith('bcrt1')
|| address.startsWith('BCRT1')
) {
return address.toLowerCase();
}
return address;
}
private isValidAddress(address: string): boolean {
try {
getAddressInfo(address);
return true;
} catch {
return false;
}
}
private getInitialDifficulty(): number {
const configured = parseFloat(
this.configService.get<string>('SV2_START_DIFFICULTY')
?? this.configService.get<string>('STRATUM_START_DIFFICULTY')
?? '',
);
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_START_DIFFICULTY;
}
private getTargetSharesPerMinute(): number {
const configured = parseFloat(this.configService.get<string>('SV2_TARGET_SHARES_PER_MINUTE') ?? '');
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_TARGET_SHARES_PER_MINUTE;
}
private getDifficultyCheckIntervalMs(): number {
const configured = parseInt(this.configService.get<string>('SV2_DIFFICULTY_CHECK_INTERVAL_MS') ?? '', 10);
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS;
}
private getNetwork(): bitcoinjs.networks.Network {
const networkConfig = this.configService.get('NETWORK');
if (networkConfig === 'mainnet') {
return bitcoinjs.networks.bitcoin;
}
if (networkConfig === 'testnet') {
return bitcoinjs.networks.testnet;
}
if (networkConfig === 'regtest') {
return bitcoinjs.networks.regtest;
}
throw new Error('Invalid network configuration');
}
private logProtocolError(error: Error): void {
if (!this.isNoisyAuthFailure(error)) {
console.error(`[SV2 ${this.sessionId}] ${error.message}`);
return;
}
const remote = this.socket.remoteAddress ?? 'unknown';
const key = `${remote}:${error.message}`;
const now = Date.now();
const logState = StratumV2Client.authFailureLogState.get(key);
if (logState != null && now < logState.nextLogAt) {
logState.suppressed += 1;
return;
}
const suppressed = logState?.suppressed ?? 0;
const suffix = suppressed > 0 ? ` (${suppressed} similar auth failures suppressed)` : '';
console.warn(`[SV2 ${this.sessionId}] Authentication failed from ${remote}: ${error.message}; firstChunk=${this.firstChunkSummary}${suffix}`);
StratumV2Client.authFailureLogState.set(key, {
nextLogAt: now + SV2_AUTH_FAILURE_LOG_INTERVAL_MS,
suppressed: 0,
});
}
private isNoisyAuthFailure(error: Error): boolean {
return error.message.includes('Unsupported state or unable to authenticate data');
}
private describeChunk(chunk: Buffer): string {
const preview = chunk.subarray(0, 16);
const printable = Array.from(preview).every(byte => byte >= 0x20 && byte <= 0x7e);
const prefix = printable
? preview.toString('ascii').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
: preview.toString('hex');
return `len=${chunk.length},${printable ? 'ascii' : 'hex'}=${prefix}`;
}
private closeSocket(): void {
if (!this.socket.destroyed) {
this.socket.destroy();
}
void this.destroy();
}
}