mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 17:15:03 -07:00
Add payout modes and production switchover schema
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user