mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 17:15:03 -07:00
Add SV2 DATUM payout hardening
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user