Add payout modes and production switchover schema

This commit is contained in:
Ben
2026-06-20 13:59:05 -04:00
parent a33c530268
commit a9d425f32f
61 changed files with 4164 additions and 1029 deletions
+7 -3
View File
@@ -547,8 +547,9 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job not found",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(0);
expect(await clientService.connectedClientCount()).toBe(1);
expect(hashSpy).not.toHaveBeenCalled();
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
});
it('should reject submissions when the job template has expired', async () => {
@@ -578,7 +579,8 @@ describe('StratumV1Client', () => {
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[23,"Difficulty too low",""]}\n`);
expect(await clientService.connectedClientCount()).toBe(0);
expect(await clientService.connectedClientCount()).toBe(1);
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
});
it('should reject submissions with short extranonce2 values', async () => {
@@ -670,7 +672,9 @@ describe('StratumV1Client', () => {
minerAddress: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
worker: 'bitaxe3',
sessionId: MockRecording1.EXTRA_NONCE,
blockData: expect.any(String)
blockData: expect.any(String),
payoutSnapshotId: null,
payoutMode: 'solo',
}));
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled();
expect(addressSettings.resetBestDifficultyAndShares).toHaveBeenCalled();
+19 -7
View File
@@ -19,6 +19,7 @@ import { RedisMessagingService } from '../services/redis-messaging.service';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import { DifficultyUtils } from '../utils/difficulty.utils';
import { hash256 } from '../utils/hash.utils';
import { PayoutMode } from '../types/payout-mode';
import { eRequestMethod } from './enums/eRequestMethod';
import { eResponseMethod } from './enums/eResponseMethod';
import { eStratumErrorCode } from './enums/eStratumErrorCode';
@@ -83,6 +84,7 @@ export class StratumV1Client {
private readonly redisMessagingService?: RedisMessagingService,
private readonly payoutSnapshotService?: PayoutSnapshotService,
private readonly accountingProtocol: 'sv1' | 'sv1_tls' = 'sv1',
private readonly payoutMode: PayoutMode = 'solo',
) {
this.socketDataHandler = (data: Buffer) => {
@@ -447,6 +449,7 @@ export class StratumV1Client {
}
private async sendNewMiningJob(jobTemplate: IJobTemplate) {
await this.ensureClientEntity();
let payoutInformation = this.getPayoutInformation(jobTemplate, this.clientAuthorization.address);
// const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
@@ -519,6 +522,7 @@ export class StratumV1Client {
clientName: this.clientAuthorization.worker,
userAgent: this.clientSubscription.userAgent,
startTime: new Date(),
payoutMode: this.payoutMode,
bestDifficulty: 0
});
await this.updateClientPresence(new Date());
@@ -539,6 +543,7 @@ export class StratumV1Client {
address: this.clientEntity.address,
clientName: this.clientEntity.clientName,
sessionId: this.clientEntity.sessionId,
payoutMode: this.payoutMode,
userAgent: this.clientEntity.userAgent,
startTime: new Date(this.clientEntity.startTime).toISOString(),
lastSeen: lastSeen.toISOString(),
@@ -651,13 +656,19 @@ export class StratumV1Client {
sessionId: this.extraNonceAndSessionId,
blockData: blockHex,
blockSubmissionResult,
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId ?? null,
});
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: jobTemplate.blockData.height,
blockSubmissionResult,
payoutSnapshotId: this.payoutMode === 'pplns'
? jobTemplate.blockData.payoutSnapshotId ?? null
: null,
payoutMode: this.payoutMode,
});
if (this.payoutMode === 'pplns') {
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: jobTemplate.blockData.height,
blockSubmissionResult,
payoutMode: this.payoutMode,
});
}
await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, blockSubmissionResult);
//success
@@ -669,6 +680,7 @@ export class StratumV1Client {
try {
await this.shareAccountingService?.recordAcceptedShare({
protocol: this.accountingProtocol,
payoutMode: this.payoutMode,
address: this.clientAuthorization.address,
clientName: this.clientAuthorization.worker,
sessionId: this.extraNonceAndSessionId,
@@ -886,7 +898,7 @@ export class StratumV1Client {
}
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] {
if (this.configService.get('PAYOUT_COINBASE_MODE') === 'snapshot' && jobTemplate.blockData.payoutOutputs?.length > 0) {
if (this.payoutMode === 'pplns' && jobTemplate.blockData.payoutOutputs?.length > 0) {
return jobTemplate.blockData.payoutOutputs;
}
+122 -1
View File
@@ -7,6 +7,7 @@ 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 { hash256 } from '../utils/hash.utils';
import { BufferReader } from './sv2/sv2-binary-codec';
import { SV2_CHANNEL_MSG_FLAG, Sv2MiningSetupFlags, Sv2MsgType, Sv2Protocol } from './sv2/sv2-constants';
import {
@@ -115,6 +116,50 @@ describe('StratumV2Client extended channels', () => {
expect(error.errorCode).toBe('invalid-extranonce-size');
});
it('reconstructs extended-channel block candidates with a matching merkle root', 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,
}));
const jobFrame = sentFrames
.filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB)
.at(-1);
const job = deserializeNewExtendedMiningJob(new BufferReader(jobFrame.payload));
const channel = (client as any).channels.get(1);
const extendedJob = channel.extendedJobs.get(job.jobId);
const extranonce = Buffer.alloc(channel.extranonceSize, 0x42);
const coinbaseTxBytes = Buffer.concat([
extendedJob.coinbasePrefix,
channel.extranoncePrefix,
extranonce,
extendedJob.coinbaseSuffix,
]);
let merkleRoot = hash256(coinbaseTxBytes);
for (const sibling of extendedJob.merklePath) {
merkleRoot = hash256(Buffer.concat([merkleRoot, sibling]));
}
const block = (client as any).reconstructExtendedBlock(
extendedJob,
{
jobId: job.jobId,
nonce: 123,
ntime: parseInt(MockRecording1.TIME, 16),
version: job.version,
extranonce,
},
merkleRoot,
channel.extranoncePrefix,
);
expect(block.merkleRoot.equals(bitcoinjs.Block.calculateMerkleRoot(block.transactions, false))).toBe(true);
expect(block.transactions[0].toBuffer()).toEqual(coinbaseTxBytes);
});
it('accepts SV2 work-selection setup and SetCustomMiningJob without sending pool work', async () => {
const { client, sentFrames } = await createClient();
await (client as any).handleSetupConnection(serializeSetupConnection({
@@ -151,7 +196,16 @@ describe('StratumV2Client extended channels', () => {
coinbaseTxVersion: 2,
coinbasePrefix: Buffer.from('51', 'hex'),
coinbaseTxInputNSequence: 0xffffffff,
coinbaseTxOutputs: Buffer.from('010000000000000000016a', 'hex'),
coinbaseTxOutputs: serializeCoinbaseOutputs([
{
address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2',
amountSats: 0,
},
{
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
amountSats: MockRecording1.BLOCK_TEMPLATE.coinbasevalue,
},
]),
coinbaseTxLocktime: 0,
merklePath: [],
}));
@@ -163,6 +217,55 @@ describe('StratumV2Client extended channels', () => {
expect((client as any).channels.get(1).extendedJobs.get(success.jobId).workProtocol).toBe('sv2_jdp');
});
it('rejects SV2 work-selection custom jobs that do not pay the pool coinbase outputs', 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,
}));
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: serializeCoinbaseOutputs([{
address: 'tb1qdyjakeepue4trak9d3hvyelrd0aw7mwju2d0c2',
amountSats: MockRecording1.BLOCK_TEMPLATE.coinbasevalue,
}]),
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('invalid-coinbase-payout-outputs');
});
it('rejects SetCustomMiningJob when work selection was not negotiated', async () => {
const { client, sentFrames } = await createClient();
await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({
@@ -308,6 +411,8 @@ describe('StratumV2Client extended channels', () => {
worker: 'worker',
sessionId: 'sv2-session',
blockData: expect.any(String),
payoutSnapshotId: null,
payoutMode: 'solo',
}));
expect(notificationService.notifySubscribersBlockFound).toHaveBeenCalled();
expect(addressSettingsService.resetBestDifficultyAndShares).toHaveBeenCalled();
@@ -452,3 +557,19 @@ describe('StratumV2Client extended channels', () => {
};
}
});
function serializeCoinbaseOutputs(outputs: { address: string; amountSats: number }[]): Buffer {
return Buffer.concat([
Buffer.from([outputs.length]),
...outputs.map(output => {
const script = bitcoinjs.address.toOutputScript(output.address, bitcoinjs.networks.testnet);
const value = Buffer.alloc(8);
value.writeBigUInt64LE(BigInt(output.amountSats), 0);
return Buffer.concat([
value,
Buffer.from([script.length]),
script,
]);
}),
]);
}
+107 -12
View File
@@ -23,6 +23,7 @@ import { DifficultyUtils } from '../utils/difficulty.utils';
import { hash256 } from '../utils/hash.utils';
import { AddressObject, MiningJob } from './MiningJob';
import { StratumV1ClientStatistics } from './StratumV1ClientStatistics';
import { PayoutMode } from '../types/payout-mode';
import { BufferReader } from './sv2/sv2-binary-codec';
import {
SV2_CHANNEL_MSG_FLAG,
@@ -149,6 +150,7 @@ export class StratumV2Client {
private readonly shareAccountingService?: ShareAccountingService,
private readonly redisMessagingService?: RedisMessagingService,
private readonly payoutSnapshotService?: PayoutSnapshotService,
private readonly payoutMode: PayoutMode = 'solo',
) {
this.firstChunkSummary = this.describeChunk(firstChunk);
this.noiseSession = new Sv2NoiseSession(this.stratumV2Service.getNoiseConfig());
@@ -766,6 +768,26 @@ export class StratumV2Client {
msg,
channel.extranoncePrefix.length + channel.extranonceSize,
);
const payoutValidation = this.validateCoinbasePayoutOutputs(
Buffer.concat([
split.coinbasePrefix,
Buffer.alloc(channel.extranoncePrefix.length + channel.extranonceSize),
split.coinbaseSuffix,
]),
latestTemplate,
this.getPayoutInformation(latestTemplate, this.address),
);
if (!payoutValidation.valid) {
await this.sendFrame(
Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR,
serializeSetCustomMiningJobError({
channelId: msg.channelId,
requestId: msg.requestId,
errorCode: payoutValidation.errorCode,
}),
);
return;
}
const placeholderJob = new MiningJob(
this.network,
jobIdHex,
@@ -862,13 +884,19 @@ export class StratumV2Client {
sessionId: this.sessionId,
blockData: blockHex,
blockSubmissionResult,
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId ?? null,
});
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: jobTemplate.blockData.height,
blockSubmissionResult,
payoutSnapshotId: this.payoutMode === 'pplns'
? jobTemplate.blockData.payoutSnapshotId ?? null
: null,
payoutMode: this.payoutMode,
});
if (this.payoutMode === 'pplns') {
await this.payoutSnapshotService?.finalizeSnapshotForBlock({
payoutSnapshotId: jobTemplate.blockData.payoutSnapshotId,
blockHeight: jobTemplate.blockData.height,
blockSubmissionResult,
payoutMode: this.payoutMode,
});
}
await this.notificationService.notifySubscribersBlockFound(
this.address,
jobTemplate.blockData.height,
@@ -882,6 +910,7 @@ export class StratumV2Client {
await this.shareAccountingService?.recordAcceptedShare({
protocol: metadata.workProtocol === 'sv2_jdp' ? 'sv2_jdp' : 'sv2',
payoutMode: this.payoutMode,
workSource: metadata.workProtocol === 'sv2_jdp' ? 'miner_template' : 'pool_template',
workProtocol: metadata.workProtocol ?? 'pool',
address: this.address,
@@ -929,13 +958,12 @@ export class StratumV2Client {
const testBlock = Object.assign(new bitcoinjs.Block(), jobTemplate.block);
testBlock.transactions = jobTemplate.block.transactions.map(tx => Object.assign(new bitcoinjs.Transaction(), tx));
const coinbaseTx = extendedJob.miningJob.cloneCoinbaseTransaction();
const script = coinbaseTx.ins[0].script;
coinbaseTx.ins[0].script = Buffer.concat([
script.subarray(0, script.length - (extranoncePrefix.length + submission.extranonce.length)),
const coinbaseTx = bitcoinjs.Transaction.fromBuffer(Buffer.concat([
extendedJob.coinbasePrefix,
extranoncePrefix,
submission.extranonce,
]);
extendedJob.coinbaseSuffix,
]));
testBlock.transactions[0] = coinbaseTx;
testBlock.version = submission.version;
testBlock.nonce = submission.nonce;
@@ -1282,6 +1310,7 @@ export class StratumV2Client {
clientName: this.workerName,
userAgent: this.userAgent,
startTime: new Date(),
payoutMode: this.payoutMode,
bestDifficulty: 0,
});
await this.updateClientPresence(new Date());
@@ -1302,6 +1331,7 @@ export class StratumV2Client {
address: this.clientEntity.address,
clientName: this.clientEntity.clientName,
sessionId: this.clientEntity.sessionId,
payoutMode: this.payoutMode,
userAgent: this.clientEntity.userAgent,
startTime: new Date(this.clientEntity.startTime).toISOString(),
lastSeen: lastSeen.toISOString(),
@@ -1394,13 +1424,78 @@ export class StratumV2Client {
}
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] {
if (this.configService.get('PAYOUT_COINBASE_MODE') === 'snapshot' && jobTemplate.blockData.payoutOutputs?.length > 0) {
if (this.payoutMode === 'pplns' && jobTemplate.blockData.payoutOutputs?.length > 0) {
return jobTemplate.blockData.payoutOutputs;
}
return [{ address: fallbackAddress, percent: 100 }];
}
private validateCoinbasePayoutOutputs(
coinbaseTxBytes: Buffer,
jobTemplate: IJobTemplate,
payoutInformation: AddressObject[],
): { valid: boolean; errorCode?: string } {
let transaction: bitcoinjs.Transaction;
try {
transaction = bitcoinjs.Transaction.fromBuffer(coinbaseTxBytes);
} catch {
return { valid: false, errorCode: 'invalid-coinbase-transaction' };
}
const expectedOutputs = this.buildExpectedPayoutOutputs(payoutInformation, jobTemplate.blockData.coinbasevalue);
const submittedOutputs = transaction.outs
.filter(output => !this.isWitnessCommitmentOutput(output.script))
.filter(output => BigInt(output.value) !== 0n)
.map(output => ({
value: BigInt(output.value),
scriptPubKey: Buffer.from(output.script),
}));
if (submittedOutputs.length !== expectedOutputs.length) {
return { valid: false, errorCode: 'invalid-coinbase-payout-outputs' };
}
for (let i = 0; i < expectedOutputs.length; i++) {
const expected = expectedOutputs[i];
const submitted = submittedOutputs[i];
if (submitted.value !== expected.value || !submitted.scriptPubKey.equals(expected.scriptPubKey)) {
return { valid: false, errorCode: 'invalid-coinbase-payout-outputs' };
}
}
return { valid: true };
}
private buildExpectedPayoutOutputs(
payoutInformation: AddressObject[],
coinbaseValue: number,
): { value: bigint; scriptPubKey: Buffer }[] {
let rewardBalance = BigInt(Math.max(0, Math.floor(coinbaseValue)));
const outputs = payoutInformation.map(recipientAddress => {
const value = recipientAddress.amountSats == null
? BigInt(Math.floor(((recipientAddress.percent ?? 0) / 100) * coinbaseValue))
: BigInt(recipientAddress.amountSats);
rewardBalance -= value;
return {
value,
scriptPubKey: bitcoinjs.address.toOutputScript(recipientAddress.address, this.network),
};
});
if (outputs.length > 0 && rewardBalance !== 0n) {
outputs[0] = {
...outputs[0],
value: outputs[0].value + rewardBalance,
};
}
return outputs;
}
private isWitnessCommitmentOutput(script: Buffer): boolean {
return script.length === 38
&& script[0] === bitcoinjs.opcodes.OP_RETURN
&& script[1] === 0x24
&& script.subarray(2, 6).equals(Buffer.from('aa21a9ed', 'hex'));
}
private isSuccessfulBlockSubmission(result?: string | null): boolean {
return result == null || result === 'SUCCESS!';
}
+41 -1
View File
@@ -80,11 +80,15 @@ describe('datum-codec', () => {
it('serializes coinbaser fetch responses', () => {
const response = serializeDatumCoinbaserFetchResponse(50n, [
{ value: 50n, scriptPubKey: Buffer.from('6a', 'hex') },
]);
], 7);
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);
expect(mining.body[12]).toBe(7);
expect(mining.body.readBigUInt64LE(13)).toBe(50n);
expect(mining.body[21]).toBe(1);
expect(mining.body.subarray(22).toString('hex')).toBe('6a');
});
it('deserializes coinbaser fetch requests', () => {
@@ -178,6 +182,42 @@ describe('datum-codec', () => {
expect(parsed.coinbasePairs.get(2)?.coinb2.toString('hex')).toBe('010203');
});
it('classifies DATUM coinbase sections by section type, not the top-level coinbase id', () => {
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] = 0xff;
base[2] = 0x02;
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 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),
coinbaseSection,
Buffer.from([0xfe]),
]));
expect(parsed.subsidyOnly).toBe(true);
expect(parsed.subsidyOnlyCoinbase).toBeUndefined();
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;
+7 -7
View File
@@ -332,21 +332,21 @@ export function deserializeDatumCoinbaserFetch(payload: Buffer): DatumCoinbaserF
return { rewardValue: payload.readBigUInt64LE(0) };
}
export function serializeDatumCoinbaserFetchResponse(rewardValue: bigint, outputs: DatumPayoutOutput[]): Buffer {
export function serializeDatumCoinbaserFetchResponse(rewardValue: bigint, outputs: DatumPayoutOutput[], coinbaserId = 1): Buffer {
const w = new BufferWriter();
w.writeU64(rewardValue);
const outputsBuffer = serializeDatumPayoutOutputs(outputs);
const outputsBuffer = serializeDatumPayoutOutputs(outputs, coinbaserId);
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');
export function serializeDatumPayoutOutputs(outputs: DatumPayoutOutput[], coinbaserId = 1): Buffer {
if (!Number.isInteger(coinbaserId) || coinbaserId < 0 || coinbaserId > 255) {
throw new RangeError('DATUM coinbaser id must fit in one byte');
}
const w = new BufferWriter();
w.writeU8(outputs.length);
w.writeU8(coinbaserId);
for (const output of outputs) {
if (output.scriptPubKey.length > 255) {
throw new RangeError('DATUM scriptPubKey length must fit in one byte');
@@ -422,7 +422,7 @@ export function deserializeDatumPowSubmit(payload: Buffer): DatumPowSubmit {
coinb1: reader.readBytes(coinb1Len),
coinb2: reader.readBytes(coinb2Len),
};
if (coinbaseType === 255 || coinbaseId === 255) {
if (coinbaseType === 255) {
result.subsidyOnlyCoinbase = coinbase;
} else {
coinbasePairs.set(coinbaseType, coinbase);