Add payout modes and production switchover schema

This commit is contained in:
Ben
2026-06-20 13:59:05 -04:00
parent a33c530268
commit a9d425f32f
61 changed files with 4164 additions and 1029 deletions
+2
View File
@@ -84,6 +84,8 @@ export class AppService implements OnModuleInit {
this.refreshingPoolSummary = true;
try {
await this.shareAccountingService.refreshPoolSummary();
await this.shareAccountingService.refreshPoolSummary('pplns');
await this.shareAccountingService.refreshPoolSummary('solo');
} catch (error) {
console.error(`Failed refreshing pool accounting summary: ${error.message}`);
} finally {
+315 -4
View File
@@ -1,9 +1,26 @@
import { Socket } from 'net';
import * as bitcoinjs from 'bitcoinjs-lib';
import { DatumProtocolCommand } from '../models/datum/datum-codec';
import { DatumService } from './datum.service';
import { TemplateProviderService } from './template-provider.service';
import { StratumV1ClientStatistics } from '../models/StratumV1ClientStatistics';
function createService(): DatumService {
function createService(overrides: {
configService?: any;
clientService?: any;
redisMessagingService?: any;
} = {}): DatumService {
const configService = overrides.configService ?? {
get: jest.fn((key: string) => {
if (key === 'NETWORK') {
return 'testnet';
}
if (key === 'PAYOUT_COINBASE_MODE') {
return 'snapshot';
}
return undefined;
}),
};
const templateProvider = {
validateTransactionData: jest.fn(({ transactionList, expectedCount, maxTotalBytes }) => {
if (expectedCount != null && transactionList.length !== expectedCount) {
@@ -17,15 +34,15 @@ function createService(): DatumService {
}),
};
return new DatumService(
configService as any,
{} as any,
{} as any,
{} as any,
{} as any,
overrides.clientService ?? {} as any,
{} as any,
{} as any,
{} as any,
templateProvider as unknown as TemplateProviderService,
undefined,
overrides.redisMessagingService,
);
}
@@ -48,6 +65,10 @@ function createState(cache: any): any {
}
describe('DatumService job validation', () => {
afterEach(() => {
jest.useRealTimers();
});
it('requests short transaction IDs once a DATUM job advertises transactions', async () => {
const service = createService() as any;
const socket = createSocket();
@@ -153,4 +174,294 @@ describe('DatumService job validation', () => {
});
expect(socket.write).not.toHaveBeenCalled();
});
it('publishes DATUM client presence with smoothed hashrate after accepted shares', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-06-13T15:00:00.000Z'));
const clientService = {
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
};
const redisMessagingService = {
setClientPresence: jest.fn().mockResolvedValue(undefined),
};
const service = createService({ clientService, redisMessagingService }) as any;
const state = {
sessionId: 'datum-session',
userAgent: 'datum/test',
clientEntity: {
id: '3db0db03-3a62-4e3b-91bc-243adff4b542',
address: 'tb1qdatum',
clientName: 'datum-worker',
sessionId: 'datum-session',
startTime: new Date('2026-06-13T14:59:00.000Z'),
bestDifficulty: 0,
hashRate: 0,
},
statistics: new StratumV1ClientStatistics(1),
};
await service.updateAcceptedSharePresence(state, 'tb1qdatum', 'datum-worker', 10, 1);
jest.setSystemTime(new Date('2026-06-13T15:00:31.000Z'));
await service.updateAcceptedSharePresence(state, 'tb1qdatum', 'datum-worker', 20, 1);
jest.setSystemTime(new Date('2026-06-13T15:01:02.000Z'));
await service.updateAcceptedSharePresence(state, 'tb1qdatum', 'datum-worker', 30, 1);
expect(clientService.updateBestDifficultyIfHigher).toHaveBeenLastCalledWith(
'3db0db03-3a62-4e3b-91bc-243adff4b542',
30,
);
expect(redisMessagingService.setClientPresence).toHaveBeenLastCalledWith(expect.objectContaining({
clientId: '3db0db03-3a62-4e3b-91bc-243adff4b542',
address: 'tb1qdatum',
clientName: 'datum-worker',
sessionId: 'datum-session',
hashRate: expect.any(Number),
bestDifficulty: 30,
}));
const lastPresence = redisMessagingService.setClientPresence.mock.calls.at(-1)[0];
expect(lastPresence.hashRate).toBeGreaterThan(0);
expect(state.clientEntity.hashRate).toBe(lastPresence.hashRate);
});
it('derives submitted share difficulty from DATUM target byte', () => {
const service = createService() as any;
expect(service.getDatumSubmittedShareDifficulty({ targetByte: 0x00 })).toBe(1);
expect(service.getDatumSubmittedShareDifficulty({ targetByte: 0x0e })).toBe(16_384);
expect(service.getDatumSubmittedShareDifficulty({ targetByte: 0x14 })).toBe(1_048_576);
});
it('falls back to configured DATUM share difficulty for invalid target bytes', () => {
const service = createService({
configService: {
get: jest.fn((key: string) => {
if (key === 'DATUM_SHARE_DIFFICULTY') {
return '4096';
}
if (key === 'NETWORK') {
return 'testnet';
}
return undefined;
}),
},
}) as any;
expect(service.getDatumSubmittedShareDifficulty({ targetByte: 0xff })).toBe(4096);
});
it('accepts DATUM coinbases that exactly match snapshot payout outputs', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const expectedOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 421 },
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 133 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 42 },
];
const coinbase = createDatumCoinbaseSplit(expectedOutputs, extranonce);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: expectedOutputs,
},
};
expect(service.validateDatumCoinbasePayouts(
coinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
596n,
undefined,
'pplns',
).valid).toBe(true);
});
it('accepts DATUM coinbases with zero-value OP_RETURN metadata plus matching snapshot payouts', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const expectedOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 421 },
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 133 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 42 },
];
const coinbase = createDatumCoinbaseSplit(expectedOutputs, extranonce, true);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: expectedOutputs,
},
};
const validation = service.validateDatumCoinbasePayouts(
coinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
596n,
undefined,
'pplns',
);
expect(validation.valid).toBe(true);
expect(validation.submittedOutputs).toHaveLength(3);
});
it('rejects DATUM coinbases with extra spendable outputs beyond snapshot payouts', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const expectedOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 421 },
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 133 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 42 },
];
const coinbase = createDatumCoinbaseSplit([
...expectedOutputs,
{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', amountSats: 1 },
], extranonce);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: expectedOutputs,
},
};
const validation = service.validateDatumCoinbasePayouts(
coinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
596n,
undefined,
'pplns',
);
expect(validation.valid).toBe(false);
expect(validation.error).toBe('output-count-mismatch');
});
it('rejects DATUM coinbases that do not pay the pool snapshot outputs', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const expectedOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 421 },
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 133 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 42 },
];
const maliciousCoinbase = createDatumCoinbaseSplit([
{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', amountSats: 596 },
], extranonce);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: expectedOutputs,
},
};
expect(service.validateDatumCoinbasePayouts(
maliciousCoinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
596n,
undefined,
'pplns',
).valid).toBe(false);
});
it('validates DATUM coinbases against the coinbaser-id payout outputs instead of the latest template', () => {
const service = createService() as any;
const extranonce = Buffer.alloc(12, 1);
const fetchedOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 596 },
];
const laterTemplateOutputs = [
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 596 },
];
const coinbase = createDatumCoinbaseSplit(fetchedOutputs, extranonce);
const latestTemplate = {
blockData: {
coinbasevalue: 596,
payoutOutputs: laterTemplateOutputs,
},
};
const expectedPayoutOutputs = (service as any).getDatumPayoutOutputs({
blockData: {
payoutOutputs: fetchedOutputs,
},
}, 596, 'pplns');
expect(service.validateDatumCoinbasePayouts(
coinbase,
{ extranonce, targetByte: 0 },
latestTemplate,
596n,
expectedPayoutOutputs,
'pplns',
).valid).toBe(true);
});
it('carries the coinbaser payout snapshot id into the DATUM job cache', () => {
const service = createService() as any;
const payoutOutputs = [{
value: 596n,
scriptPubKey: bitcoinjs.address.toOutputScript(
'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh',
bitcoinjs.networks.testnet,
),
}];
const state = {
datumJobs: new Map(),
coinbaserPayoutContexts: new Map([[9, {
payoutOutputs,
payoutSnapshotId: '95',
blockHeight: 4991366,
}]]),
};
const cache = service.updateDatumJobCache(state, {
jobId: 7,
coinbaserId: 9,
coinbasePairs: new Map(),
});
expect(cache.coinbaserId).toBe(9);
expect(cache.expectedPayoutOutputs).toBe(payoutOutputs);
expect(cache.payoutSnapshotId).toBe('95');
});
});
function createDatumCoinbaseSplit(
outputs: { address: string; amountSats: number }[],
extranonce: Buffer,
includeDatumMetadata = false,
): { coinb1: Buffer; coinb2: Buffer } {
const tx = new bitcoinjs.Transaction();
tx.version = 2;
tx.addInput(Buffer.alloc(32), 0xffffffff, 0xffffffff);
tx.ins[0].script = Buffer.concat([
Buffer.from([0x03, 0x51, 0x27, 0x4c]),
extranonce,
]);
tx.ins[0].witness = [Buffer.alloc(32)];
for (const output of outputs) {
tx.addOutput(
bitcoinjs.address.toOutputScript(output.address, bitcoinjs.networks.testnet),
output.amountSats,
);
}
if (includeDatumMetadata) {
tx.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.from([0])]), 0);
}
tx.addOutput(
bitcoinjs.script.compile([
bitcoinjs.opcodes.OP_RETURN,
Buffer.concat([Buffer.from('aa21a9ed', 'hex'), Buffer.alloc(32, 2)]),
]),
0,
);
const serialized = tx.toBuffer();
const index = serialized.indexOf(extranonce);
if (index < 0) {
throw new Error('test coinbase split missing extranonce');
}
return {
coinb1: serialized.subarray(0, index),
coinb2: serialized.subarray(index + extranonce.length),
};
}
+293 -29
View File
@@ -19,6 +19,7 @@ import {
DatumPowSubmit,
DatumJobValidationStatus,
DatumJobValidationResponse,
DatumPayoutOutput,
deserializeDatumCoinbaserFetch,
deserializeDatumJobValidationResponse,
deserializeDatumMiningCommand,
@@ -33,9 +34,13 @@ 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 { 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;
@@ -55,6 +60,7 @@ export class DatumService implements OnModuleInit {
private readonly customWorkService: CustomWorkService,
private readonly templateProvider: TemplateProviderService,
private readonly redisMessagingService?: RedisMessagingService,
private readonly payoutSnapshotService?: PayoutSnapshotService,
) {}
public async onModuleInit(): Promise<void> {
@@ -70,25 +76,25 @@ export class DatumService implements OnModuleInit {
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);
for (const { port, payoutMode } of ports) {
await this.startServer(port, payoutMode);
}
}
private async startServer(port: number): Promise<void> {
private async startServer(port: number, payoutMode: PayoutMode): Promise<void> {
const server = new Server(socket => {
void this.handleSocket(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 server is listening on port ${port}`);
console.log(`DATUM ${payoutMode} server is listening on port ${port}`);
});
this.servers.push(server);
}
private async handleSocket(socket: Socket): Promise<void> {
private async handleSocket(socket: Socket, payoutMode: PayoutMode): Promise<void> {
socket.setKeepAlive(true, 60_000);
socket.setNoDelay(true);
@@ -96,6 +102,7 @@ export class DatumService implements OnModuleInit {
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,
@@ -103,6 +110,9 @@ export class DatumService implements OnModuleInit {
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}`);
@@ -188,7 +198,7 @@ export class DatumService implements OnModuleInit {
throw new Error(`DATUM coinbase tag is too long: ${coinbaseTag.length}`);
}
const minDifficulty = Math.max(1, Math.floor(this.getDatumShareDifficulty()));
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);
@@ -208,16 +218,27 @@ export class DatumService implements OnModuleInit {
private async handleCoinbaserFetch(socket: Socket, state: DatumClientState, payload: Buffer): Promise<void> {
const fetch = deserializeDatumCoinbaserFetch(payload);
const payoutAddress = this.getDatumPoolPayoutAddress();
if (!payoutAddress) {
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 scriptPubKey = bitcoinjs.address.toOutputScript(payoutAddress, this.getNetwork());
const response = serializeDatumCoinbaserFetchResponse(fetch.rewardValue, [{
value: fetch.rewardValue,
scriptPubKey,
}]);
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));
}
@@ -273,8 +294,14 @@ export class DatumService implements OnModuleInit {
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.getDatumShareDifficulty();
const shareDifficulty = this.getDatumSubmittedShareDifficulty(pow);
const nBits = datumJob.nBits.readUInt32LE(0);
const validation = this.customWorkService.validateShare({
coinbasePrefix: coinbase.coinb1,
@@ -310,11 +337,23 @@ export class DatumService implements OnModuleInit {
sessionId: state.sessionId,
blockData: validation.header.toString('hex'),
blockSubmissionResult,
payoutSnapshotId: null,
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,
@@ -334,16 +373,41 @@ export class DatumService implements OnModuleInit {
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: 0,
bestDifficulty: validation.submissionDifficulty,
hashRate: state.statistics.hashRate,
bestDifficulty: Math.max(
Number(state.clientEntity.bestDifficulty ?? 0),
submissionDifficulty,
),
});
}
@@ -367,6 +431,11 @@ export class DatumService implements OnModuleInit {
}
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;
@@ -507,6 +576,152 @@ export class DatumService implements OnModuleInit {
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,
@@ -530,8 +745,33 @@ export class DatumService implements OnModuleInit {
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> {
@@ -612,11 +852,18 @@ export class DatumService implements OnModuleInit {
|| '';
}
private getDatumShareDifficulty(): number {
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':
@@ -655,15 +902,11 @@ export class DatumService implements OnModuleInit {
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 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 {
@@ -683,6 +926,7 @@ export class DatumService implements OnModuleInit {
interface DatumClientState {
sessionId: string;
payoutMode: PayoutMode;
session: DatumCryptoSession;
clientEntity: ClientEntity | null;
address: string | null;
@@ -690,6 +934,17 @@ interface DatumClientState {
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 {
@@ -713,6 +968,15 @@ interface DatumJobCache {
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 {
@@ -113,6 +113,28 @@ describe('RedisMessagingService', () => {
expect([...store.keys()].filter(key => key.startsWith('client-presence'))).toHaveLength(0);
expect([...sets.keys()].filter(key => key.startsWith('client-presence'))).toHaveLength(0);
});
it('should not return address presence that is no longer in the active global set', async () => {
await service.connect();
await service.setClientPresence({
clientId: '00000000-0000-4000-8000-000000000001',
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
clientName: 'worker',
sessionId: '57a6f098',
userAgent: 'bitaxe',
startTime: '2026-06-07T12:00:00.000Z',
lastSeen: '2026-06-07T12:01:00.000Z',
hashRate: 100,
bestDifficulty: 200,
});
sets.get('client-presence:all')?.delete('00000000-0000-4000-8000-000000000001');
expect(await service.getClientPresenceByAddress('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'))
.toHaveLength(0);
expect(sets.get('client-presence:address:tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'))
?.not.toContain('00000000-0000-4000-8000-000000000001');
});
});
const store = new Map<string, string>();
+13 -1
View File
@@ -4,6 +4,7 @@ import { createClient, RedisClientType } from 'redis';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
import { normalizePayoutMode, PayoutMode } from '../types/payout-mode';
const MINING_INFO_CHANNEL = 'mining-info.updated';
const MINING_INFO_KEY = 'mining-info:latest';
@@ -20,6 +21,7 @@ export interface ClientPresence {
address: string;
clientName: string;
sessionId: string;
payoutMode?: PayoutMode;
userAgent?: string | null;
startTime: string;
lastSeen: string;
@@ -143,6 +145,7 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
const serialized = JSON.stringify({
...presence,
payoutMode: normalizePayoutMode(presence.payoutMode),
userAgent: presence.userAgent ?? null,
hashRate: Number.isFinite(Number(presence.hashRate)) ? Number(presence.hashRate) : 0,
bestDifficulty: Number.isFinite(Number(presence.bestDifficulty)) ? Number(presence.bestDifficulty) : 0,
@@ -267,6 +270,9 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
if (clientIds.length === 0) {
return [];
}
const activeClientIds = setKey === CLIENT_PRESENCE_ALL_KEY
? null
: new Set(await this.publisher.sMembers(CLIENT_PRESENCE_ALL_KEY));
const presences: ClientPresence[] = [];
const staleClientIds: string[] = [];
@@ -274,9 +280,14 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
const chunk = clientIds.slice(i, i + 1000);
const values = await this.publisher.mGet(chunk.map(clientPresenceKey));
values.forEach((value, index) => {
const clientId = chunk[index];
if (activeClientIds != null && !activeClientIds.has(clientId)) {
staleClientIds.push(clientId);
return;
}
const presence = this.parseClientPresence(value);
if (presence == null) {
staleClientIds.push(chunk[index]);
staleClientIds.push(clientId);
return;
}
presences.push(presence);
@@ -312,6 +323,7 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
address: parsed.address,
clientName: parsed.clientName ?? 'default',
sessionId: parsed.sessionId ?? parsed.clientId,
payoutMode: normalizePayoutMode(parsed.payoutMode),
userAgent: parsed.userAgent ?? null,
startTime: parsed.startTime,
lastSeen: parsed.lastSeen,
+8 -3
View File
@@ -85,8 +85,10 @@ describe('StratumV1Service', () => {
it('should start Stratum listeners in worker processes', async () => {
process.env.MASTER = 'false';
process.env.STRATUM_PORTS = '3333,3334';
process.env.PPLNS_STRATUM_PORTS = '13333';
process.env.STRATUM_SECURE = 'true';
process.env.SECURE_STRATUM_PORTS = '4333';
process.env.PPLNS_SECURE_STRATUM_PORTS = '14333';
const startSocketServerSpy = jest.spyOn(service as any, 'startSocketServer').mockImplementation(() => undefined);
const startSecureSocketServerSpy = jest.spyOn(service as any, 'startSecureSocketServer').mockImplementation(() => undefined);
@@ -95,9 +97,11 @@ describe('StratumV1Service', () => {
expect(clientService.deleteAll).not.toHaveBeenCalled();
expect(userAgentReportService.refreshReport).not.toHaveBeenCalled();
expect(startSocketServerSpy).toHaveBeenCalledWith(3333);
expect(startSocketServerSpy).toHaveBeenCalledWith(3334);
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(4333);
expect(startSocketServerSpy).toHaveBeenCalledWith(3333, 'solo');
expect(startSocketServerSpy).toHaveBeenCalledWith(3334, 'solo');
expect(startSocketServerSpy).toHaveBeenCalledWith(13333, 'pplns');
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(4333, 'solo');
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(14333, 'pplns');
});
it('should pause listeners when worker backpressure is high', () => {
@@ -105,6 +109,7 @@ describe('StratumV1Service', () => {
(service as any).listeners.push({
port: 3333,
secure: false,
payoutMode: 'solo',
server: { close },
paused: false
});
+25 -14
View File
@@ -16,6 +16,7 @@ import { NotificationService } from './notification.service';
import { RedisMessagingService } from './redis-messaging.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { StratumV2Service } from './stratum-v2.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
import { readFileSync } from 'fs';
import { TlsOptions, TLSSocket, createServer } from 'tls';
@@ -24,6 +25,7 @@ import * as path from 'path';
interface StratumListenerState {
port: number;
secure: boolean;
payoutMode: PayoutMode;
server: Server | null;
paused: boolean;
}
@@ -87,12 +89,18 @@ export class StratumV1Service implements OnModuleInit {
// wait for all the other processes to init for an even connection distribution
setTimeout(() => {
process.env.STRATUM_PORTS.split(',').forEach(port => {
this.startSocketServer(parseInt(port));
parsePayoutModePorts(
process.env.STRATUM_PORTS,
process.env.PPLNS_STRATUM_PORTS,
).forEach(({ port, payoutMode }) => {
this.startSocketServer(port, payoutMode);
});
if (process.env.STRATUM_SECURE?.toLowerCase() === 'true') {
process.env.SECURE_STRATUM_PORTS.split(',').forEach(port => {
this.startSecureSocketServer(parseInt(port));
parsePayoutModePorts(
process.env.SECURE_STRATUM_PORTS,
process.env.PPLNS_SECURE_STRATUM_PORTS,
).forEach(({ port, payoutMode }) => {
this.startSecureSocketServer(port, payoutMode);
});
}
}, (10000));
@@ -109,10 +117,11 @@ export class StratumV1Service implements OnModuleInit {
}
private startSocketServer(port: number) {
private startSocketServer(port: number, payoutMode: PayoutMode) {
const listener: StratumListenerState = {
port,
secure: false,
payoutMode,
server: null,
paused: false
};
@@ -120,7 +129,7 @@ export class StratumV1Service implements OnModuleInit {
this.listen(listener);
}
private createSocketServer(): Server {
private createSocketServer(payoutMode: PayoutMode): Server {
const server = new Server(async (socket: Socket) => {
socket.setTimeout(this.getSocketTimeoutMs());
socket.setKeepAlive(true, this.getTcpKeepAliveInitialDelayMs());
@@ -188,14 +197,14 @@ export class StratumV1Service implements OnModuleInit {
try {
protocol = this.detectProtocol(firstChunk);
if (protocol === 'v1') {
client = this.createV1Client(socket, 'sv1');
client = this.createV1Client(socket, 'sv1', payoutMode);
socket.emit('data', firstChunk);
return;
}
if (protocol === 'v2') {
await this.stratumV2Service.ensureInitialized();
client = this.stratumV2Service.createClient(socket, firstChunk);
client = this.stratumV2Service.createClient(socket, firstChunk, payoutMode);
return;
}
@@ -220,7 +229,7 @@ export class StratumV1Service implements OnModuleInit {
return server;
}
private createV1Client(socket: Socket, accountingProtocol: 'sv1' | 'sv1_tls'): StratumV1Client {
private createV1Client(socket: Socket, accountingProtocol: 'sv1' | 'sv1_tls', payoutMode: PayoutMode): StratumV1Client {
return new StratumV1Client(
socket,
this.stratumV1JobsService,
@@ -234,13 +243,15 @@ export class StratumV1Service implements OnModuleInit {
this.redisMessagingService,
this.payoutSnapshotService,
accountingProtocol,
payoutMode,
);
}
private startSecureSocketServer(port: number) {
private startSecureSocketServer(port: number, payoutMode: PayoutMode) {
const listener: StratumListenerState = {
port,
secure: true,
payoutMode,
server: null,
paused: false
};
@@ -248,7 +259,7 @@ export class StratumV1Service implements OnModuleInit {
this.listen(listener);
}
private createSecureSocketServer(): Server {
private createSecureSocketServer(payoutMode: PayoutMode): Server {
const currentDirectory = process.cwd();
const keyPath = path.join(currentDirectory, 'secrets', 'key.pem');
@@ -264,7 +275,7 @@ export class StratumV1Service implements OnModuleInit {
socket.setTimeout(this.getSocketTimeoutMs());
socket.setKeepAlive(true, this.getTcpKeepAliveInitialDelayMs());
const client = this.createV1Client(socket, 'sv1_tls');
const client = this.createV1Client(socket, 'sv1_tls', payoutMode);
let cleanedUp = false;
const cleanup = async (reason: string) => {
@@ -329,12 +340,12 @@ export class StratumV1Service implements OnModuleInit {
return;
}
const server = listener.secure ? this.createSecureSocketServer() : this.createSocketServer();
const server = listener.secure ? this.createSecureSocketServer(listener.payoutMode) : this.createSocketServer(listener.payoutMode);
listener.server = server;
listener.paused = false;
server.listen(listener.port, () => {
console.log(`${listener.secure ? 'Stratum TLS' : 'Stratum'} server is listening on port ${listener.port}`);
console.log(`${listener.secure ? 'Stratum TLS' : 'Stratum'} ${listener.payoutMode} server is listening on port ${listener.port}`);
});
}
+12 -17
View File
@@ -24,6 +24,7 @@ import { NotificationService } from './notification.service';
import { RedisMessagingService } from './redis-messaging.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { Sv2JobDeclarationRegistryService } from './sv2-job-declaration-registry.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
const DEFAULT_SOCKET_TIMEOUT_MS = 1000 * 60 * 60;
const DEFAULT_TCP_KEEPALIVE_INITIAL_DELAY_MS = 1000 * 60;
@@ -70,7 +71,7 @@ export class StratumV2Service implements OnModuleInit {
}
await this.ensureInitialized();
ports.forEach(port => this.startSocketServer(port));
ports.forEach(({ port, payoutMode }) => this.startSocketServer(port, payoutMode));
}
public async ensureInitialized(): Promise<void> {
@@ -81,7 +82,7 @@ export class StratumV2Service implements OnModuleInit {
await this.initializeNoiseConfig();
}
public createClient(socket: Socket, firstChunk: Buffer): StratumV2Client {
public createClient(socket: Socket, firstChunk: Buffer, payoutMode: PayoutMode = 'solo'): StratumV2Client {
if (this.noiseConfig == null) {
throw new Error('Stratum V2 service is not initialized');
}
@@ -102,6 +103,7 @@ export class StratumV2Service implements OnModuleInit {
this.shareAccountingService,
this.redisMessagingService,
this.payoutSnapshotService,
payoutMode,
);
}
@@ -170,21 +172,14 @@ export class StratumV2Service implements OnModuleInit {
};
}
private getPorts(): number[] {
const configuredPorts = this.configService.get<string>('STRATUM_V2_PORTS');
if (!configuredPorts?.trim()) {
return [];
}
const ports = configuredPorts
.split(',')
.map(port => parseInt(port.trim(), 10))
.filter(port => Number.isInteger(port) && port > 0 && port <= 65535);
return Array.from(new Set(ports));
private getPorts(): { port: number; payoutMode: PayoutMode }[] {
return parsePayoutModePorts(
this.configService.get<string>('STRATUM_V2_PORTS'),
this.configService.get<string>('PPLNS_STRATUM_V2_PORTS'),
);
}
private startSocketServer(port: number): void {
private startSocketServer(port: number, payoutMode: PayoutMode): void {
const server = new Server((socket: Socket) => {
socket.setTimeout(this.getSocketTimeoutMs());
socket.setKeepAlive(true, this.getTcpKeepAliveInitialDelayMs());
@@ -202,7 +197,7 @@ export class StratumV2Service implements OnModuleInit {
};
socket.once('data', (firstChunk: Buffer) => {
client = this.createClient(socket, firstChunk);
client = this.createClient(socket, firstChunk, payoutMode);
});
socket.on('timeout', closeSocket);
@@ -224,7 +219,7 @@ export class StratumV2Service implements OnModuleInit {
});
server.listen(port, () => {
console.log(`Stratum V2 server is listening on port ${port}`);
console.log(`Stratum V2 ${payoutMode} server is listening on port ${port}`);
});
this.servers.push(server);
}
@@ -3,9 +3,11 @@ import * as bitcoinjs from 'bitcoinjs-lib';
import { BufferReader } from '../models/sv2/sv2-binary-codec';
import { Sv2JdpSetupFlags, Sv2MsgType, Sv2Protocol } from '../models/sv2/sv2-constants';
import {
deserializeAllocateMiningJobTokenSuccess,
deserializeDeclareMiningJobError,
deserializeDeclareMiningJobSuccess,
deserializeProvideMissingTransactions,
serializeAllocateMiningJobToken,
serializeDeclareMiningJob,
serializeProvideMissingTransactionsSuccess,
serializePushSolution,
@@ -60,6 +62,43 @@ describe('Sv2JobDeclarationConnection compliance', () => {
expect(registry.declareJob).not.toHaveBeenCalled();
});
it('allocates a zero-value JDP token output while fixed payouts remain in the template', async () => {
const registry = new Sv2JobDeclarationRegistryService();
const payoutOutputs = [
{ address: 'tb1q42vtlphyjjcun9wcv9f0d9pkhup9dcf5z9k4gh', amountSats: 391 },
{ address: 'tb1q9r8gvnx3j4d6jvl0fqjrmy3dar4k4l3052af7q', amountSats: 110 },
{ address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', amountSats: 95 },
];
const { connection, sentFrames } = createConnection({
registry,
templateProvider: {
getLatestTemplate: jest.fn().mockReturnValue({
jobTemplate: {
blockData: {
coinbasevalue: 596,
payoutOutputs,
},
},
}),
},
});
await (connection as any).handleAllocateMiningJobToken(serializeAllocateMiningJobToken({
requestId: 7,
userIdentifier: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2.sri-jdc',
}));
const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.JDP_ALLOCATE_MINING_JOB_TOKEN_SUCCESS);
const success = deserializeAllocateMiningJobTokenSuccess(new BufferReader(successFrame.payload));
expect(success.requestId).toBe(7);
const tokenOutputs = readSerializedTxOutputs(success.coinbaseOutputs);
expect(tokenOutputs).toHaveLength(1);
expect(tokenOutputs[0].value).toBe(0);
expect(bitcoinjs.address.fromOutputScript(tokenOutputs[0].script, bitcoinjs.networks.testnet))
.toBe('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4');
});
it('accepts DeclareMiningJob when all wtxids are known to the template provider', async () => {
const registry = new Sv2JobDeclarationRegistryService();
const allocated = registry.allocateToken('bc1ptest.worker', Buffer.alloc(0));
@@ -208,6 +247,7 @@ describe('Sv2JobDeclarationConnection compliance', () => {
blocksService,
payoutSnapshotService,
notificationService,
payoutMode: 'pplns',
});
(connection as any).latestDeclaredJob = {
token: Buffer.from('0123456789abcdef0123456789abcdef', 'hex'),
@@ -241,6 +281,7 @@ describe('Sv2JobDeclarationConnection compliance', () => {
payoutSnapshotId: '17',
blockHeight: 4990255,
blockSubmissionResult: null,
payoutMode: 'pplns',
});
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalledWith(
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
@@ -283,6 +324,7 @@ describe('Sv2JobDeclarationConnection compliance', () => {
blocksService,
payoutSnapshotService,
notificationService,
payoutMode: 'pplns',
});
(connection as any).latestDeclaredJob = {
token: Buffer.from('0123456789abcdef0123456789abcdef', 'hex'),
@@ -409,6 +451,7 @@ describe('Sv2JobDeclarationConnection compliance', () => {
blocksService?: any;
payoutSnapshotService?: any;
notificationService?: any;
payoutMode?: 'solo' | 'pplns';
} = {}): {
connection: Sv2JobDeclarationConnection;
sentFrames: any[];
@@ -428,6 +471,24 @@ describe('Sv2JobDeclarationConnection compliance', () => {
declareJob: jest.fn(),
};
const templateProvider = {
getLatestTemplate: jest.fn().mockReturnValue(undefined),
buildCoinbasePayoutOutputs: jest.fn((payoutInformation, coinbaseValue, network) => {
let balance = BigInt(coinbaseValue);
const outputs = payoutInformation.map(recipient => {
const value = recipient.amountSats == null
? BigInt(Math.floor(((recipient.percent ?? 0) / 100) * Number(coinbaseValue)))
: BigInt(recipient.amountSats);
balance -= value;
return {
value,
scriptPubKey: bitcoinjs.address.toOutputScript(recipient.address, network),
};
});
if (outputs.length > 0 && balance !== 0n) {
outputs[0] = { ...outputs[0], value: outputs[0].value + balance };
}
return outputs;
}),
validateDeclaredWtxids: jest.fn().mockReturnValue({ valid: false, errorCode: 'template-not-found' }),
validateProvidedTransactions: jest.fn().mockReturnValue({ valid: false, errorCode: 'template-not-found' }),
validateCoinbaseTransactionHeight: jest.fn().mockReturnValue({ valid: true }),
@@ -450,7 +511,9 @@ describe('Sv2JobDeclarationConnection compliance', () => {
}),
} as any,
registry as any,
{} as any,
{
encodeBitcoinVarInt: encodeBitcoinVarIntForTest,
} as any,
templateProvider,
overrides.bitcoinRpcService ?? {
SUBMIT_BLOCK: jest.fn().mockResolvedValue(null),
@@ -466,6 +529,7 @@ describe('Sv2JobDeclarationConnection compliance', () => {
},
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
bitcoinjs.networks.testnet,
overrides.payoutMode ?? 'solo',
);
const sentFrames: any[] = [];
(connection as any).sendFrame = jest.fn((msgType: number, payload: Buffer) => {
@@ -500,4 +564,28 @@ describe('Sv2JobDeclarationConnection compliance', () => {
}],
};
}
function readSerializedTxOutputs(outputs: Buffer): bitcoinjs.TxOutput[] {
const tx = bitcoinjs.Transaction.fromBuffer(Buffer.concat([
Buffer.from('0200000001', 'hex'),
Buffer.alloc(32),
Buffer.from('ffffffff00ffffffff', 'hex'),
outputs,
Buffer.from('00000000', 'hex'),
]));
return tx.outs;
}
function encodeBitcoinVarIntForTest(value: number): Buffer {
if (value < 0xfd) {
return Buffer.from([value]);
}
if (value <= 0xffff) {
const result = Buffer.alloc(3);
result[0] = 0xfd;
result.writeUInt16LE(value, 1);
return result;
}
throw new RangeError(`Unsupported test varint ${value}`);
}
});
+49 -26
View File
@@ -31,6 +31,7 @@ import { NotificationService } from './notification.service';
import { StratumV2Service } from './stratum-v2.service';
import { Sv2DeclaredMiningJob, Sv2JobDeclarationRegistryService } from './sv2-job-declaration-registry.service';
import { TemplateProviderService } from './template-provider.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
@Injectable()
export class Sv2JobDeclarationService implements OnModuleInit {
@@ -59,12 +60,12 @@ export class Sv2JobDeclarationService implements OnModuleInit {
}
await this.stratumV2Service.ensureInitialized();
for (const port of ports) {
this.startServer(port);
for (const { port, payoutMode } of ports) {
this.startServer(port, payoutMode);
}
}
private startServer(port: number): void {
private startServer(port: number, payoutMode: PayoutMode): void {
const server = new Server(socket => {
void new Sv2JobDeclarationConnection(
socket,
@@ -78,10 +79,11 @@ export class Sv2JobDeclarationService implements OnModuleInit {
this.notificationService,
this.getPoolPayoutAddress(),
this.getNetwork(),
payoutMode,
).start();
});
server.on('error', error => console.error(`SV2 JDP server error on port ${port}: ${error.message}`));
server.listen(port, () => console.log(`SV2 Job Declaration server is listening on port ${port}`));
server.listen(port, () => console.log(`SV2 Job Declaration ${payoutMode} server is listening on port ${port}`));
this.servers.push(server);
}
@@ -93,15 +95,11 @@ export class Sv2JobDeclarationService implements OnModuleInit {
|| '';
}
private getPorts(): number[] {
const configured = this.configService.get<string>('SV2_JDP_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 getPorts(): { port: number; payoutMode: PayoutMode }[] {
return parsePayoutModePorts(
this.configService.get<string>('SV2_JDP_PORTS'),
this.configService.get<string>('PPLNS_SV2_JDP_PORTS'),
);
}
private getNetwork(): bitcoinjs.networks.Network {
@@ -148,6 +146,7 @@ export class Sv2JobDeclarationConnection {
private readonly notificationService: NotificationService,
private readonly poolPayoutAddress: string,
private readonly network: bitcoinjs.networks.Network,
private readonly payoutMode: PayoutMode,
) {
this.noiseSession = new Sv2NoiseSession(stratumV2Service.getNoiseConfig());
}
@@ -239,8 +238,8 @@ export class Sv2JobDeclarationConnection {
private async handleAllocateMiningJobToken(payload: Buffer): Promise<void> {
const request = deserializeAllocateMiningJobToken(new BufferReader(payload));
const token = this.registry.allocateToken(
request.userIdentifier,
const token = this.registry.allocateToken(
request.userIdentifier,
this.buildPoolCoinbaseOutputs(request.userIdentifier),
);
await this.sendFrame(Sv2MsgType.JDP_ALLOCATE_MINING_JOB_TOKEN_SUCCESS, serializeAllocateMiningJobTokenSuccess({
@@ -413,13 +412,19 @@ export class Sv2JobDeclarationConnection {
sessionId: declared.token.toString('hex').slice(0, 8),
blockData: blockHex,
blockSubmissionResult: result,
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId ?? null,
});
await this.payoutSnapshotService.finalizeSnapshotForBlock({
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId,
blockHeight: template.height,
blockSubmissionResult: result,
payoutSnapshotId: this.payoutMode === 'pplns'
? template.jobTemplate.blockData.payoutSnapshotId ?? null
: null,
payoutMode: this.payoutMode,
});
if (this.payoutMode === 'pplns') {
await this.payoutSnapshotService.finalizeSnapshotForBlock({
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId,
blockHeight: template.height,
blockSubmissionResult: result,
payoutMode: this.payoutMode,
});
}
await this.notificationService.notifySubscribersBlockFound(address, template.height, block, result);
console.log(`[SV2 JDP] PushSolution submitted block at height ${template.height}: ${result ?? 'accepted'}`);
} catch (error) {
@@ -428,14 +433,32 @@ export class Sv2JobDeclarationConnection {
}
private buildPoolCoinbaseOutputs(userIdentifier: string): Buffer {
const snapshotOutputs = this.payoutMode === 'pplns'
? this.templateProvider.getLatestTemplate()?.jobTemplate.blockData.payoutOutputs ?? []
: [];
if (snapshotOutputs.length > 0) {
return this.serializeCoinbaseOutputs(snapshotOutputs.map(output => ({
value: 0n,
scriptPubKey: bitcoinjs.address.toOutputScript(output.address, this.network),
})));
}
const address = this.poolPayoutAddress || userIdentifier.split('.')[0];
const script = bitcoinjs.address.toOutputScript(address, this.network);
const value = Buffer.alloc(8);
return this.serializeCoinbaseOutputs([{ value: 0n, scriptPubKey: script }]);
}
private serializeCoinbaseOutputs(outputs: Array<{ value: bigint; scriptPubKey: Buffer }>): Buffer {
return Buffer.concat([
this.customWorkService.encodeBitcoinVarInt(1),
value,
this.customWorkService.encodeBitcoinVarInt(script.length),
script,
this.customWorkService.encodeBitcoinVarInt(outputs.length),
...outputs.flatMap(output => {
const value = Buffer.alloc(8);
value.writeBigUInt64LE(output.value, 0);
return [
value,
this.customWorkService.encodeBitcoinVarInt(output.scriptPubKey.length),
output.scriptPubKey,
];
}),
]);
}
@@ -3,7 +3,6 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { BufferReader } from '../models/sv2/sv2-binary-codec';
import { MiningJob } from '../models/MiningJob';
import { Sv2MsgType, Sv2Protocol } from '../models/sv2/sv2-constants';
import {
deserializeSetupConnectionSuccess,
@@ -56,20 +55,23 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.TDP_SET_NEW_PREV_HASH)).toBe(true);
const templateFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.TDP_NEW_TEMPLATE);
const template = deserializeTdpNewTemplate(new BufferReader(templateFrame.payload));
const expectedJob = new MiningJob(
const heightPrefix = templateProvider.buildCoinbaseHeightPrefix(MockRecording1.BLOCK_TEMPLATE.height);
expect(template.coinbasePrefix).toEqual(heightPrefix);
expect(template.coinbasePrefix.subarray(0, 4)).not.toEqual(Buffer.from('02000000', 'hex'));
expect(template.coinbaseTxVersion).toBe(2);
expect(template.coinbaseTxInputSequence).toBe(0xffffffff);
expect(template.coinbaseTxValueRemaining).toBe(0n);
expect(template.coinbaseTxOutputsCount).toBe(2);
expect(template.coinbaseTxOutputs).toEqual((connection as any).serializeFixedCoinbaseOutputs(jobTemplate).serialized);
expect(template.coinbaseTxOutputs.includes(bitcoinjs.address.toOutputScript(
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
bitcoinjs.networks.testnet,
jobTemplate.blockData.id,
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
jobTemplate,
);
const expectedCoinbaseTx = expectedJob.cloneCoinbaseTransaction();
expect(template.coinbasePrefix).toEqual(expectedJob.getCoinbasePrefixBuffer());
expect(template.coinbasePrefix.includes(templateProvider.buildCoinbaseHeightPrefix(MockRecording1.BLOCK_TEMPLATE.height))).toBe(true);
expect(template.coinbaseTxVersion).toBe(expectedCoinbaseTx.version);
expect(template.coinbaseTxInputSequence).toBe(expectedCoinbaseTx.ins[0].sequence);
expect(template.coinbaseTxOutputsCount).toBe(expectedCoinbaseTx.outs.length);
expect(template.coinbaseTxOutputs).toEqual((connection as any).serializeCoinbaseOutputs(expectedCoinbaseTx));
expect(template.coinbaseTxLocktime).toBe(expectedCoinbaseTx.locktime);
))).toBe(true);
expect(template.coinbaseTxOutputs.includes(Buffer.concat([
Buffer.from('aa21a9ed', 'hex'),
jobTemplate.block.witnessCommit,
]))).toBe(true);
expect(template.coinbaseTxLocktime).toBe(0);
});
it('serves transaction data for known templates', async () => {
@@ -88,7 +90,7 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
});
it('submits reconstructed blocks from SubmitSolution', async () => {
const { connection, templateProvider, jobTemplate, bitcoinRpcService, blocksService, payoutSnapshotService, notificationService } = await createConnection();
const { connection, templateProvider, jobTemplate, bitcoinRpcService, blocksService, payoutSnapshotService, notificationService } = await createConnection('pplns');
const template = templateProvider.upsert(jobTemplate);
const callOrder: string[] = [];
bitcoinRpcService.SUBMIT_BLOCK.mockImplementation(async () => {
@@ -98,7 +100,10 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
blocksService.save.mockImplementation(async () => {
callOrder.push('save');
});
const coinbaseTx = createCoinbaseTransaction(templateProvider.buildCoinbaseHeightPrefix(template.height));
const coinbaseTx = createPayoutCoinbaseTransaction(
templateProvider.buildCoinbaseHeightPrefix(template.height),
jobTemplate.blockData.coinbasevalue,
);
await (connection as any).handleFrame(
Sv2MsgType.TDP_SUBMIT_SOLUTION,
@@ -123,6 +128,7 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: MockRecording1.BLOCK_TEMPLATE.height,
blockSubmissionResult: null,
payoutMode: 'pplns',
});
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalledWith(
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
@@ -135,7 +141,10 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
it('does not resubmit duplicate SubmitSolution messages', async () => {
const { connection, templateProvider, jobTemplate, bitcoinRpcService } = await createConnection();
const template = templateProvider.upsert(jobTemplate);
const coinbaseTx = createCoinbaseTransaction(templateProvider.buildCoinbaseHeightPrefix(template.height));
const coinbaseTx = createPayoutCoinbaseTransaction(
templateProvider.buildCoinbaseHeightPrefix(template.height),
jobTemplate.blockData.coinbasevalue,
);
const payload = serializeTdpSubmitSolution({
templateId: template.templateId,
version: template.version,
@@ -173,13 +182,44 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
warn.mockRestore();
});
it('rejects SubmitSolution locally when the coinbase does not pay the pool outputs', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const { connection, templateProvider, jobTemplate, bitcoinRpcService, blocksService } = await createConnection();
const template = templateProvider.upsert(jobTemplate);
const coinbaseTx = createPayoutCoinbaseTransaction(
templateProvider.buildCoinbaseHeightPrefix(template.height),
jobTemplate.blockData.coinbasevalue,
'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2',
);
await (connection as any).handleFrame(
Sv2MsgType.TDP_SUBMIT_SOLUTION,
serializeTdpSubmitSolution({
templateId: template.templateId,
version: template.version,
headerTimestamp: template.minNtime,
headerNonce: 123,
coinbaseTx: coinbaseTx.toBuffer(),
}),
);
expect(bitcoinRpcService.SUBMIT_BLOCK).not.toHaveBeenCalled();
expect(blocksService.save).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('coinbase-payout-output-mismatch'));
warn.mockRestore();
});
it('submits using a served template snapshot after provider cache cleanup', async () => {
const { connection, sentFrames, templateProvider, jobTemplate, bitcoinRpcService, blocksService } = await createConnection();
await (connection as any).sendTemplate(jobTemplate);
const templateId = BigInt(parseInt(jobTemplate.blockData.id, 16));
jest.spyOn(templateProvider, 'getTemplate').mockReturnValue(undefined);
sentFrames.length = 0;
const coinbaseTx = createCoinbaseTransaction(templateProvider.buildCoinbaseHeightPrefix(jobTemplate.blockData.height));
const coinbaseTx = createPayoutCoinbaseTransaction(
templateProvider.buildCoinbaseHeightPrefix(jobTemplate.blockData.height),
jobTemplate.blockData.coinbasevalue,
);
await (connection as any).handleFrame(
Sv2MsgType.TDP_SUBMIT_SOLUTION,
@@ -200,7 +240,7 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.TDP_REQUEST_TRANSACTION_DATA_ERROR)).toBe(false);
});
async function createConnection(): Promise<{
async function createConnection(payoutMode: 'solo' | 'pplns' = 'solo'): Promise<{
connection: Sv2TemplateDistributionConnection;
sentFrames: any[];
templateProvider: TemplateProviderService;
@@ -262,6 +302,7 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
notificationService as any,
'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
bitcoinjs.networks.testnet,
payoutMode,
);
const sentFrames: any[] = [];
(connection as any).sendFrame = jest.fn((msgType: number, payload: Buffer) => {
@@ -287,4 +328,19 @@ describe('Sv2TemplateDistributionConnection compliance', () => {
tx.addOutput(Buffer.from('6a', 'hex'), 0);
return tx;
}
function createPayoutCoinbaseTransaction(
script: Buffer,
coinbaseValue: number,
address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
): bitcoinjs.Transaction {
const tx = new bitcoinjs.Transaction();
tx.version = 2;
tx.addInput(Buffer.alloc(32), 0xffffffff, 0xffffffff, script);
tx.addOutput(
bitcoinjs.address.toOutputScript(address, bitcoinjs.networks.testnet),
coinbaseValue,
);
return tx;
}
});
@@ -14,7 +14,6 @@ import {
serializeSetupConnectionError,
serializeSetupConnectionSuccess,
} from '../models/sv2/sv2-messages';
import { AddressObject, MiningJob } from '../models/MiningJob';
import { Sv2NoiseSession } from '../models/sv2/sv2-noise';
import {
deserializeTdpCoinbaseOutputConstraints,
@@ -30,6 +29,8 @@ import { NotificationService } from './notification.service';
import { IJobTemplate, StratumV1JobsService } from './stratum-v1-jobs.service';
import { StratumV2Service } from './stratum-v2.service';
import { TemplateProviderService, TemplateProviderTemplate } from './template-provider.service';
import { AddressObject } from '../models/MiningJob';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
interface TdpCoinbaseTemplateFields {
coinbaseTxVersion: number;
@@ -67,12 +68,12 @@ export class Sv2TemplateDistributionService implements OnModuleInit {
}
await this.stratumV2Service.ensureInitialized();
for (const port of ports) {
this.startServer(port);
for (const { port, payoutMode } of ports) {
this.startServer(port, payoutMode);
}
}
private startServer(port: number): void {
private startServer(port: number, payoutMode: PayoutMode): void {
const server = new Server(socket => {
void new Sv2TemplateDistributionConnection(
socket,
@@ -85,22 +86,19 @@ export class Sv2TemplateDistributionService implements OnModuleInit {
this.notificationService,
this.getPoolPayoutAddress(),
this.getNetwork(),
payoutMode,
).start();
});
server.on('error', error => console.error(`SV2 TDP server error on port ${port}: ${error.message}`));
server.listen(port, () => console.log(`SV2 Template Distribution server is listening on port ${port}`));
server.listen(port, () => console.log(`SV2 Template Distribution ${payoutMode} server is listening on port ${port}`));
this.servers.push(server);
}
private getPorts(): number[] {
const configured = this.configService.get<string>('SV2_TDP_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 getPorts(): { port: number; payoutMode: PayoutMode }[] {
return parsePayoutModePorts(
this.configService.get<string>('SV2_TDP_PORTS'),
this.configService.get<string>('PPLNS_SV2_TDP_PORTS'),
);
}
private getPoolPayoutAddress(): string {
@@ -149,6 +147,7 @@ export class Sv2TemplateDistributionConnection {
private readonly notificationService: NotificationService,
private readonly poolPayoutAddress: string,
private readonly network: bitcoinjs.networks.Network,
private readonly payoutMode: PayoutMode,
) {
this.noiseSession = new Sv2NoiseSession(stratumV2Service.getNoiseConfig());
}
@@ -272,43 +271,65 @@ export class Sv2TemplateDistributionConnection {
}
private buildCoinbaseTemplateFields(template: IJobTemplate): TdpCoinbaseTemplateFields {
const job = new MiningJob(
this.network,
template.blockData.id,
this.getPayoutInformation(template),
template,
);
const coinbaseTx = job.cloneCoinbaseTransaction();
const coinbaseTxOutputs = this.serializeFixedCoinbaseOutputs(template);
const spendableValue = coinbaseTxOutputs.spendableValue;
return {
coinbaseTxVersion: coinbaseTx.version,
coinbasePrefix: job.getCoinbasePrefixBuffer(),
coinbaseTxInputSequence: coinbaseTx.ins[0]?.sequence ?? 0xffffffff,
coinbaseTxValueRemaining: BigInt(template.blockData.coinbasevalue),
coinbaseTxOutputsCount: coinbaseTx.outs.length,
coinbaseTxOutputs: this.serializeCoinbaseOutputs(coinbaseTx),
coinbaseTxLocktime: coinbaseTx.locktime,
coinbaseTxVersion: 2,
coinbasePrefix: this.templateProvider.buildCoinbaseHeightPrefix(template.blockData.height),
coinbaseTxInputSequence: 0xffffffff,
coinbaseTxValueRemaining: BigInt(template.blockData.coinbasevalue) - spendableValue,
coinbaseTxOutputsCount: coinbaseTxOutputs.count,
coinbaseTxOutputs: coinbaseTxOutputs.serialized,
coinbaseTxLocktime: 0,
};
}
private serializeFixedCoinbaseOutputs(template: IJobTemplate): { count: number; serialized: Buffer; spendableValue: bigint } {
const payoutOutputs = this.templateProvider.buildCoinbasePayoutOutputs(
this.getPayoutInformation(template),
template.blockData.coinbasevalue,
this.network,
);
const outputs: { value: bigint; script: Buffer }[] = payoutOutputs.map(output => ({
value: output.value,
script: output.scriptPubKey,
}));
const spendableValue = payoutOutputs.reduce((sum, output) => sum + output.value, 0n);
if (template.block.witnessCommit != null) {
const segwitMagicBits = Buffer.from('aa21a9ed', 'hex');
outputs.push({
value: 0n,
script: bitcoinjs.script.compile([
bitcoinjs.opcodes.OP_RETURN,
Buffer.concat([segwitMagicBits, template.block.witnessCommit]),
]),
});
}
return {
count: outputs.length,
serialized: Buffer.concat(outputs.map(output => this.serializeCoinbaseOutput(output.value, output.script))),
spendableValue,
};
}
private getPayoutInformation(template: IJobTemplate): AddressObject[] {
if (template.blockData.payoutOutputs?.length > 0) {
if (this.payoutMode === 'pplns' && template.blockData.payoutOutputs?.length > 0) {
return template.blockData.payoutOutputs;
}
return [{ address: this.poolPayoutAddress, percent: 100 }];
}
private serializeCoinbaseOutputs(coinbaseTx: bitcoinjs.Transaction): Buffer {
return Buffer.concat(coinbaseTx.outs.map(output => {
const value = Buffer.alloc(8);
value.writeBigUInt64LE(BigInt(output.value), 0);
return Buffer.concat([
value,
this.encodeBitcoinVarInt(output.script.length),
Buffer.from(output.script),
]);
}));
private serializeCoinbaseOutput(valueSats: bigint, script: Buffer): Buffer {
const value = Buffer.alloc(8);
value.writeBigUInt64LE(valueSats, 0);
return Buffer.concat([
value,
this.encodeBitcoinVarInt(script.length),
Buffer.from(script),
]);
}
private encodeBitcoinVarInt(value: number): Buffer {
@@ -369,6 +390,16 @@ export class Sv2TemplateDistributionConnection {
console.warn(`[SV2 TDP] SubmitSolution rejected locally: ${coinbaseValidation.errorCode}`);
return;
}
const payoutValidation = this.templateProvider.validateCoinbaseTransactionPayoutOutputs({
coinbaseTx: solution.coinbaseTx,
payoutInformation: this.getPayoutInformation(template.jobTemplate),
coinbaseValue: template.coinbaseValue,
network: this.network,
});
if (!payoutValidation.valid) {
console.warn(`[SV2 TDP] SubmitSolution rejected locally: ${payoutValidation.errorCode}`);
return;
}
const block = this.templateProvider.buildBlockFromSolution({
template,
coinbaseTx: solution.coinbaseTx,
@@ -390,13 +421,19 @@ export class Sv2TemplateDistributionConnection {
sessionId: solution.templateId.toString(16).slice(-8).padStart(8, '0'),
blockData: blockHex,
blockSubmissionResult: result,
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId ?? null,
});
await this.payoutSnapshotService.finalizeSnapshotForBlock({
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId,
blockHeight: template.height,
blockSubmissionResult: result,
payoutSnapshotId: this.payoutMode === 'pplns'
? template.jobTemplate.blockData.payoutSnapshotId ?? null
: null,
payoutMode: this.payoutMode,
});
if (this.payoutMode === 'pplns') {
await this.payoutSnapshotService.finalizeSnapshotForBlock({
payoutSnapshotId: template.jobTemplate.blockData.payoutSnapshotId,
blockHeight: template.height,
blockSubmissionResult: result,
payoutMode: this.payoutMode,
});
}
await this.notificationService.notifySubscribersBlockFound(
this.poolPayoutAddress || 'sv2-tdp',
template.height,
@@ -114,6 +114,49 @@ describe('TemplateProviderService', () => {
expect(validation.template).toBe(template);
});
it('accepts declared SV2 JDP script-only coinbase prefixes that start with the required BIP34 height', async () => {
const { provider, jobTemplate } = await createProvider();
const template = provider.upsert(jobTemplate);
const scriptOnlyPrefix = Buffer.concat([
provider.buildCoinbaseHeightPrefix(template.height),
Buffer.from('/public-pool-sri-jdc-e2e/', 'utf8'),
]);
const validation = provider.validateDeclaredWtxids({
version: MockRecording1.BLOCK_TEMPLATE.version,
coinbaseTxPrefix: scriptOnlyPrefix,
wtxidList: [],
});
expect(validation.valid).toBe(true);
expect(validation.template).toBe(template);
});
it('ignores zero-value extra outputs when validating coinbase payouts', async () => {
const { provider, jobTemplate } = await createProvider();
const coinbaseTx = createCoinbaseTransaction(provider.buildCoinbaseHeightPrefix(jobTemplate.blockData.height));
coinbaseTx.addOutput(
bitcoinjs.address.toOutputScript('tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2', bitcoinjs.networks.testnet),
0,
);
coinbaseTx.addOutput(
bitcoinjs.address.toOutputScript('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', bitcoinjs.networks.testnet),
jobTemplate.blockData.coinbasevalue,
);
const validation = provider.validateCoinbaseTransactionPayoutOutputs({
coinbaseTx: coinbaseTx.toBuffer(),
payoutInformation: [{
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
amountSats: jobTemplate.blockData.coinbasevalue,
}],
coinbaseValue: jobTemplate.blockData.coinbasevalue,
network: bitcoinjs.networks.testnet,
});
expect(validation.valid).toBe(true);
});
it('accepts DATUM template metadata matching the latest template fast path', async () => {
const { provider, jobTemplate } = await createProvider();
const template = provider.upsert(jobTemplate);
+109 -1
View File
@@ -3,6 +3,7 @@ import * as bitcoinjs from 'bitcoinjs-lib';
import { Subscription } from 'rxjs';
import { IBlockTemplateTx } from '../models/bitcoin-rpc/IBlockTemplate';
import { AddressObject } from '../models/MiningJob';
import { Sv2DeclareMiningJob, Sv2PushSolution } from '../models/sv2/sv2-jdp-messages';
import { hash256 } from '../utils/hash.utils';
import { BitcoinRpcService } from './bitcoin-rpc.service';
@@ -76,6 +77,18 @@ export interface CoinbaseHeightValidationResult {
errorCode?: string;
}
export interface CoinbasePayoutOutput {
value: bigint;
scriptPubKey: Buffer;
}
export interface CoinbasePayoutValidationResult {
valid: boolean;
errorCode?: string;
expectedOutputs?: CoinbasePayoutOutput[];
submittedOutputs?: CoinbasePayoutOutput[];
}
const VERSION_ROLLING_MASK = 0x1fffe000;
@Injectable()
@@ -211,10 +224,98 @@ export class TemplateProviderService implements OnModuleInit {
return this.validateCoinbaseScriptHeight(tx.ins[0].script, height);
}
public buildCoinbasePayoutOutputs(
payoutInformation: AddressObject[],
coinbaseValue: number | bigint,
network: bitcoinjs.networks.Network,
): CoinbasePayoutOutput[] {
const rewardValue = typeof coinbaseValue === 'bigint' ? coinbaseValue : BigInt(Math.max(0, Math.floor(coinbaseValue)));
let rewardBalance = rewardValue;
const outputs = payoutInformation.map(recipientAddress => {
const value = recipientAddress.amountSats == null
? BigInt(Math.floor(((recipientAddress.percent ?? 0) / 100) * Number(rewardValue)))
: BigInt(recipientAddress.amountSats);
rewardBalance -= value;
return {
value,
scriptPubKey: bitcoinjs.address.toOutputScript(recipientAddress.address, network),
};
});
if (outputs.length > 0 && rewardBalance !== 0n) {
outputs[0] = {
...outputs[0],
value: outputs[0].value + rewardBalance,
};
}
return outputs;
}
public validateCoinbaseTransactionPayoutOutputs(input: {
coinbaseTx: Buffer;
payoutInformation: AddressObject[];
coinbaseValue: number | bigint;
network: bitcoinjs.networks.Network;
}): CoinbasePayoutValidationResult {
const expectedOutputs = this.buildCoinbasePayoutOutputs(
input.payoutInformation,
input.coinbaseValue,
input.network,
);
if (expectedOutputs.length === 0) {
return { valid: false, errorCode: 'missing-expected-coinbase-payouts', expectedOutputs, submittedOutputs: [] };
}
let tx: bitcoinjs.Transaction;
try {
tx = bitcoinjs.Transaction.fromBuffer(input.coinbaseTx);
} catch (error) {
return {
valid: false,
errorCode: `invalid-coinbase-transaction:${error instanceof Error ? error.message : String(error)}`,
expectedOutputs,
submittedOutputs: [],
};
}
const submittedOutputs = tx.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: `coinbase-payout-output-count-mismatch:${expectedOutputs.length}:${submittedOutputs.length}`,
expectedOutputs,
submittedOutputs,
};
}
for (let i = 0; i < expectedOutputs.length; i++) {
const expected = expectedOutputs[i];
const submitted = submittedOutputs[i];
if (expected.value !== submitted.value || !expected.scriptPubKey.equals(submitted.scriptPubKey)) {
return {
valid: false,
errorCode: `coinbase-payout-output-mismatch:${i}`,
expectedOutputs,
submittedOutputs,
};
}
}
return { valid: true, expectedOutputs, submittedOutputs };
}
public validateDeclaredCoinbasePrefixHeight(coinbaseTxPrefix: Buffer, height: number): CoinbaseHeightValidationResult {
const scriptStart = this.getCoinbaseInputScriptStart(coinbaseTxPrefix);
if (scriptStart == null) {
return { valid: false, errorCode: 'invalid-job-param-value-coinbase_tx_prefix' };
return this.validateCoinbaseScriptHeight(coinbaseTxPrefix, height);
}
const expected = this.buildCoinbaseHeightPrefix(height);
if (coinbaseTxPrefix.length < scriptStart + expected.length) {
@@ -482,6 +583,13 @@ export class TemplateProviderService implements OnModuleInit {
return { valid: true };
}
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 getCoinbaseInputScriptStart(txPrefix: Buffer): number | null {
let cursor = 4;
if (txPrefix.length < cursor + 1) {