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