Add SV2 DATUM payout hardening

This commit is contained in:
Ben
2026-06-12 23:54:50 -04:00
parent bcd6bb0aa7
commit a33c530268
59 changed files with 7852 additions and 81 deletions
+19
View File
@@ -64,6 +64,25 @@ describe('MiningJob', () => {
.toContain(`${extraNonce1}${extraNonce2}`);
});
it('should support exact satoshi payout outputs', () => {
const firstAmount = Math.floor(jobTemplate.blockData.coinbasevalue / 3);
const secondAmount = jobTemplate.blockData.coinbasevalue - firstAmount;
const exactPayoutJob = new MiningJob(
bitcoinjs.networks.testnet,
'2',
[
{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', amountSats: firstAmount },
{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', amountSats: secondAmount },
],
jobTemplate
);
const coinbase = exactPayoutJob.cloneCoinbaseTransaction();
expect(coinbase.outs[0].value).toBe(firstAmount);
expect(coinbase.outs[1].value).toBe(secondAmount);
expect(coinbase.outs[2].value).toBe(0);
});
it('should update block nonce, timestamp, version mask, and coinbase script', () => {
const extraNonce1 = '57a6f098';
const extraNonce2 = 'c708000000000000';
+6 -3
View File
@@ -8,9 +8,10 @@ import { IMiningNotify } from './stratum-messages/IMiningNotify';
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
interface AddressObject {
export interface AddressObject {
address: string;
percent: number;
percent?: number;
amountSats?: number;
}
export class MiningJob {
@@ -177,7 +178,9 @@ export class MiningJob {
let rewardBalance = reward;
addresses.forEach(recipientAddress => {
const amount = Math.floor((recipientAddress.percent / 100) * reward);
const amount = recipientAddress.amountSats == null
? Math.floor(((recipientAddress.percent ?? 0) / 100) * reward)
: recipientAddress.amountSats;
rewardBalance -= amount;
coinbaseTransaction.addOutput(this.getPaymentScript(recipientAddress.address), amount);
})
+1
View File
@@ -646,6 +646,7 @@ describe('StratumV1Client', () => {
});
it('should submit and persist found blocks', async () => {
(bitcoinRpcService.SUBMIT_BLOCK as jest.Mock).mockResolvedValue('SUCCESS!');
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: Number.MAX_SAFE_INTEGER,
+28 -8
View File
@@ -11,6 +11,7 @@ import { AddressSettingsService } from '../ORM/address-settings/address-settings
import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientEntity } from '../ORM/client/client.entity';
import { ClientService } from '../ORM/client/client.service';
import { PayoutSnapshotService } from '../ORM/payout-snapshot/payout-snapshot.service';
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
import { BitcoinRpcService } from '../services/bitcoin-rpc.service';
import { NotificationService } from '../services/notification.service';
@@ -21,7 +22,7 @@ import { hash256 } from '../utils/hash.utils';
import { eRequestMethod } from './enums/eRequestMethod';
import { eResponseMethod } from './enums/eResponseMethod';
import { eStratumErrorCode } from './enums/eStratumErrorCode';
import { MiningJob } from './MiningJob';
import { AddressObject, MiningJob } from './MiningJob';
import { AuthorizationMessage } from './stratum-messages/AuthorizationMessage';
import { ConfigurationMessage } from './stratum-messages/ConfigurationMessage';
import { MiningSubmitMessage } from './stratum-messages/MiningSubmitMessage';
@@ -79,7 +80,9 @@ export class StratumV1Client {
private readonly configService: ConfigService,
private readonly addressSettingsService: AddressSettingsService,
private readonly shareAccountingService?: ShareAccountingService,
private readonly redisMessagingService?: RedisMessagingService
private readonly redisMessagingService?: RedisMessagingService,
private readonly payoutSnapshotService?: PayoutSnapshotService,
private readonly accountingProtocol: 'sv1' | 'sv1_tls' = 'sv1',
) {
this.socketDataHandler = (data: Buffer) => {
@@ -445,9 +448,7 @@ export class StratumV1Client {
private async sendNewMiningJob(jobTemplate: IJobTemplate) {
let payoutInformation= [
{ address: this.clientAuthorization.address, percent: 100 }
];
let payoutInformation = this.getPayoutInformation(jobTemplate, this.clientAuthorization.address);
// const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
// //50Th/s
// this.noFee = false;
@@ -648,19 +649,26 @@ export class StratumV1Client {
minerAddress: this.clientAuthorization.address,
worker: this.clientAuthorization.worker,
sessionId: this.extraNonceAndSessionId,
blockData: blockHex
blockData: blockHex,
blockSubmissionResult,
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId ?? null,
});
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: jobTemplate.blockData.height,
blockSubmissionResult,
});
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, blockSubmissionResult);
//success
if (blockSubmissionResult == null) {
if (this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
await this.addressSettingsService.resetBestDifficultyAndShares();
}
}
await this.ensureClientEntity();
try {
await this.shareAccountingService?.recordAcceptedShare({
protocol: 'sv1',
protocol: this.accountingProtocol,
address: this.clientAuthorization.address,
clientName: this.clientAuthorization.worker,
sessionId: this.extraNonceAndSessionId,
@@ -877,6 +885,18 @@ export class StratumV1Client {
return Number.isFinite(configured) && configured > 0 ? configured : null;
}
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] {
if (this.configService.get('PAYOUT_COINBASE_MODE') === 'snapshot' && jobTemplate.blockData.payoutOutputs?.length > 0) {
return jobTemplate.blockData.payoutOutputs;
}
return [{ address: fallbackAddress, percent: 100 }];
}
private isSuccessfulBlockSubmission(result?: string | null): boolean {
return result == null || result === 'SUCCESS!';
}
private closeSocket() {
this.connectionClosed = true;
if (!this.socket.destroyed) {
+110 -7
View File
@@ -5,16 +5,22 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { ClientEntity } from '../ORM/client/client.entity';
import { CustomWorkService } from '../services/custom-work.service';
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { BufferReader } from './sv2/sv2-binary-codec';
import { SV2_CHANNEL_MSG_FLAG, Sv2MsgType } from './sv2/sv2-constants';
import { SV2_CHANNEL_MSG_FLAG, Sv2MiningSetupFlags, Sv2MsgType, Sv2Protocol } from './sv2/sv2-constants';
import {
deserializeOpenExtendedMiningChannelSuccess,
deserializeNewExtendedMiningJob,
serializeOpenExtendedMiningChannel,
serializeSubmitSharesExtended,
} from './sv2/sv2-extended-messages';
import { deserializeSetNewPrevHash, deserializeSubmitSharesError } from './sv2/sv2-messages';
import { deserializeSetNewPrevHash, deserializeSubmitSharesError, serializeSetupConnection } from './sv2/sv2-messages';
import {
deserializeSetCustomMiningJobError,
deserializeSetCustomMiningJobSuccess,
serializeSetCustomMiningJob,
} from './sv2/sv2-jdp-messages';
import { MiningJob } from './MiningJob';
import { StratumV2Client } from './StratumV2Client';
@@ -60,7 +66,7 @@ describe('StratumV2Client extended channels', () => {
const success = deserializeOpenExtendedMiningChannelSuccess(new BufferReader(successFrame.payload));
expect(success.requestId).toBe(7);
expect(success.channelId).toBe(1);
expect(success.extranonceSize).toBe(8);
expect(success.extranonceSize).toBe(10);
expect(success.extranoncePrefix).toEqual(Buffer.from('00000001', 'hex'));
expect(jobFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG);
@@ -109,6 +115,89 @@ describe('StratumV2Client extended channels', () => {
expect(error.errorCode).toBe('invalid-extranonce-size');
});
it('accepts SV2 work-selection setup and SetCustomMiningJob without sending pool work', async () => {
const { client, sentFrames } = await createClient();
await (client as any).handleSetupConnection(serializeSetupConnection({
protocol: Sv2Protocol.MINING,
minVersion: 2,
maxVersion: 2,
flags: Sv2MiningSetupFlags.REQUIRES_WORK_SELECTION | Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING,
endpoint_host: 'localhost',
endpoint_port: 3333,
vendor: 'jd-client',
hardwareVersion: '',
firmwareVersion: '',
deviceId: '',
}));
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
requestId: 1,
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
nominalHashRate: 0,
maxTarget: Buffer.alloc(32, 0xff),
minExtranonceSize: 8,
}));
expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB)).toBe(false);
sentFrames.length = 0;
await (client as any).handleSetCustomMiningJob(serializeSetCustomMiningJob({
channelId: 1,
requestId: 99,
token: Buffer.from('aa', 'hex'),
version: MockRecording1.BLOCK_TEMPLATE.version,
prevHash: Buffer.alloc(32, 1),
minNtime: parseInt(MockRecording1.TIME, 16),
nBits: parseInt(MockRecording1.BLOCK_TEMPLATE.bits, 16),
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from('51', 'hex'),
coinbaseTxInputNSequence: 0xffffffff,
coinbaseTxOutputs: Buffer.from('010000000000000000016a', 'hex'),
coinbaseTxLocktime: 0,
merklePath: [],
}));
const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SET_CUSTOM_MINING_JOB_SUCCESS);
const success = deserializeSetCustomMiningJobSuccess(new BufferReader(successFrame.payload));
expect(success.channelId).toBe(1);
expect(success.requestId).toBe(99);
expect((client as any).channels.get(1).extendedJobs.get(success.jobId).workProtocol).toBe('sv2_jdp');
});
it('rejects SetCustomMiningJob when work selection was not negotiated', async () => {
const { client, sentFrames } = await createClient();
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
requestId: 1,
userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker',
nominalHashRate: 0,
maxTarget: Buffer.alloc(32, 0xff),
minExtranonceSize: 8,
}));
sentFrames.length = 0;
await (client as any).handleSetCustomMiningJob(serializeSetCustomMiningJob({
channelId: 1,
requestId: 100,
token: Buffer.from('aa', 'hex'),
version: MockRecording1.BLOCK_TEMPLATE.version,
prevHash: Buffer.alloc(32, 1),
minNtime: parseInt(MockRecording1.TIME, 16),
nBits: parseInt(MockRecording1.BLOCK_TEMPLATE.bits, 16),
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from('51', 'hex'),
coinbaseTxInputNSequence: 0xffffffff,
coinbaseTxOutputs: Buffer.from('010000000000000000016a', 'hex'),
coinbaseTxLocktime: 0,
merklePath: [],
}));
const errorFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR);
const error = deserializeSetCustomMiningJobError(new BufferReader(errorFrame.payload));
expect(error.channelId).toBe(1);
expect(error.requestId).toBe(100);
expect(error.errorCode).toBe('work-selection-not-negotiated');
expect((client as any).channels.get(1).extendedJobs.has(100)).toBe(false);
});
it('records accepted SV2 shares before presence updates', async () => {
const { client, shareAccountingService, redisMessagingService, jobTemplate } = await createClient();
(client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
@@ -184,7 +273,8 @@ describe('StratumV2Client extended channels', () => {
});
it('submits a block when the SV2 hash target exactly meets network target', async () => {
const { client, bitcoinRpcService, blocksService, notificationService, jobTemplate } = await createClient();
const { client, bitcoinRpcService, blocksService, notificationService, addressSettingsService, jobTemplate } = await createClient();
bitcoinRpcService.SUBMIT_BLOCK.mockResolvedValue('SUCCESS!');
(client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
(client as any).workerName = 'worker';
(client as any).sessionId = 'sv2-session';
@@ -220,6 +310,7 @@ describe('StratumV2Client extended channels', () => {
blockData: expect.any(String),
}));
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled();
expect(addressSettingsService.resetBestDifficultyAndShares).toHaveBeenCalled();
});
async function createClient(): Promise<{
@@ -228,6 +319,7 @@ describe('StratumV2Client extended channels', () => {
bitcoinRpcService: { SUBMIT_BLOCK: jest.Mock };
blocksService: { save: jest.Mock };
notificationService: { notifySubscribersBlockFound: jest.Mock };
addressSettingsService: { resetBestDifficultyAndShares: jest.Mock; updateBestDifficultyIfHigher: jest.Mock };
shareAccountingService: { recordAcceptedShare: jest.Mock };
redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock };
jobTemplate: any;
@@ -286,6 +378,10 @@ describe('StratumV2Client extended channels', () => {
setClientPresence: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
};
const addressSettingsService = {
resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined),
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
};
const client = new StratumV2Client(
socket,
Buffer.alloc(0),
@@ -306,7 +402,8 @@ describe('StratumV2Client extended channels', () => {
generateExtranoncePrefix: () => Buffer.from('00000002', 'hex'),
allocateExtendedExtranoncePrefix: jest.fn(() => Buffer.from('00000001', 'hex')),
releaseExtendedExtranoncePrefix: jest.fn(),
getExtendedMinerExtranonceSize: () => 8,
getExtendedMinerExtranonceSize: () => 10,
getExtendedTotalExtranonceSize: () => 14,
} as any,
stratumV1JobsService,
bitcoinRpcService as any,
@@ -325,9 +422,14 @@ describe('StratumV2Client extended channels', () => {
}
}),
} as unknown as ConfigService,
addressSettingsService as any,
new CustomWorkService(),
{
resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined),
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
hasKnownToken: jest.fn().mockReturnValue(true),
getDeclaredJob: jest.fn().mockReturnValue({
validationMode: 'full_template',
job: { version: MockRecording1.BLOCK_TEMPLATE.version },
}),
} as any,
shareAccountingService as any,
redisMessagingService as any,
@@ -343,6 +445,7 @@ describe('StratumV2Client extended channels', () => {
bitcoinRpcService,
blocksService,
notificationService,
addressSettingsService,
shareAccountingService,
redisMessagingService,
jobTemplate,
+182 -20
View File
@@ -9,18 +9,20 @@ import { AddressSettingsService } from '../ORM/address-settings/address-settings
import { BlocksService } from '../ORM/blocks/blocks.service';
import { ClientEntity } from '../ORM/client/client.entity';
import { ClientService } from '../ORM/client/client.service';
import { PayoutSnapshotService } from '../ORM/payout-snapshot/payout-snapshot.service';
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
import { BitcoinRpcService } from '../services/bitcoin-rpc.service';
import { CustomWorkService } from '../services/custom-work.service';
import { NotificationService } from '../services/notification.service';
import { RedisMessagingService } from '../services/redis-messaging.service';
import { StratumV2Service } from '../services/stratum-v2.service';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { Sv2JobDeclarationRegistryService } from '../services/sv2-job-declaration-registry.service';
import { patchCoinbasePrefixVarint } from '../utils/coinbase-prefix.utils';
import { DifficultyUtils } from '../utils/difficulty.utils';
import { hash256 } from '../utils/hash.utils';
import { MiningJob } from './MiningJob';
import { AddressObject, MiningJob } from './MiningJob';
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
import { TOTAL_EXTRANONCE_SIZE_BYTES } from './stratum.constants';
import { BufferReader } from './sv2/sv2-binary-codec';
import {
SV2_CHANNEL_MSG_FLAG,
@@ -57,6 +59,11 @@ import {
Sv2SubmitSharesExtended,
} from './sv2/sv2-extended-messages';
import { Sv2NoiseSession } from './sv2/sv2-noise';
import {
deserializeSetCustomMiningJob,
serializeSetCustomMiningJobError,
serializeSetCustomMiningJobSuccess,
} from './sv2/sv2-jdp-messages';
const DEFAULT_START_DIFFICULTY = 100000;
const DEFAULT_MIN_DIFFICULTY = 0.001;
@@ -77,6 +84,7 @@ interface ExtendedJobData {
miningJob: MiningJob;
retiredAt?: number;
creation: number;
workProtocol?: 'pool' | 'sv2_jdp';
}
interface ChannelState {
@@ -123,6 +131,7 @@ export class StratumV2Client {
private clientEntity: ClientEntity = null;
private creatingEntity: Promise<void> = null;
private readonly firstChunkSummary: string;
private workSelectionEnabled = false;
constructor(
private readonly socket: Socket,
@@ -135,8 +144,11 @@ export class StratumV2Client {
private readonly blocksService: BlocksService,
private readonly configService: ConfigService,
private readonly addressSettingsService: AddressSettingsService,
private readonly customWorkService: CustomWorkService,
private readonly jobDeclarationRegistry: Sv2JobDeclarationRegistryService,
private readonly shareAccountingService?: ShareAccountingService,
private readonly redisMessagingService?: RedisMessagingService,
private readonly payoutSnapshotService?: PayoutSnapshotService,
) {
this.firstChunkSummary = this.describeChunk(firstChunk);
this.noiseSession = new Sv2NoiseSession(this.stratumV2Service.getNoiseConfig());
@@ -258,6 +270,9 @@ export class StratumV2Client {
case Sv2MsgType.SUBMIT_SHARES_EXTENDED:
await this.handleSubmitSharesExtended(payload);
break;
case Sv2MsgType.SET_CUSTOM_MINING_JOB:
await this.handleSetCustomMiningJob(payload);
break;
case Sv2MsgType.UPDATE_CHANNEL:
await this.handleUpdateChannel(payload);
break;
@@ -299,6 +314,7 @@ export class StratumV2Client {
}
const supportedFlags = Sv2MiningSetupFlags.REQUIRES_STANDARD_JOBS
| Sv2MiningSetupFlags.REQUIRES_WORK_SELECTION
| Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING;
const unsupportedFlags = message.flags & ~supportedFlags;
if (unsupportedFlags !== 0) {
@@ -314,6 +330,7 @@ export class StratumV2Client {
}
const versionRolling = (message.flags & Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING) !== 0;
this.workSelectionEnabled = (message.flags & Sv2MiningSetupFlags.REQUIRES_WORK_SELECTION) !== 0;
const successFlags = versionRolling ? 0 : Sv2MiningSetupSuccessFlags.REQUIRES_FIXED_VERSION;
await this.sendFrame(
Sv2MsgType.SETUP_CONNECTION_SUCCESS,
@@ -399,10 +416,12 @@ export class StratumV2Client {
}),
);
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
await this.sendNewMiningJob(channel, jobTemplate, true);
if (!this.workSelectionEnabled) {
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
await this.sendNewMiningJob(channel, jobTemplate, true);
}
if (isFirstChannel) {
if (isFirstChannel && !this.workSelectionEnabled) {
this.subscribeToJobs();
this.startDifficultyTimer();
}
@@ -436,14 +455,18 @@ export class StratumV2Client {
const channelId = this.stratumV2Service.getNextChannelId();
const extranoncePrefix = this.stratumV2Service.allocateExtendedExtranoncePrefix(channelId);
const maxMinerExtranonceSize = Math.max(0, TOTAL_EXTRANONCE_SIZE_BYTES - extranoncePrefix.length);
const maxMinerExtranonceSize = Math.max(0, this.stratumV2Service.getExtendedTotalExtranonceSize() - extranoncePrefix.length);
const defaultMinerExtranonceSize = Math.min(
this.stratumV2Service.getExtendedMinerExtranonceSize(),
maxMinerExtranonceSize,
);
const extranonceSize = message.minExtranonceSize > 0
? Math.min(message.minExtranonceSize, maxMinerExtranonceSize)
: defaultMinerExtranonceSize;
const requestedMinerExtranonceSize = Math.max(0, message.minExtranonceSize);
const extranonceSize = Math.max(defaultMinerExtranonceSize, requestedMinerExtranonceSize);
if (extranonceSize > maxMinerExtranonceSize) {
this.stratumV2Service.releaseExtendedExtranoncePrefix(channelId);
await this.sendOpenChannelError(message.requestId, 'min-extranonce-size-too-large');
return;
}
let channelDifficulty = this.sessionDifficulty;
if (Number.isFinite(message.nominalHashRate) && message.nominalHashRate > 0) {
@@ -493,10 +516,12 @@ export class StratumV2Client {
}),
);
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
await this.sendNewExtendedMiningJob(channel, jobTemplate, true);
if (!this.workSelectionEnabled) {
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
await this.sendNewExtendedMiningJob(channel, jobTemplate, true);
}
if (isFirstChannel) {
if (isFirstChannel && !this.workSelectionEnabled) {
this.subscribeToJobs();
this.startDifficultyTimer();
}
@@ -651,11 +676,16 @@ export class StratumV2Client {
channel.acceptedShareCount++;
let updatedJobBlock: bitcoinjs.Block = null;
let blockCandidateResult: string | null = null;
if (DifficultyUtils.meetsTarget(
hashBuffer,
DifficultyUtils.difficultyToTarget(extendedJob.jobTemplate.blockData.networkDifficulty),
)) {
updatedJobBlock = this.reconstructExtendedBlock(extendedJob, submission, merkleRoot, channel.extranoncePrefix);
if (extendedJob.workProtocol === 'sv2_jdp') {
blockCandidateResult = 'sv2-jdp-client-submit-expected';
} else {
updatedJobBlock = this.reconstructExtendedBlock(extendedJob, submission, merkleRoot, channel.extranoncePrefix);
}
}
await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, extendedJob.jobTemplate, updatedJobBlock, {
jobId: submission.jobId.toString(16),
@@ -663,9 +693,119 @@ export class StratumV2Client {
ntime: submission.ntime,
version: submission.version,
extraNonce2: submission.extranonce.toString('hex'),
}, {
workProtocol: extendedJob.workProtocol ?? 'pool',
blockCandidateResult,
});
}
private async handleSetCustomMiningJob(payload: Buffer): Promise<void> {
const msg = deserializeSetCustomMiningJob(new BufferReader(payload));
if (!this.workSelectionEnabled) {
await this.sendFrame(
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
serializeSetCustomMiningJobError({
channelId: msg.channelId,
requestId: msg.requestId,
errorCode: 'work-selection-not-negotiated',
}),
);
return;
}
const channel = this.channels.get(msg.channelId);
if (channel == null) {
await this.sendFrame(
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
serializeSetCustomMiningJobError({
channelId: msg.channelId,
requestId: msg.requestId,
errorCode: 'invalid-channel-id',
}),
);
return;
}
if (channel.channelType !== 'extended') {
await this.sendFrame(
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
serializeSetCustomMiningJobError({
channelId: msg.channelId,
requestId: msg.requestId,
errorCode: 'invalid-channel-type',
}),
);
return;
}
const declaredJob = this.jobDeclarationRegistry?.getDeclaredJob(msg.token);
if (declaredJob == null || declaredJob.validationMode !== 'full_template') {
await this.sendFrame(
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
serializeSetCustomMiningJobError({
channelId: msg.channelId,
requestId: msg.requestId,
errorCode: 'invalid-mining-job-token',
}),
);
return;
}
if (declaredJob.job.version !== msg.version) {
await this.sendFrame(
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
serializeSetCustomMiningJobError({
channelId: msg.channelId,
requestId: msg.requestId,
errorCode: 'declared-job-mismatch',
}),
);
return;
}
const latestTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
const jobIdHex = this.stratumV1JobsService.getNextId();
const jobId = parseInt(jobIdHex, 16);
const split = this.customWorkService.buildSv2CoinbaseSplit(
msg,
channel.extranoncePrefix.length + channel.extranonceSize,
);
const placeholderJob = new MiningJob(
this.network,
jobIdHex,
this.getPayoutInformation(latestTemplate, this.address),
latestTemplate,
);
this.stratumV1JobsService.addJob(placeholderJob);
channel.jobIdToDifficulty.set(jobId, channel.sessionDifficulty);
channel.extendedJobs.set(jobId, {
coinbasePrefix: split.coinbasePrefix,
coinbaseSuffix: split.coinbaseSuffix,
merklePath: msg.merklePath.map(branch => Buffer.from(branch)),
prevHash: Buffer.from(msg.prevHash),
nBits: msg.nBits,
minNtime: msg.minNtime,
jobTemplate: {
...latestTemplate,
block: Object.assign(new bitcoinjs.Block(), latestTemplate.block, {
prevHash: Buffer.from(msg.prevHash),
bits: msg.nBits,
version: msg.version,
timestamp: msg.minNtime,
}),
},
miningJob: placeholderJob,
creation: Date.now(),
workProtocol: 'sv2_jdp',
});
await this.sendFrame(
Sv2MsgType.SET_CUSTOM_MINING_JOB_SUCCESS,
serializeSetCustomMiningJobSuccess({
channelId: msg.channelId,
requestId: msg.requestId,
jobId,
}),
);
}
private async handleAcceptedShare(
submission: { nonce: number; ntime: number; version: number },
channel: ChannelState,
@@ -706,10 +846,11 @@ export class StratumV2Client {
jobTemplate: IJobTemplate,
updatedJobBlock: bitcoinjs.Block | null,
share: { jobId: string; nonce: number; ntime: number; version: number; extraNonce2: string },
metadata: { workProtocol?: 'pool' | 'sv2_jdp'; blockCandidateResult?: string | null } = {},
): Promise<void> {
await this.ensureClientEntity();
let blockSubmissionResult: string = null;
let blockSubmissionResult: string = metadata.blockCandidateResult ?? null;
if (updatedJobBlock != null) {
console.log(`[SV2 ${this.sessionId}] BLOCK FOUND at height ${jobTemplate.blockData.height}`);
const blockHex = updatedJobBlock.toHex(false);
@@ -720,6 +861,13 @@ export class StratumV2Client {
worker: this.workerName,
sessionId: this.sessionId,
blockData: blockHex,
blockSubmissionResult,
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId ?? null,
});
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: jobTemplate.blockData.height,
blockSubmissionResult,
});
await this.notificationService.notifySubscribersBlockFound(
this.address,
@@ -727,13 +875,15 @@ export class StratumV2Client {
updatedJobBlock,
blockSubmissionResult,
);
if (blockSubmissionResult == null) {
if (this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
await this.addressSettingsService.resetBestDifficultyAndShares();
}
}
await this.shareAccountingService?.recordAcceptedShare({
protocol: 'sv2',
protocol: metadata.workProtocol === 'sv2_jdp' ? 'sv2_jdp' : 'sv2',
workSource: metadata.workProtocol === 'sv2_jdp' ? 'miner_template' : 'pool_template',
workProtocol: metadata.workProtocol ?? 'pool',
address: this.address,
clientName: this.workerName,
sessionId: this.sessionId,
@@ -748,7 +898,7 @@ export class StratumV2Client {
ntime: share.ntime,
version: share.version,
extraNonce2: share.extraNonce2,
isBlockCandidate: updatedJobBlock != null,
isBlockCandidate: updatedJobBlock != null || metadata.blockCandidateResult != null,
blockSubmissionResult,
});
await this.statistics.addShares(this.clientEntity, jobDifficulty);
@@ -782,7 +932,7 @@ export class StratumV2Client {
const coinbaseTx = extendedJob.miningJob.cloneCoinbaseTransaction();
const script = coinbaseTx.ins[0].script;
coinbaseTx.ins[0].script = Buffer.concat([
script.subarray(0, script.length - TOTAL_EXTRANONCE_SIZE_BYTES),
script.subarray(0, script.length - (extranoncePrefix.length + submission.extranonce.length)),
extranoncePrefix,
submission.extranonce,
]);
@@ -928,7 +1078,7 @@ export class StratumV2Client {
const job = new MiningJob(
this.network,
jobIdHex,
[{ address: this.address, percent: 100 }],
this.getPayoutInformation(jobTemplate, this.address),
jobTemplate,
);
this.stratumV1JobsService.addJob(job);
@@ -982,7 +1132,7 @@ export class StratumV2Client {
const job = new MiningJob(
this.network,
jobIdHex,
[{ address: this.address, percent: 100 }],
this.getPayoutInformation(jobTemplate, this.address),
jobTemplate,
);
this.stratumV1JobsService.addJob(job);
@@ -1243,6 +1393,18 @@ export class StratumV2Client {
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS;
}
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] {
if (this.configService.get('PAYOUT_COINBASE_MODE') === 'snapshot' && jobTemplate.blockData.payoutOutputs?.length > 0) {
return jobTemplate.blockData.payoutOutputs;
}
return [{ address: fallbackAddress, percent: 100 }];
}
private isSuccessfulBlockSubmission(result?: string | null): boolean {
return result == null || result === 'SUCCESS!';
}
private getNetwork(): bitcoinjs.networks.Network {
const networkConfig = this.configService.get('NETWORK');
if (networkConfig === 'mainnet') {
+10 -1
View File
@@ -7,6 +7,13 @@ export interface IBlockTemplateTx {
sigops: number, // (numeric) total SigOps cost, as counted for purposes of block limits; if key is not present, sigop cost is unknown and clients MUST NOT assume it is zero
weight: number // (numeric) total transaction weight, as counted for purposes of block limits
}
export interface IBlockTemplatePayoutOutput {
address: string;
percent?: number;
amountSats?: number;
}
export interface IBlockTemplate {
// (json object)
version: number; // (numeric) The preferred block version
@@ -35,5 +42,7 @@ export interface IBlockTemplate {
height: number, // (numeric) The height of the next block
default_witness_commitment: string // (string, optional) a valid witness commitment for the unmodified block template
capabilities: string[]
payoutSnapshotId?: string;
payoutOutputs?: IBlockTemplatePayoutOutput[];
}
}
+257
View File
@@ -0,0 +1,257 @@
import {
DATUM_INITIAL_HEADER_KEY,
DATUM_EXTRANONCE_SIZE,
DatumFrameReader,
DatumMiningCommand,
DatumProtocolCommand,
DatumShareResponseStatus,
datumHeaderXorFeedback,
decodeDatumHeader,
deserializeDatumCoinbaserFetch,
deserializeDatumJobValidationResponse,
deserializeDatumMiningCommand,
deserializeDatumPowSubmit,
encodeDatumFrame,
encodeDatumHeader,
serializeDatumJobValidationFullTransactionBlobRequest,
serializeDatumJobValidationFullTransactionsRequest,
serializeDatumJobValidationShortTxIdsRequest,
serializeDatumCoinbaserFetchResponse,
serializeDatumMiningCommand,
serializeDatumShareResponse,
} from './datum-codec';
describe('datum-codec', () => {
it('round-trips DATUM packed headers with XOR key', () => {
const encoded = encodeDatumHeader({
cmdLen: 1234,
reserved: 0,
isSigned: true,
isEncryptedPubkey: false,
isEncryptedChannel: true,
protoCmd: DatumProtocolCommand.MINING,
}, DATUM_INITIAL_HEADER_KEY);
expect(decodeDatumHeader(encoded, DATUM_INITIAL_HEADER_KEY)).toEqual({
cmdLen: 1234,
reserved: 0,
isSigned: true,
isEncryptedPubkey: false,
isEncryptedChannel: true,
protoCmd: DatumProtocolCommand.MINING,
});
});
it('matches the DATUM header feedback function for stable inputs', () => {
expect(datumHeaderXorFeedback(0).toString(16)).toBe('74a55cf6');
expect(datumHeaderXorFeedback(0xdc871829).toString(16)).toBe('88e1697d');
});
it('splits framed DATUM payloads', () => {
const first = encodeDatumFrame({
header: {
cmdLen: 0,
reserved: 0,
isSigned: false,
isEncryptedPubkey: false,
isEncryptedChannel: true,
protoCmd: DatumProtocolCommand.MINING,
},
payload: serializeDatumMiningCommand(DatumMiningCommand.TEMPLATE_REFRESH),
});
const second = encodeDatumFrame({
header: {
cmdLen: 0,
reserved: 0,
isSigned: false,
isEncryptedPubkey: false,
isEncryptedChannel: true,
protoCmd: DatumProtocolCommand.MINING,
},
payload: serializeDatumMiningCommand(DatumMiningCommand.FETCH_COINBASER, Buffer.alloc(8)),
}, datumHeaderXorFeedback(DATUM_INITIAL_HEADER_KEY));
const frames = new DatumFrameReader().feed(Buffer.concat([first, second]));
expect(frames).toHaveLength(2);
expect(deserializeDatumMiningCommand(frames[0].payload).command).toBe(DatumMiningCommand.TEMPLATE_REFRESH);
expect(deserializeDatumMiningCommand(frames[1].payload).command).toBe(DatumMiningCommand.FETCH_COINBASER);
});
it('serializes coinbaser fetch responses', () => {
const response = serializeDatumCoinbaserFetchResponse(50n, [
{ value: 50n, scriptPubKey: Buffer.from('6a', 'hex') },
]);
const mining = deserializeDatumMiningCommand(response);
expect(mining.command).toBe(DatumMiningCommand.FETCH_COINBASER_RESPONSE);
expect(mining.body.readBigUInt64LE(0)).toBe(50n);
expect(mining.body.readUInt32LE(8)).toBe(11);
});
it('deserializes coinbaser fetch requests', () => {
const payload = Buffer.alloc(8);
payload.writeBigUInt64LE(123n, 0);
expect(deserializeDatumCoinbaserFetch(payload)).toEqual({ rewardValue: 123n });
});
it('deserializes fixed DATUM POW submissions', () => {
const username = Buffer.from('bc1ptest.worker\0', 'utf8');
const extranonce = Buffer.from('000102030405060708090a0b', 'hex');
const payload = Buffer.alloc(17 + DATUM_EXTRANONCE_SIZE);
payload[0] = 7;
payload[1] = 1;
payload[2] = 0x01;
payload[3] = 4;
payload.writeUInt32LE(100, 4);
payload.writeUInt32LE(200, 8);
payload.writeUInt32LE(0x20000000, 12);
payload[16] = DATUM_EXTRANONCE_SIZE;
extranonce.copy(payload, 17);
const parsed = deserializeDatumPowSubmit(Buffer.concat([
payload,
username,
Buffer.alloc(4),
Buffer.from([0xfe]),
]));
expect(parsed.jobId).toBe(7);
expect(parsed.coinbaseId).toBe(1);
expect(parsed.isBlock).toBe(true);
expect(parsed.extranonce).toEqual(extranonce);
expect(parsed.username).toBe('bc1ptest.worker');
});
it('deserializes cached DATUM POW context sections', () => {
const username = Buffer.from('bc1ptest.worker\0', 'utf8');
const extranonce = Buffer.from('000102030405060708090a0b', 'hex');
const base = Buffer.alloc(17 + DATUM_EXTRANONCE_SIZE);
base[0] = 3;
base[1] = 2;
base[2] = 0;
base[3] = 0x12;
base.writeUInt32LE(100, 4);
base.writeUInt32LE(200, 8);
base.writeUInt32LE(0x20000000, 12);
base[16] = DATUM_EXTRANONCE_SIZE;
extranonce.copy(base, 17);
const templateSection = Buffer.concat([
Buffer.from([0x01]),
Buffer.alloc(32, 0xaa),
Buffer.from([0x09, 0x00]),
Buffer.from('ffff7f20', 'hex'),
Buffer.from([7]),
Buffer.from('64000000', 'hex'),
Buffer.from('0100000000000000', 'hex'),
Buffer.from('02000000', 'hex'),
Buffer.from('03000000', 'hex'),
Buffer.from('04000000', 'hex'),
Buffer.from('05000000', 'hex'),
Buffer.from([1]),
Buffer.alloc(32, 0xbb),
]);
const coinbaseSection = Buffer.concat([
Buffer.from([0x02, 2]),
Buffer.from([0x02, 0x00]),
Buffer.from([0x03, 0x00]),
Buffer.from('abcd', 'hex'),
Buffer.from('010203', 'hex'),
]);
const parsed = deserializeDatumPowSubmit(Buffer.concat([
base,
username,
Buffer.alloc(4),
templateSection,
coinbaseSection,
Buffer.from([0xfe]),
]));
expect(parsed.jobId).toBe(3);
expect(parsed.coinbaseId).toBe(2);
expect(parsed.targetByte).toBe(0x12);
expect(parsed.prevBlockHash?.equals(Buffer.alloc(32, 0xaa))).toBe(true);
expect(parsed.targetByteIndex).toBe(9);
expect(parsed.nBits?.toString('hex')).toBe('ffff7f20');
expect(parsed.height).toBe(100);
expect(parsed.merkleBranches?.[0].equals(Buffer.alloc(32, 0xbb))).toBe(true);
expect(parsed.coinbasePairs.get(2)?.coinb1.toString('hex')).toBe('abcd');
expect(parsed.coinbasePairs.get(2)?.coinb2.toString('hex')).toBe('010203');
});
it('rejects DATUM POW submissions with non-standard extranonce sizes', () => {
const payload = Buffer.alloc(18);
payload[16] = 1;
payload[17] = 0xaa;
expect(() => deserializeDatumPowSubmit(payload)).toThrow('Unsupported DATUM extranonce size 1');
});
it('serializes share responses', () => {
const response = serializeDatumShareResponse({
status: DatumShareResponseStatus.ACCEPTED,
reasonCode: 0,
nonce: 123,
targetByte: 4,
jobId: 7,
});
expect(response.toString('hex')).toBe('8f5000007b0000000407');
});
it('serializes DATUM job validation requests', () => {
expect(serializeDatumJobValidationShortTxIdsRequest(7).toString('hex')).toBe('501007');
expect(serializeDatumJobValidationFullTransactionBlobRequest(7).toString('hex')).toBe('501207');
expect(serializeDatumJobValidationFullTransactionsRequest(7, [1, 258]).toString('hex')).toBe('501107020001000201');
});
it('deserializes DATUM short transaction ID validation responses', () => {
const payload = Buffer.concat([
Buffer.from([0x90, 7, 0x01]),
Buffer.from('0200', 'hex'),
Buffer.from('010203040506', 'hex'),
Buffer.from('111213141516', 'hex'),
Buffer.alloc(32, 0xaa),
Buffer.from([0xfe, 0x00, 0x00]),
]);
const parsed = deserializeDatumJobValidationResponse(payload);
expect(parsed.kind).toBe('short-txids');
expect(parsed.jobId).toBe(7);
expect(parsed.status).toBe(1);
expect(parsed.transactionCount).toBe(2);
if (parsed.kind !== 'short-txids') {
throw new Error(`Unexpected DATUM validation response kind ${parsed.kind}`);
}
expect(parsed.shortTxIds.map(id => id.toString('hex'))).toEqual(['010203040506', '111213141516']);
expect(parsed.crosscheck?.equals(Buffer.alloc(32, 0xaa))).toBe(true);
});
it('deserializes DATUM full transaction blob validation responses', () => {
const tx = Buffer.from(
'0100000001'
+ '0000000000000000000000000000000000000000000000000000000000000000'
+ 'ffffffff00ffffffff'
+ '01000000000000000000'
+ '00000000',
'hex',
);
const txSize = Buffer.alloc(3);
txSize.writeUIntLE(tx.length, 0, 3);
const payload = Buffer.concat([
Buffer.from([0x92, 9, 0x01]),
Buffer.from('0100', 'hex'),
txSize,
tx,
Buffer.from([0xfe]),
]);
const parsed = deserializeDatumJobValidationResponse(payload);
expect(parsed.kind).toBe('full-transaction-blob');
expect(parsed.jobId).toBe(9);
expect(parsed.status).toBe(1);
expect(parsed.transactionCount).toBe(1);
if (parsed.kind !== 'full-transaction-blob') {
throw new Error(`Unexpected DATUM validation response kind ${parsed.kind}`);
}
expect(parsed.transactions[0]).toEqual(tx);
});
});
+506
View File
@@ -0,0 +1,506 @@
import { BufferReader, BufferWriter } from '../sv2/sv2-binary-codec';
export const DATUM_PROTOCOL_VERSION = 'v0.4.1-beta';
export const DATUM_INITIAL_HEADER_KEY = 0xdc871829;
export const DATUM_MAX_PAYLOAD_SIZE = 4194304;
export const DATUM_MAX_JOBS = 8;
export const DATUM_EXTRANONCE_SIZE = 12;
export enum DatumProtocolCommand {
PING = 0x01,
HANDSHAKE_INIT = 0x01,
HANDSHAKE_RESPONSE = 0x02,
MINING = 0x05,
}
export enum DatumMiningCommand {
FETCH_COINBASER = 0x10,
FETCH_COINBASER_RESPONSE = 0x11,
SUBMIT_POW = 0x27,
JOB_VALIDATION = 0x50,
SHARE_RESPONSE = 0x8f,
CLIENT_CONFIGURE = 0x99,
TEMPLATE_REFRESH = 0xf9,
}
export enum DatumJobValidationCommand {
REQUEST_SHORT_TX_IDS = 0x10,
REQUEST_FULL_TRANSACTIONS = 0x11,
REQUEST_FULL_TRANSACTION_BLOB = 0x12,
SHORT_TX_IDS_RESPONSE = 0x90,
FULL_TRANSACTIONS_RESPONSE = 0x91,
FULL_TRANSACTION_BLOB_RESPONSE = 0x92,
}
export enum DatumJobValidationStatus {
SUCCESS = 0x01,
}
export enum DatumShareResponseStatus {
ACCEPTED = 0x50,
ACCEPTED_TENTATIVE = 0x55,
REJECTED = 0x66,
}
export enum DatumRejectReason {
BAD_JOB_ID = 10,
BAD_COINBASE_ID = 11,
BAD_EXTRANONCE_SIZE = 12,
BAD_TARGET = 13,
BAD_USERNAME = 14,
BAD_COINBASER_ID = 15,
BAD_MERKLE_COUNT = 16,
BAD_COINBASE_TOO_LARGE = 17,
COINBASE_MISSING = 18,
TARGET_MISMATCH = 19,
HIGH_HASH = 21,
BAD_NTIME = 23,
BAD_VERSION = 24,
STALE_BLOCK = 25,
DUPLICATE_WORK = 29,
OTHER = 30,
}
export interface DatumHeader {
cmdLen: number;
reserved: number;
isSigned: boolean;
isEncryptedPubkey: boolean;
isEncryptedChannel: boolean;
protoCmd: number;
}
export interface DatumFrame {
header: DatumHeader;
payload: Buffer;
}
export interface DatumCoinbaserFetch {
rewardValue: bigint;
}
export interface DatumPayoutOutput {
value: bigint;
scriptPubKey: Buffer;
}
export interface DatumPowSubmit {
jobId: number;
coinbaseId: number;
isBlock: boolean;
subsidyOnly: boolean;
quickDiff: boolean;
targetByte: number;
ntime: number;
nonce: number;
version: number;
extranonce: Buffer;
username: string;
reserved: Buffer;
prevBlockHash?: Buffer;
targetByteIndex?: number;
nBits?: Buffer;
coinbaserId?: number;
height?: number;
coinbaseValue?: bigint;
transactionCount?: number;
totalWeight?: number;
totalSize?: number;
totalSigops?: number;
merkleBranches?: Buffer[];
coinbasePairs: Map<number, { coinb1: Buffer; coinb2: Buffer }>;
subsidyOnlyCoinbase?: { coinb1: Buffer; coinb2: Buffer };
}
export interface DatumShortTxIdsResponse {
kind: 'short-txids';
jobId: number;
status: number;
transactionCount: number;
shortTxIds: Buffer[];
crosscheck: Buffer | null;
}
export interface DatumFullTransactionsResponse {
kind: 'full-transactions';
jobId: number;
status: number;
transactionCount: number;
transactions: Buffer[];
}
export interface DatumFullTransactionBlobResponse {
kind: 'full-transaction-blob';
jobId: number;
status: number;
transactionCount: number;
transactions: Buffer[];
}
export type DatumJobValidationResponse =
| DatumShortTxIdsResponse
| DatumFullTransactionsResponse
| DatumFullTransactionBlobResponse;
export function encodeDatumHeader(header: DatumHeader, xorKey = 0): Buffer {
if (header.cmdLen < 0 || header.cmdLen > DATUM_MAX_PAYLOAD_SIZE - 1) {
throw new RangeError(`DATUM payload length ${header.cmdLen} exceeds protocol limit`);
}
let raw = header.cmdLen & 0x3fffff;
raw |= (header.reserved & 0x03) << 22;
raw |= (header.isSigned ? 1 : 0) << 24;
raw |= (header.isEncryptedPubkey ? 1 : 0) << 25;
raw |= (header.isEncryptedChannel ? 1 : 0) << 26;
raw |= (header.protoCmd & 0x1f) << 27;
const out = Buffer.alloc(4);
out.writeUInt32LE((raw ^ xorKey) >>> 0, 0);
return out;
}
export function decodeDatumHeader(input: Buffer, xorKey = 0): DatumHeader {
if (input.length < 4) {
throw new RangeError('DATUM header requires 4 bytes');
}
const raw = (input.readUInt32LE(0) ^ xorKey) >>> 0;
return {
cmdLen: raw & 0x3fffff,
reserved: (raw >>> 22) & 0x03,
isSigned: ((raw >>> 24) & 0x01) !== 0,
isEncryptedPubkey: ((raw >>> 25) & 0x01) !== 0,
isEncryptedChannel: ((raw >>> 26) & 0x01) !== 0,
protoCmd: (raw >>> 27) & 0x1f,
};
}
export function datumHeaderXorFeedback(input: number): number {
let h = 0xb10cfeed >>> 0;
let k = input >>> 0;
k = Math.imul(k, 0xcc9e2d51) >>> 0;
k = (((k << 15) >>> 0) | (k >>> 17)) >>> 0;
k = Math.imul(k, 0x1b873593) >>> 0;
h = (h ^ k) >>> 0;
h = (((h << 13) >>> 0) | (h >>> 19)) >>> 0;
h = (Math.imul(h, 5) + 0xe6546b64) >>> 0;
h = (h ^ 4) >>> 0;
h = (h ^ (h >>> 16)) >>> 0;
h = Math.imul(h, 0x85ebca6b) >>> 0;
h = (h ^ (h >>> 13)) >>> 0;
h = Math.imul(h, 0xc2b2ae35) >>> 0;
h = (h ^ (h >>> 16)) >>> 0;
return h >>> 0;
}
export class DatumFrameReader {
private buffer = Buffer.alloc(0);
constructor(private headerXorKey = DATUM_INITIAL_HEADER_KEY) {}
public setHeaderXorKey(headerXorKey: number): void {
this.headerXorKey = headerXorKey >>> 0;
}
public feed(data: Buffer): DatumFrame[] {
this.buffer = Buffer.concat([this.buffer, data]);
const frames: DatumFrame[] = [];
while (this.buffer.length >= 4) {
const header = decodeDatumHeader(this.buffer.subarray(0, 4), this.headerXorKey);
if (header.cmdLen > DATUM_MAX_PAYLOAD_SIZE) {
throw new RangeError(`DATUM payload length ${header.cmdLen} exceeds protocol limit`);
}
if (this.buffer.length < 4 + header.cmdLen) {
break;
}
frames.push({
header,
payload: Buffer.from(this.buffer.subarray(4, 4 + header.cmdLen)),
});
this.buffer = this.buffer.subarray(4 + header.cmdLen);
if (header.isEncryptedChannel) {
this.headerXorKey = datumHeaderXorFeedback(this.headerXorKey);
}
}
return frames;
}
}
export function encodeDatumFrame(frame: DatumFrame, xorKey = DATUM_INITIAL_HEADER_KEY): Buffer {
return Buffer.concat([
encodeDatumHeader({ ...frame.header, cmdLen: frame.payload.length }, xorKey),
frame.payload,
]);
}
export function deserializeDatumMiningCommand(payload: Buffer): { command: DatumMiningCommand; body: Buffer } {
if (payload.length < 1) {
throw new RangeError('DATUM mining command requires a sub-command byte');
}
return {
command: payload[0],
body: Buffer.from(payload.subarray(1)),
};
}
export function serializeDatumMiningCommand(command: DatumMiningCommand, body = Buffer.alloc(0)): Buffer {
return Buffer.concat([Buffer.from([command]), body]);
}
export function serializeDatumJobValidationShortTxIdsRequest(jobId: number): Buffer {
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from([
DatumJobValidationCommand.REQUEST_SHORT_TX_IDS,
jobId & 0xff,
]));
}
export function serializeDatumJobValidationFullTransactionBlobRequest(jobId: number): Buffer {
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from([
DatumJobValidationCommand.REQUEST_FULL_TRANSACTION_BLOB,
jobId & 0xff,
]));
}
export function serializeDatumJobValidationFullTransactionsRequest(jobId: number, transactionIndexes: number[]): Buffer {
if (transactionIndexes.length > 0xffff) {
throw new RangeError('DATUM requested transaction index count must fit in u16');
}
const w = new BufferWriter();
w.writeU8(DatumJobValidationCommand.REQUEST_FULL_TRANSACTIONS);
w.writeU8(jobId);
w.writeU16(transactionIndexes.length);
for (const index of transactionIndexes) {
if (!Number.isInteger(index) || index < 0 || index > 0xffff) {
throw new RangeError(`Invalid DATUM transaction index ${index}`);
}
w.writeU16(index);
}
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from(w.toBuffer()));
}
export function deserializeDatumJobValidationResponse(payload: Buffer): DatumJobValidationResponse {
const reader = new BufferReader(payload);
const responseCommand = reader.readU8();
const jobId = reader.readU8();
const status = reader.readU8();
if (responseCommand === DatumJobValidationCommand.SHORT_TX_IDS_RESPONSE) {
if (status !== DatumJobValidationStatus.SUCCESS) {
return {
kind: 'short-txids',
jobId,
status,
transactionCount: 0,
shortTxIds: [],
crosscheck: null,
};
}
const transactionCount = reader.readU16();
const shortTxIds: Buffer[] = [];
for (let i = 0; i < transactionCount; i++) {
shortTxIds.push(reader.readBytes(6));
}
const crosscheck = reader.remaining >= 32 ? reader.readBytes(32) : null;
consumeDatumTerminatorAndPadding(reader);
return {
kind: 'short-txids',
jobId,
status,
transactionCount,
shortTxIds,
crosscheck,
};
}
if (responseCommand === DatumJobValidationCommand.FULL_TRANSACTIONS_RESPONSE) {
return deserializeDatumTransactionListResponse('full-transactions', reader, jobId, status);
}
if (responseCommand === DatumJobValidationCommand.FULL_TRANSACTION_BLOB_RESPONSE) {
return deserializeDatumTransactionListResponse('full-transaction-blob', reader, jobId, status);
}
throw new RangeError(`Unsupported DATUM job validation response 0x${responseCommand.toString(16)}`);
}
export function deserializeDatumCoinbaserFetch(payload: Buffer): DatumCoinbaserFetch {
if (payload.length < 8) {
throw new RangeError('DATUM coinbaser fetch requires reward value');
}
return { rewardValue: payload.readBigUInt64LE(0) };
}
export function serializeDatumCoinbaserFetchResponse(rewardValue: bigint, outputs: DatumPayoutOutput[]): Buffer {
const w = new BufferWriter();
w.writeU64(rewardValue);
const outputsBuffer = serializeDatumPayoutOutputs(outputs);
w.writeU32(outputsBuffer.length);
w.writeBytes(outputsBuffer);
return serializeDatumMiningCommand(DatumMiningCommand.FETCH_COINBASER_RESPONSE, Buffer.from(w.toBuffer()));
}
export function serializeDatumPayoutOutputs(outputs: DatumPayoutOutput[]): Buffer {
if (outputs.length > 255) {
throw new RangeError('DATUM payout output count must fit in one byte');
}
const w = new BufferWriter();
w.writeU8(outputs.length);
for (const output of outputs) {
if (output.scriptPubKey.length > 255) {
throw new RangeError('DATUM scriptPubKey length must fit in one byte');
}
w.writeU64(output.value);
w.writeU8(output.scriptPubKey.length);
w.writeBytes(output.scriptPubKey);
}
return w.toBuffer();
}
export function deserializeDatumPowSubmit(payload: Buffer): DatumPowSubmit {
const reader = new BufferReader(payload);
const jobId = reader.readU8();
const coinbaseId = reader.readU8();
const flags = reader.readU8();
const targetByte = reader.readU8();
const ntime = reader.readU32();
const nonce = reader.readU32();
const version = reader.readU32();
const extranonceSize = reader.readU8();
if (extranonceSize !== DATUM_EXTRANONCE_SIZE) {
throw new RangeError(`Unsupported DATUM extranonce size ${extranonceSize}`);
}
const extranonce = reader.readBytes(extranonceSize);
const username = readNullTerminatedString(reader);
const reserved = reader.readBytes(Math.min(4, reader.remaining));
const coinbasePairs = new Map<number, { coinb1: Buffer; coinb2: Buffer }>();
const result: DatumPowSubmit = {
jobId,
coinbaseId,
isBlock: (flags & 0x01) !== 0,
subsidyOnly: (flags & 0x02) !== 0,
quickDiff: (flags & 0x04) !== 0,
targetByte,
ntime,
nonce,
version,
extranonce,
username,
reserved,
coinbasePairs,
};
while (reader.remaining > 0) {
const section = reader.readU8();
if (section === 0xfe) {
break;
}
if (section === 0x01) {
result.prevBlockHash = reader.readBytes(32);
result.targetByteIndex = reader.readU16();
result.nBits = reader.readBytes(4);
result.coinbaserId = reader.readU8();
result.height = reader.readU32();
result.coinbaseValue = reader.readU64();
result.transactionCount = reader.readU32();
result.totalWeight = reader.readU32();
result.totalSize = reader.readU32();
result.totalSigops = reader.readU32();
const merkleBranchCount = reader.readU8();
result.merkleBranches = [];
for (let i = 0; i < merkleBranchCount; i++) {
result.merkleBranches.push(reader.readBytes(32));
}
continue;
}
if (section === 0x02) {
const coinbaseType = reader.readU8();
const coinb1Len = reader.readU16();
const coinb2Len = reader.readU16();
const coinbase = {
coinb1: reader.readBytes(coinb1Len),
coinb2: reader.readBytes(coinb2Len),
};
if (coinbaseType === 255 || coinbaseId === 255) {
result.subsidyOnlyCoinbase = coinbase;
} else {
coinbasePairs.set(coinbaseType, coinbase);
}
continue;
}
throw new RangeError(`Unsupported DATUM POW section 0x${section.toString(16)}`);
}
return result;
}
export function serializeDatumShareResponse(input: {
status: DatumShareResponseStatus;
reasonCode: number;
nonce: number;
targetByte: number;
jobId: number;
}): Buffer {
const w = new BufferWriter();
w.writeU8(input.status);
w.writeU16(input.reasonCode);
w.writeU32(input.nonce);
w.writeU8(input.targetByte);
w.writeU8(input.jobId);
return serializeDatumMiningCommand(DatumMiningCommand.SHARE_RESPONSE, Buffer.from(w.toBuffer()));
}
function deserializeDatumTransactionListResponse(
kind: 'full-transactions' | 'full-transaction-blob',
reader: BufferReader,
jobId: number,
status: number,
): DatumFullTransactionsResponse | DatumFullTransactionBlobResponse {
if (status !== DatumJobValidationStatus.SUCCESS) {
return {
kind,
jobId,
status,
transactionCount: 0,
transactions: [],
};
}
const transactionCount = reader.readU16();
const transactions: Buffer[] = [];
for (let i = 0; i < transactionCount; i++) {
const transactionSize = reader.readU24();
transactions.push(reader.readBytes(transactionSize));
}
consumeDatumTerminatorAndPadding(reader);
return {
kind,
jobId,
status,
transactionCount,
transactions,
};
}
function consumeDatumTerminatorAndPadding(reader: BufferReader): void {
if (reader.remaining <= 0) {
return;
}
const terminator = reader.readU8();
if (terminator !== 0xfe) {
throw new RangeError(`DATUM job validation response missing terminator; got 0x${terminator.toString(16)}`);
}
}
function readNullTerminatedString(reader: BufferReader): string {
const bytes: number[] = [];
while (reader.remaining > 0) {
const byte = reader.readU8();
if (byte === 0) {
break;
}
bytes.push(byte);
}
return Buffer.from(bytes).toString('utf8');
}
+94
View File
@@ -0,0 +1,94 @@
import * as sodium from 'libsodium-wrappers-sumo';
import {
DATUM_INITIAL_HEADER_KEY,
DatumProtocolCommand,
datumHeaderXorFeedback,
decodeDatumHeader,
encodeDatumFrame,
} from './datum-codec';
import { DatumCryptoSession } from './datum-crypto';
describe('DatumCryptoSession', () => {
it('derives stable DATUM public keys from a configured seed', async () => {
await sodium.ready;
const seed = Buffer.from('000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', 'hex');
const first = DatumCryptoSession.generateKeyPairFromSeed(seed);
const second = DatumCryptoSession.generateKeyPairFromSeed(seed);
expect(Buffer.concat([first.edPublicKey, first.xPublicKey]).toString('hex'))
.toBe(Buffer.concat([second.edPublicKey, second.xPublicKey]).toString('hex'));
expect(first.edSecretKey).toEqual(second.edSecretKey);
});
it('opens signed sealed handshakes and builds encrypted responses', async () => {
await sodium.ready;
const serverIdentity = DatumCryptoSession.generateKeyPair();
const serverSessionKeys = DatumCryptoSession.generateKeyPair();
const clientIdentity = DatumCryptoSession.generateKeyPair();
const clientSessionKeys = DatumCryptoSession.generateKeyPair();
const server = await DatumCryptoSession.create(serverIdentity, serverSessionKeys);
const nextHeaderKey = 0x12345678;
const signedBody = Buffer.concat([
clientIdentity.edPublicKey,
clientIdentity.xPublicKey,
clientSessionKeys.edPublicKey,
clientSessionKeys.xPublicKey,
Buffer.from('datum-test/0\0', 'utf8'),
Buffer.from([0xfe]),
uint32(nextHeaderKey),
Buffer.from([1, 2, 3]),
]);
const signature = Buffer.from(sodium.crypto_sign_detached(signedBody, clientIdentity.edSecretKey));
const encrypted = Buffer.from(sodium.crypto_box_seal(
Buffer.concat([signedBody, signature]),
serverIdentity.xPublicKey,
));
const frame = encodeDatumFrame({
header: {
cmdLen: encrypted.length,
reserved: 0,
isSigned: true,
isEncryptedPubkey: true,
isEncryptedChannel: false,
protoCmd: DatumProtocolCommand.HANDSHAKE_INIT,
},
payload: encrypted,
}, DATUM_INITIAL_HEADER_KEY);
const header = decodeDatumHeader(frame.subarray(0, 4), DATUM_INITIAL_HEADER_KEY);
const hello = server.openHandshake(frame.subarray(4, 4 + header.cmdLen));
expect(hello.userAgent).toBe('datum-test/0');
expect(hello.nextHeaderKey).toBe(nextHeaderKey);
expect(hello.sessionXPublicKey).toEqual(clientSessionKeys.xPublicKey);
const response = server.buildHandshakeResponse(hello, 'motd');
const responseHeader = decodeDatumHeader(response.subarray(0, 4), datumHeaderXorFeedback((~nextHeaderKey) >>> 0));
expect(responseHeader.protoCmd).toBe(DatumProtocolCommand.HANDSHAKE_RESPONSE);
expect(responseHeader.isSigned).toBe(true);
expect(responseHeader.isEncryptedPubkey).toBe(true);
const opened = Buffer.from(sodium.crypto_box_seal_open(
response.subarray(4),
clientSessionKeys.xPublicKey,
clientSessionKeys.xSecretKey,
));
expect(opened.subarray(0, 32)).toEqual(clientIdentity.edPublicKey);
expect(opened.subarray(128, 160)).toEqual(serverSessionKeys.edPublicKey);
const ping = server.encryptChannelFrame(DatumProtocolCommand.PING, Buffer.alloc(0));
const pingHeader = decodeDatumHeader(
ping.subarray(0, 4),
datumHeaderXorFeedback(datumHeaderXorFeedback((~nextHeaderKey) >>> 0)),
);
expect(pingHeader.protoCmd).toBe(DatumProtocolCommand.PING);
expect(pingHeader.isEncryptedChannel).toBe(true);
expect(pingHeader.cmdLen).toBe(sodium.crypto_box_MACBYTES);
});
});
function uint32(value: number): Buffer {
const result = Buffer.alloc(4);
result.writeUInt32LE(value >>> 0, 0);
return result;
}
+245
View File
@@ -0,0 +1,245 @@
import * as crypto from 'crypto';
import * as sodium from 'libsodium-wrappers-sumo';
import {
DATUM_INITIAL_HEADER_KEY,
DatumFrame,
DatumProtocolCommand,
decodeDatumHeader,
encodeDatumFrame,
datumHeaderXorFeedback,
} from './datum-codec';
export interface DatumKeyPair {
edPublicKey: Buffer;
edSecretKey: Buffer;
xPublicKey: Buffer;
xSecretKey: Buffer;
}
export interface DatumHello {
identityEdPublicKey: Buffer;
identityXPublicKey: Buffer;
sessionEdPublicKey: Buffer;
sessionXPublicKey: Buffer;
userAgent: string;
nextHeaderKey: number;
}
export class DatumCryptoSession {
private initialized = false;
private sharedSecret: Uint8Array | null = null;
private receiveNonce: Buffer | null = null;
private sendNonce: Buffer | null = null;
private receiveHeaderKey = DATUM_INITIAL_HEADER_KEY;
private sendHeaderKey = DATUM_INITIAL_HEADER_KEY;
private clientIdentityXPublicKey: Buffer | null = null;
private constructor(
private readonly identityKeys: DatumKeyPair,
private readonly sessionKeys: DatumKeyPair,
) {}
public static async create(identityKeys?: DatumKeyPair, sessionKeys?: DatumKeyPair): Promise<DatumCryptoSession> {
await sodium.ready;
return new DatumCryptoSession(
identityKeys ?? DatumCryptoSession.generateKeyPair(),
sessionKeys ?? DatumCryptoSession.generateKeyPair(),
);
}
public get publicKeyHex(): string {
return Buffer.concat([this.identityKeys.edPublicKey, this.identityKeys.xPublicKey]).toString('hex');
}
public get currentReceiveHeaderKey(): number {
return this.receiveHeaderKey;
}
public get currentSendHeaderKey(): number {
return this.sendHeaderKey;
}
public openHandshake(framePayload: Buffer): DatumHello {
const opened = Buffer.from(sodium.crypto_box_seal_open(
framePayload,
this.identityKeys.xPublicKey,
this.identityKeys.xSecretKey,
));
if (opened.length < 32 * 4 + 1 + 4 + sodium.crypto_sign_BYTES) {
throw new Error('DATUM handshake payload is too short');
}
const signature = opened.subarray(opened.length - sodium.crypto_sign_BYTES);
const signedPayload = opened.subarray(0, opened.length - sodium.crypto_sign_BYTES);
const clientIdentityEdPublicKey = signedPayload.subarray(0, 32);
if (!sodium.crypto_sign_verify_detached(signature, signedPayload, clientIdentityEdPublicKey)) {
throw new Error('DATUM handshake signature verification failed');
}
let offset = 0;
const hello: DatumHello = {
identityEdPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
identityXPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
sessionEdPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
sessionXPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
userAgent: '',
nextHeaderKey: 0,
};
const userAgentStart = offset;
while (offset < signedPayload.length && signedPayload[offset] !== 0) {
offset++;
}
hello.userAgent = signedPayload.subarray(userAgentStart, offset).toString('utf8');
offset++;
if (signedPayload[offset] !== 0xfe) {
throw new Error('DATUM handshake missing key delimiter');
}
offset++;
hello.nextHeaderKey = signedPayload.readUInt32LE(offset);
this.clientIdentityXPublicKey = hello.identityXPublicKey;
this.sharedSecret = sodium.crypto_box_beforenm(hello.sessionXPublicKey, this.sessionKeys.xSecretKey);
this.receiveHeaderKey = datumHeaderXorFeedback(hello.nextHeaderKey);
this.sendHeaderKey = datumHeaderXorFeedback((~hello.nextHeaderKey) >>> 0);
this.initializeNonces(hello.nextHeaderKey, hello.sessionEdPublicKey);
this.initialized = true;
return hello;
}
public buildHandshakeResponse(hello: DatumHello, motd: string): Buffer {
if (this.clientIdentityXPublicKey == null) {
throw new Error('Cannot build DATUM handshake response before opening client hello');
}
const body = Buffer.concat([
hello.identityEdPublicKey,
hello.identityXPublicKey,
hello.sessionEdPublicKey,
hello.sessionXPublicKey,
this.sessionKeys.edPublicKey,
this.sessionKeys.xPublicKey,
Buffer.from(`${motd}\0`, 'utf8'),
]);
const signature = Buffer.from(sodium.crypto_sign_detached(body, this.identityKeys.edSecretKey));
const encrypted = Buffer.from(sodium.crypto_box_seal(
Buffer.concat([body, signature]),
hello.sessionXPublicKey,
));
const response = encodeDatumFrame({
header: {
cmdLen: encrypted.length,
reserved: 0,
isSigned: true,
isEncryptedPubkey: true,
isEncryptedChannel: false,
protoCmd: DatumProtocolCommand.HANDSHAKE_RESPONSE,
},
payload: encrypted,
}, this.sendHeaderKey);
this.sendHeaderKey = datumHeaderXorFeedback(this.sendHeaderKey);
return response;
}
public decryptChannelPayload(payload: Buffer): Buffer {
if (!this.initialized || this.sharedSecret == null || this.receiveNonce == null) {
throw new Error('DATUM channel is not initialized');
}
const opened = sodium.crypto_box_open_easy_afternm(payload, this.receiveNonce, this.sharedSecret);
this.incrementNonce(this.receiveNonce);
this.receiveHeaderKey = datumHeaderXorFeedback(this.receiveHeaderKey);
return Buffer.from(opened);
}
public encryptChannelFrame(protoCmd: DatumProtocolCommand, payload: Buffer, signed = false): Buffer {
if (!this.initialized || this.sharedSecret == null || this.sendNonce == null) {
throw new Error('DATUM channel is not initialized');
}
const signedPayload = signed
? Buffer.concat([payload, Buffer.from(sodium.crypto_sign_detached(payload, this.sessionKeys.edSecretKey))])
: payload;
const encrypted = Buffer.from(sodium.crypto_box_easy_afternm(signedPayload, this.sendNonce, this.sharedSecret));
const frame = encodeDatumFrame({
header: {
cmdLen: encrypted.length,
reserved: 0,
isSigned: signed,
isEncryptedPubkey: false,
isEncryptedChannel: true,
protoCmd,
},
payload: encrypted,
}, this.sendHeaderKey);
this.incrementNonce(this.sendNonce);
this.sendHeaderKey = datumHeaderXorFeedback(this.sendHeaderKey);
return frame;
}
public decodeHeader(headerBytes: Buffer): ReturnType<typeof decodeDatumHeader> {
return decodeDatumHeader(headerBytes, this.receiveHeaderKey);
}
private initializeNonces(nextHeaderKey: number, clientSessionEdPublicKey: Buffer): void {
const receiver = Buffer.alloc(sodium.crypto_box_NONCEBYTES);
let key = ((nextHeaderKey - 42) ^ clientSessionEdPublicKey.readUInt32LE(7)) >>> 0;
for (let i = 0; i < receiver.length; i += 4) {
receiver.writeUInt32LE(datumHeaderXorFeedback((key - 42) >>> 0), i);
key = (~receiver.readUInt32LE(i)) >>> 0;
}
const sender = Buffer.alloc(receiver.length);
for (let i = 0; i < receiver.length; i += 4) {
sender.writeUInt32LE((receiver.readUInt32LE(i) ^ 0x57575757) >>> 0, i);
}
this.sendNonce = receiver;
this.receiveNonce = sender;
}
private incrementNonce(nonce: Buffer): void {
for (let offset = 0; offset < nonce.length; offset += 4) {
const next = (nonce.readUInt32LE(offset) + 1) >>> 0;
nonce.writeUInt32LE(next, offset);
if (next !== 0) {
return;
}
}
}
public static generateKeyPair(): DatumKeyPair {
const seed = crypto.randomBytes(32);
return DatumCryptoSession.generateKeyPairFromSeed(seed);
}
public static generateKeyPairFromSeed(seed: Buffer): DatumKeyPair {
if (seed.length !== 32) {
throw new RangeError(`DATUM key seed must be 32 bytes, got ${seed.length}`);
}
const ed = sodium.crypto_sign_seed_keypair(seed);
const xPublicKey = sodium.crypto_sign_ed25519_pk_to_curve25519(ed.publicKey);
const xSecretKey = sodium.crypto_sign_ed25519_sk_to_curve25519(ed.privateKey);
return {
edPublicKey: Buffer.from(ed.publicKey),
edSecretKey: Buffer.from(ed.privateKey),
xPublicKey: Buffer.from(xPublicKey),
xSecretKey: Buffer.from(xSecretKey),
};
}
}
export function buildDatumPlainFrame(protoCmd: DatumProtocolCommand, payload: Buffer): DatumFrame {
return {
header: {
cmdLen: payload.length,
reserved: 0,
isSigned: false,
isEncryptedPubkey: false,
isEncryptedChannel: false,
protoCmd,
},
payload,
};
}
+2
View File
@@ -1,3 +1,5 @@
export const EXTRANONCE1_SIZE_BYTES = 4;
export const EXTRANONCE2_SIZE_BYTES = 8;
export const TOTAL_EXTRANONCE_SIZE_BYTES = EXTRANONCE1_SIZE_BYTES + EXTRANONCE2_SIZE_BYTES;
export const SV2_EXTENDED_EXTRANONCE2_SIZE_BYTES = 10;
export const SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES = EXTRANONCE1_SIZE_BYTES + SV2_EXTENDED_EXTRANONCE2_SIZE_BYTES;
@@ -27,6 +27,9 @@ describe('Sv2ExtranonceManager', () => {
});
it('minerExtranonceSize is total minus prefix', () => {
const defaultMgr = new Sv2ExtranonceManager();
expect(defaultMgr.minerExtranonceSize).toBe(10);
const mgr = new Sv2ExtranonceManager(4, 8);
expect(mgr.minerExtranonceSize).toBe(4);
+11 -7
View File
@@ -1,4 +1,6 @@
// ── SV2 Extranonce Manager ──────────────────────────────────────────
import { SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES } from '../stratum.constants';
// -- SV2 Extranonce Manager ------------------------------------------
// Manages extranonce prefix allocation for extended mining channels.
// Ensures no two channels share the same prefix to prevent hash collisions.
@@ -13,13 +15,11 @@ export class Sv2ExtranonceManager {
/**
* @param prefixSize Bytes used for pool-assigned prefix (default 4)
* @param totalExtranonceSize Total extranonce bytes (default 12: 4 prefix + 8 miner-controlled).
* Must match MiningJob.ts's coinbase slot size (12 bytes) — both
* protocols share the same coinbase template. Bumped from the
* previous 8-byte total because the Braiins Hashpower marketplace
* requires extranonce2_size >= 7 (compatibility spec).
* @param totalExtranonceSize Total extranonce bytes (default 14: 4 prefix + 10 miner/JDC-controlled).
* SV2 extended jobs patch the coinbase script length when the
* negotiated total differs from the SV1-compatible template slot.
*/
constructor(prefixSize = 4, totalExtranonceSize = 12) {
constructor(prefixSize = 4, totalExtranonceSize = SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES) {
this.prefixSize = prefixSize;
this.totalExtranonceSize = totalExtranonceSize;
@@ -34,6 +34,10 @@ export class Sv2ExtranonceManager {
return this.totalExtranonceSize - this.prefixSize;
}
get totalSize(): number {
return this.totalExtranonceSize;
}
/**
* Allocate a unique extranonce prefix for a channel.
* Returns a Buffer of `prefixSize` bytes.