Files
public-pool/src/services/datum.service.ts
T

987 lines
40 KiB
TypeScript

import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getAddressInfo } from 'bitcoin-address-validation';
import * as bitcoinjs from 'bitcoinjs-lib';
import { firstValueFrom } from 'rxjs';
import { Server, Socket } from 'net';
import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientEntity } from '../ORM/client/client.entity';
import { ClientService } from '../ORM/client/client.service';
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
import {
DATUM_INITIAL_HEADER_KEY,
DatumFrameReader,
DatumMiningCommand,
DatumProtocolCommand,
DatumRejectReason,
DatumShareResponseStatus,
DatumPowSubmit,
DatumJobValidationStatus,
DatumJobValidationResponse,
DatumPayoutOutput,
deserializeDatumCoinbaserFetch,
deserializeDatumJobValidationResponse,
deserializeDatumMiningCommand,
deserializeDatumPowSubmit,
encodeDatumFrame,
serializeDatumJobValidationFullTransactionBlobRequest,
serializeDatumJobValidationShortTxIdsRequest,
serializeDatumCoinbaserFetchResponse,
serializeDatumShareResponse,
} from '../models/datum/datum-codec';
import { DatumCryptoSession } from '../models/datum/datum-crypto';
import { BitcoinRpcService } from './bitcoin-rpc.service';
import { CustomWorkService } from './custom-work.service';
import { RedisMessagingService } from './redis-messaging.service';
import { IJobTemplate, StratumV1JobsService } from './stratum-v1-jobs.service';
import { DatumKeyPair } from '../models/datum/datum-crypto';
import { TemplateProviderService } from './template-provider.service';
import { StratumV1ClientStatistics } from '../models/StratumV1ClientStatistics';
import type { AddressObject } from '../models/MiningJob';
import { PayoutSnapshotService } from '../ORM/payout-snapshot/payout-snapshot.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
const DEFAULT_DATUM_SHARE_DIFFICULTY = 1;
const DEFAULT_DATUM_PING_INTERVAL_MS = 30_000;
@Injectable()
export class DatumService implements OnModuleInit {
private readonly servers: Server[] = [];
private identityKeys: DatumKeyPair | null = null;
constructor(
private readonly configService: ConfigService,
private readonly jobsService: StratumV1JobsService,
private readonly bitcoinRpcService: BitcoinRpcService,
private readonly clientService: ClientService,
private readonly blocksService: BlocksService,
private readonly shareAccountingService: ShareAccountingService,
private readonly customWorkService: CustomWorkService,
private readonly templateProvider: TemplateProviderService,
private readonly redisMessagingService?: RedisMessagingService,
private readonly payoutSnapshotService?: PayoutSnapshotService,
) {}
public async onModuleInit(): Promise<void> {
if (process.env.API_ONLY === 'true' || process.env.MASTER === 'true') {
return;
}
const ports = this.getPorts();
if (ports.length === 0) {
return;
}
this.identityKeys = this.getIdentityKeys();
console.log(`DATUM server public key: ${Buffer.concat([this.identityKeys.edPublicKey, this.identityKeys.xPublicKey]).toString('hex')}`);
for (const { port, payoutMode } of ports) {
await this.startServer(port, payoutMode);
}
}
private async startServer(port: number, payoutMode: PayoutMode): Promise<void> {
const server = new Server(socket => {
void this.handleSocket(socket, payoutMode);
});
server.on('error', error => {
console.error(`DATUM server error on port ${port}: ${error.message}`);
});
server.listen(port, () => {
console.log(`DATUM ${payoutMode} server is listening on port ${port}`);
});
this.servers.push(server);
}
private async handleSocket(socket: Socket, payoutMode: PayoutMode): Promise<void> {
socket.setKeepAlive(true, 60_000);
socket.setNoDelay(true);
const session = await DatumCryptoSession.create(this.identityKeys ?? undefined);
const reader = new DatumFrameReader(DATUM_INITIAL_HEADER_KEY);
const state: DatumClientState = {
sessionId: Math.random().toString(16).slice(2, 10),
payoutMode,
session,
clientEntity: null,
address: null,
workerName: 'default',
userAgent: 'datum/unknown',
pingTimer: null,
datumJobs: new Map(),
coinbaserPayoutContexts: new Map(),
nextCoinbaserId: 1,
statistics: new StratumV1ClientStatistics(this.getConfiguredDatumShareDifficulty()),
};
console.log(`[DATUM ${state.sessionId}] connection accepted from ${socket.remoteAddress}:${socket.remotePort}`);
const close = () => {
void this.destroyClient(state);
if (!socket.destroyed) {
socket.destroy();
}
};
socket.on('data', data => {
void (async () => {
try {
reader.setHeaderXorKey(session.currentReceiveHeaderKey);
const frames = reader.feed(data);
for (const frame of frames) {
let payload = frame.payload;
if (frame.header.isEncryptedChannel) {
payload = session.decryptChannelPayload(payload);
}
await this.handleFrame(socket, state, frame.header.protoCmd, payload);
reader.setHeaderXorKey(session.currentReceiveHeaderKey);
}
} catch (error) {
console.error(`[DATUM ${state.sessionId}] ${error.message}`);
close();
}
})();
});
socket.on('error', close);
socket.on('close', () => void this.destroyClient(state));
}
private async handleFrame(socket: Socket, state: DatumClientState, protoCmd: number, payload: Buffer): Promise<void> {
if (protoCmd === DatumProtocolCommand.HANDSHAKE_INIT) {
const hello = state.session.openHandshake(payload);
state.userAgent = hello.userAgent || 'datum/unknown';
console.log(`[DATUM ${state.sessionId}] handshake init from ${state.userAgent}`);
await this.writeRaw(socket, state.session.buildHandshakeResponse(hello, 'public-pool DATUM'));
console.log(`[DATUM ${state.sessionId}] handshake response sent`);
await this.sendDatumClientConfigure(socket, state);
console.log(`[DATUM ${state.sessionId}] client configure sent`);
this.startDatumPing(socket, state);
return;
}
if (protoCmd !== DatumProtocolCommand.MINING) {
return;
}
const mining = deserializeDatumMiningCommand(payload);
switch (mining.command) {
case DatumMiningCommand.FETCH_COINBASER:
await this.handleCoinbaserFetch(socket, state, mining.body);
break;
case DatumMiningCommand.SUBMIT_POW:
await this.handlePowSubmit(socket, state, mining.body);
break;
case DatumMiningCommand.JOB_VALIDATION:
await this.handleJobValidationResponse(socket, state, mining.body);
break;
case DatumMiningCommand.TEMPLATE_REFRESH:
break;
default:
console.warn(`[DATUM ${state.sessionId}] Ignoring unsupported mining command 0x${mining.command.toString(16)}`);
break;
}
}
private async sendDatumClientConfigure(socket: Socket, state: DatumClientState): Promise<void> {
const payoutAddress = this.getDatumPoolPayoutAddress();
if (!payoutAddress) {
throw new Error('DATUM_POOL_PAYOUT_ADDRESS or DEV_FEE_ADDRESS must be set before DATUM client configuration can be sent');
}
const payoutScript = bitcoinjs.address.toOutputScript(payoutAddress, this.getNetwork());
if (payoutScript.length > 255) {
throw new Error(`DATUM payout script is too long: ${payoutScript.length}`);
}
const coinbaseTag = Buffer.from('public-pool', 'utf8');
if (coinbaseTag.length > 255) {
throw new Error(`DATUM coinbase tag is too long: ${coinbaseTag.length}`);
}
const minDifficulty = Math.max(1, Math.floor(this.getConfiguredDatumShareDifficulty()));
const vardiffMin = 1n << BigInt(Math.ceil(Math.log2(minDifficulty)));
const vardiffMinBuffer = Buffer.alloc(8);
vardiffMinBuffer.writeBigUInt64LE(vardiffMin, 0);
const payload = Buffer.concat([
Buffer.from([DatumMiningCommand.CLIENT_CONFIGURE]),
Buffer.from([1, payoutScript.length]),
payoutScript,
uint32le(0x50554250),
Buffer.from([coinbaseTag.length]),
coinbaseTag,
vardiffMinBuffer,
Buffer.from([0, 0xfe]),
]);
await this.writeRaw(socket, state.session.encryptChannelFrame(DatumProtocolCommand.MINING, payload, true));
}
private async handleCoinbaserFetch(socket: Socket, state: DatumClientState, payload: Buffer): Promise<void> {
const fetch = deserializeDatumCoinbaserFetch(payload);
const latestTemplate = await firstValueFrom(this.jobsService.newMiningJob$);
const payoutOutputs = this.getDatumPayoutOutputs(latestTemplate, Number(fetch.rewardValue), state.payoutMode);
if (payoutOutputs.length === 0) {
throw new Error('DATUM_POOL_PAYOUT_ADDRESS or DEV_FEE_ADDRESS must be set before DATUM coinbaser fetches can be served');
}
const coinbaserId = this.nextDatumCoinbaserId(state);
state.coinbaserPayoutContexts.set(coinbaserId, {
payoutOutputs,
payoutSnapshotId: latestTemplate.blockData.payoutSnapshotId ?? null,
blockHeight: latestTemplate.blockData.height,
payoutMode: state.payoutMode,
});
if (state.coinbaserPayoutContexts.size > 512) {
const oldestCoinbaserId = state.coinbaserPayoutContexts.keys().next().value;
if (oldestCoinbaserId != null) {
state.coinbaserPayoutContexts.delete(oldestCoinbaserId);
}
}
const response = serializeDatumCoinbaserFetchResponse(fetch.rewardValue, payoutOutputs, coinbaserId);
await this.writeRaw(socket, state.session.encryptChannelFrame(DatumProtocolCommand.MINING, response));
}
private async handlePowSubmit(socket: Socket, state: DatumClientState, payload: Buffer): Promise<void> {
const pow = deserializeDatumPowSubmit(payload);
const { address, workerName } = this.parseUserIdentity(pow.username);
if (!this.isValidAddress(address)) {
await this.sendShareResponse(socket, state, DatumShareResponseStatus.REJECTED, DatumRejectReason.BAD_USERNAME, pow.nonce, pow.targetByte, pow.jobId);
return;
}
state.address = address;
state.workerName = workerName;
await this.ensureClientEntity(state);
const datumJob = this.updateDatumJobCache(state, pow);
await this.maybeRequestDatumJobValidation(socket, state, pow.jobId, datumJob);
const coinbase = this.getDatumCoinbase(datumJob, pow);
if (coinbase == null || datumJob.prevBlockHash == null || datumJob.nBits == null || datumJob.merkleBranches == null) {
await this.sendShareResponse(socket, state, DatumShareResponseStatus.REJECTED, DatumRejectReason.COINBASE_MISSING, pow.nonce, pow.targetByte, pow.jobId);
return;
}
if (datumJob.targetByteIndex == null) {
await this.sendShareResponse(socket, state, DatumShareResponseStatus.REJECTED, DatumRejectReason.BAD_TARGET, pow.nonce, pow.targetByte, pow.jobId);
return;
}
const latestTemplate = await firstValueFrom(this.jobsService.newMiningJob$);
const templateValidation = this.templateProvider.validateDatumTemplateFastPath({
prevBlockHash: datumJob.prevBlockHash,
nBits: datumJob.nBits,
height: datumJob.height,
version: pow.version,
coinbaseValue: datumJob.coinbaseValue,
totalWeight: datumJob.totalWeight,
totalSize: datumJob.totalSize,
totalSigops: datumJob.totalSigops,
merkleBranches: datumJob.merkleBranches,
});
if (!templateValidation.valid) {
await this.sendShareResponse(
socket,
state,
DatumShareResponseStatus.REJECTED,
this.mapDatumTemplateRejectReason(templateValidation.errorCode),
pow.nonce,
pow.targetByte,
pow.jobId,
);
return;
}
if (datumJob.validationState === 'failed') {
await this.sendShareResponse(socket, state, DatumShareResponseStatus.REJECTED, DatumRejectReason.OTHER, pow.nonce, pow.targetByte, pow.jobId);
return;
}
const payoutValidation = this.validateDatumCoinbasePayouts(coinbase, pow, latestTemplate, datumJob.coinbaseValue, datumJob.expectedPayoutOutputs, state.payoutMode);
if (!payoutValidation.valid) {
this.logDatumCoinbaseMismatchOnce(state, payoutValidation);
await this.sendShareResponse(socket, state, DatumShareResponseStatus.REJECTED, DatumRejectReason.BAD_COINBASER_ID, pow.nonce, pow.targetByte, pow.jobId);
return;
}
const shareDifficulty = this.getDatumSubmittedShareDifficulty(pow);
const nBits = datumJob.nBits.readUInt32LE(0);
const validation = this.customWorkService.validateShare({
coinbasePrefix: coinbase.coinb1,
coinbaseSuffix: coinbase.coinb2,
coinbaseTargetByteIndex: datumJob.targetByteIndex,
coinbaseTargetByte: pow.targetByte,
merklePath: datumJob.merkleBranches,
extranoncePrefix: Buffer.alloc(0),
extranonce: pow.extranonce,
prevHash: datumJob.prevBlockHash,
nBits,
version: pow.version,
ntime: pow.ntime,
nonce: pow.nonce,
shareDifficulty,
networkDifficulty: latestTemplate.blockData.networkDifficulty,
});
if (!validation.accepted) {
await this.sendShareResponse(socket, state, DatumShareResponseStatus.REJECTED, DatumRejectReason.HIGH_HASH, pow.nonce, pow.targetByte, pow.jobId);
return;
}
const isBlockCandidate = validation.isBlockCandidate || pow.isBlock;
const blockSubmissionResult = validation.isBlockCandidate ? 'datum-gateway-submit-expected' : null;
await this.sendShareResponse(socket, state, DatumShareResponseStatus.ACCEPTED, 0, pow.nonce, pow.targetByte, pow.jobId);
if (isBlockCandidate) {
await this.blocksService.save({
height: datumJob.height ?? latestTemplate.blockData.height,
minerAddress: address,
worker: workerName,
sessionId: state.sessionId,
blockData: validation.header.toString('hex'),
blockSubmissionResult,
payoutSnapshotId: state.payoutMode === 'pplns'
? datumJob.payoutSnapshotId ?? null
: null,
payoutMode: state.payoutMode,
});
if (state.payoutMode === 'pplns') {
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: datumJob.payoutSnapshotId,
blockHeight: datumJob.height ?? latestTemplate.blockData.height,
blockSubmissionResult,
payoutMode: state.payoutMode,
});
}
}
await this.shareAccountingService.recordAcceptedShare({
protocol: 'datum',
payoutMode: state.payoutMode,
workSource: 'miner_template',
workProtocol: 'datum',
address,
clientName: workerName,
sessionId: state.sessionId,
clientId: state.clientEntity.id,
jobId: pow.jobId.toString(16),
jobTemplateId: latestTemplate.blockData.id,
blockHeight: datumJob.height ?? latestTemplate.blockData.height,
creditedDifficulty: shareDifficulty,
submissionDifficulty: validation.submissionDifficulty,
networkDifficulty: latestTemplate.blockData.networkDifficulty,
nonce: pow.nonce,
ntime: pow.ntime,
version: pow.version,
extraNonce2: pow.extranonce.toString('hex'),
isBlockCandidate,
blockSubmissionResult,
});
await this.updateAcceptedSharePresence(state, address, workerName, validation.submissionDifficulty, shareDifficulty);
}
private async updateAcceptedSharePresence(
state: DatumClientState,
address: string,
workerName: string,
submissionDifficulty: number,
creditedDifficulty: number,
): Promise<void> {
if (state.clientEntity == null) {
return;
}
await state.statistics.addShares(state.clientEntity, creditedDifficulty);
state.clientEntity.hashRate = state.statistics.hashRate;
if (submissionDifficulty > Number(state.clientEntity.bestDifficulty ?? 0)) {
await this.clientService.updateBestDifficultyIfHigher(state.clientEntity.id, submissionDifficulty);
state.clientEntity.bestDifficulty = submissionDifficulty;
}
await this.redisMessagingService?.setClientPresence({
clientId: state.clientEntity.id,
address,
clientName: workerName,
sessionId: state.sessionId,
payoutMode: state.payoutMode,
userAgent: state.userAgent,
startTime: new Date(state.clientEntity.startTime).toISOString(),
lastSeen: new Date().toISOString(),
hashRate: state.statistics.hashRate,
bestDifficulty: Math.max(
Number(state.clientEntity.bestDifficulty ?? 0),
submissionDifficulty,
),
});
}
private updateDatumJobCache(state: DatumClientState, pow: DatumPowSubmit): DatumJobCache {
let cache = state.datumJobs.get(pow.jobId);
if (cache == null || pow.prevBlockHash != null) {
cache = {
coinbasePairs: new Map(),
};
state.datumJobs.set(pow.jobId, cache);
}
if (pow.prevBlockHash != null) {
cache.prevBlockHash = pow.prevBlockHash;
}
if (pow.targetByteIndex != null) {
cache.targetByteIndex = pow.targetByteIndex;
}
if (pow.nBits != null) {
cache.nBits = pow.nBits;
}
if (pow.coinbaserId != null) {
cache.coinbaserId = pow.coinbaserId;
const payoutContext = state.coinbaserPayoutContexts.get(pow.coinbaserId);
if (payoutContext != null) {
cache.expectedPayoutOutputs = payoutContext.payoutOutputs;
cache.payoutSnapshotId = payoutContext.payoutSnapshotId;
}
}
if (pow.height != null) {
cache.height = pow.height;
}
if (pow.coinbaseValue != null) {
cache.coinbaseValue = pow.coinbaseValue;
}
if (pow.transactionCount != null) {
cache.transactionCount = pow.transactionCount;
}
if (pow.totalWeight != null) {
cache.totalWeight = pow.totalWeight;
}
if (pow.totalSize != null) {
cache.totalSize = pow.totalSize;
}
if (pow.totalSigops != null) {
cache.totalSigops = pow.totalSigops;
}
if (pow.merkleBranches != null) {
cache.merkleBranches = pow.merkleBranches;
}
for (const [coinbaseId, coinbase] of pow.coinbasePairs) {
cache.coinbasePairs.set(coinbaseId, coinbase);
}
if (pow.subsidyOnlyCoinbase != null) {
cache.subsidyOnlyCoinbase = pow.subsidyOnlyCoinbase;
}
return cache;
}
private async maybeRequestDatumJobValidation(
socket: Socket,
state: DatumClientState,
jobId: number,
cache: DatumJobCache,
): Promise<void> {
if (cache.transactionCount == null || cache.validationState != null) {
return;
}
if (cache.transactionCount === 0) {
cache.validationState = 'validated';
cache.validationTransactions = [];
return;
}
cache.validationState = 'requested-short-txids';
cache.validationRequestedAt = new Date();
await this.writeRaw(
socket,
state.session.encryptChannelFrame(
DatumProtocolCommand.MINING,
serializeDatumJobValidationShortTxIdsRequest(jobId),
),
);
}
private async handleJobValidationResponse(socket: Socket, state: DatumClientState, payload: Buffer): Promise<void> {
const response = deserializeDatumJobValidationResponse(payload);
const cache = state.datumJobs.get(response.jobId);
if (cache == null) {
console.warn(`[DATUM ${state.sessionId}] Ignoring validation response for unknown job ${response.jobId}`);
return;
}
if (response.status !== DatumJobValidationStatus.SUCCESS) {
cache.validationState = 'failed';
cache.validationError = `gateway returned status 0x${response.status.toString(16)}`;
console.warn(`[DATUM ${state.sessionId}] Job ${response.jobId} validation failed: ${cache.validationError}`);
return;
}
if (response.kind === 'short-txids') {
if (cache.transactionCount != null && response.transactionCount !== cache.transactionCount) {
cache.validationState = 'failed';
cache.validationError = `short txid count mismatch: advertised ${cache.transactionCount}, received ${response.transactionCount}`;
console.warn(`[DATUM ${state.sessionId}] Job ${response.jobId} validation failed: ${cache.validationError}`);
return;
}
cache.validationShortTxIds = response.shortTxIds;
cache.validationShortTxCrosscheck = response.crosscheck ?? undefined;
cache.validationState = 'requested-full-transaction-blob';
await this.writeRaw(
socket,
state.session.encryptChannelFrame(
DatumProtocolCommand.MINING,
serializeDatumJobValidationFullTransactionBlobRequest(response.jobId),
),
);
return;
}
if (response.kind === 'full-transaction-blob' || response.kind === 'full-transactions') {
const validationError = this.validateDatumTransactionResponse(cache, response);
if (validationError != null) {
cache.validationState = 'failed';
cache.validationError = validationError;
console.warn(`[DATUM ${state.sessionId}] Job ${response.jobId} transaction validation failed: ${validationError}`);
return;
}
cache.validationState = 'validated';
cache.validationError = undefined;
cache.validationTransactions = response.transactions;
cache.validationCompletedAt = new Date();
}
}
private validateDatumTransactionResponse(
cache: DatumJobCache,
response: DatumJobValidationResponse,
): string | null {
if (response.kind === 'short-txids') {
return null;
}
if (cache.transactionCount != null && response.transactionCount !== cache.transactionCount) {
return `transaction count mismatch: advertised ${cache.transactionCount}, received ${response.transactionCount}`;
}
const validation = this.templateProvider.validateTransactionData({
transactionList: response.transactions,
expectedCount: cache.transactionCount,
maxTotalBytes: cache.totalSize,
expectedCoinbaseMerklePath: cache.merkleBranches,
});
if (!validation.valid) {
return validation.errorCode ?? 'invalid-transaction-data';
}
return null;
}
private getDatumCoinbase(cache: DatumJobCache, pow: DatumPowSubmit): { coinb1: Buffer; coinb2: Buffer } | undefined {
if (pow.subsidyOnly) {
return cache.subsidyOnlyCoinbase ?? cache.coinbasePairs.get(pow.coinbaseId);
}
return cache.coinbasePairs.get(pow.coinbaseId) ?? cache.subsidyOnlyCoinbase;
}
private validateDatumCoinbasePayouts(
coinbase: { coinb1: Buffer; coinb2: Buffer },
pow: Pick<DatumPowSubmit, 'extranonce' | 'targetByteIndex' | 'targetByte'>,
latestTemplate: IJobTemplate,
coinbaseValue?: bigint,
expectedPayoutOutputs?: DatumPayoutOutput[],
payoutMode?: PayoutMode,
): DatumCoinbasePayoutValidation {
const rewardValue = coinbaseValue == null
? latestTemplate.blockData.coinbasevalue
: Number(coinbaseValue);
const expectedOutputs = expectedPayoutOutputs ?? this.getDatumPayoutOutputs(latestTemplate, rewardValue, payoutMode);
if (expectedOutputs.length === 0) {
return { valid: false, expectedOutputs, submittedOutputs: [], error: 'missing-expected-outputs' };
}
let coinbaseTx = Buffer.concat([
coinbase.coinb1,
pow.extranonce,
coinbase.coinb2,
]);
coinbaseTx = Buffer.from(coinbaseTx);
if (pow.targetByteIndex != null) {
if (pow.targetByteIndex < 0 || pow.targetByteIndex >= coinbaseTx.length) {
return { valid: false, expectedOutputs, submittedOutputs: [], error: 'target-byte-out-of-range' };
}
coinbaseTx[pow.targetByteIndex] = pow.targetByte & 0xff;
}
let transaction: bitcoinjs.Transaction;
try {
transaction = bitcoinjs.Transaction.fromBuffer(coinbaseTx);
} catch {
return { valid: false, expectedOutputs, submittedOutputs: [], error: 'invalid-coinbase-transaction' };
}
const submittedOutputs = transaction.outs
.filter(output => !this.isIgnoredCoinbaseMetadataOutput(output))
.map(output => ({
value: BigInt(output.value),
scriptPubKey: Buffer.from(output.script),
}));
if (submittedOutputs.length !== expectedOutputs.length) {
return { valid: false, expectedOutputs, submittedOutputs, error: 'output-count-mismatch' };
}
const valid = expectedOutputs.every((expected, index) => {
const submitted = submittedOutputs[index];
return submitted.value === expected.value
&& submitted.scriptPubKey.equals(expected.scriptPubKey);
});
return {
valid,
expectedOutputs,
submittedOutputs,
error: valid ? undefined : 'output-mismatch',
};
}
private getDatumPayoutOutputs(latestTemplate: { blockData?: { payoutOutputs?: AddressObject[] } }, rewardValue: number, payoutMode: PayoutMode = 'solo'): DatumPayoutOutput[] {
const configuredOutputs = latestTemplate?.blockData?.payoutOutputs;
const payoutAddresses = payoutMode === 'pplns' && configuredOutputs?.length > 0
? configuredOutputs
: this.getDatumFallbackPayoutOutputs(rewardValue);
let rewardBalance = Math.max(0, Math.floor(rewardValue));
const outputs = payoutAddresses.map((recipientAddress, index) => {
const amount = recipientAddress.amountSats == null
? Math.floor(((recipientAddress.percent ?? 0) / 100) * rewardValue)
: recipientAddress.amountSats;
rewardBalance -= amount;
return {
value: BigInt(amount),
scriptPubKey: bitcoinjs.address.toOutputScript(recipientAddress.address, this.getNetwork()),
};
});
if (outputs.length > 0 && rewardBalance !== 0) {
outputs[0] = {
...outputs[0],
value: outputs[0].value + BigInt(rewardBalance),
};
}
return outputs;
}
private nextDatumCoinbaserId(state: DatumClientState): number {
const id = state.nextCoinbaserId;
state.nextCoinbaserId = state.nextCoinbaserId >= 254 ? 1 : state.nextCoinbaserId + 1;
return id;
}
private getDatumFallbackPayoutOutputs(rewardValue: number): AddressObject[] {
const payoutAddress = this.getDatumPoolPayoutAddress();
if (!payoutAddress) {
return [];
}
return [{ address: payoutAddress, amountSats: Math.max(0, Math.floor(rewardValue)) }];
}
private isIgnoredCoinbaseMetadataOutput(output: { value: bigint | number; script: Buffer }): boolean {
if (this.isWitnessCommitmentOutput(output.script)) {
return true;
}
return BigInt(output.value) === 0n
&& output.script.length > 0
&& output.script[0] === bitcoinjs.opcodes.OP_RETURN;
}
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 logDatumCoinbaseMismatchOnce(
state: DatumClientState,
validation: DatumCoinbasePayoutValidation,
): void {
if (state.coinbaseMismatchLogged) {
return;
}
state.coinbaseMismatchLogged = true;
console.warn(`[DATUM ${state.sessionId}] Coinbase payout mismatch (${validation.error}); expected=${this.describeDatumOutputs(validation.expectedOutputs)} submitted=${this.describeDatumOutputs(validation.submittedOutputs)}`);
}
private describeDatumOutputs(outputs: DatumPayoutOutput[]): string {
return outputs
.map(output => {
const address = this.tryOutputAddress(output.scriptPubKey);
return `${output.value.toString()}:${address ?? output.scriptPubKey.toString('hex')}`;
})
.join(',');
}
private tryOutputAddress(scriptPubKey: Buffer): string | null {
try {
return bitcoinjs.address.fromOutputScript(scriptPubKey, this.getNetwork());
} catch {
return null;
}
}
private async sendShareResponse(
socket: Socket,
state: DatumClientState,
status: DatumShareResponseStatus,
reasonCode: number,
nonce: number,
targetByte: number,
jobId: number,
): Promise<void> {
const payload = serializeDatumShareResponse({ status, reasonCode, nonce, targetByte, jobId });
await this.writeRaw(socket, state.session.encryptChannelFrame(DatumProtocolCommand.MINING, payload));
}
private async ensureClientEntity(state: DatumClientState): Promise<void> {
if (state.clientEntity != null) {
return;
}
state.clientEntity = await this.clientService.insert({
sessionId: state.sessionId,
address: state.address,
clientName: state.workerName,
userAgent: state.userAgent,
startTime: new Date(),
payoutMode: state.payoutMode,
bestDifficulty: 0,
});
await this.updateClientPresence(state, new Date());
}
private async updateClientPresence(state: DatumClientState, lastSeen: Date): Promise<void> {
if (state.clientEntity == null) {
return;
}
try {
await this.redisMessagingService?.setClientPresence({
clientId: state.clientEntity.id,
address: state.clientEntity.address,
clientName: state.clientEntity.clientName,
sessionId: state.clientEntity.sessionId,
payoutMode: state.payoutMode,
userAgent: state.clientEntity.userAgent,
startTime: new Date(state.clientEntity.startTime).toISOString(),
lastSeen: lastSeen.toISOString(),
hashRate: state.statistics.hashRate,
bestDifficulty: Number(state.clientEntity.bestDifficulty ?? 0),
});
} catch (error) {
console.error(`Failed to update DATUM client presence: ${error.message}`);
}
}
private async destroyClient(state: DatumClientState): Promise<void> {
this.stopDatumPing(state);
if (state.clientEntity?.id == null) {
return;
}
await this.redisMessagingService?.removeClientPresence(state.clientEntity.id, state.clientEntity.address);
await this.clientService.delete(state.clientEntity.id);
state.clientEntity = null;
}
private startDatumPing(socket: Socket, state: DatumClientState): void {
this.stopDatumPing(state);
state.pingTimer = setInterval(() => {
if (socket.destroyed || socket.writableEnded) {
this.stopDatumPing(state);
return;
}
void this.writeRaw(
socket,
state.session.encryptChannelFrame(DatumProtocolCommand.PING, Buffer.alloc(0)),
).catch(error => {
console.error(`[DATUM ${state.sessionId}] ping failed: ${error.message}`);
if (!socket.destroyed) {
socket.destroy();
}
});
}, this.getDatumPingIntervalMs());
state.pingTimer.unref?.();
}
private stopDatumPing(state: DatumClientState): void {
if (state.pingTimer != null) {
clearInterval(state.pingTimer);
state.pingTimer = null;
}
}
private async writeRaw(socket: Socket, data: Buffer): Promise<void> {
if (socket.destroyed || socket.writableEnded) {
return;
}
await new Promise<void>((resolve, reject) => {
socket.write(data, error => error ? reject(error) : resolve());
});
}
private parseUserIdentity(userIdentity: string): { address: string; workerName: string } {
const parts = userIdentity.split('.');
const address = parts[0] ?? '';
return {
address: this.normalizeAddress(address),
workerName: parts.length > 1 ? parts.slice(1).join('.') : 'default',
};
}
private normalizeAddress(address: string): string {
if (/^(bc1|tb1|bcrt1)/i.test(address)) {
return address.toLowerCase();
}
return address;
}
private isValidAddress(address: string): boolean {
try {
getAddressInfo(address);
return true;
} catch {
return false;
}
}
private getDatumPoolPayoutAddress(): string {
return this.configService.get<string>('DATUM_POOL_PAYOUT_ADDRESS')
|| this.configService.get<string>('PAYOUT_FEE_ADDRESS')
|| this.configService.get<string>('DEV_FEE_ADDRESS')
|| '';
}
private getConfiguredDatumShareDifficulty(): number {
const configured = parseFloat(this.configService.get<string>('DATUM_SHARE_DIFFICULTY') ?? '');
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_DATUM_SHARE_DIFFICULTY;
}
private getDatumSubmittedShareDifficulty(pow: Pick<DatumPowSubmit, 'targetByte'>): number {
if (!Number.isInteger(pow.targetByte) || pow.targetByte < 0 || pow.targetByte > 63) {
return this.getConfiguredDatumShareDifficulty();
}
return Math.pow(2, pow.targetByte);
}
private mapDatumTemplateRejectReason(errorCode?: string): DatumRejectReason {
switch (errorCode) {
case 'prevhash-mismatch':
case 'height-mismatch':
return DatumRejectReason.STALE_BLOCK;
case 'nbits-mismatch':
case 'weight-limit-exceeded':
case 'size-limit-exceeded':
case 'sigop-limit-exceeded':
case 'coinbase-value-too-high':
return DatumRejectReason.OTHER;
case 'version-mismatch':
return DatumRejectReason.BAD_VERSION;
case 'bad-merkle-branch':
return DatumRejectReason.BAD_MERKLE_COUNT;
default:
return DatumRejectReason.OTHER;
}
}
private getDatumPingIntervalMs(): number {
const configured = parseInt(this.configService.get<string>('DATUM_PING_INTERVAL_MS') ?? '', 10);
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_DATUM_PING_INTERVAL_MS;
}
private getIdentityKeys(): DatumKeyPair {
const seedHex = this.configService.get<string>('DATUM_IDENTITY_SEED')?.trim();
if (!seedHex) {
const keys = DatumCryptoSession.generateKeyPair();
console.warn('DATUM_IDENTITY_SEED is not set; generated an ephemeral DATUM server identity key');
return keys;
}
if (!/^[0-9a-fA-F]{64}$/.test(seedHex)) {
throw new Error('DATUM_IDENTITY_SEED must be a 32-byte hex string');
}
return DatumCryptoSession.generateKeyPairFromSeed(Buffer.from(seedHex, 'hex'));
}
private getPorts(): { port: number; payoutMode: PayoutMode }[] {
return parsePayoutModePorts(
this.configService.get<string>('DATUM_PORTS'),
this.configService.get<string>('PPLNS_DATUM_PORTS'),
);
}
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');
}
}
interface DatumClientState {
sessionId: string;
payoutMode: PayoutMode;
session: DatumCryptoSession;
clientEntity: ClientEntity | null;
address: string | null;
workerName: string;
userAgent: string;
pingTimer: NodeJS.Timeout | null;
datumJobs: Map<number, DatumJobCache>;
coinbaserPayoutContexts: Map<number, DatumCoinbaserPayoutContext>;
nextCoinbaserId: number;
statistics: StratumV1ClientStatistics;
coinbaseMismatchLogged?: boolean;
}
interface DatumCoinbaserPayoutContext {
payoutOutputs: DatumPayoutOutput[];
payoutSnapshotId?: string | null;
blockHeight?: number;
payoutMode?: PayoutMode;
}
interface DatumJobCache {
prevBlockHash?: Buffer;
targetByteIndex?: number;
nBits?: Buffer;
coinbaserId?: number;
height?: number;
coinbaseValue?: bigint;
transactionCount?: number;
totalWeight?: number;
totalSize?: number;
totalSigops?: number;
merkleBranches?: Buffer[];
coinbasePairs: Map<number, { coinb1: Buffer; coinb2: Buffer }>;
subsidyOnlyCoinbase?: { coinb1: Buffer; coinb2: Buffer };
validationState?: 'requested-short-txids' | 'requested-full-transaction-blob' | 'validated' | 'failed';
validationRequestedAt?: Date;
validationCompletedAt?: Date;
validationShortTxIds?: Buffer[];
validationShortTxCrosscheck?: Buffer;
validationTransactions?: Buffer[];
validationError?: string;
expectedPayoutOutputs?: DatumPayoutOutput[];
payoutSnapshotId?: string | null;
}
interface DatumCoinbasePayoutValidation {
valid: boolean;
expectedOutputs: DatumPayoutOutput[];
submittedOutputs: DatumPayoutOutput[];
error?: string;
}
function uint32le(value: number): Buffer {
const result = Buffer.alloc(4);
result.writeUInt32LE(value >>> 0, 0);
return result;
}