mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
2621 lines
103 KiB
TypeScript
2621 lines
103 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 { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
|
|
import { BlocksService } from '../ORM/blocks/blocks.service';
|
|
import { ClientEntity } from '../ORM/client/client.entity';
|
|
import { ClientService } from '../ORM/client/client.service';
|
|
import { PayoutSnapshotService } from '../ORM/payout-snapshot/payout-snapshot.service';
|
|
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
|
|
import { BitcoinRpcService } from '../services/bitcoin-rpc.service';
|
|
import { CustomWorkService } from '../services/custom-work.service';
|
|
import { NotificationService } from '../services/notification.service';
|
|
import { RedisMessagingService } from '../services/redis-messaging.service';
|
|
import { StratumV2Service } from '../services/stratum-v2.service';
|
|
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
|
|
import {
|
|
BitcoinNetworkName,
|
|
calculateBlockSubsidySats,
|
|
EMPTY_WITNESS_COMMITMENT_HASH,
|
|
} from '../services/subsidy-only-template.factory';
|
|
import { Sv2JobDeclarationRegistryService } from '../services/sv2-job-declaration-registry.service';
|
|
import { patchCoinbasePrefixVarint } from '../utils/coinbase-prefix.utils';
|
|
import { DifficultyUtils } from '../utils/difficulty.utils';
|
|
import { hash256 } from '../utils/hash.utils';
|
|
import { AddressObject, MiningJob } from './MiningJob';
|
|
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
|
|
import { PayoutMode } from '../types/payout-mode';
|
|
import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate';
|
|
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';
|
|
import {
|
|
deserializeSetCustomMiningJob,
|
|
serializeSetCustomMiningJobError,
|
|
serializeSetCustomMiningJobSuccess,
|
|
} from './sv2/sv2-jdp-messages';
|
|
|
|
const DEFAULT_START_DIFFICULTY = 100000;
|
|
const DEFAULT_MIN_DIFFICULTY = 0.001;
|
|
const DEFAULT_TARGET_SHARES_PER_MINUTE = 2;
|
|
const DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS = 60 * 1000;
|
|
const DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS = 60 * 1000;
|
|
const FIXED_STANDARD_EXTRANONCE2 = '0000000000000000';
|
|
const DEFAULT_SV2_JOB_RETENTION_MS = 5 * 60 * 1000;
|
|
const DEFAULT_SV2_MAX_RETAINED_JOBS_PER_CHANNEL = 64;
|
|
const DEFAULT_SV2_MAX_QUEUED_JOB_OPERATIONS = 4;
|
|
const DEFAULT_SV2_MAX_SOCKET_BUFFER_BYTES = 256 * 1024;
|
|
const DEFAULT_SV2_SOCKET_WRITE_TIMEOUT_MS = 2 * 1000;
|
|
const SV2_AUTH_FAILURE_LOG_INTERVAL_MS = 60 * 1000;
|
|
const BIP320_CONSENSUS_VERSION_MASK = 0xe0001fff;
|
|
|
|
type Sv2JobOperationKind = 'canonical' | 'activation' | 'ordered';
|
|
|
|
interface QueuedSv2JobOperation {
|
|
kind: Sv2JobOperationKind;
|
|
operation: () => Promise<void>;
|
|
resolve: () => void;
|
|
tipKey?: string;
|
|
}
|
|
|
|
interface Sv2HeaderContext {
|
|
tipKey: string;
|
|
prevHash: Buffer;
|
|
nBits: number;
|
|
minNtime: number;
|
|
height: number;
|
|
networkDifficulty: number;
|
|
baseVersion: number;
|
|
requiredVersionBits: number;
|
|
activationMinNtime?: number;
|
|
activatedAtMonotonicNs?: bigint;
|
|
}
|
|
|
|
interface Sv2WorkActivation extends Sv2HeaderContext {
|
|
version: number;
|
|
}
|
|
|
|
interface StandardJobData {
|
|
merkleRoot: Buffer;
|
|
jobTemplate: IJobTemplate;
|
|
miningJob: MiningJob;
|
|
expectedHeight: number;
|
|
expectedVersion: number;
|
|
headerContext?: Sv2HeaderContext;
|
|
retiredAt?: number;
|
|
creation: number;
|
|
}
|
|
|
|
interface ExtendedJobData {
|
|
coinbasePrefix: Buffer;
|
|
coinbaseSuffix: Buffer;
|
|
merklePath: Buffer[];
|
|
prevHash: Buffer;
|
|
nBits: number;
|
|
minNtime: number;
|
|
jobTemplate: IJobTemplate;
|
|
miningJob: MiningJob;
|
|
expectedHeight: number;
|
|
expectedVersion: number;
|
|
headerContext?: Sv2HeaderContext;
|
|
retiredAt?: number;
|
|
creation: number;
|
|
workProtocol?: 'pool' | 'sv2_jdp';
|
|
}
|
|
|
|
interface ChannelState {
|
|
channelId: number;
|
|
channelType: 'standard' | 'extended';
|
|
extranoncePrefix: Buffer;
|
|
extranonceSize: number;
|
|
sessionDifficulty: number;
|
|
declaredMaxTarget: Buffer;
|
|
jobIdToDifficulty: Map<number, number>;
|
|
jobIdToMerkleRoot: Map<number, Buffer>;
|
|
standardJobs: Map<number, StandardJobData>;
|
|
extendedJobs: Map<number, ExtendedJobData>;
|
|
latestExtendedPrevHash: Buffer;
|
|
latestExtendedNBits: number;
|
|
latestExtendedMinNtime: number;
|
|
miningSubmissionHashes: Set<string>;
|
|
acceptedShareCount: number;
|
|
readyForCanonicalJobs: boolean;
|
|
lastCanonicalJobSignature?: string;
|
|
stagedFutureJobId?: number;
|
|
activeTipKey?: string;
|
|
activatedFutureTipKey?: string;
|
|
activePrevHashMinNtime?: number;
|
|
activePrevHashAtMonotonicNs?: bigint;
|
|
activePrevHashJobId?: 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 readonly jobRetentionMs: number;
|
|
private readonly maxRetainedJobsPerChannel: number;
|
|
private readonly maxQueuedJobOperations: number;
|
|
private readonly maxSocketBufferBytes: number;
|
|
private readonly socketWriteTimeoutMs: number;
|
|
|
|
private handshakeBuffer = Buffer.alloc(0);
|
|
private handshakeComplete = false;
|
|
private processingHandshake = false;
|
|
private destroyed = false;
|
|
private jobOperationQueue: QueuedSv2JobOperation[] = [];
|
|
private drainingJobOperations = false;
|
|
private activeJobOperation: QueuedSv2JobOperation = null;
|
|
private pendingSocketWriteBytes = 0;
|
|
private futureTemplateCounter = 0;
|
|
private nextMiningJobId = 1;
|
|
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;
|
|
private workSelectionEnabled = false;
|
|
private versionRollingEnabled = false;
|
|
private lastHashRatePersistedAt = 0;
|
|
|
|
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 notificationService: NotificationService,
|
|
private readonly blocksService: BlocksService,
|
|
private readonly configService: ConfigService,
|
|
private readonly addressSettingsService: AddressSettingsService,
|
|
private readonly customWorkService: CustomWorkService,
|
|
private readonly jobDeclarationRegistry: Sv2JobDeclarationRegistryService,
|
|
private readonly shareAccountingService?: ShareAccountingService,
|
|
private readonly redisMessagingService?: RedisMessagingService,
|
|
private readonly payoutSnapshotService?: PayoutSnapshotService,
|
|
private readonly payoutMode: PayoutMode = 'solo',
|
|
) {
|
|
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.jobRetentionMs = this.getPositiveIntegerConfig(
|
|
'SV2_JOB_RETENTION_MS',
|
|
this.getPositiveIntegerConfig('STRATUM_JOB_RETENTION_MS', DEFAULT_SV2_JOB_RETENTION_MS),
|
|
);
|
|
this.maxRetainedJobsPerChannel = this.getPositiveIntegerConfig(
|
|
'SV2_MAX_RETAINED_JOBS_PER_CHANNEL',
|
|
DEFAULT_SV2_MAX_RETAINED_JOBS_PER_CHANNEL,
|
|
);
|
|
this.maxQueuedJobOperations = this.getPositiveIntegerConfig(
|
|
'SV2_MAX_QUEUED_JOB_OPERATIONS',
|
|
DEFAULT_SV2_MAX_QUEUED_JOB_OPERATIONS,
|
|
);
|
|
this.maxSocketBufferBytes = this.getPositiveIntegerConfig(
|
|
'SV2_MAX_SOCKET_BUFFER_BYTES',
|
|
DEFAULT_SV2_MAX_SOCKET_BUFFER_BYTES,
|
|
);
|
|
this.socketWriteTimeoutMs = this.getPositiveIntegerConfig(
|
|
'SV2_SOCKET_WRITE_TIMEOUT_MS',
|
|
DEFAULT_SV2_SOCKET_WRITE_TIMEOUT_MS,
|
|
);
|
|
this.statistics = new StratumV1ClientStatistics(this.getMinimumDifficulty());
|
|
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;
|
|
this.settleQueuedJobOperations();
|
|
this.stratumV2Service.unregisterClient?.(this);
|
|
|
|
if (this.difficultyTimer != null) {
|
|
clearInterval(this.difficultyTimer);
|
|
this.difficultyTimer = null;
|
|
}
|
|
for (const channel of this.channels.values()) {
|
|
this.stratumV2Service.releaseExtranoncePrefix(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.SET_CUSTOM_MINING_JOB:
|
|
await this.handleSetCustomMiningJob(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_WORK_SELECTION
|
|
| 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;
|
|
this.versionRollingEnabled = versionRolling;
|
|
this.workSelectionEnabled = (message.flags & Sv2MiningSetupFlags.REQUIRES_WORK_SELECTION) !== 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(channelId);
|
|
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 = this.clampDifficulty(calculatedDifficulty);
|
|
}
|
|
}
|
|
channelDifficulty = this.clampDifficulty(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(),
|
|
standardJobs: new Map(),
|
|
extendedJobs: new Map(),
|
|
latestExtendedPrevHash: Buffer.alloc(32),
|
|
latestExtendedNBits: 0,
|
|
latestExtendedMinNtime: 0,
|
|
miningSubmissionHashes: new Set(),
|
|
acceptedShareCount: 0,
|
|
readyForCanonicalJobs: false,
|
|
};
|
|
this.channels.set(channelId, channel);
|
|
if (this.primaryChannelId == null) {
|
|
this.primaryChannelId = channelId;
|
|
}
|
|
|
|
await this.ensureClientEntity();
|
|
this.disableApplicationIdleTimeout();
|
|
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,
|
|
}),
|
|
);
|
|
channel.readyForCanonicalJobs = true;
|
|
|
|
if (!this.workSelectionEnabled) {
|
|
const jobTemplate = this.stratumV2Service.getLatestCanonicalJob(this.payoutMode);
|
|
if (jobTemplate != null) {
|
|
await this.enqueueCanonicalJob(jobTemplate);
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
}
|
|
const activationTemplate = this.stratumV2Service.getLatestWorkActivationTemplate?.();
|
|
if (activationTemplate != null) {
|
|
await this.enqueueWorkActivation(activationTemplate);
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isFirstChannel && !this.workSelectionEnabled) {
|
|
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, this.stratumV2Service.getExtendedTotalExtranonceSize() - extranoncePrefix.length);
|
|
const defaultMinerExtranonceSize = Math.min(
|
|
this.stratumV2Service.getExtendedMinerExtranonceSize(),
|
|
maxMinerExtranonceSize,
|
|
);
|
|
const requestedMinerExtranonceSize = Math.max(0, message.minExtranonceSize);
|
|
const extranonceSize = Math.max(defaultMinerExtranonceSize, requestedMinerExtranonceSize);
|
|
if (extranonceSize > maxMinerExtranonceSize) {
|
|
this.stratumV2Service.releaseExtendedExtranoncePrefix(channelId);
|
|
await this.sendOpenChannelError(message.requestId, 'min-extranonce-size-too-large');
|
|
return;
|
|
}
|
|
|
|
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 = this.clampDifficulty(calculatedDifficulty);
|
|
}
|
|
}
|
|
channelDifficulty = this.clampDifficulty(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(),
|
|
standardJobs: new Map(),
|
|
extendedJobs: new Map(),
|
|
latestExtendedPrevHash: Buffer.alloc(32),
|
|
latestExtendedNBits: 0,
|
|
latestExtendedMinNtime: 0,
|
|
miningSubmissionHashes: new Set(),
|
|
acceptedShareCount: 0,
|
|
readyForCanonicalJobs: false,
|
|
};
|
|
this.channels.set(channelId, channel);
|
|
if (this.primaryChannelId == null) {
|
|
this.primaryChannelId = channelId;
|
|
}
|
|
|
|
await this.ensureClientEntity();
|
|
this.disableApplicationIdleTimeout();
|
|
await this.sendFrame(
|
|
Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL_SUCCESS,
|
|
serializeOpenExtendedMiningChannelSuccess({
|
|
requestId: message.requestId,
|
|
channelId,
|
|
target: DifficultyUtils.difficultyToTarget(channel.sessionDifficulty),
|
|
extranonceSize,
|
|
extranoncePrefix,
|
|
groupChannelId: 0,
|
|
}),
|
|
);
|
|
channel.readyForCanonicalJobs = true;
|
|
|
|
if (!this.workSelectionEnabled) {
|
|
const jobTemplate = this.stratumV2Service.getLatestCanonicalJob(this.payoutMode);
|
|
if (jobTemplate != null) {
|
|
await this.enqueueCanonicalJob(jobTemplate);
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
}
|
|
const activationTemplate = this.stratumV2Service.getLatestWorkActivationTemplate?.();
|
|
if (activationTemplate != null) {
|
|
await this.enqueueWorkActivation(activationTemplate);
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isFirstChannel && !this.workSelectionEnabled) {
|
|
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 standardJob = channel.standardJobs.get(submission.jobId);
|
|
if (standardJob == null) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-job-id');
|
|
return;
|
|
}
|
|
if (standardJob.headerContext == null) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share');
|
|
return;
|
|
}
|
|
const headerContext = standardJob.headerContext;
|
|
if (!this.isSubmissionHeaderValid(headerContext, submission.version, submission.ntime)) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share');
|
|
return;
|
|
}
|
|
|
|
const header = this.buildHeader(
|
|
headerContext,
|
|
standardJob.merkleRoot,
|
|
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);
|
|
const isBlockCandidate = DifficultyUtils.meetsCompactTarget(hashBuffer, headerContext.nBits);
|
|
const meetsJobTarget = DifficultyUtils.meetsTarget(hashBuffer, target);
|
|
const isStale = standardJob.retiredAt != null
|
|
|| headerContext.tipKey !== channel.activeTipKey;
|
|
if (isStale) {
|
|
if (!isBlockCandidate) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share');
|
|
return;
|
|
}
|
|
|
|
const candidate = this.reconstructStandardBlock({
|
|
jobTemplate: standardJob.jobTemplate,
|
|
miningJob: standardJob.miningJob,
|
|
headerContext,
|
|
}, submission, channel.extranoncePrefix);
|
|
const blockSubmissionResult = await this.submitBlockCandidate(
|
|
standardJob.jobTemplate,
|
|
candidate,
|
|
);
|
|
if (!this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share');
|
|
return;
|
|
}
|
|
|
|
const creditedDifficulty = this.getCreditedDifficulty(
|
|
jobDifficulty,
|
|
submissionDifficulty,
|
|
meetsJobTarget,
|
|
);
|
|
await this.sendShareSuccess(
|
|
submission.channelId,
|
|
submission.sequenceNumber,
|
|
creditedDifficulty,
|
|
);
|
|
channel.acceptedShareCount++;
|
|
await this.recordAcceptedShare(
|
|
submissionDifficulty,
|
|
creditedDifficulty,
|
|
standardJob.jobTemplate,
|
|
null,
|
|
{
|
|
jobId: standardJob.miningJob.jobId,
|
|
nonce: submission.nonce,
|
|
ntime: submission.ntime,
|
|
version: submission.version,
|
|
extraNonce2: FIXED_STANDARD_EXTRANONCE2,
|
|
},
|
|
{
|
|
blockCandidateResult: blockSubmissionResult,
|
|
isBlockCandidate: true,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!isBlockCandidate && !meetsJobTarget) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'difficulty-too-low');
|
|
return;
|
|
}
|
|
const creditedDifficulty = this.getCreditedDifficulty(
|
|
jobDifficulty,
|
|
submissionDifficulty,
|
|
meetsJobTarget,
|
|
);
|
|
|
|
await this.sendShareSuccess(
|
|
submission.channelId,
|
|
submission.sequenceNumber,
|
|
creditedDifficulty,
|
|
);
|
|
|
|
channel.acceptedShareCount++;
|
|
await this.handleAcceptedShare(
|
|
submission,
|
|
channel,
|
|
standardJob.miningJob,
|
|
standardJob.jobTemplate,
|
|
submissionDifficulty,
|
|
creditedDifficulty,
|
|
hashBuffer,
|
|
headerContext,
|
|
isBlockCandidate,
|
|
);
|
|
}
|
|
|
|
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 (submission.extranonce.length !== channel.extranonceSize) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-extranonce-size');
|
|
return;
|
|
}
|
|
if (extendedJob.headerContext == null) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share');
|
|
return;
|
|
}
|
|
if (!this.isSubmissionHeaderValid(extendedJob.headerContext, submission.version, submission.ntime)) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share');
|
|
return;
|
|
}
|
|
|
|
const coinbaseTxBytes = Buffer.concat([
|
|
extendedJob.coinbasePrefix,
|
|
channel.extranoncePrefix,
|
|
submission.extranonce,
|
|
extendedJob.coinbaseSuffix,
|
|
]);
|
|
let merkleRoot = hash256(coinbaseTxBytes);
|
|
const merklePair = Buffer.alloc(64);
|
|
for (const sibling of extendedJob.merklePath) {
|
|
merklePair.set(merkleRoot, 0);
|
|
merklePair.set(sibling, 32);
|
|
merkleRoot = hash256(merklePair);
|
|
}
|
|
|
|
const header = this.buildHeader(
|
|
extendedJob.headerContext,
|
|
merkleRoot,
|
|
submission.version,
|
|
submission.ntime,
|
|
submission.nonce,
|
|
);
|
|
const { submissionDifficulty, hashBuffer } = DifficultyUtils.calculateDifficulty(header);
|
|
const isStale = extendedJob.retiredAt != null
|
|
|| (extendedJob.workProtocol !== 'sv2_jdp'
|
|
&& extendedJob.headerContext.tipKey !== channel.activeTipKey);
|
|
if (isStale) {
|
|
const isBlockCandidate = extendedJob.workProtocol !== 'sv2_jdp'
|
|
&& DifficultyUtils.meetsCompactTarget(hashBuffer, extendedJob.headerContext.nBits);
|
|
if (!isBlockCandidate) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share');
|
|
return;
|
|
}
|
|
|
|
const target = DifficultyUtils.difficultyToTarget(jobDifficulty);
|
|
const meetsJobTarget = DifficultyUtils.meetsTarget(hashBuffer, target);
|
|
const candidate = this.reconstructExtendedBlock(
|
|
extendedJob,
|
|
submission,
|
|
merkleRoot,
|
|
channel.extranoncePrefix,
|
|
);
|
|
const blockSubmissionResult = await this.submitBlockCandidate(
|
|
extendedJob.jobTemplate,
|
|
candidate,
|
|
);
|
|
if (!this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share');
|
|
return;
|
|
}
|
|
|
|
const creditedDifficulty = this.getCreditedDifficulty(
|
|
jobDifficulty,
|
|
submissionDifficulty,
|
|
meetsJobTarget,
|
|
);
|
|
await this.sendShareSuccess(
|
|
submission.channelId,
|
|
submission.sequenceNumber,
|
|
creditedDifficulty,
|
|
);
|
|
channel.acceptedShareCount++;
|
|
await this.recordAcceptedShare(
|
|
submissionDifficulty,
|
|
creditedDifficulty,
|
|
extendedJob.jobTemplate,
|
|
null,
|
|
{
|
|
jobId: submission.jobId.toString(16),
|
|
nonce: submission.nonce,
|
|
ntime: submission.ntime,
|
|
version: submission.version,
|
|
extraNonce2: submission.extranonce.toString('hex'),
|
|
},
|
|
{
|
|
workProtocol: 'pool',
|
|
blockCandidateResult: blockSubmissionResult,
|
|
isBlockCandidate: true,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
const target = DifficultyUtils.difficultyToTarget(jobDifficulty);
|
|
const isBlockCandidate = DifficultyUtils.meetsCompactTarget(
|
|
hashBuffer,
|
|
extendedJob.headerContext.nBits,
|
|
);
|
|
// Pool jobs bind nBits to the canonical template. A work-selection
|
|
// client supplies its own nBits and submits its solution through JDP,
|
|
// so that untrusted value must not bypass this channel's share target.
|
|
const canBypassJobTarget = extendedJob.workProtocol !== 'sv2_jdp' && isBlockCandidate;
|
|
const meetsJobTarget = DifficultyUtils.meetsTarget(hashBuffer, target);
|
|
|
|
if (!canBypassJobTarget && !meetsJobTarget) {
|
|
await this.sendShareError(submission.channelId, submission.sequenceNumber, 'difficulty-too-low');
|
|
return;
|
|
}
|
|
const creditedDifficulty = this.getCreditedDifficulty(
|
|
jobDifficulty,
|
|
submissionDifficulty,
|
|
meetsJobTarget,
|
|
);
|
|
|
|
await this.sendShareSuccess(
|
|
submission.channelId,
|
|
submission.sequenceNumber,
|
|
creditedDifficulty,
|
|
);
|
|
|
|
channel.acceptedShareCount++;
|
|
let updatedJobBlock: bitcoinjs.Block = null;
|
|
let blockCandidateResult: string | null = null;
|
|
if (isBlockCandidate) {
|
|
if (extendedJob.workProtocol === 'sv2_jdp') {
|
|
blockCandidateResult = 'sv2-jdp-client-submit-expected';
|
|
} else {
|
|
updatedJobBlock = this.reconstructExtendedBlock(extendedJob, submission, merkleRoot, channel.extranoncePrefix);
|
|
}
|
|
}
|
|
await this.recordAcceptedShare(submissionDifficulty, creditedDifficulty, extendedJob.jobTemplate, updatedJobBlock, {
|
|
jobId: submission.jobId.toString(16),
|
|
nonce: submission.nonce,
|
|
ntime: submission.ntime,
|
|
version: submission.version,
|
|
extraNonce2: submission.extranonce.toString('hex'),
|
|
}, {
|
|
workProtocol: extendedJob.workProtocol ?? 'pool',
|
|
blockCandidateResult,
|
|
});
|
|
}
|
|
|
|
private async handleSetCustomMiningJob(payload: Buffer): Promise<void> {
|
|
const msg = deserializeSetCustomMiningJob(new BufferReader(payload));
|
|
if (!this.workSelectionEnabled) {
|
|
await this.sendFrame(
|
|
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
|
|
serializeSetCustomMiningJobError({
|
|
channelId: msg.channelId,
|
|
requestId: msg.requestId,
|
|
errorCode: 'work-selection-not-negotiated',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const channel = this.channels.get(msg.channelId);
|
|
if (channel == null) {
|
|
await this.sendFrame(
|
|
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
|
|
serializeSetCustomMiningJobError({
|
|
channelId: msg.channelId,
|
|
requestId: msg.requestId,
|
|
errorCode: 'invalid-channel-id',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (channel.channelType !== 'extended') {
|
|
await this.sendFrame(
|
|
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
|
|
serializeSetCustomMiningJobError({
|
|
channelId: msg.channelId,
|
|
requestId: msg.requestId,
|
|
errorCode: 'invalid-channel-type',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const declaredJob = this.jobDeclarationRegistry?.getDeclaredJob(msg.token);
|
|
if (declaredJob == null || declaredJob.validationMode !== 'full_template') {
|
|
await this.sendFrame(
|
|
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
|
|
serializeSetCustomMiningJobError({
|
|
channelId: msg.channelId,
|
|
requestId: msg.requestId,
|
|
errorCode: 'invalid-mining-job-token',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (declaredJob.job.version !== msg.version) {
|
|
await this.sendFrame(
|
|
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
|
|
serializeSetCustomMiningJobError({
|
|
channelId: msg.channelId,
|
|
requestId: msg.requestId,
|
|
errorCode: 'declared-job-mismatch',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const latestTemplate = this.stratumV2Service.getLatestCanonicalJob(this.payoutMode);
|
|
if (latestTemplate == null) {
|
|
await this.sendFrame(
|
|
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
|
|
serializeSetCustomMiningJobError({
|
|
channelId: msg.channelId,
|
|
requestId: msg.requestId,
|
|
errorCode: 'template-not-found',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const payoutInformation = this.getPayoutInformation(latestTemplate, this.address);
|
|
if (payoutInformation == null) {
|
|
await this.sendFrame(
|
|
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
|
|
serializeSetCustomMiningJobError({
|
|
channelId: msg.channelId,
|
|
requestId: msg.requestId,
|
|
errorCode: 'template-not-found',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const { jobId, jobIdHex } = this.allocateMiningJobId();
|
|
const split = this.customWorkService.buildSv2CoinbaseSplit(
|
|
msg,
|
|
channel.extranoncePrefix.length + channel.extranonceSize,
|
|
);
|
|
const payoutValidation = this.validateCoinbasePayoutOutputs(
|
|
Buffer.concat([
|
|
split.coinbasePrefix,
|
|
Buffer.alloc(channel.extranoncePrefix.length + channel.extranonceSize),
|
|
split.coinbaseSuffix,
|
|
]),
|
|
latestTemplate,
|
|
payoutInformation,
|
|
);
|
|
if (!payoutValidation.valid) {
|
|
await this.sendFrame(
|
|
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
|
|
serializeSetCustomMiningJobError({
|
|
channelId: msg.channelId,
|
|
requestId: msg.requestId,
|
|
errorCode: payoutValidation.errorCode,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const placeholderJob = new MiningJob(
|
|
this.network,
|
|
jobIdHex,
|
|
payoutInformation,
|
|
latestTemplate,
|
|
);
|
|
const activatedAtMonotonicNs = process.hrtime.bigint();
|
|
channel.jobIdToDifficulty.set(jobId, channel.sessionDifficulty);
|
|
channel.extendedJobs.set(jobId, {
|
|
coinbasePrefix: split.coinbasePrefix,
|
|
coinbaseSuffix: split.coinbaseSuffix,
|
|
merklePath: msg.merklePath.map(branch => Buffer.from(branch)),
|
|
prevHash: Buffer.from(msg.prevHash),
|
|
nBits: msg.nBits,
|
|
minNtime: msg.minNtime,
|
|
jobTemplate: {
|
|
...latestTemplate,
|
|
block: Object.assign(new bitcoinjs.Block(), latestTemplate.block, {
|
|
prevHash: Buffer.from(msg.prevHash),
|
|
bits: msg.nBits,
|
|
version: msg.version,
|
|
timestamp: msg.minNtime,
|
|
}),
|
|
},
|
|
miningJob: placeholderJob,
|
|
expectedHeight: latestTemplate.blockData.height,
|
|
expectedVersion: msg.version,
|
|
headerContext: {
|
|
tipKey: `sv2-jdp:${msg.prevHash.toString('hex')}`,
|
|
prevHash: Buffer.from(msg.prevHash),
|
|
nBits: msg.nBits,
|
|
minNtime: msg.minNtime,
|
|
height: latestTemplate.blockData.height,
|
|
networkDifficulty: DifficultyUtils.targetToDifficulty(
|
|
DifficultyUtils.compactToTarget(msg.nBits) ?? Buffer.alloc(32, 0xff),
|
|
),
|
|
baseVersion: msg.version,
|
|
requiredVersionBits: 0,
|
|
activationMinNtime: msg.minNtime,
|
|
activatedAtMonotonicNs,
|
|
},
|
|
creation: Date.now(),
|
|
workProtocol: 'sv2_jdp',
|
|
});
|
|
|
|
await this.sendFrame(
|
|
Sv2MsgType.SET_CUSTOM_MINING_JOB_SUCCESS,
|
|
serializeSetCustomMiningJobSuccess({
|
|
channelId: msg.channelId,
|
|
requestId: msg.requestId,
|
|
jobId,
|
|
}),
|
|
);
|
|
}
|
|
|
|
private reconstructStandardBlock(
|
|
jobData: Pick<StandardJobData, 'jobTemplate' | 'miningJob'> & { headerContext: Sv2HeaderContext },
|
|
submission: { nonce: number; ntime: number; version: number },
|
|
extranoncePrefix: Buffer,
|
|
): bitcoinjs.Block {
|
|
const versionMask = submission.version ^ jobData.jobTemplate.block.version;
|
|
const block = jobData.miningJob.copyAndUpdateBlock(
|
|
jobData.jobTemplate,
|
|
versionMask,
|
|
submission.nonce,
|
|
extranoncePrefix.toString('hex'),
|
|
FIXED_STANDARD_EXTRANONCE2,
|
|
submission.ntime,
|
|
);
|
|
block.prevHash = Buffer.from(jobData.headerContext.prevHash);
|
|
block.bits = jobData.headerContext.nBits;
|
|
return block;
|
|
}
|
|
|
|
private async handleAcceptedShare(
|
|
submission: { nonce: number; ntime: number; version: number },
|
|
channel: ChannelState,
|
|
job: MiningJob,
|
|
jobTemplate: IJobTemplate,
|
|
submissionDifficulty: number,
|
|
jobDifficulty: number,
|
|
hashBuffer: Buffer,
|
|
headerContext: Sv2HeaderContext = this.createHeaderContext(jobTemplate),
|
|
isBlockCandidate = DifficultyUtils.meetsCompactTarget(hashBuffer, headerContext.nBits),
|
|
): Promise<void> {
|
|
let updatedJobBlock: bitcoinjs.Block = null;
|
|
if (isBlockCandidate) {
|
|
updatedJobBlock = this.reconstructStandardBlock({
|
|
jobTemplate,
|
|
miningJob: job,
|
|
headerContext,
|
|
}, submission, channel.extranoncePrefix);
|
|
}
|
|
|
|
await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, jobTemplate, updatedJobBlock, {
|
|
jobId: job.jobId,
|
|
nonce: submission.nonce,
|
|
ntime: submission.ntime,
|
|
version: submission.version,
|
|
extraNonce2: FIXED_STANDARD_EXTRANONCE2,
|
|
});
|
|
}
|
|
|
|
private async submitBlockCandidate(
|
|
jobTemplate: IJobTemplate,
|
|
updatedJobBlock: bitcoinjs.Block,
|
|
): Promise<string | null> {
|
|
await this.ensureClientEntity();
|
|
console.log(`[SV2 ${this.sessionId}] BLOCK FOUND at height ${jobTemplate.blockData.height}`);
|
|
const blockHex = updatedJobBlock.toHex(false);
|
|
const blockSubmissionResult = 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,
|
|
blockSubmissionResult,
|
|
payoutSnapshotId: this.payoutMode === 'pplns'
|
|
? jobTemplate.blockData.payoutSnapshotId ?? null
|
|
: null,
|
|
payoutMode: this.payoutMode,
|
|
});
|
|
if (this.payoutMode === 'pplns') {
|
|
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
|
|
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
|
|
blockHeight: jobTemplate.blockData.height,
|
|
blockSubmissionResult,
|
|
payoutMode: this.payoutMode,
|
|
});
|
|
}
|
|
await this.notificationService.notifySubscribersBlockFound(
|
|
this.address,
|
|
jobTemplate.blockData.height,
|
|
updatedJobBlock,
|
|
blockSubmissionResult,
|
|
);
|
|
if (this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
|
|
await this.addressSettingsService.resetBestDifficultyAndShares();
|
|
}
|
|
return blockSubmissionResult;
|
|
}
|
|
|
|
private async recordAcceptedShare(
|
|
submissionDifficulty: number,
|
|
jobDifficulty: number,
|
|
jobTemplate: IJobTemplate,
|
|
updatedJobBlock: bitcoinjs.Block | null,
|
|
share: { jobId: string; nonce: number; ntime: number; version: number; extraNonce2: string },
|
|
metadata: {
|
|
workProtocol?: 'pool' | 'sv2_jdp';
|
|
blockCandidateResult?: string | null;
|
|
isBlockCandidate?: boolean;
|
|
} = {},
|
|
): Promise<void> {
|
|
await this.ensureClientEntity();
|
|
|
|
let blockSubmissionResult: string = metadata.blockCandidateResult ?? null;
|
|
if (updatedJobBlock != null) {
|
|
blockSubmissionResult = await this.submitBlockCandidate(jobTemplate, updatedJobBlock);
|
|
}
|
|
|
|
await this.shareAccountingService?.recordAcceptedShare({
|
|
protocol: metadata.workProtocol === 'sv2_jdp' ? 'sv2_jdp' : 'sv2',
|
|
payoutMode: this.payoutMode,
|
|
workSource: metadata.workProtocol === 'sv2_jdp' ? 'miner_template' : 'pool_template',
|
|
workProtocol: metadata.workProtocol ?? 'pool',
|
|
address: this.address,
|
|
clientName: this.workerName,
|
|
sessionId: this.sessionId,
|
|
clientId: this.clientEntity.id,
|
|
jobId: share.jobId,
|
|
jobTemplateId: jobTemplate.blockData.id,
|
|
blockHeight: jobTemplate.blockData.height,
|
|
creditedDifficulty: jobDifficulty,
|
|
submissionDifficulty,
|
|
networkDifficulty: jobTemplate.blockData.networkDifficulty,
|
|
nonce: share.nonce,
|
|
ntime: share.ntime,
|
|
version: share.version,
|
|
extraNonce2: share.extraNonce2,
|
|
isBlockCandidate: updatedJobBlock != null
|
|
|| metadata.isBlockCandidate === true
|
|
|| metadata.blockCandidateResult != null,
|
|
blockSubmissionResult,
|
|
});
|
|
await this.statistics.addShares(this.clientEntity, jobDifficulty);
|
|
const now = new Date();
|
|
this.clientEntity.updatedAt = now;
|
|
this.clientEntity.hashRate = this.statistics.hashRate;
|
|
await this.persistClientHashRate(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 = [
|
|
Object.assign(new bitcoinjs.Transaction(), jobTemplate.block.transactions[0]),
|
|
...(jobTemplate.blockData.transactions == null
|
|
? jobTemplate.block.transactions.slice(1).map(tx =>
|
|
Object.assign(new bitcoinjs.Transaction(), tx))
|
|
: jobTemplate.blockData.transactions.map(tx => bitcoinjs.Transaction.fromHex(tx.data))),
|
|
];
|
|
|
|
const coinbaseTx = bitcoinjs.Transaction.fromBuffer(Buffer.concat([
|
|
extendedJob.coinbasePrefix,
|
|
extranoncePrefix,
|
|
submission.extranonce,
|
|
extendedJob.coinbaseSuffix,
|
|
]));
|
|
coinbaseTx.ins[0].witness = [Buffer.alloc(32)];
|
|
testBlock.transactions[0] = coinbaseTx;
|
|
testBlock.version = submission.version | 0;
|
|
testBlock.nonce = submission.nonce;
|
|
testBlock.timestamp = submission.ntime;
|
|
testBlock.merkleRoot = merkleRoot;
|
|
testBlock.prevHash = Buffer.from(extendedJob.headerContext.prevHash);
|
|
testBlock.bits = extendedJob.headerContext.nBits;
|
|
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;
|
|
}
|
|
|
|
await this.enqueueJobOperation(async () => {
|
|
if (this.channels.get(channel.channelId) !== channel) {
|
|
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 = this.clampDifficulty(DifficultyUtils.clampDifficultyToMaxTarget(
|
|
nextDifficulty,
|
|
channel.declaredMaxTarget,
|
|
));
|
|
if (this.channels.get(channel.channelId) === channel) {
|
|
this.updateStagedFutureDifficulty(channel);
|
|
await this.sendSetTarget(channel);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private handleCloseChannel(payload: Buffer): void {
|
|
const message = deserializeCloseChannel(new BufferReader(payload));
|
|
const channel = this.channels.get(message.channelId);
|
|
if (channel != null) {
|
|
this.stratumV2Service.releaseExtranoncePrefix(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();
|
|
}
|
|
}
|
|
|
|
public enqueueCanonicalJob(jobTemplate: IJobTemplate): Promise<void> {
|
|
if (!this.shouldReceiveCanonicalJob(jobTemplate)) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return this.enqueueJobOperation(
|
|
() => this.applyCanonicalJob(jobTemplate),
|
|
'canonical',
|
|
jobTemplate.blockData.tipKey,
|
|
);
|
|
}
|
|
|
|
public enqueueWorkActivation(template: IBlockTemplate): Promise<void> {
|
|
if (this.destroyed || this.workSelectionEnabled || this.payoutMode !== 'solo') {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const tipKey = this.getQueuedActivationTipKey(template);
|
|
return this.enqueueJobOperation(
|
|
() => this.applyWorkActivation(template),
|
|
tipKey == null ? 'ordered' : 'activation',
|
|
tipKey,
|
|
);
|
|
}
|
|
|
|
private enqueueJobOperation(
|
|
operation: () => Promise<void>,
|
|
kind: Sv2JobOperationKind = 'ordered',
|
|
tipKey?: string,
|
|
): Promise<void> {
|
|
if (this.destroyed) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return new Promise<void>(resolve => {
|
|
const queuedOperation: QueuedSv2JobOperation = {
|
|
kind,
|
|
operation,
|
|
resolve,
|
|
tipKey,
|
|
};
|
|
|
|
if (kind === 'canonical') {
|
|
const activeActivation = this.activeJobOperation?.kind === 'activation'
|
|
? this.activeJobOperation
|
|
: null;
|
|
const pendingActivation = [...this.jobOperationQueue]
|
|
.reverse()
|
|
.find(queued => queued.kind === 'activation');
|
|
const activationTipKey = pendingActivation?.tipKey ?? activeActivation?.tipKey;
|
|
if (activationTipKey != null && activationTipKey !== tipKey) {
|
|
// A late old-tip refresh must not be replayed after a newer
|
|
// activation. A matching full job is retained behind it.
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const retainedOperations: QueuedSv2JobOperation[] = [];
|
|
for (const queued of this.jobOperationQueue) {
|
|
if (queued.kind === 'canonical') {
|
|
// Only the newest not-yet-started canonical job can be
|
|
// useful to this client. Resolve superseded waiters and
|
|
// retain the replacement at its newest queue position.
|
|
queued.resolve();
|
|
} else {
|
|
retainedOperations.push(queued);
|
|
}
|
|
}
|
|
this.jobOperationQueue = retainedOperations;
|
|
this.jobOperationQueue.push(queuedOperation);
|
|
} else if (kind === 'activation') {
|
|
const retainedOperations: QueuedSv2JobOperation[] = [];
|
|
let matchingCanonical: QueuedSv2JobOperation = null;
|
|
for (const queued of this.jobOperationQueue) {
|
|
if (queued.kind === 'activation') {
|
|
queued.resolve();
|
|
continue;
|
|
}
|
|
if (queued.kind === 'canonical') {
|
|
if (queued.tipKey === tipKey) {
|
|
matchingCanonical?.resolve();
|
|
matchingCanonical = queued;
|
|
} else {
|
|
queued.resolve();
|
|
}
|
|
continue;
|
|
}
|
|
retainedOperations.push(queued);
|
|
}
|
|
|
|
// Target/difficulty mutations keep FIFO order. Obsolete jobs
|
|
// are removed, then the activation runs before a matching full
|
|
// job even if that full job arrived first.
|
|
this.jobOperationQueue = retainedOperations;
|
|
this.jobOperationQueue.push(queuedOperation);
|
|
if (matchingCanonical != null) {
|
|
this.jobOperationQueue.push(matchingCanonical);
|
|
}
|
|
} else {
|
|
this.jobOperationQueue.push(queuedOperation);
|
|
}
|
|
|
|
if (this.jobOperationQueue.length > this.maxQueuedJobOperations) {
|
|
console.warn(
|
|
`[SV2 ${this.sessionId}] Closing slow client with ${this.jobOperationQueue.length} queued operations `
|
|
+ `(limit ${this.maxQueuedJobOperations})`,
|
|
);
|
|
this.settleQueuedJobOperations();
|
|
this.closeSocket();
|
|
return;
|
|
}
|
|
|
|
this.startJobOperationDrain();
|
|
});
|
|
}
|
|
|
|
private startJobOperationDrain(): void {
|
|
if (this.drainingJobOperations) {
|
|
return;
|
|
}
|
|
this.drainingJobOperations = true;
|
|
void this.drainJobOperations();
|
|
}
|
|
|
|
private async drainJobOperations(): Promise<void> {
|
|
while (!this.destroyed && this.jobOperationQueue.length > 0) {
|
|
const queuedOperation = this.jobOperationQueue.shift();
|
|
this.activeJobOperation = queuedOperation;
|
|
try {
|
|
await queuedOperation.operation();
|
|
} catch (error) {
|
|
if (!this.destroyed) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`[SV2 ${this.sessionId}] Failed to send mining job: ${message}`);
|
|
this.closeSocket();
|
|
}
|
|
} finally {
|
|
queuedOperation.resolve();
|
|
this.activeJobOperation = null;
|
|
}
|
|
}
|
|
|
|
if (this.destroyed) {
|
|
this.settleQueuedJobOperations();
|
|
}
|
|
this.drainingJobOperations = false;
|
|
}
|
|
|
|
private settleQueuedJobOperations(): void {
|
|
const queuedOperations = this.jobOperationQueue.splice(0);
|
|
for (const queuedOperation of queuedOperations) {
|
|
queuedOperation.resolve();
|
|
}
|
|
}
|
|
|
|
private getQueuedActivationTipKey(template: IBlockTemplate): string | undefined {
|
|
return Number.isSafeInteger(template?.height)
|
|
&& typeof template?.previousblockhash === 'string'
|
|
&& /^[0-9a-fA-F]{64}$/.test(template.previousblockhash)
|
|
? `${template.height}:${template.previousblockhash}`
|
|
: undefined;
|
|
}
|
|
|
|
private allocateMiningJobId(): { jobId: number; jobIdHex: string } {
|
|
for (let attempt = 0; attempt < 0xffffffff; attempt++) {
|
|
const jobId = this.nextMiningJobId >>> 0;
|
|
this.nextMiningJobId = (jobId + 1) >>> 0;
|
|
if (this.nextMiningJobId === 0) {
|
|
this.nextMiningJobId = 1;
|
|
}
|
|
const isInUse = Array.from(this.channels.values()).some(channel =>
|
|
channel.standardJobs.has(jobId) || channel.extendedJobs.has(jobId));
|
|
if (!isInUse) {
|
|
return { jobId, jobIdHex: jobId.toString(16) };
|
|
}
|
|
}
|
|
throw new Error('No available SV2 mining job IDs');
|
|
}
|
|
|
|
private async applyCanonicalJob(jobTemplate: IJobTemplate, force = false): Promise<void> {
|
|
if (!this.shouldReceiveCanonicalJob(jobTemplate)) {
|
|
return;
|
|
}
|
|
|
|
const signature = this.getCanonicalJobSignature(jobTemplate);
|
|
const channels = Array.from(this.channels.values())
|
|
.filter(channel => channel.readyForCanonicalJobs)
|
|
.filter(channel => force || channel.lastCanonicalJobSignature !== signature);
|
|
for (const channel of channels) {
|
|
if (this.destroyed || this.channels.get(channel.channelId) !== channel) {
|
|
continue;
|
|
}
|
|
|
|
const headerMatchesActiveTip = this.canonicalHeaderMatchesActiveTip(channel, jobTemplate);
|
|
const followsActivatedFuture = channel.activatedFutureTipKey === jobTemplate.blockData.tipKey
|
|
&& jobTemplate.blockData.jobType === 'full'
|
|
&& headerMatchesActiveTip;
|
|
const sendPrevHash = channel.activeTipKey !== jobTemplate.blockData.tipKey
|
|
|| !headerMatchesActiveTip;
|
|
|
|
if (sendPrevHash) {
|
|
this.retireChannelJobs(channel);
|
|
} else if (jobTemplate.blockData.clearJobs && !followsActivatedFuture) {
|
|
// A second SetNewPrevHash would invalidate the already-advertised
|
|
// next-height future at the miner. Retire only current server-side
|
|
// jobs and keep that future valid while sending active Some(ntime).
|
|
this.retireChannelJobs(channel, channel.stagedFutureJobId);
|
|
}
|
|
if (channel.channelType === 'extended') {
|
|
await this.sendNewExtendedMiningJob(channel, jobTemplate, sendPrevHash);
|
|
} else {
|
|
await this.sendNewMiningJob(channel, jobTemplate, sendPrevHash);
|
|
}
|
|
channel.activeTipKey = jobTemplate.blockData.tipKey;
|
|
if (channel.activatedFutureTipKey === jobTemplate.blockData.tipKey) {
|
|
channel.activatedFutureTipKey = undefined;
|
|
}
|
|
channel.lastCanonicalJobSignature = signature;
|
|
await this.stageSubsidyFutureJob(channel, jobTemplate);
|
|
this.cleanupRetiredJobs(channel);
|
|
}
|
|
}
|
|
|
|
private async applyWorkActivation(template: IBlockTemplate): Promise<void> {
|
|
const activation = this.parseWorkActivation(template);
|
|
if (activation == null) {
|
|
return;
|
|
}
|
|
|
|
const activated: Array<{
|
|
channel: ChannelState;
|
|
jobId: number;
|
|
}> = [];
|
|
for (const channel of this.channels.values()) {
|
|
if (!channel.readyForCanonicalJobs || channel.stagedFutureJobId == null) {
|
|
continue;
|
|
}
|
|
|
|
const jobId = channel.stagedFutureJobId;
|
|
const jobData = channel.channelType === 'extended'
|
|
? channel.extendedJobs.get(jobId)
|
|
: channel.standardJobs.get(jobId);
|
|
if (
|
|
jobData == null
|
|
|| jobData.retiredAt != null
|
|
|| jobData.headerContext != null
|
|
|| jobData.expectedHeight !== activation.height
|
|
|| !this.isFutureVersionCompatible(jobData.expectedVersion, template)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const activatedTemplate = this.createActivatedFutureTemplate(jobData.jobTemplate, template, activation);
|
|
this.retireChannelJobs(channel, jobId);
|
|
jobData.jobTemplate = activatedTemplate;
|
|
jobData.headerContext = {
|
|
...this.cloneHeaderContext(activation),
|
|
baseVersion: jobData.expectedVersion,
|
|
};
|
|
jobData.miningJob.tipKey = activation.tipKey;
|
|
jobData.miningJob.networkDifficulty = activation.networkDifficulty;
|
|
if (channel.channelType === 'extended') {
|
|
const extendedJob = jobData as ExtendedJobData;
|
|
extendedJob.prevHash = Buffer.from(activation.prevHash);
|
|
extendedJob.nBits = activation.nBits;
|
|
extendedJob.minNtime = activation.minNtime;
|
|
channel.latestExtendedPrevHash = Buffer.from(activation.prevHash);
|
|
channel.latestExtendedNBits = activation.nBits;
|
|
channel.latestExtendedMinNtime = activation.minNtime;
|
|
}
|
|
channel.stagedFutureJobId = undefined;
|
|
channel.activeTipKey = activation.tipKey;
|
|
channel.activatedFutureTipKey = activation.tipKey;
|
|
channel.activePrevHashMinNtime = activation.minNtime;
|
|
channel.activePrevHashAtMonotonicNs = activation.activatedAtMonotonicNs;
|
|
channel.activePrevHashJobId = jobId;
|
|
activated.push({ channel, jobId });
|
|
}
|
|
|
|
// All matching jobs are bound before the first socket write, so a share
|
|
// arriving immediately after SetNewPrevHash always sees the new header.
|
|
for (const { channel, jobId } of activated) {
|
|
await this.sendFrame(
|
|
Sv2MsgType.SET_NEW_PREV_HASH,
|
|
serializeSetNewPrevHash({
|
|
channelId: channel.channelId,
|
|
jobId,
|
|
prevHash: Buffer.from(activation.prevHash),
|
|
minNtime: activation.minNtime,
|
|
nBits: activation.nBits,
|
|
}),
|
|
SV2_CHANNEL_MSG_FLAG,
|
|
);
|
|
}
|
|
}
|
|
|
|
private retireChannelJobs(channel: ChannelState, keepJobId?: number): void {
|
|
const now = Date.now();
|
|
channel.standardJobs.forEach((job, jobId) => {
|
|
if (jobId !== keepJobId && job.retiredAt == null) {
|
|
job.retiredAt = now;
|
|
}
|
|
});
|
|
channel.extendedJobs.forEach((job, jobId) => {
|
|
if (jobId !== keepJobId && job.retiredAt == null) {
|
|
job.retiredAt = now;
|
|
}
|
|
});
|
|
if (channel.stagedFutureJobId !== keepJobId) {
|
|
channel.stagedFutureJobId = undefined;
|
|
}
|
|
this.cleanupRetiredJobs(channel);
|
|
}
|
|
|
|
private canonicalHeaderMatchesActiveTip(channel: ChannelState, jobTemplate: IJobTemplate): boolean {
|
|
const activeJobs = channel.channelType === 'extended'
|
|
? channel.extendedJobs.values()
|
|
: channel.standardJobs.values();
|
|
for (const job of activeJobs) {
|
|
if (
|
|
job.retiredAt == null
|
|
&& job.headerContext?.tipKey === jobTemplate.blockData.tipKey
|
|
&& job.headerContext.nBits === jobTemplate.block.bits
|
|
&& job.headerContext.prevHash.equals(jobTemplate.block.prevHash)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private shouldReceiveCanonicalJob(jobTemplate: IJobTemplate): boolean {
|
|
return !this.destroyed
|
|
&& !this.workSelectionEnabled
|
|
&& (jobTemplate.blockData.payoutMode === 'all'
|
|
|| jobTemplate.blockData.payoutMode === this.payoutMode)
|
|
&& (this.payoutMode !== 'pplns'
|
|
|| jobTemplate.blockData.payoutOutputs?.length > 0);
|
|
}
|
|
|
|
private getCanonicalJobSignature(jobTemplate: IJobTemplate): string {
|
|
return [
|
|
jobTemplate.blockData.id,
|
|
jobTemplate.block.timestamp,
|
|
jobTemplate.blockData.clearJobs,
|
|
jobTemplate.blockData.jobType,
|
|
jobTemplate.blockData.payoutMode,
|
|
].join(':');
|
|
}
|
|
|
|
private startDifficultyTimer(): void {
|
|
if (this.difficultyTimer != null) {
|
|
return;
|
|
}
|
|
|
|
this.difficultyTimer = setInterval(() => {
|
|
void this.checkDifficulty();
|
|
}, this.difficultyCheckIntervalMs);
|
|
}
|
|
|
|
private async checkDifficulty(): Promise<void> {
|
|
const targetDiff = this.clampDifficulty(this.statistics.getSuggestedDifficulty(this.sessionDifficulty));
|
|
if (targetDiff == null || targetDiff === this.sessionDifficulty || !Number.isFinite(targetDiff)) {
|
|
return;
|
|
}
|
|
|
|
await this.enqueueJobOperation(async () => {
|
|
this.sessionDifficulty = targetDiff;
|
|
for (const channel of this.channels.values()) {
|
|
channel.sessionDifficulty = this.clampDifficulty(DifficultyUtils.clampDifficultyToMaxTarget(
|
|
targetDiff,
|
|
channel.declaredMaxTarget,
|
|
));
|
|
this.updateStagedFutureDifficulty(channel);
|
|
await this.sendSetTarget(channel);
|
|
}
|
|
|
|
const jobTemplate = this.stratumV2Service.getLatestCanonicalJob(this.payoutMode);
|
|
if (jobTemplate != null) {
|
|
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 },
|
|
};
|
|
await this.applyCanonicalJob(refreshedTemplate, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
private async sendNewMiningJob(
|
|
channel: ChannelState,
|
|
jobTemplate: IJobTemplate,
|
|
sendPrevHash: boolean,
|
|
isFuture = false,
|
|
): Promise<number | null> {
|
|
if (this.address == null) {
|
|
return null;
|
|
}
|
|
|
|
const payoutInformation = this.getPayoutInformation(jobTemplate, this.address);
|
|
if (payoutInformation == null) {
|
|
return null;
|
|
}
|
|
const { jobId, jobIdHex } = this.allocateMiningJobId();
|
|
const job = new MiningJob(
|
|
this.network,
|
|
jobIdHex,
|
|
payoutInformation,
|
|
jobTemplate,
|
|
);
|
|
// The standard job needs only its coinbase-derived Merkle root. Parsing
|
|
// and cloning the full transaction body here made canonical fanout O(body
|
|
// size * channel count); retain that work solely for a block candidate.
|
|
const merkleRoot = job.buildCoinbaseMerkleRoot(
|
|
channel.extranoncePrefix.toString('hex'),
|
|
FIXED_STANDARD_EXTRANONCE2,
|
|
);
|
|
channel.jobIdToDifficulty.set(jobId, channel.sessionDifficulty);
|
|
channel.jobIdToMerkleRoot.set(jobId, merkleRoot);
|
|
const headerContext = isFuture ? undefined : this.createHeaderContext(jobTemplate, channel);
|
|
channel.standardJobs.set(jobId, {
|
|
merkleRoot,
|
|
jobTemplate,
|
|
miningJob: job,
|
|
expectedHeight: jobTemplate.blockData.height,
|
|
expectedVersion: jobTemplate.block.version,
|
|
headerContext,
|
|
creation: Date.now(),
|
|
});
|
|
|
|
await this.sendFrame(
|
|
Sv2MsgType.NEW_MINING_JOB,
|
|
serializeNewMiningJob({
|
|
channelId: channel.channelId,
|
|
jobId,
|
|
minNtime: sendPrevHash || isFuture ? null : jobTemplate.block.timestamp,
|
|
version: jobTemplate.block.version,
|
|
merkleRoot,
|
|
}),
|
|
SV2_CHANNEL_MSG_FLAG,
|
|
);
|
|
|
|
if (sendPrevHash) {
|
|
const activatedAtMonotonicNs = process.hrtime.bigint();
|
|
headerContext.activationMinNtime = jobTemplate.block.timestamp;
|
|
headerContext.activatedAtMonotonicNs = activatedAtMonotonicNs;
|
|
channel.activePrevHashMinNtime = jobTemplate.block.timestamp;
|
|
channel.activePrevHashAtMonotonicNs = activatedAtMonotonicNs;
|
|
channel.activePrevHashJobId = jobId;
|
|
channel.activeTipKey = jobTemplate.blockData.tipKey;
|
|
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,
|
|
);
|
|
}
|
|
return jobId;
|
|
}
|
|
|
|
private async sendNewExtendedMiningJob(
|
|
channel: ChannelState,
|
|
jobTemplate: IJobTemplate,
|
|
sendPrevHash: boolean,
|
|
isFuture = false,
|
|
): Promise<number | null> {
|
|
if (this.address == null) {
|
|
return null;
|
|
}
|
|
|
|
const payoutInformation = this.getPayoutInformation(jobTemplate, this.address);
|
|
if (payoutInformation == null) {
|
|
return null;
|
|
}
|
|
const { jobId, jobIdHex } = this.allocateMiningJobId();
|
|
const job = new MiningJob(
|
|
this.network,
|
|
jobIdHex,
|
|
payoutInformation,
|
|
jobTemplate,
|
|
);
|
|
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();
|
|
|
|
const headerContext = isFuture ? undefined : this.createHeaderContext(jobTemplate, channel);
|
|
const prevHash = headerContext == null
|
|
? Buffer.alloc(32)
|
|
: Buffer.from(headerContext.prevHash);
|
|
const nBits = headerContext?.nBits ?? 0;
|
|
const minNtime = headerContext?.minNtime ?? 0;
|
|
|
|
channel.jobIdToDifficulty.set(jobId, channel.sessionDifficulty);
|
|
channel.extendedJobs.set(jobId, {
|
|
coinbasePrefix,
|
|
coinbaseSuffix,
|
|
merklePath,
|
|
prevHash,
|
|
nBits,
|
|
minNtime,
|
|
jobTemplate,
|
|
miningJob: job,
|
|
expectedHeight: jobTemplate.blockData.height,
|
|
expectedVersion: jobTemplate.block.version,
|
|
headerContext,
|
|
creation: Date.now(),
|
|
});
|
|
|
|
await this.sendFrame(
|
|
Sv2MsgType.NEW_EXTENDED_MINING_JOB,
|
|
serializeNewExtendedMiningJob({
|
|
channelId: channel.channelId,
|
|
jobId,
|
|
minNtime: sendPrevHash || isFuture ? null : jobTemplate.block.timestamp,
|
|
version: jobTemplate.block.version,
|
|
versionRollingAllowed: this.versionRollingEnabled,
|
|
merklePath,
|
|
coinbasePrefix,
|
|
coinbaseSuffix,
|
|
}),
|
|
SV2_CHANNEL_MSG_FLAG,
|
|
);
|
|
|
|
if (sendPrevHash) {
|
|
const activatedAtMonotonicNs = process.hrtime.bigint();
|
|
headerContext.activationMinNtime = minNtime;
|
|
headerContext.activatedAtMonotonicNs = activatedAtMonotonicNs;
|
|
channel.activePrevHashMinNtime = minNtime;
|
|
channel.activePrevHashAtMonotonicNs = activatedAtMonotonicNs;
|
|
channel.activePrevHashJobId = jobId;
|
|
channel.latestExtendedPrevHash = prevHash;
|
|
channel.latestExtendedNBits = nBits;
|
|
channel.latestExtendedMinNtime = minNtime;
|
|
channel.activeTipKey = jobTemplate.blockData.tipKey;
|
|
await this.sendFrame(
|
|
Sv2MsgType.SET_NEW_PREV_HASH,
|
|
serializeSetNewPrevHash({
|
|
channelId: channel.channelId,
|
|
jobId,
|
|
prevHash,
|
|
minNtime,
|
|
nBits,
|
|
}),
|
|
SV2_CHANNEL_MSG_FLAG,
|
|
);
|
|
}
|
|
return jobId;
|
|
}
|
|
|
|
private async stageSubsidyFutureJob(channel: ChannelState, sourceTemplate: IJobTemplate): Promise<void> {
|
|
if (
|
|
this.payoutMode !== 'solo'
|
|
|| this.workSelectionEnabled
|
|
|| !channel.readyForCanonicalJobs
|
|
|| !Number.isSafeInteger(sourceTemplate.blockData.height)
|
|
|| sourceTemplate.blockData.height < 0
|
|
|| sourceTemplate.blockData.height >= Number.MAX_SAFE_INTEGER
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const expectedHeight = sourceTemplate.blockData.height + 1;
|
|
const expectedVersion = sourceTemplate.block.version;
|
|
if (channel.stagedFutureJobId != null) {
|
|
const staged = channel.channelType === 'extended'
|
|
? channel.extendedJobs.get(channel.stagedFutureJobId)
|
|
: channel.standardJobs.get(channel.stagedFutureJobId);
|
|
if (
|
|
staged != null
|
|
&& staged.retiredAt == null
|
|
&& staged.headerContext == null
|
|
&& staged.expectedHeight === expectedHeight
|
|
&& (staged.expectedVersion >>> 0) === (expectedVersion >>> 0)
|
|
) {
|
|
return;
|
|
}
|
|
if (staged != null && staged.retiredAt == null) {
|
|
staged.retiredAt = Date.now();
|
|
}
|
|
channel.stagedFutureJobId = undefined;
|
|
}
|
|
|
|
const futureTemplate = this.createSubsidyFutureTemplate(expectedHeight, expectedVersion);
|
|
const jobId = channel.channelType === 'extended'
|
|
? await this.sendNewExtendedMiningJob(channel, futureTemplate, false, true)
|
|
: await this.sendNewMiningJob(channel, futureTemplate, false, true);
|
|
if (jobId != null) {
|
|
channel.stagedFutureJobId = jobId;
|
|
}
|
|
this.cleanupRetiredJobs(channel);
|
|
}
|
|
|
|
private createSubsidyFutureTemplate(height: number, version: number): IJobTemplate {
|
|
const block = new bitcoinjs.Block();
|
|
const placeholderCoinbase = new bitcoinjs.Transaction();
|
|
placeholderCoinbase.version = 2;
|
|
placeholderCoinbase.addInput(Buffer.alloc(32), 0xffffffff, 0xffffffff);
|
|
placeholderCoinbase.ins[0].witness = [Buffer.alloc(32)];
|
|
block.version = version;
|
|
block.prevHash = Buffer.alloc(32);
|
|
block.bits = 0;
|
|
block.timestamp = Math.floor(Date.now() / 1000);
|
|
block.transactions = [placeholderCoinbase];
|
|
block.merkleRoot = placeholderCoinbase.getHash(false);
|
|
block.witnessCommit = Buffer.from(EMPTY_WITNESS_COMMITMENT_HASH, 'hex');
|
|
|
|
const halvingInterval = this.getOptionalPositiveIntegerConfig('SUBSIDY_HALVING_INTERVAL');
|
|
const coinbasevalue = calculateBlockSubsidySats(
|
|
height,
|
|
this.getNetworkName(),
|
|
halvingInterval,
|
|
);
|
|
const id = `sv2-future:${this.sessionId}:${height}:${++this.futureTemplateCounter}`;
|
|
return {
|
|
block,
|
|
merkle_branch: [],
|
|
blockData: {
|
|
id,
|
|
creation: Date.now(),
|
|
coinbasevalue,
|
|
networkDifficulty: 0,
|
|
height,
|
|
tipKey: `future:${height}:${version >>> 0}`,
|
|
clearJobs: true,
|
|
isNewBlock: true,
|
|
jobType: 'empty',
|
|
payoutMode: 'solo',
|
|
transactions: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
private parseWorkActivation(template: IBlockTemplate): Sv2WorkActivation | null {
|
|
if (
|
|
template?.jobType !== 'empty'
|
|
|| (template.payoutMode !== 'solo' && template.payoutMode !== 'pplns')
|
|
|| !Number.isSafeInteger(template.height)
|
|
|| template.height < 0
|
|
|| !Number.isInteger(template.version)
|
|
|| !Number.isInteger(template.vbrequired)
|
|
|| template.vbrequired < 0
|
|
|| template.vbrequired > 0xffffffff
|
|
|| !Number.isInteger(template.mintime)
|
|
|| template.mintime < 0
|
|
|| template.mintime > 0xffffffff
|
|
|| !/^[0-9a-fA-F]{64}$/.test(template.previousblockhash ?? '')
|
|
|| !/^[0-9a-fA-F]{8}$/.test(template.bits ?? '')
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const nBits = Number.parseInt(template.bits, 16);
|
|
const target = DifficultyUtils.compactToTarget(nBits);
|
|
if (target == null) {
|
|
return null;
|
|
}
|
|
return {
|
|
tipKey: `${template.height}:${template.previousblockhash}`,
|
|
prevHash: Buffer.from(template.previousblockhash, 'hex').reverse(),
|
|
nBits,
|
|
minNtime: template.mintime,
|
|
height: template.height,
|
|
version: template.version,
|
|
networkDifficulty: DifficultyUtils.targetToDifficulty(target),
|
|
baseVersion: template.version,
|
|
requiredVersionBits: template.vbrequired >>> 0,
|
|
activationMinNtime: template.mintime,
|
|
activatedAtMonotonicNs: process.hrtime.bigint(),
|
|
};
|
|
}
|
|
|
|
private createActivatedFutureTemplate(
|
|
stagedTemplate: IJobTemplate,
|
|
rawTemplate: IBlockTemplate,
|
|
activation: Sv2WorkActivation,
|
|
): IJobTemplate {
|
|
const block = Object.assign(new bitcoinjs.Block(), stagedTemplate.block, {
|
|
prevHash: Buffer.from(activation.prevHash),
|
|
bits: activation.nBits,
|
|
timestamp: activation.minNtime,
|
|
// The future job's version was already sent to the miner. GBT may
|
|
// change BIP320 general-purpose bits without making it incompatible.
|
|
version: stagedTemplate.block.version,
|
|
});
|
|
block.transactions = stagedTemplate.block.transactions.map(transaction =>
|
|
Object.assign(new bitcoinjs.Transaction(), transaction));
|
|
return {
|
|
...stagedTemplate,
|
|
block,
|
|
blockData: {
|
|
...stagedTemplate.blockData,
|
|
networkDifficulty: activation.networkDifficulty,
|
|
height: activation.height,
|
|
tipKey: activation.tipKey,
|
|
clearJobs: true,
|
|
isNewBlock: true,
|
|
jobType: 'empty',
|
|
payoutMode: 'solo',
|
|
notificationEventId: rawTemplate.notificationEventId,
|
|
notificationPublishedAtMs: rawTemplate.notificationPublishedAtMs,
|
|
},
|
|
};
|
|
}
|
|
|
|
private createHeaderContext(
|
|
jobTemplate: IJobTemplate,
|
|
channel?: ChannelState,
|
|
): Sv2HeaderContext {
|
|
return {
|
|
tipKey: jobTemplate.blockData.tipKey,
|
|
prevHash: Buffer.from(jobTemplate.block.prevHash),
|
|
nBits: jobTemplate.block.bits,
|
|
minNtime: jobTemplate.block.timestamp,
|
|
height: jobTemplate.blockData.height,
|
|
networkDifficulty: jobTemplate.blockData.networkDifficulty,
|
|
baseVersion: jobTemplate.block.version,
|
|
requiredVersionBits: jobTemplate.blockData.requiredVersionBits ?? 0,
|
|
activationMinNtime: channel?.activePrevHashMinNtime,
|
|
activatedAtMonotonicNs: channel?.activePrevHashAtMonotonicNs,
|
|
};
|
|
}
|
|
|
|
private isFutureVersionCompatible(stagedVersion: number, template: IBlockTemplate): boolean {
|
|
const staged = stagedVersion >>> 0;
|
|
const authoritative = template.version >>> 0;
|
|
const required = template.vbrequired >>> 0;
|
|
return ((staged ^ authoritative) & BIP320_CONSENSUS_VERSION_MASK) === 0
|
|
&& (staged & required) === required;
|
|
}
|
|
|
|
private isSubmissionHeaderValid(
|
|
headerContext: Sv2HeaderContext,
|
|
submittedVersion: number,
|
|
submittedNtime: number,
|
|
): boolean {
|
|
const submitted = submittedVersion >>> 0;
|
|
const base = headerContext.baseVersion >>> 0;
|
|
if (this.versionRollingEnabled) {
|
|
if (((submitted ^ base) & BIP320_CONSENSUS_VERSION_MASK) !== 0) {
|
|
return false;
|
|
}
|
|
} else if (submitted !== base) {
|
|
return false;
|
|
}
|
|
if ((submitted & headerContext.requiredVersionBits) !== headerContext.requiredVersionBits) {
|
|
return false;
|
|
}
|
|
return Number.isInteger(submittedNtime)
|
|
&& submittedNtime >= headerContext.minNtime
|
|
&& submittedNtime <= 0xffffffff;
|
|
}
|
|
|
|
private cloneHeaderContext(context: Sv2HeaderContext): Sv2HeaderContext {
|
|
return {
|
|
...context,
|
|
prevHash: Buffer.from(context.prevHash),
|
|
};
|
|
}
|
|
|
|
private updateStagedFutureDifficulty(channel: ChannelState): void {
|
|
if (channel.stagedFutureJobId != null) {
|
|
channel.jobIdToDifficulty.set(channel.stagedFutureJobId, channel.sessionDifficulty);
|
|
}
|
|
}
|
|
|
|
private cleanupRetiredJobs(channel: ChannelState): void {
|
|
const cutoff = Date.now() - this.jobRetentionMs;
|
|
const jobs = channel.channelType === 'extended'
|
|
? channel.extendedJobs
|
|
: channel.standardJobs;
|
|
let latestActiveJobId: number = null;
|
|
let latestActiveCreation = Number.NEGATIVE_INFINITY;
|
|
for (const [jobId, job] of jobs.entries()) {
|
|
if (
|
|
job.retiredAt == null
|
|
&& job.headerContext != null
|
|
&& job.creation >= latestActiveCreation
|
|
) {
|
|
latestActiveJobId = jobId;
|
|
latestActiveCreation = job.creation;
|
|
}
|
|
}
|
|
const protectedJobIds = new Set<number>();
|
|
if (channel.stagedFutureJobId != null) {
|
|
protectedJobIds.add(channel.stagedFutureJobId);
|
|
}
|
|
if (latestActiveJobId != null) {
|
|
protectedJobIds.add(latestActiveJobId);
|
|
}
|
|
const activePrevHashJob = channel.activePrevHashJobId == null
|
|
? null
|
|
: jobs.get(channel.activePrevHashJobId);
|
|
if (activePrevHashJob?.retiredAt == null) {
|
|
protectedJobIds.add(channel.activePrevHashJobId);
|
|
}
|
|
|
|
for (const [jobId, job] of channel.standardJobs.entries()) {
|
|
const expiredRetiredJob = job.retiredAt != null && job.retiredAt <= cutoff;
|
|
const expiredSameTipJob = job.retiredAt == null
|
|
&& job.headerContext != null
|
|
&& job.creation <= cutoff
|
|
&& !protectedJobIds.has(jobId);
|
|
if (expiredRetiredJob || expiredSameTipJob) {
|
|
this.deleteChannelJob(channel, jobId);
|
|
}
|
|
}
|
|
for (const [jobId, job] of channel.extendedJobs.entries()) {
|
|
const expiredRetiredJob = job.retiredAt != null && job.retiredAt <= cutoff;
|
|
const expiredSameTipJob = job.retiredAt == null
|
|
&& job.headerContext != null
|
|
&& job.creation <= cutoff
|
|
&& !protectedJobIds.has(jobId);
|
|
if (expiredRetiredJob || expiredSameTipJob) {
|
|
this.deleteChannelJob(channel, jobId);
|
|
}
|
|
}
|
|
|
|
const retainedJobCount = channel.standardJobs.size + channel.extendedJobs.size;
|
|
if (retainedJobCount > this.maxRetainedJobsPerChannel) {
|
|
throw new Error(
|
|
`SV2 channel ${channel.channelId} retained ${retainedJobCount} jobs `
|
|
+ `(limit ${this.maxRetainedJobsPerChannel})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private deleteChannelJob(channel: ChannelState, jobId: number): void {
|
|
channel.standardJobs.delete(jobId);
|
|
channel.extendedJobs.delete(jobId);
|
|
channel.jobIdToDifficulty.delete(jobId);
|
|
channel.jobIdToMerkleRoot.delete(jobId);
|
|
}
|
|
|
|
private buildHeader(
|
|
headerContext: Sv2HeaderContext,
|
|
merkleRoot: Buffer,
|
|
version: number,
|
|
timestamp: number,
|
|
nonce: number,
|
|
): Buffer {
|
|
const header = Buffer.alloc(80);
|
|
header.writeUInt32LE(version >>> 0, 0);
|
|
headerContext.prevHash.copy(header, 4);
|
|
merkleRoot.copy(header, 36);
|
|
header.writeUInt32LE(timestamp >>> 0, 68);
|
|
header.writeUInt32LE(headerContext.nBits >>> 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 sendShareSuccess(
|
|
channelId: number,
|
|
sequenceNumber: number,
|
|
creditedDifficulty: number,
|
|
): Promise<void> {
|
|
await this.sendFrame(
|
|
Sv2MsgType.SUBMIT_SHARES_SUCCESS,
|
|
serializeSubmitSharesSuccess({
|
|
channelId,
|
|
lastSequenceNumber: sequenceNumber,
|
|
newSubmitsAcceptedCount: 1,
|
|
newSharesSum: BigInt(Math.round(creditedDifficulty)),
|
|
}),
|
|
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.destroyed || this.socket.destroyed || this.socket.writableEnded) {
|
|
return;
|
|
}
|
|
|
|
const socketBufferedBytes = Number.isFinite(this.socket.writableLength)
|
|
? this.socket.writableLength
|
|
: 0;
|
|
const bufferedBytes = Math.max(socketBufferedBytes, this.pendingSocketWriteBytes);
|
|
if (bufferedBytes + data.length > this.maxSocketBufferBytes) {
|
|
const error = new Error(
|
|
`SV2 socket buffer would reach ${bufferedBytes + data.length} bytes `
|
|
+ `(limit ${this.maxSocketBufferBytes})`,
|
|
);
|
|
console.warn(`[SV2 ${this.sessionId}] ${error.message}; closing slow client`);
|
|
this.closeSocket();
|
|
throw error;
|
|
}
|
|
|
|
this.pendingSocketWriteBytes += data.length;
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
let completed = false;
|
|
const finish = (error?: Error): void => {
|
|
if (completed) {
|
|
return;
|
|
}
|
|
completed = true;
|
|
clearTimeout(timeout);
|
|
if (error != null) {
|
|
reject(error);
|
|
} else {
|
|
resolve();
|
|
}
|
|
};
|
|
const timeout = setTimeout(() => {
|
|
const error = new Error(
|
|
`SV2 socket write callback exceeded ${this.socketWriteTimeoutMs}ms`,
|
|
);
|
|
console.warn(`[SV2 ${this.sessionId}] ${error.message}; closing slow client`);
|
|
this.closeSocket();
|
|
finish(error);
|
|
}, this.socketWriteTimeoutMs);
|
|
timeout.unref?.();
|
|
|
|
try {
|
|
this.socket.write(data, error => finish(error ?? undefined));
|
|
} catch (error) {
|
|
finish(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
});
|
|
} finally {
|
|
this.pendingSocketWriteBytes = Math.max(0, this.pendingSocketWriteBytes - data.length);
|
|
}
|
|
}
|
|
|
|
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(),
|
|
payoutMode: this.payoutMode,
|
|
bestDifficulty: 0,
|
|
});
|
|
})();
|
|
}
|
|
|
|
await this.creatingEntity;
|
|
}
|
|
|
|
private async persistClientHashRate(now: Date): Promise<void> {
|
|
if (this.clientEntity?.id == null) {
|
|
return;
|
|
}
|
|
|
|
const hashRate = Number(this.statistics?.hashRate ?? 0);
|
|
if (!Number.isFinite(hashRate) || hashRate <= 0) {
|
|
return;
|
|
}
|
|
|
|
const intervalMs = this.getHashRatePersistIntervalMs();
|
|
const nowMs = now.getTime();
|
|
if (this.lastHashRatePersistedAt > 0 && nowMs - this.lastHashRatePersistedAt < intervalMs) {
|
|
return;
|
|
}
|
|
|
|
this.lastHashRatePersistedAt = nowMs;
|
|
try {
|
|
await this.clientService.updateHashRate(this.clientEntity.id, hashRate, now);
|
|
} catch (error) {
|
|
console.error(`Failed to persist SV2 client hashrate: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
private getHashRatePersistIntervalMs(): number {
|
|
const configured = Number(this.configService.get<string>('CLIENT_HASHRATE_PERSIST_INTERVAL_MS'));
|
|
if (Number.isFinite(configured) && configured >= 0) {
|
|
return configured;
|
|
}
|
|
|
|
return DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS;
|
|
}
|
|
|
|
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')
|
|
?? '',
|
|
);
|
|
const difficulty = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_START_DIFFICULTY;
|
|
return this.clampDifficulty(difficulty);
|
|
}
|
|
|
|
private getMinimumDifficulty(): number {
|
|
return this.getConfiguredMinimumDifficulty() ?? DEFAULT_MIN_DIFFICULTY;
|
|
}
|
|
|
|
private getConfiguredMinimumDifficulty(): number | null {
|
|
const configured = parseFloat(
|
|
this.configService.get<string>('STRATUM_MIN_DIFFICULTY')
|
|
?? process.env.STRATUM_MIN_DIFFICULTY
|
|
?? '',
|
|
);
|
|
return Number.isFinite(configured) && configured > 0 ? configured : null;
|
|
}
|
|
|
|
private clampDifficulty(difficulty: number | null): number | null {
|
|
if (difficulty == null || !Number.isFinite(difficulty)) {
|
|
return null;
|
|
}
|
|
const configuredMinimum = this.getConfiguredMinimumDifficulty();
|
|
return configuredMinimum == null ? difficulty : Math.max(difficulty, configuredMinimum);
|
|
}
|
|
|
|
private disableApplicationIdleTimeout(): void {
|
|
if (typeof this.socket.setTimeout === 'function') {
|
|
this.socket.setTimeout(0);
|
|
}
|
|
}
|
|
|
|
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 getOptionalPositiveIntegerConfig(key: string): number | undefined {
|
|
const configured = Number(this.configService.get<string>(key));
|
|
return Number.isSafeInteger(configured) && configured > 0 ? configured : undefined;
|
|
}
|
|
|
|
private getPositiveIntegerConfig(key: string, fallback: number): number {
|
|
return this.getOptionalPositiveIntegerConfig(key) ?? fallback;
|
|
}
|
|
|
|
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] | null {
|
|
if (this.payoutMode === 'pplns') {
|
|
return jobTemplate.blockData.payoutOutputs?.length > 0
|
|
? jobTemplate.blockData.payoutOutputs
|
|
: null;
|
|
}
|
|
|
|
return [{ address: fallbackAddress, percent: 100 }];
|
|
}
|
|
|
|
private getCreditedDifficulty(
|
|
jobDifficulty: number,
|
|
submissionDifficulty: number,
|
|
meetsJobTarget: boolean,
|
|
): number {
|
|
if (meetsJobTarget || !Number.isFinite(submissionDifficulty) || submissionDifficulty <= 0) {
|
|
return jobDifficulty;
|
|
}
|
|
|
|
return Math.min(jobDifficulty, submissionDifficulty);
|
|
}
|
|
|
|
private validateCoinbasePayoutOutputs(
|
|
coinbaseTxBytes: Buffer,
|
|
jobTemplate: IJobTemplate,
|
|
payoutInformation: AddressObject[],
|
|
): { valid: boolean; errorCode?: string } {
|
|
let transaction: bitcoinjs.Transaction;
|
|
try {
|
|
transaction = bitcoinjs.Transaction.fromBuffer(coinbaseTxBytes);
|
|
} catch {
|
|
return { valid: false, errorCode: 'invalid-coinbase-transaction' };
|
|
}
|
|
|
|
const expectedOutputs = this.buildExpectedPayoutOutputs(payoutInformation, jobTemplate.blockData.coinbasevalue);
|
|
const submittedOutputs = transaction.outs
|
|
.filter(output => !this.isWitnessCommitmentOutput(output.script))
|
|
.filter(output => BigInt(output.value) !== 0n)
|
|
.map(output => ({
|
|
value: BigInt(output.value),
|
|
scriptPubKey: Buffer.from(output.script),
|
|
}));
|
|
|
|
if (submittedOutputs.length !== expectedOutputs.length) {
|
|
return { valid: false, errorCode: 'invalid-coinbase-payout-outputs' };
|
|
}
|
|
for (let i = 0; i < expectedOutputs.length; i++) {
|
|
const expected = expectedOutputs[i];
|
|
const submitted = submittedOutputs[i];
|
|
if (submitted.value !== expected.value || !submitted.scriptPubKey.equals(expected.scriptPubKey)) {
|
|
return { valid: false, errorCode: 'invalid-coinbase-payout-outputs' };
|
|
}
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
private buildExpectedPayoutOutputs(
|
|
payoutInformation: AddressObject[],
|
|
coinbaseValue: number,
|
|
): { value: bigint; scriptPubKey: Buffer }[] {
|
|
let rewardBalance = BigInt(Math.max(0, Math.floor(coinbaseValue)));
|
|
const outputs = payoutInformation.map(recipientAddress => {
|
|
const value = recipientAddress.amountSats == null
|
|
? BigInt(Math.floor(((recipientAddress.percent ?? 0) / 100) * coinbaseValue))
|
|
: BigInt(recipientAddress.amountSats);
|
|
rewardBalance -= value;
|
|
return {
|
|
value,
|
|
scriptPubKey: bitcoinjs.address.toOutputScript(recipientAddress.address, this.network),
|
|
};
|
|
});
|
|
if (outputs.length > 0 && rewardBalance !== 0n) {
|
|
outputs[0] = {
|
|
...outputs[0],
|
|
value: outputs[0].value + rewardBalance,
|
|
};
|
|
}
|
|
return outputs;
|
|
}
|
|
|
|
private isWitnessCommitmentOutput(script: Buffer): boolean {
|
|
return script.length === 38
|
|
&& script[0] === bitcoinjs.opcodes.OP_RETURN
|
|
&& script[1] === 0x24
|
|
&& script.subarray(2, 6).equals(Buffer.from('aa21a9ed', 'hex'));
|
|
}
|
|
|
|
private isSuccessfulBlockSubmission(result?: string | null): boolean {
|
|
return result == null || result === 'SUCCESS!';
|
|
}
|
|
|
|
private getNetwork(): bitcoinjs.networks.Network {
|
|
const networkConfig = this.getNetworkName();
|
|
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 getNetworkName(): BitcoinNetworkName {
|
|
const networkConfig = this.configService.get<string>('NETWORK');
|
|
if (networkConfig === 'mainnet' || networkConfig === 'testnet' || networkConfig === 'regtest') {
|
|
return networkConfig;
|
|
}
|
|
throw new Error('Invalid network configuration');
|
|
}
|
|
|
|
private logProtocolError(error: Error): void {
|
|
if (!this.isNoisyAuthFailure(error)) {
|
|
console.error(`[SV2 ${this.sessionId}] ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
if (process.env.SV2_AUTH_FAILURE_LOG_ENABLED?.toLowerCase() !== 'true') {
|
|
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();
|
|
}
|
|
}
|