mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
import * as bitcoinjs from 'bitcoinjs-lib';
|
|
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
|
|
|
import { MockRecording1 } from '../../test/models/MockRecording1';
|
|
import { createSubsidyOnlyBlockTemplate } from '../services/subsidy-only-template.factory';
|
|
import { StratumV1JobsService } from '../services/stratum-v1-jobs.service';
|
|
import { MiningJob } from './MiningJob';
|
|
|
|
describe('MiningJob', () => {
|
|
let jobTemplate;
|
|
let job: MiningJob;
|
|
|
|
beforeEach(async () => {
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(new Date(parseInt(MockRecording1.TIME, 16) * 1000));
|
|
|
|
const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
|
|
const bitcoinRpcService = {
|
|
newBlockTemplate$: blockTemplate$.asObservable(),
|
|
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
|
|
};
|
|
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
|
|
const jobsService = new StratumV1JobsService(bitcoinRpcService as any);
|
|
jobTemplate = await firstValueFrom(jobsService.newMiningJob$);
|
|
job = new MiningJob(
|
|
bitcoinjs.networks.testnet,
|
|
'1',
|
|
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
|
jobTemplate
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('should split coinbase around 12 bytes of extranonce space', () => {
|
|
const notify = JSON.parse(job.response(jobTemplate));
|
|
const coinbasePart1 = notify.params[2];
|
|
const coinbasePart2 = notify.params[3];
|
|
const extraNonce1 = '57a6f098';
|
|
const extraNonce2 = 'c708000000000000';
|
|
const coinbase = bitcoinjs.Transaction.fromHex(`${coinbasePart1}${extraNonce1}${extraNonce2}${coinbasePart2}`);
|
|
|
|
expect(Buffer.byteLength(extraNonce1 + extraNonce2, 'hex')).toBe(12);
|
|
expect(coinbase.ins[0].script.toString('hex')).toContain(`${extraNonce1}${extraNonce2}`);
|
|
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
{ height: 1, expectedPrefix: '51' },
|
|
{ height: 16, expectedPrefix: '60' },
|
|
{ height: 17, expectedPrefix: '0111' },
|
|
])('minimally encodes BIP34 coinbase height $height', ({ height, expectedPrefix }) => {
|
|
const heightTemplate = {
|
|
...jobTemplate,
|
|
blockData: { ...jobTemplate.blockData, height },
|
|
};
|
|
const heightJob = new MiningJob(
|
|
bitcoinjs.networks.testnet,
|
|
`height-${height}`,
|
|
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
|
heightTemplate,
|
|
);
|
|
const extraNonce1 = '57a6f098';
|
|
const extraNonce2 = 'c708000000000000';
|
|
const coinbase = bitcoinjs.Transaction.fromHex([
|
|
heightJob.getCoinbasePrefixBuffer().toString('hex'),
|
|
extraNonce1,
|
|
extraNonce2,
|
|
heightJob.getCoinbaseSuffixBuffer().toString('hex'),
|
|
].join(''));
|
|
|
|
expect(coinbase.ins[0].script.toString('hex').startsWith(expectedPrefix)).toBe(true);
|
|
expect(coinbase.ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
|
|
});
|
|
|
|
it('should expose coinbase prefix and suffix buffers for SV2 extended jobs', () => {
|
|
const notify = JSON.parse(job.response(jobTemplate));
|
|
const extraNonce1 = '57a6f098';
|
|
const extraNonce2 = 'c708000000000000';
|
|
const fromNotify = Buffer.from(`${notify.params[2]}${extraNonce1}${extraNonce2}${notify.params[3]}`, 'hex');
|
|
const fromBuffers = Buffer.concat([
|
|
job.getCoinbasePrefixBuffer(),
|
|
Buffer.from(`${extraNonce1}${extraNonce2}`, 'hex'),
|
|
job.getCoinbaseSuffixBuffer(),
|
|
]);
|
|
|
|
expect(fromBuffers).toEqual(fromNotify);
|
|
expect(bitcoinjs.Transaction.fromBuffer(fromBuffers).ins[0].script.toString('hex'))
|
|
.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';
|
|
const timestamp = parseInt(MockRecording1.TIME, 16);
|
|
const originalMerkleRoot = Buffer.from(jobTemplate.block.merkleRoot);
|
|
|
|
const updatedBlock = job.copyAndUpdateBlock(
|
|
jobTemplate,
|
|
parseInt('00002000', 16),
|
|
parseInt('ed460d91', 16),
|
|
extraNonce1,
|
|
extraNonce2,
|
|
timestamp
|
|
);
|
|
|
|
expect(updatedBlock.nonce).toBe(parseInt('ed460d91', 16));
|
|
expect(updatedBlock.timestamp).toBe(timestamp);
|
|
expect(updatedBlock.version).toBe(jobTemplate.block.version ^ parseInt('00002000', 16));
|
|
expect(updatedBlock.transactions[0].ins[0].script.toString('hex').endsWith(`${extraNonce1}${extraNonce2}`)).toBe(true);
|
|
expect(updatedBlock.merkleRoot.equals(originalMerkleRoot)).toBe(false);
|
|
});
|
|
|
|
it('replaces negotiated BIP310 bits without toggling bits already set in the job version', () => {
|
|
const versionRollingMask = 0x1fffe000;
|
|
const versionBits = 0x00002000;
|
|
const baseVersion = 0xa0002000 | 0;
|
|
const versionedTemplate = {
|
|
...jobTemplate,
|
|
block: Object.assign(new bitcoinjs.Block(), jobTemplate.block, {
|
|
version: baseVersion,
|
|
}),
|
|
};
|
|
const expectedVersion = 0xa0002000 >>> 0;
|
|
|
|
expect(MiningJob.applyVersionRolling(baseVersion, versionBits, versionRollingMask))
|
|
.toBe(expectedVersion);
|
|
const updatedBlock = job.copyAndUpdateBlock(
|
|
versionedTemplate,
|
|
versionBits,
|
|
parseInt('ed460d91', 16),
|
|
'57a6f098',
|
|
'c708000000000000',
|
|
parseInt(MockRecording1.TIME, 16),
|
|
versionRollingMask,
|
|
);
|
|
const header = job.buildHeaderBuffer(
|
|
versionedTemplate,
|
|
versionBits,
|
|
parseInt('ed460d91', 16),
|
|
'57a6f098',
|
|
'c708000000000000',
|
|
parseInt(MockRecording1.TIME, 16),
|
|
versionRollingMask,
|
|
);
|
|
|
|
expect(updatedBlock.version >>> 0).toBe(expectedVersion);
|
|
expect(header.readUInt32LE(0)).toBe(expectedVersion);
|
|
expect(header).toEqual(updatedBlock.toBuffer(true));
|
|
});
|
|
|
|
it('should leave block version unchanged without a version mask', () => {
|
|
const updatedBlock = job.copyAndUpdateBlock(
|
|
jobTemplate,
|
|
0,
|
|
parseInt('ed460d91', 16),
|
|
'57a6f098',
|
|
'c708000000000000',
|
|
parseInt(MockRecording1.TIME, 16)
|
|
);
|
|
|
|
expect(updatedBlock.version).toBe(jobTemplate.block.version);
|
|
});
|
|
|
|
it('should build the same header as the full block update path', () => {
|
|
const versionMask = parseInt('00002000', 16);
|
|
const nonce = parseInt('ed460d91', 16);
|
|
const extraNonce1 = '57a6f098';
|
|
const extraNonce2 = 'c708000000000000';
|
|
const timestamp = parseInt(MockRecording1.TIME, 16);
|
|
|
|
const updatedBlock = job.copyAndUpdateBlock(
|
|
jobTemplate,
|
|
versionMask,
|
|
nonce,
|
|
extraNonce1,
|
|
extraNonce2,
|
|
timestamp
|
|
);
|
|
const fastHeader = job.buildHeaderBuffer(
|
|
jobTemplate,
|
|
versionMask,
|
|
nonce,
|
|
extraNonce1,
|
|
extraNonce2,
|
|
timestamp
|
|
);
|
|
|
|
expect(fastHeader.equals(updatedBlock.toBuffer(true))).toBe(true);
|
|
});
|
|
|
|
it('hydrates the raw transaction body only when reconstructing a full candidate', () => {
|
|
const versionMask = parseInt('00002000', 16);
|
|
const nonce = parseInt('ed460d91', 16);
|
|
const extraNonce1 = '57a6f098';
|
|
const extraNonce2 = 'c708000000000000';
|
|
const timestamp = parseInt(MockRecording1.TIME, 16);
|
|
const fromHexSpy = jest.spyOn(bitcoinjs.Transaction, 'fromHex');
|
|
|
|
const merkleRoot = job.buildCoinbaseMerkleRoot(extraNonce1, extraNonce2);
|
|
job.buildHeaderBuffer(
|
|
jobTemplate,
|
|
versionMask,
|
|
nonce,
|
|
extraNonce1,
|
|
extraNonce2,
|
|
timestamp,
|
|
);
|
|
expect(fromHexSpy).not.toHaveBeenCalled();
|
|
|
|
const candidate = job.copyAndUpdateBlock(
|
|
jobTemplate,
|
|
versionMask,
|
|
nonce,
|
|
extraNonce1,
|
|
extraNonce2,
|
|
timestamp,
|
|
);
|
|
|
|
expect(fromHexSpy).toHaveBeenCalledTimes(jobTemplate.blockData.transactions.length);
|
|
expect(candidate.transactions).toHaveLength(jobTemplate.blockData.transactions.length + 1);
|
|
expect(candidate.transactions.slice(1).map(transaction => transaction.toHex()))
|
|
.toEqual(jobTemplate.blockData.transactions.map(transaction => transaction.data));
|
|
expect(candidate.transactions.slice(1).map(transaction => transaction.getId()))
|
|
.toEqual(jobTemplate.blockData.transactions.map(transaction => transaction.txid));
|
|
expect(candidate.merkleRoot).toEqual(merkleRoot);
|
|
expect(bitcoinjs.Block.calculateMerkleRoot(candidate.transactions, false))
|
|
.toEqual(candidate.merkleRoot);
|
|
expect(candidate.checkTxRoots()).toBe(true);
|
|
});
|
|
|
|
it('rejects a lazily hydrated candidate body whose bytes do not match the GBT txid', () => {
|
|
const corruptedTemplate = {
|
|
...jobTemplate,
|
|
blockData: {
|
|
...jobTemplate.blockData,
|
|
transactions: jobTemplate.blockData.transactions.map((transaction, index) => ({
|
|
...transaction,
|
|
txid: index === 0 ? '00'.repeat(32) : transaction.txid,
|
|
})),
|
|
},
|
|
};
|
|
|
|
expect(() => job.copyAndUpdateBlock(
|
|
corruptedTemplate,
|
|
0,
|
|
1,
|
|
'57a6f098',
|
|
'c708000000000000',
|
|
parseInt(MockRecording1.TIME, 16),
|
|
)).toThrow('data does not match its txid');
|
|
});
|
|
|
|
it('builds a consensus-shaped subsidy bridge with one coinbase and a clean notify', async () => {
|
|
const authoritative = {
|
|
...MockRecording1.BLOCK_TEMPLATE,
|
|
height: 840_000,
|
|
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
|
|
};
|
|
authoritative.coinbasevalue = 312_500_000
|
|
+ authoritative.transactions.reduce((sum, transaction) => sum + transaction.fee, 0);
|
|
const emptyTemplate = createSubsidyOnlyBlockTemplate({
|
|
authoritativeTemplate: authoritative,
|
|
network: 'mainnet',
|
|
payoutMode: 'solo',
|
|
});
|
|
const emptyTemplate$ = new BehaviorSubject(emptyTemplate);
|
|
const jobsService = new StratumV1JobsService({
|
|
newBlockTemplate$: emptyTemplate$.asObservable(),
|
|
miningInfo: { blocks: emptyTemplate.height - 1 },
|
|
} as any);
|
|
const emptyJobTemplate = await firstValueFrom(jobsService.newMiningJob$);
|
|
const emptyJob = new MiningJob(
|
|
bitcoinjs.networks.testnet,
|
|
'bridge',
|
|
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
|
|
emptyJobTemplate,
|
|
);
|
|
|
|
const notify = JSON.parse(emptyJob.response(emptyJobTemplate));
|
|
const fromHexSpy = jest.spyOn(bitcoinjs.Transaction, 'fromHex');
|
|
const reconstructed = emptyJob.copyAndUpdateBlock(
|
|
emptyJobTemplate,
|
|
0,
|
|
1,
|
|
'57a6f098',
|
|
'c708000000000000',
|
|
emptyJobTemplate.block.timestamp,
|
|
);
|
|
const coinbaseScript = reconstructed.transactions[0].ins[0].script;
|
|
const encodedHeight = coinbaseScript.subarray(1, 1 + coinbaseScript[0]);
|
|
|
|
expect(emptyJobTemplate.merkle_branch).toEqual([]);
|
|
expect(notify.params[8]).toBe(true);
|
|
expect(fromHexSpy).not.toHaveBeenCalled();
|
|
expect(reconstructed.transactions).toHaveLength(1);
|
|
expect(reconstructed.transactions[0].outs.reduce((sum, output) => sum + output.value, 0))
|
|
.toBe(312_500_000);
|
|
expect(bitcoinjs.script.number.decode(encodedHeight)).toBe(840_000);
|
|
expect(reconstructed.transactions[0].outs[1].script.toString('hex'))
|
|
.toBe(emptyTemplate.default_witness_commitment);
|
|
expect(bitcoinjs.Block.calculateMerkleRoot(reconstructed.transactions, false))
|
|
.toEqual(reconstructed.merkleRoot);
|
|
});
|
|
});
|