mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
Add SV2 DATUM payout hardening
This commit is contained in:
@@ -0,0 +1,722 @@
|
||||
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,
|
||||
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 { StratumV1JobsService } from './stratum-v1-jobs.service';
|
||||
import { DatumKeyPair } from '../models/datum/datum-crypto';
|
||||
import { TemplateProviderService } from './template-provider.service';
|
||||
|
||||
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,
|
||||
) {}
|
||||
|
||||
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 of ports) {
|
||||
await this.startServer(port);
|
||||
}
|
||||
}
|
||||
|
||||
private async startServer(port: number): Promise<void> {
|
||||
const server = new Server(socket => {
|
||||
void this.handleSocket(socket);
|
||||
});
|
||||
server.on('error', error => {
|
||||
console.error(`DATUM server error on port ${port}: ${error.message}`);
|
||||
});
|
||||
server.listen(port, () => {
|
||||
console.log(`DATUM server is listening on port ${port}`);
|
||||
});
|
||||
this.servers.push(server);
|
||||
}
|
||||
|
||||
private async handleSocket(socket: Socket): 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),
|
||||
session,
|
||||
clientEntity: null,
|
||||
address: null,
|
||||
workerName: 'default',
|
||||
userAgent: 'datum/unknown',
|
||||
pingTimer: null,
|
||||
datumJobs: new Map(),
|
||||
};
|
||||
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.getDatumShareDifficulty()));
|
||||
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 payoutAddress = this.getDatumPoolPayoutAddress();
|
||||
if (!payoutAddress) {
|
||||
throw new Error('DATUM_POOL_PAYOUT_ADDRESS or DEV_FEE_ADDRESS must be set before DATUM coinbaser fetches can be served');
|
||||
}
|
||||
|
||||
const scriptPubKey = bitcoinjs.address.toOutputScript(payoutAddress, this.getNetwork());
|
||||
const response = serializeDatumCoinbaserFetchResponse(fetch.rewardValue, [{
|
||||
value: fetch.rewardValue,
|
||||
scriptPubKey,
|
||||
}]);
|
||||
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 shareDifficulty = this.getDatumShareDifficulty();
|
||||
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: null,
|
||||
});
|
||||
}
|
||||
await this.shareAccountingService.recordAcceptedShare({
|
||||
protocol: 'datum',
|
||||
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.redisMessagingService?.setClientPresence({
|
||||
clientId: state.clientEntity.id,
|
||||
address,
|
||||
clientName: workerName,
|
||||
sessionId: state.sessionId,
|
||||
userAgent: state.userAgent,
|
||||
startTime: new Date(state.clientEntity.startTime).toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
hashRate: 0,
|
||||
bestDifficulty: validation.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;
|
||||
}
|
||||
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 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(),
|
||||
bestDifficulty: 0,
|
||||
});
|
||||
}
|
||||
|
||||
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 getDatumShareDifficulty(): number {
|
||||
const configured = parseFloat(this.configService.get<string>('DATUM_SHARE_DIFFICULTY') ?? '');
|
||||
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_DATUM_SHARE_DIFFICULTY;
|
||||
}
|
||||
|
||||
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(): number[] {
|
||||
const configured = this.configService.get<string>('DATUM_PORTS');
|
||||
if (!configured?.trim()) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(configured
|
||||
.split(',')
|
||||
.map(port => parseInt(port.trim(), 10))
|
||||
.filter(port => Number.isInteger(port) && port > 0 && port <= 65535)));
|
||||
}
|
||||
|
||||
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;
|
||||
session: DatumCryptoSession;
|
||||
clientEntity: ClientEntity | null;
|
||||
address: string | null;
|
||||
workerName: string;
|
||||
userAgent: string;
|
||||
pingTimer: NodeJS.Timeout | null;
|
||||
datumJobs: Map<number, DatumJobCache>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function uint32le(value: number): Buffer {
|
||||
const result = Buffer.alloc(4);
|
||||
result.writeUInt32LE(value >>> 0, 0);
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user