Speed up template publication and share validation

This commit is contained in:
Ben
2026-07-11 23:33:24 -04:00
parent 126b05224f
commit 4f29269fed
34 changed files with 9789 additions and 724 deletions
+181
View File
@@ -2,6 +2,7 @@ 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';
@@ -48,6 +49,34 @@ describe('MiningJob', () => {
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';
@@ -105,6 +134,44 @@ describe('MiningJob', () => {
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,
@@ -144,4 +211,118 @@ describe('MiningJob', () => {
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);
});
});