Files
public-pool/src/models/MiningJob.ts
T
2026-07-13 11:55:26 -04:00

489 lines
18 KiB
TypeScript

import { AddressType, getAddressInfo } from 'bitcoin-address-validation';
import * as bitcoinjs from 'bitcoinjs-lib';
import { IJobTemplate } from '../services/stratum-v1-jobs.service';
import { PayoutMode } from '../types/payout-mode';
import { hash256 } from '../utils/hash.utils';
import { eResponseMethod } from './enums/eResponseMethod';
import { IMiningNotify } from './stratum-messages/IMiningNotify';
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
export interface AddressObject {
address: string;
percent?: number;
amountSats?: number;
}
export interface MiningJobOwnership {
payoutMode: PayoutMode;
payoutIdentity: string;
}
export interface MiningNotifyHeaderFields {
previousBlockHash: string;
version: string;
bits: string;
timestamp: string;
cleanJobs: boolean;
}
interface PreStagedNotifyLayout {
fields: MiningNotifyHeaderFields;
offsets: {
previousBlockHash: number;
version: number;
bits: number;
timestamp: number;
};
}
export class MiningJob {
private static readonly paymentScriptCache = new Map<string, Buffer>();
private static readonly paymentScriptCacheMaxEntries = 250_000;
private static readonly placeholderPrevHash = Buffer.alloc(32, 0);
private coinbaseTransaction: bitcoinjs.Transaction;
private coinbasePart1: string;
private coinbasePart2: string;
private coinbasePart1Buffer: Buffer;
private coinbasePart2Buffer: Buffer;
private merkleBranchBuffers: Buffer[];
private miningNotifyResponse: string;
private miningNotifyResponseBuffer: Buffer;
private preStagedNotifyLayout: PreStagedNotifyLayout;
public jobTemplateId: string;
public tipKey: string;
public networkDifficulty: number;
public creation: number;
constructor(
private network: bitcoinjs.networks.Network,
public jobId: string,
payoutInformation: AddressObject[],
jobTemplate: IJobTemplate,
public readonly ownership?: MiningJobOwnership,
) {
this.creation = new Date().getTime();
this.jobTemplateId = jobTemplate.blockData.id;
this.tipKey = jobTemplate.blockData.tipKey;
this.networkDifficulty = jobTemplate.blockData.networkDifficulty;
this.merkleBranchBuffers = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex'));
this.coinbaseTransaction = this.createCoinbaseTransaction(payoutInformation, jobTemplate.blockData.coinbasevalue);
//The commitment is recorded in a scriptPubKey of the coinbase transaction. It must be at least 38 bytes, with the first 6-byte of 0x6a24aa21a9ed, that is:
// 1-byte - OP_RETURN (0x6a)
// 1-byte - Push the following 36 bytes (0x24)
// 4-byte - Commitment header (0xaa21a9ed)
const segwitMagicBits = Buffer.from('aa21a9ed', 'hex');
// 32-byte - Commitment hash: Double-SHA256(witness root hash|witness reserved value)
// 39th byte onwards: Optional data with no consensus meaning
const extra = Buffer.from('Public-Pool');
// BIP34 uses Bitcoin Script's minimally encoded integer push. Heights
// 1..16 are OP_1..OP_16, not a one-byte PUSHDATA operation. Keeping the
// extranonce padding independent of the height width also prevents a
// future four-byte height from consuming part of the pool tag.
const blockHeightPrefix = this.encodeCoinbaseHeight(jobTemplate.blockData.height);
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES, 0);
// Build the script with the extranonce placeholder at the very end.
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightPrefix, extra, padding]);
this.coinbaseTransaction.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.concat([segwitMagicBits, jobTemplate.block.witnessCommit])]), 0);
// get the non-witness coinbase tx
//@ts-ignore
const serializedCoinbaseTx = this.coinbaseTransaction.__toBuffer().toString('hex');
const inputScript = this.coinbaseTransaction.ins[0].script.toString('hex');
const partOneIndex = serializedCoinbaseTx.indexOf(inputScript) + inputScript.length;
this.coinbasePart1 = serializedCoinbaseTx.slice(0, partOneIndex - (TOTAL_EXTRANONCE_SIZE_BYTES * 2));
this.coinbasePart2 = serializedCoinbaseTx.slice(partOneIndex);
this.coinbasePart1Buffer = Buffer.from(this.coinbasePart1, 'hex');
this.coinbasePart2Buffer = Buffer.from(this.coinbasePart2, 'hex');
}
public cloneCoinbaseTransaction(): bitcoinjs.Transaction {
return bitcoinjs.Transaction.fromBuffer(this.coinbaseTransaction.toBuffer());
}
public getCoinbasePrefixBuffer(): Buffer {
return Buffer.from(this.coinbasePart1Buffer);
}
public getCoinbaseSuffixBuffer(): Buffer {
return Buffer.from(this.coinbasePart2Buffer);
}
/**
* Promote a coinbase that was built during the prior height to the
* authoritative empty template. The coinbase bytes are height/payout bound;
* header fields and submission ownership are replaced only at activation.
*/
public activatePreStagedTemplate(
jobTemplate: IJobTemplate,
headerFields = MiningJob.createNotifyHeaderFields(jobTemplate),
): void {
this.jobTemplateId = jobTemplate.blockData.id;
this.tipKey = jobTemplate.blockData.tipKey;
this.networkDifficulty = jobTemplate.blockData.networkDifficulty;
this.creation = Date.now();
if (!this.patchPreStagedNotify(headerFields)) {
this.miningNotifyResponseBuffer = null;
}
this.miningNotifyResponse = null;
this.preStagedNotifyLayout = null;
}
/**
* Serialize the miner-specific notify while the prior height is active.
* Activation can then overwrite the fixed-width header fields in place,
* avoiding JSON serialization and Buffer allocation in the new-tip loop.
*/
public preparePreStagedNotify(
jobTemplate: IJobTemplate,
headerFields = MiningJob.createNotifyHeaderFields(jobTemplate),
): void {
if (!jobTemplate.block.prevHash.equals(MiningJob.placeholderPrevHash)) {
throw new Error('Pre-staged notify requires a placeholder prevhash');
}
this.responseBuffer(jobTemplate, headerFields);
const response = this.miningNotifyResponse;
let cursor = response.indexOf(`"${this.jobId}"`);
const locate = (value: string): number => {
const offset = response.indexOf(`"${value}"`, cursor);
if (offset < 0) {
throw new Error('Unable to locate pre-staged notify field');
}
cursor = offset + value.length + 2;
return offset + 1;
};
this.preStagedNotifyLayout = {
fields: { ...headerFields },
offsets: {
previousBlockHash: locate(headerFields.previousBlockHash),
version: locate(headerFields.version),
bits: locate(headerFields.bits),
timestamp: locate(headerFields.timestamp),
},
};
}
public buildCoinbaseMerkleRoot(extraNonce: string, extraNonce2: string): Buffer {
const coinbaseBuffer = Buffer.concat([
this.coinbasePart1Buffer,
Buffer.from(`${extraNonce}${extraNonce2}`, 'hex'),
this.coinbasePart2Buffer,
]);
return MiningJob.calculateMerkleRootFromCoinbaseHash(
hash256(coinbaseBuffer),
this.merkleBranchBuffers,
);
}
public static calculateMerkleRootFromCoinbaseHash(
coinbaseHash: Buffer,
merkleBranches: readonly Buffer[],
): Buffer {
let merkleRoot = coinbaseHash;
const bothMerkles = Buffer.alloc(64);
for (const merkleBranch of merkleBranches) {
bothMerkles.set(merkleRoot, 0);
bothMerkles.set(merkleBranch, 32);
merkleRoot = hash256(bothMerkles);
}
return merkleRoot;
}
public static applyVersionRolling(jobVersion: number, versionBits: number, mask: number): number {
const unsignedJobVersion = jobVersion >>> 0;
const unsignedVersionBits = versionBits >>> 0;
const unsignedMask = mask >>> 0;
return (
(unsignedJobVersion & (~unsignedMask >>> 0))
| (unsignedVersionBits & unsignedMask)
) >>> 0;
}
public buildHeaderBuffer(
jobTemplate: IJobTemplate,
versionBits: number,
nonce: number,
extraNonce: string,
extraNonce2: string,
timestamp: number,
versionRollingMask?: number,
): Buffer {
const merkleRoot = this.buildCoinbaseMerkleRoot(extraNonce, extraNonce2);
const version = versionRollingMask == null
// SV2 standard-channel reconstruction passes an XOR delta because
// its submit message contains the complete version field.
? (jobTemplate.block.version ^ versionBits) >>> 0
: MiningJob.applyVersionRolling(jobTemplate.block.version, versionBits, versionRollingMask);
const header = Buffer.alloc(80);
header.writeUInt32LE(version, 0);
jobTemplate.block.prevHash.copy(header, 4);
merkleRoot.copy(header, 36);
header.writeUInt32LE(timestamp, 68);
header.writeUInt32LE(jobTemplate.block.bits, 72);
header.writeUInt32LE(nonce, 76);
return header;
}
public copyAndUpdateBlock(
jobTemplate: IJobTemplate,
versionBits: number,
nonce: number,
extraNonce: string,
extraNonce2: string,
timestamp: number,
versionRollingMask?: number,
): bitcoinjs.Block {
const testBlock = Object.assign(new bitcoinjs.Block(), jobTemplate.block);
const rawTransactions = jobTemplate.blockData.transactions;
testBlock.transactions = [
this.cloneCoinbaseTransaction(),
...(rawTransactions == null
? jobTemplate.block.transactions.slice(1).map(tx =>
Object.assign(new bitcoinjs.Transaction(), tx))
: rawTransactions.map((rawTransaction, index) => {
const transaction = bitcoinjs.Transaction.fromHex(rawTransaction.data);
if (transaction.getId().toLowerCase() !== rawTransaction.txid.toLowerCase()) {
throw new Error(`transactions[${index}] data does not match its txid`);
}
return transaction;
})),
];
testBlock.nonce = nonce;
const version = versionRollingMask == null
? (testBlock.version ^ versionBits) >>> 0
: MiningJob.applyVersionRolling(testBlock.version, versionBits, versionRollingMask);
// bitcoinjs-lib serializes Block.version as a signed int32, while the
// protocol and bit-mask arithmetic use the corresponding uint32 bits.
testBlock.version = version | 0;
// set the nonces
const nonceScript = testBlock.transactions[0].ins[0].script.toString('hex');
testBlock.transactions[0].ins[0].script = Buffer.from(`${nonceScript.substring(0, nonceScript.length - (TOTAL_EXTRANONCE_SIZE_BYTES * 2))}${extraNonce}${extraNonce2}`, 'hex');
//recompute the root since we updated the coinbase script with the nonces
testBlock.merkleRoot = MiningJob.calculateMerkleRootFromCoinbaseHash(
testBlock.transactions[0].getHash(false),
this.merkleBranchBuffers,
);
testBlock.timestamp = timestamp;
return testBlock;
}
private createCoinbaseTransaction(addresses: AddressObject[], reward: number): bitcoinjs.Transaction {
// Part 1
const coinbaseTransaction = new bitcoinjs.Transaction();
// Set the version of the transaction
coinbaseTransaction.version = 2;
// Add the coinbase input (input with no previous output)
coinbaseTransaction.addInput(Buffer.alloc(32, 0), 0xffffffff, 0xffffffff);
// Add an output
let rewardBalance = reward;
addresses.forEach(recipientAddress => {
const amount = recipientAddress.amountSats == null
? Math.floor(((recipientAddress.percent ?? 0) / 100) * reward)
: recipientAddress.amountSats;
rewardBalance -= amount;
coinbaseTransaction.addOutput(this.getPaymentScript(recipientAddress.address), amount);
})
//Add any remaining sats from the Math.floor
coinbaseTransaction.outs[0].value += rewardBalance;
const segwitWitnessReservedValue = Buffer.alloc(32, 0);
//and the coinbase's input's witness must consist of a single 32-byte array for the witness reserved value
coinbaseTransaction.ins[0].witness = [segwitWitnessReservedValue];
return coinbaseTransaction;
}
private encodeCoinbaseHeight(height: number): Buffer {
if (!Number.isSafeInteger(height) || height < 0) {
throw new Error('Coinbase height must be a non-negative safe integer');
}
if (height === 0) {
return Buffer.from([bitcoinjs.opcodes.OP_0]);
}
if (height <= 16) {
return Buffer.from([bitcoinjs.opcodes.OP_1 + height - 1]);
}
const encoded = bitcoinjs.script.number.encode(height);
if (encoded.length >= bitcoinjs.opcodes.OP_PUSHDATA1) {
throw new Error('Coinbase height encoding is unexpectedly large');
}
return Buffer.concat([Buffer.from([encoded.length]), encoded]);
}
private getPaymentScript(address: string): Buffer {
const cacheKey = `${this.network.bech32}:${this.network.pubKeyHash}:${this.network.scriptHash}:${address}`;
const cached = MiningJob.paymentScriptCache.get(cacheKey);
if (cached != null) {
return cached;
}
const addressInfo = getAddressInfo(address);
let paymentScript: Buffer;
switch (addressInfo.type) {
case AddressType.p2wpkh: {
paymentScript = bitcoinjs.payments.p2wpkh({ address, network: this.network }).output;
break;
}
case AddressType.p2pkh: {
paymentScript = bitcoinjs.payments.p2pkh({ address, network: this.network }).output;
break;
}
case AddressType.p2sh: {
paymentScript = bitcoinjs.payments.p2sh({ address, network: this.network }).output;
break;
}
case AddressType.p2tr: {
paymentScript = bitcoinjs.payments.p2tr({ address, network: this.network }).output;
break;
}
case AddressType.p2wsh: {
paymentScript = bitcoinjs.payments.p2wsh({ address, network: this.network }).output;
break;
}
default: {
paymentScript = Buffer.alloc(0);
break;
}
}
MiningJob.paymentScriptCache.set(cacheKey, paymentScript);
if (MiningJob.paymentScriptCache.size > MiningJob.paymentScriptCacheMaxEntries) {
const oldest = MiningJob.paymentScriptCache.keys().next().value;
if (oldest != null) {
MiningJob.paymentScriptCache.delete(oldest);
}
}
return paymentScript;
}
public response(
jobTemplate: IJobTemplate,
headerFields = MiningJob.createNotifyHeaderFields(jobTemplate),
): string {
if (this.miningNotifyResponse != null) {
return this.miningNotifyResponse;
}
if (this.miningNotifyResponseBuffer != null) {
this.miningNotifyResponse = this.miningNotifyResponseBuffer.toString();
return this.miningNotifyResponse;
}
const job: IMiningNotify = {
id: null,
method: eResponseMethod.MINING_NOTIFY,
params: [
this.jobId,
headerFields.previousBlockHash,
this.coinbasePart1,
this.coinbasePart2,
jobTemplate.merkle_branch,
headerFields.version,
headerFields.bits,
headerFields.timestamp,
headerFields.cleanJobs
]
};
this.miningNotifyResponse = JSON.stringify(job) + '\n';
this.miningNotifyResponseBuffer = Buffer.from(this.miningNotifyResponse);
return this.miningNotifyResponse;
}
public responseBuffer(
jobTemplate: IJobTemplate,
headerFields = MiningJob.createNotifyHeaderFields(jobTemplate),
): Buffer {
if (this.miningNotifyResponseBuffer == null) {
this.response(jobTemplate, headerFields);
}
return this.miningNotifyResponseBuffer;
}
public static createNotifyHeaderFields(jobTemplate: IJobTemplate): MiningNotifyHeaderFields {
return {
previousBlockHash: MiningJob.swapEndianWords(jobTemplate.block.prevHash).toString('hex'),
version: jobTemplate.block.version.toString(16),
bits: jobTemplate.block.bits.toString(16),
timestamp: jobTemplate.block.timestamp.toString(16),
cleanJobs: jobTemplate.blockData.clearJobs,
};
}
private patchPreStagedNotify(headerFields: MiningNotifyHeaderFields): boolean {
const layout = this.preStagedNotifyLayout;
const response = this.miningNotifyResponseBuffer;
if (layout == null
|| response == null
|| layout.fields.cleanJobs !== headerFields.cleanJobs) {
return false;
}
const fields = [
['previousBlockHash', headerFields.previousBlockHash],
['version', headerFields.version],
['bits', headerFields.bits],
['timestamp', headerFields.timestamp],
] as const;
if (fields.some(([name, value]) =>
value.length !== layout.fields[name].length)) {
return false;
}
for (const [name, value] of fields) {
response.write(value, layout.offsets[name], value.length, 'ascii');
}
return true;
}
private static swapEndianWords(buffer: Buffer): Buffer {
const swappedBuffer = Buffer.alloc(buffer.length);
for (let i = 0; i < buffer.length; i += 4) {
swappedBuffer[i] = buffer[i + 3];
swappedBuffer[i + 1] = buffer[i + 2];
swappedBuffer[i + 2] = buffer[i + 1];
swappedBuffer[i + 3] = buffer[i];
}
return swappedBuffer;
}
}