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
@@ -17,7 +17,7 @@ export class PayoutSnapshotEntity {
payoutMode: PayoutMode;
@Column({ length: 16, type: 'varchar', default: 'finalized' })
status: 'finalized';
status: 'finalized' | 'bridge_seed';
@Column({ type: 'bigint' })
blockHeight: number;
@@ -1,5 +1,6 @@
import { DataSource, EntityManager } from 'typeorm';
import type { PayoutDistributionEntry } from './payout-distribution';
import { PayoutSnapshotService } from './payout-snapshot.service';
const ADDRESS_A = 'bc1qs29kyaqqc0fkvj897ke9e5xa9utljjey0y5jjn';
@@ -52,7 +53,6 @@ describe('PayoutSnapshotService', () => {
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: '55' }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{
id: '55',
method: 'pplns',
@@ -91,8 +91,106 @@ describe('PayoutSnapshotService', () => {
{ address: ADDRESS_A, amountSats: 600, percent: 60 },
{ address: ADDRESS_B, amountSats: 400, percent: 40 },
]);
expect(manager.query).toHaveBeenCalledTimes(9);
expect(manager.query).toHaveBeenCalledTimes(8);
expect(manager.query.mock.calls[0][1]).toEqual([100, 'pplns']);
const entryInsertCalls = manager.query.mock.calls.filter(([sql]) => (
sql.includes('INSERT INTO "payout_snapshot_entry"')
));
expect(entryInsertCalls).toHaveLength(1);
expect(entryInsertCalls[0][0]).toContain('FROM UNNEST(');
expect(entryInsertCalls[0][0]).toContain('WITH ORDINALITY');
expect(entryInsertCalls[0][0]).toContain('ORDER BY entry."ordinality"');
expect(entryInsertCalls[0][1]).toEqual([
'55',
'pplns',
[ADDRESS_A, ADDRESS_B],
[60, 40],
[3, 2],
[0.6, 0.4],
[600, 400],
[600, 400],
[0, 0],
[0, 0],
[true, true],
['coinbase', 'coinbase'],
[1, 2],
]);
});
it('should bound and chunk set-based snapshot entry inserts without changing values', async () => {
const entries: PayoutDistributionEntry[] = Array.from({ length: 1_001 }, (_, index) => {
const includedInCoinbase = index % 3 !== 0;
const balanceBeforeSats = 200 + index;
const grossPayoutSats = 10_000 + index;
return {
address: `test-address-${index}`,
creditedDifficulty: index + 0.5,
acceptedShareCount: index + 1,
payoutWeight: (index + 1) / 1_001,
grossPayoutSats,
payoutSats: includedInCoinbase ? 1_000 + index : 0,
balanceBeforeSats,
balanceAfterSats: includedInCoinbase
? balanceBeforeSats
: balanceBeforeSats + grossPayoutSats,
includedInCoinbase,
rank: 1_001 - index,
};
});
const insertSnapshotEntries = Reflect.get(service, 'insertSnapshotEntries') as (
manager: EntityManager,
snapshotId: string,
entries: PayoutDistributionEntry[],
) => Promise<void>;
let resolveFirstInsert: () => void = () => {
throw new Error('First insert did not start');
};
manager.query
.mockImplementationOnce(() => new Promise<void>(resolve => {
resolveFirstInsert = () => resolve();
}))
.mockResolvedValueOnce([]);
const insertion = insertSnapshotEntries.call(
service,
manager as unknown as EntityManager,
'77',
entries,
);
expect(manager.query).toHaveBeenCalledTimes(1);
resolveFirstInsert();
await insertion;
expect(manager.query).toHaveBeenCalledTimes(2);
const calls = manager.query.mock.calls;
expect(calls.map(([, parameters]) => parameters.length)).toEqual([13, 13]);
expect(calls.map(([, parameters]) => parameters[2].length)).toEqual([1_000, 1]);
for (const [sql, parameters] of calls) {
expect(sql).toContain('FROM UNNEST(');
expect(sql).toContain('ORDER BY entry."ordinality"');
for (const values of parameters.slice(2)) {
expect(values.length).toBeLessThanOrEqual(1_000);
}
}
const expectedParameters = (batch: PayoutDistributionEntry[]) => [
'77',
'pplns',
batch.map(entry => entry.address),
batch.map(entry => entry.creditedDifficulty),
batch.map(entry => entry.acceptedShareCount),
batch.map(entry => entry.payoutWeight),
batch.map(entry => entry.grossPayoutSats),
batch.map(entry => entry.payoutSats),
batch.map(entry => entry.balanceBeforeSats),
batch.map(entry => entry.balanceAfterSats),
batch.map(entry => entry.includedInCoinbase),
batch.map(entry => entry.includedInCoinbase ? 'coinbase' : 'pending'),
batch.map(entry => entry.rank),
];
expect(calls[0][1]).toEqual(expectedParameters(entries.slice(0, 1_000)));
expect(calls[1][1]).toEqual(expectedParameters(entries.slice(1_000)));
});
it('should bootstrap the PPLNS window from paid block count up to the configured factor', async () => {
@@ -116,7 +214,6 @@ describe('PayoutSnapshotService', () => {
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: '56' }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{
id: '56',
method: 'pplns',
@@ -213,6 +310,56 @@ describe('PayoutSnapshotService', () => {
expect(manager.query).toHaveBeenCalledTimes(4);
});
it('stores bridge seeds with a non-active status excluded from latest payout queries', async () => {
manager.query
.mockResolvedValueOnce([{
startBatchId: '10',
endBatchId: '12',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: '100',
totalAcceptedShareCount: '5',
}])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ address: ADDRESS_A, creditedDifficulty: 100, acceptedShareCount: 5 },
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'seed-57' }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{
id: 'seed-57',
method: 'pplns',
payoutMode: 'pplns',
blockHeight: 900002,
coinbaseValueSats: '1000',
windowStartShareIndex: '1000',
windowEndShareIndex: '2000',
totalCreditedDifficulty: 100,
totalAcceptedShareCount: '5',
eligibleAddressCount: 1,
includedOutputCount: 1,
distributedSats: '1000',
unallocatedRemainderSats: '0',
}])
.mockResolvedValueOnce([{ address: ADDRESS_A, payoutSats: '1000' }]);
await expect(service.createSnapshotForTemplate({
blockHeight: 900002,
coinbaseValueSats: 1000,
networkDifficulty: 25,
visibility: 'bridge_seed',
})).resolves.toEqual(expect.objectContaining({ id: 'seed-57' }));
const existingLookupSql = manager.query.mock.calls[1][0] as string;
const insertSql = manager.query.mock.calls[4][0] as string;
expect(existingLookupSql).toContain(`"status" = 'bridge_seed'`);
expect(insertSql).toContain(`'bridge_seed'`);
dataSource.query.mockResolvedValueOnce([]);
await expect(service.getLatestSnapshot()).resolves.toBeNull();
expect(dataSource.query.mock.calls[0][0]).toContain(`"status" = 'finalized'`);
});
it('should finalize snapshot balances and payout history for a found block', async () => {
manager.query
.mockResolvedValueOnce([{ id: '55', coinbaseValueSats: '1000' }])
@@ -58,6 +58,9 @@ const DEFAULT_PAYOUT_WINDOW_FACTOR = 4;
const DEFAULT_COINBASE_WEIGHT_BUDGET = 26_000;
const DEFAULT_PAYOUT_BOOTSTRAP_WINDOW = true;
const PPLNS_PAYOUT_MODE: PayoutMode = 'pplns';
// UNNEST keeps each statement at 13 bind parameters; this cap bounds array payload size.
const PAYOUT_SNAPSHOT_ENTRY_INSERT_BATCH_SIZE = 1_000;
type PayoutSnapshotStatus = 'finalized' | 'bridge_seed';
@Injectable()
export class PayoutSnapshotService {
@@ -79,12 +82,17 @@ export class PayoutSnapshotService {
blockHeight: number;
coinbaseValueSats: number;
networkDifficulty: number;
/** Bridge seeds are frozen/reconstructable but excluded from active payout APIs. */
visibility?: 'active' | 'bridge_seed';
}): Promise<PayoutSnapshotForTemplate | null> {
if (!this.snapshotsEnabled || input.coinbaseValueSats <= 0 || input.networkDifficulty <= 0) {
return null;
}
return this.dataSource.transaction(async manager => {
const snapshotStatus: PayoutSnapshotStatus = input.visibility === 'bridge_seed'
? 'bridge_seed'
: 'finalized';
const effectiveWindowFactor = await this.getEffectiveWindowFactor(manager);
const windowTargetDifficulty = input.networkDifficulty * effectiveWindowFactor;
const window = await this.getPplnsWindow(manager, windowTargetDifficulty);
@@ -96,6 +104,7 @@ export class PayoutSnapshotService {
blockHeight: input.blockHeight,
coinbaseValueSats: input.coinbaseValueSats,
windowEndShareIndex: window.windowEndShareIndex,
status: snapshotStatus,
});
if (existing != null) {
return existing;
@@ -153,7 +162,7 @@ export class PayoutSnapshotService {
) VALUES (
$1,
$2,
'finalized',
'${snapshotStatus}',
$3::bigint,
$4::bigint,
$5::numeric,
@@ -199,9 +208,7 @@ export class PayoutSnapshotService {
unallocatedRemainderSats.toString(),
]);
for (const entry of entries) {
await this.insertSnapshotEntry(manager, snapshotRow.id, entry);
}
await this.insertSnapshotEntries(manager, snapshotRow.id, entries);
return this.getSnapshotById(manager, snapshotRow.id);
});
@@ -554,14 +561,19 @@ export class PayoutSnapshotService {
private async getExistingSnapshot(
manager: EntityManager,
input: { blockHeight: number; coinbaseValueSats: number; windowEndShareIndex: string },
input: {
blockHeight: number;
coinbaseValueSats: number;
windowEndShareIndex: string;
status: PayoutSnapshotStatus;
},
): Promise<PayoutSnapshotForTemplate | null> {
const [snapshot] = await manager.query(`
SELECT "id"::text AS "id"
FROM "payout_snapshot"
WHERE "method" = $1
AND "payoutMode" = $5
AND "status" = 'finalized'
AND "status" = '${input.status}'
AND "blockHeight" = $2::bigint
AND "coinbaseValueSats" = $3::bigint
AND "windowEndShareIndex" = $4::bigint
@@ -641,52 +653,86 @@ export class PayoutSnapshotService {
};
}
private async insertSnapshotEntry(manager: EntityManager, snapshotId: string, entry: PayoutDistributionEntry): Promise<void> {
await manager.query(`
INSERT INTO "payout_snapshot_entry" (
"snapshotId",
"payoutMode",
"address",
"creditedDifficulty",
"acceptedShareCount",
"payoutWeight",
"grossPayoutSats",
"payoutSats",
"balanceBeforeSats",
"balanceAfterSats",
"includedInCoinbase",
"rowType",
"rank"
) VALUES (
$1::bigint,
$2,
$3,
$4::numeric,
$5::bigint,
$6::numeric,
$7::bigint,
$8::bigint,
$9::bigint,
$10::bigint,
$11::boolean,
$12,
$13::int
)
`, [
snapshotId,
PPLNS_PAYOUT_MODE,
entry.address,
entry.creditedDifficulty,
entry.acceptedShareCount,
entry.payoutWeight,
entry.grossPayoutSats,
entry.payoutSats,
entry.balanceBeforeSats,
entry.balanceAfterSats,
entry.includedInCoinbase,
entry.includedInCoinbase ? 'coinbase' : 'pending',
entry.rank,
]);
private async insertSnapshotEntries(
manager: EntityManager,
snapshotId: string,
entries: PayoutDistributionEntry[],
): Promise<void> {
for (let offset = 0; offset < entries.length; offset += PAYOUT_SNAPSHOT_ENTRY_INSERT_BATCH_SIZE) {
const batch = entries.slice(offset, offset + PAYOUT_SNAPSHOT_ENTRY_INSERT_BATCH_SIZE);
await manager.query(`
INSERT INTO "payout_snapshot_entry" (
"snapshotId",
"payoutMode",
"address",
"creditedDifficulty",
"acceptedShareCount",
"payoutWeight",
"grossPayoutSats",
"payoutSats",
"balanceBeforeSats",
"balanceAfterSats",
"includedInCoinbase",
"rowType",
"rank"
)
SELECT
$1::bigint,
$2,
entry."address",
entry."creditedDifficulty",
entry."acceptedShareCount",
entry."payoutWeight",
entry."grossPayoutSats",
entry."payoutSats",
entry."balanceBeforeSats",
entry."balanceAfterSats",
entry."includedInCoinbase",
entry."rowType",
entry."rank"
FROM UNNEST(
$3::varchar[],
$4::numeric[],
$5::bigint[],
$6::numeric[],
$7::bigint[],
$8::bigint[],
$9::bigint[],
$10::bigint[],
$11::boolean[],
$12::varchar[],
$13::int[]
) WITH ORDINALITY AS entry(
"address",
"creditedDifficulty",
"acceptedShareCount",
"payoutWeight",
"grossPayoutSats",
"payoutSats",
"balanceBeforeSats",
"balanceAfterSats",
"includedInCoinbase",
"rowType",
"rank",
"ordinality"
)
ORDER BY entry."ordinality"
`, [
snapshotId,
PPLNS_PAYOUT_MODE,
batch.map(entry => entry.address),
batch.map(entry => entry.creditedDifficulty),
batch.map(entry => entry.acceptedShareCount),
batch.map(entry => entry.payoutWeight),
batch.map(entry => entry.grossPayoutSats),
batch.map(entry => entry.payoutSats),
batch.map(entry => entry.balanceBeforeSats),
batch.map(entry => entry.balanceAfterSats),
batch.map(entry => entry.includedInCoinbase),
batch.map(entry => entry.includedInCoinbase ? 'coinbase' : 'pending'),
batch.map(entry => entry.rank),
]);
}
}
private limitCoinbaseOutputs(entries: PayoutDistributionEntry[]): PayoutDistributionEntry[] {
+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);
});
});
+163 -53
View File
@@ -2,6 +2,7 @@ import { AddressType, getAddressInfo } from 'bitcoin-address-validation';
import * as bitcoinjs from 'bitcoinjs-lib';
import { IJobTemplate } from '../services/stratum-v1-jobs.service';
import { PayoutMode } from '../types/payout-mode';
import { hash256 } from '../utils/hash.utils';
import { eResponseMethod } from './enums/eResponseMethod';
import { IMiningNotify } from './stratum-messages/IMiningNotify';
@@ -13,16 +14,28 @@ export interface AddressObject {
percent?: number;
amountSats?: number;
}
export interface MiningJobOwnership {
payoutMode: PayoutMode;
payoutIdentity: string;
}
export class MiningJob {
private static readonly paymentScriptCache = new Map<string, Buffer>();
private static readonly paymentScriptCacheMaxEntries = 250_000;
private coinbaseTransaction: bitcoinjs.Transaction;
private coinbasePart1: string;
private coinbasePart2: string;
private coinbasePart1Buffer: Buffer;
private coinbasePart2Buffer: Buffer;
private merkleBranchBuffers: Buffer[];
private miningNotifyResponse: string;
private miningNotifyResponseBuffer: Buffer;
public jobTemplateId: string;
public tipKey: string;
public networkDifficulty: number;
public creation: number;
@@ -30,11 +43,13 @@ export class MiningJob {
private network: bitcoinjs.networks.Network,
public jobId: string,
payoutInformation: AddressObject[],
jobTemplate: IJobTemplate
jobTemplate: IJobTemplate,
public readonly ownership?: MiningJobOwnership,
) {
this.creation = new Date().getTime();
this.jobTemplateId = jobTemplate.blockData.id;
this.tipKey = jobTemplate.blockData.tipKey;
this.merkleBranchBuffers = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex'));
this.coinbaseTransaction = this.createCoinbaseTransaction(payoutInformation, jobTemplate.blockData.coinbasevalue);
@@ -49,18 +64,15 @@ export class MiningJob {
// 39th byte onwards: Optional data with no consensus meaning
const extra = Buffer.from('Public-Pool');
// Encode the block height
// https://github.com/bitcoin/bips/blob/master/bip-0034.mediawiki
const blockHeightEncoded = bitcoinjs.script.number.encode(jobTemplate.blockData.height);
// BIP34 uses Bitcoin Script's minimally encoded integer push. Heights
// 1..16 are OP_1..OP_16, not a one-byte PUSHDATA operation. Keeping the
// extranonce padding independent of the height width also prevents a
// future four-byte height from consuming part of the pool tag.
const blockHeightPrefix = this.encodeCoinbaseHeight(jobTemplate.blockData.height);
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES, 0);
// Get the length of the block height encoding
const blockHeightLengthByte = Buffer.from([blockHeightEncoded.length]);
// generate padding and take length of encode blockHeight into account
const padding = Buffer.alloc(TOTAL_EXTRANONCE_SIZE_BYTES + (3 - blockHeightEncoded.length), 0)
// build the script
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightLengthByte, blockHeightEncoded, extra, padding])
// Build the script with the extranonce placeholder at the very end.
this.coinbaseTransaction.ins[0].script = Buffer.concat([blockHeightPrefix, extra, padding]);
this.coinbaseTransaction.addOutput(bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, Buffer.concat([segwitMagicBits, jobTemplate.block.witnessCommit])]), 0);
@@ -92,22 +104,61 @@ export class MiningJob {
return Buffer.from(this.coinbasePart2Buffer);
}
public buildHeaderBuffer(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): Buffer {
public buildCoinbaseMerkleRoot(extraNonce: string, extraNonce2: string): Buffer {
const coinbaseBuffer = Buffer.concat([
this.coinbasePart1Buffer,
Buffer.from(`${extraNonce}${extraNonce2}`, 'hex'),
this.coinbasePart2Buffer,
]);
const coinbaseHash = hash256(coinbaseBuffer);
const merkleRoot = this.calculateMerkleRootHash(coinbaseHash, this.merkleBranchBuffers);
return MiningJob.calculateMerkleRootFromCoinbaseHash(
hash256(coinbaseBuffer),
this.merkleBranchBuffers,
);
}
let version = jobTemplate.block.version;
if (versionMask !== undefined && versionMask != 0) {
version = version ^ versionMask;
public static calculateMerkleRootFromCoinbaseHash(
coinbaseHash: Buffer,
merkleBranches: readonly Buffer[],
): Buffer {
let merkleRoot = coinbaseHash;
const bothMerkles = Buffer.alloc(64);
for (const merkleBranch of merkleBranches) {
bothMerkles.set(merkleRoot, 0);
bothMerkles.set(merkleBranch, 32);
merkleRoot = hash256(bothMerkles);
}
return merkleRoot;
}
public static applyVersionRolling(jobVersion: number, versionBits: number, mask: number): number {
const unsignedJobVersion = jobVersion >>> 0;
const unsignedVersionBits = versionBits >>> 0;
const unsignedMask = mask >>> 0;
return (
(unsignedJobVersion & (~unsignedMask >>> 0))
| (unsignedVersionBits & unsignedMask)
) >>> 0;
}
public buildHeaderBuffer(
jobTemplate: IJobTemplate,
versionBits: number,
nonce: number,
extraNonce: string,
extraNonce2: string,
timestamp: number,
versionRollingMask?: number,
): Buffer {
const merkleRoot = this.buildCoinbaseMerkleRoot(extraNonce, extraNonce2);
const version = versionRollingMask == null
// SV2 standard-channel reconstruction passes an XOR delta because
// its submit message contains the complete version field.
? (jobTemplate.block.version ^ versionBits) >>> 0
: MiningJob.applyVersionRolling(jobTemplate.block.version, versionBits, versionRollingMask);
const header = Buffer.alloc(80);
header.writeInt32LE(version, 0);
header.writeUInt32LE(version, 0);
jobTemplate.block.prevHash.copy(header, 4);
merkleRoot.copy(header, 36);
header.writeUInt32LE(timestamp, 68);
@@ -117,21 +168,40 @@ export class MiningJob {
return header;
}
public copyAndUpdateBlock(jobTemplate: IJobTemplate, versionMask: number, nonce: number, extraNonce: string, extraNonce2: string, timestamp: number): bitcoinjs.Block {
public copyAndUpdateBlock(
jobTemplate: IJobTemplate,
versionBits: number,
nonce: number,
extraNonce: string,
extraNonce2: string,
timestamp: number,
versionRollingMask?: number,
): bitcoinjs.Block {
const testBlock = Object.assign(new bitcoinjs.Block(), jobTemplate.block);
testBlock.transactions = jobTemplate.block.transactions.map(tx => {
return Object.assign(new bitcoinjs.Transaction(), tx);
});
testBlock.transactions[0] = this.cloneCoinbaseTransaction();
const rawTransactions = jobTemplate.blockData.transactions;
testBlock.transactions = [
this.cloneCoinbaseTransaction(),
...(rawTransactions == null
? jobTemplate.block.transactions.slice(1).map(tx =>
Object.assign(new bitcoinjs.Transaction(), tx))
: rawTransactions.map((rawTransaction, index) => {
const transaction = bitcoinjs.Transaction.fromHex(rawTransaction.data);
if (transaction.getId().toLowerCase() !== rawTransaction.txid.toLowerCase()) {
throw new Error(`transactions[${index}] data does not match its txid`);
}
return transaction;
})),
];
testBlock.nonce = nonce;
// recompute version mask
if (versionMask !== undefined && versionMask != 0) {
testBlock.version = (testBlock.version ^ versionMask);
}
const version = versionRollingMask == null
? (testBlock.version ^ versionBits) >>> 0
: MiningJob.applyVersionRolling(testBlock.version, versionBits, versionRollingMask);
// bitcoinjs-lib serializes Block.version as a signed int32, while the
// protocol and bit-mask arithmetic use the corresponding uint32 bits.
testBlock.version = version | 0;
// set the nonces
const nonceScript = testBlock.transactions[0].ins[0].script.toString('hex');
@@ -139,7 +209,10 @@ export class MiningJob {
testBlock.transactions[0].ins[0].script = Buffer.from(`${nonceScript.substring(0, nonceScript.length - (TOTAL_EXTRANONCE_SIZE_BYTES * 2))}${extraNonce}${extraNonce2}`, 'hex');
//recompute the root since we updated the coinbase script with the nonces
testBlock.merkleRoot = this.calculateMerkleRootHash(testBlock.transactions[0].getHash(false), this.merkleBranchBuffers);
testBlock.merkleRoot = MiningJob.calculateMerkleRootFromCoinbaseHash(
testBlock.transactions[0].getHash(false),
this.merkleBranchBuffers,
);
testBlock.timestamp = timestamp;
@@ -148,22 +221,6 @@ export class MiningJob {
}
private calculateMerkleRootHash(newRoot: Buffer, merkleBranches: Buffer[]): Buffer {
const bothMerkles = Buffer.alloc(64);
bothMerkles.set(newRoot);
for (let i = 0; i < merkleBranches.length; i++) {
bothMerkles.set(merkleBranches[i], 32);
newRoot = hash256(bothMerkles);
bothMerkles.set(newRoot);
}
return bothMerkles.subarray(0, 32)
}
private createCoinbaseTransaction(addresses: AddressObject[], reward: number): bitcoinjs.Transaction {
// Part 1
const coinbaseTransaction = new bitcoinjs.Transaction();
@@ -196,32 +253,76 @@ export class MiningJob {
return coinbaseTransaction;
}
private encodeCoinbaseHeight(height: number): Buffer {
if (!Number.isSafeInteger(height) || height < 0) {
throw new Error('Coinbase height must be a non-negative safe integer');
}
if (height === 0) {
return Buffer.from([bitcoinjs.opcodes.OP_0]);
}
if (height <= 16) {
return Buffer.from([bitcoinjs.opcodes.OP_1 + height - 1]);
}
const encoded = bitcoinjs.script.number.encode(height);
if (encoded.length >= bitcoinjs.opcodes.OP_PUSHDATA1) {
throw new Error('Coinbase height encoding is unexpectedly large');
}
return Buffer.concat([Buffer.from([encoded.length]), encoded]);
}
private getPaymentScript(address: string): Buffer {
const cacheKey = `${this.network.bech32}:${this.network.pubKeyHash}:${this.network.scriptHash}:${address}`;
const cached = MiningJob.paymentScriptCache.get(cacheKey);
if (cached != null) {
return cached;
}
const addressInfo = getAddressInfo(address);
let paymentScript: Buffer;
switch (addressInfo.type) {
case AddressType.p2wpkh: {
return bitcoinjs.payments.p2wpkh({ address, network: this.network }).output;
paymentScript = bitcoinjs.payments.p2wpkh({ address, network: this.network }).output;
break;
}
case AddressType.p2pkh: {
return bitcoinjs.payments.p2pkh({ address, network: this.network }).output;
paymentScript = bitcoinjs.payments.p2pkh({ address, network: this.network }).output;
break;
}
case AddressType.p2sh: {
return bitcoinjs.payments.p2sh({ address, network: this.network }).output;
paymentScript = bitcoinjs.payments.p2sh({ address, network: this.network }).output;
break;
}
case AddressType.p2tr: {
return bitcoinjs.payments.p2tr({ address, network: this.network }).output;
paymentScript = bitcoinjs.payments.p2tr({ address, network: this.network }).output;
break;
}
case AddressType.p2wsh: {
return bitcoinjs.payments.p2wsh({ address, network: this.network }).output;
paymentScript = bitcoinjs.payments.p2wsh({ address, network: this.network }).output;
break;
}
default: {
return Buffer.alloc(0);
paymentScript = Buffer.alloc(0);
break;
}
}
MiningJob.paymentScriptCache.set(cacheKey, paymentScript);
if (MiningJob.paymentScriptCache.size > MiningJob.paymentScriptCacheMaxEntries) {
const oldest = MiningJob.paymentScriptCache.keys().next().value;
if (oldest != null) {
MiningJob.paymentScriptCache.delete(oldest);
}
}
return paymentScript;
}
public response(jobTemplate: IJobTemplate): string {
if (this.miningNotifyResponse != null) {
return this.miningNotifyResponse;
}
const job: IMiningNotify = {
id: null,
method: eResponseMethod.MINING_NOTIFY,
@@ -238,7 +339,16 @@ export class MiningJob {
]
};
return JSON.stringify(job) + '\n';
this.miningNotifyResponse = JSON.stringify(job) + '\n';
this.miningNotifyResponseBuffer = Buffer.from(this.miningNotifyResponse);
return this.miningNotifyResponse;
}
public responseBuffer(jobTemplate: IJobTemplate): Buffer {
if (this.miningNotifyResponseBuffer == null) {
this.response(jobTemplate);
}
return this.miningNotifyResponseBuffer;
}
+478 -8
View File
@@ -1,6 +1,7 @@
import { ConfigService } from '@nestjs/config';
import * as bitcoinjs from 'bitcoinjs-lib';
import { Socket } from 'net';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
@@ -51,7 +52,7 @@ describe('StratumV1Client', () => {
let consoleErrorSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
let newBlockEmitter: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
let newBlockEmitter: BehaviorSubject<IBlockTemplate>;
beforeEach(async () => {
@@ -60,6 +61,7 @@ describe('StratumV1Client', () => {
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
newBlockEmitter = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
const clients = new Map<string, any>();
let nextClientId = 1;
@@ -168,7 +170,6 @@ describe('StratumV1Client', () => {
});
it('should clean up socket state only once when destroyed repeatedly', async () => {
const unsubscribe = jest.fn();
const timer = setInterval(() => undefined, 1000);
const removeListenerSpy = jest.spyOn(socket, 'removeListener');
@@ -176,16 +177,14 @@ describe('StratumV1Client', () => {
id: '00000000-0000-4000-8000-000000000001',
address: 'tb1qcleanup',
};
(client as any).stratumSubscription = { unsubscribe };
(client as any).backgroundWork = [timer];
(client as any).miningSubmissionHashes.add('submitted-share');
(client as any).miningSubmissionHashes.set('submitted-share', Date.now() + 1000);
(client as any).buffer = 'partial-message';
await Promise.all([client.destroy(), client.destroy()]);
expect(clientService.delete).toHaveBeenCalledTimes(1);
expect(clientService.delete).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000001');
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(removeListenerSpy).toHaveBeenCalledWith('data', expect.any(Function));
expect((client as any).backgroundWork).toEqual([]);
expect((client as any).miningSubmissionHashes.size).toBe(0);
@@ -221,6 +220,99 @@ describe('StratumV1Client', () => {
expect(setTimeoutSpy).toHaveBeenCalledWith(0);
});
it('disconnects a buffered client before building another mining job', async () => {
jest.spyOn(client as any, 'write').mockResolvedValue(true);
jest.spyOn(socket, 'write').mockImplementation(() => true);
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise(resolve => setTimeout(resolve, 100));
(client as any).maxSocketBufferBytes = 256;
const boundedSocket = {
destroyed: false,
writableEnded: false,
writableLength: 256,
write: jest.fn(),
destroy: jest.fn(),
removeListener: jest.fn(),
};
(client as any).socket = boundedSocket;
const buildJob = jest.spyOn(stratumV1JobsService, 'getOrCreateJob');
const result = client.broadcastMiningJob(
stratumV1JobsService.getLatestJobTemplate('solo'),
true,
);
expect(result).toEqual({ status: 'closed', bytes: 0, bufferedBytes: 256 });
expect(buildJob).not.toHaveBeenCalled();
expect(boundedSocket.write).not.toHaveBeenCalled();
expect(boundedSocket.destroy).toHaveBeenCalled();
});
it('disconnects without writing when a mining job would reach the socket buffer limit', async () => {
jest.spyOn(client as any, 'write').mockResolvedValue(true);
jest.spyOn(socket, 'write').mockImplementation(() => true);
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise(resolve => setTimeout(resolve, 100));
(client as any).maxSocketBufferBytes = 1;
const boundedSocket = {
destroyed: false,
writableEnded: false,
writableLength: 0,
write: jest.fn(),
destroy: jest.fn(),
removeListener: jest.fn(),
};
(client as any).socket = boundedSocket;
const buildJob = jest.spyOn(stratumV1JobsService, 'getOrCreateJob');
const result = client.broadcastMiningJob(
stratumV1JobsService.getLatestJobTemplate('solo'),
true,
);
expect(result).toEqual({ status: 'closed', bytes: 0, bufferedBytes: 0 });
expect(buildJob).toHaveBeenCalledTimes(1);
expect(boundedSocket.write).not.toHaveBeenCalled();
expect(boundedSocket.destroy).toHaveBeenCalled();
});
it('reports bytes already queued when a write crosses the socket buffer limit', async () => {
jest.spyOn(client as any, 'write').mockResolvedValue(true);
jest.spyOn(socket, 'write').mockImplementation(() => true);
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise(resolve => setTimeout(resolve, 100));
(client as any).maxSocketBufferBytes = 4096;
let writableLengthReads = 0;
const boundedSocket = {
destroyed: false,
writableEnded: false,
get writableLength() {
return writableLengthReads++ < 2 ? 0 : 4096;
},
write: jest.fn(() => true),
destroy: jest.fn(),
removeListener: jest.fn(),
};
(client as any).socket = boundedSocket;
const result = client.broadcastMiningJob(
stratumV1JobsService.getLatestJobTemplate('solo'),
true,
);
expect(result.status).toBe('closed');
expect(result.bytes).toBeGreaterThan(0);
expect(result.bufferedBytes).toBe(4096);
expect(boundedSocket.write).toHaveBeenCalledTimes(1);
expect(boundedSocket.destroy).toHaveBeenCalled();
});
it('should block non-compliant user agents on subscribe without allocating a session', async () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
switch (key) {
@@ -348,6 +440,44 @@ describe('StratumV1Client', () => {
});
it('should pair a solo difficulty change with clean solo work when merged latest is PPLNS', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(socket, 'write').mockImplementation(() => true);
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
newBlockEmitter.next({
...MockRecording1.BLOCK_TEMPLATE,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.slice(0, -1),
payoutMode: 'solo',
});
newBlockEmitter.next({
...MockRecording1.BLOCK_TEMPLATE,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.slice(0, -2),
payoutMode: 'pplns',
payoutSnapshotId: 'opposite-mode-snapshot',
payoutOutputs: [{
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
amountSats: MockRecording1.BLOCK_TEMPLATE.coinbasevalue,
}],
});
expect((await firstValueFrom(stratumV1JobsService.sv1MiningJob$)).blockData.payoutMode)
.toBe('pplns');
jest.spyOn((client as any).statistics, 'getSuggestedDifficulty').mockReturnValue(200000);
const broadcastSpy = jest.spyOn(client, 'broadcastMiningJob');
await (client as any).checkDifficulty();
const [refreshedTemplate, force] = broadcastSpy.mock.calls.at(-1);
expect(refreshedTemplate.blockData).toEqual(expect.objectContaining({
payoutMode: 'solo',
clearJobs: true,
}));
expect(force).toBe(true);
});
it('should save client', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
@@ -376,6 +506,7 @@ describe('StratumV1Client', () => {
jest.setSystemTime(date);
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
const socketWriteSpy = jest.spyOn(socket, 'write').mockImplementation(() => true);
emitMessage(MockRecording1.MINING_SUBSCRIBE);
@@ -389,7 +520,7 @@ describe('StratumV1Client', () => {
expect((client as any).write).lastCalledWith(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1b03c943255075626c69632d506f6f6c","ffffffff02b59f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",true]}\n`);
expect(socketWriteSpy).toHaveBeenCalledWith(Buffer.from(`{"id":null,"method":"mining.notify","params":["1","171592f223740e92d223f6e68bff25279af7ac4f2246451e0000000200000000","02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1b03c943255075626c69632d506f6f6c","ffffffff02b59f250000000000160014e6f22ca44dc800e9d049621a3b9a42c509f1c4bc0000000000000000266a24aa21a9edbd3d1d916aa0b57326a2d88ebe1b68a1d7c48585f26d8335fe6a94b62755f64c00000000",["175335649d5e8746982969ec88f52e85ac9917106fba5468e699c8879ab974a1","d5644ab3e708c54cd68dc5aedc92b8d3037449687f92ec41ed6e37673d969d4a","5c9ec187517edc0698556cca5ce27e54c96acb014770599ed9df4d4937fbf2b0"],"20000000","192495f8","${MockRecording1.TIME}",true]}\n`));
emitMessage(MockRecording1.MINING_SUBMIT);
@@ -504,6 +635,38 @@ describe('StratumV1Client', () => {
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
});
it('should submit a network-target candidate even when it misses the harder session target', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
const jobTemplate = stratumV1JobsService.getLatestJobTemplate('solo');
const networkTarget = DifficultyUtils.compactToTarget(jobTemplate.block.bits);
expect(networkTarget).not.toBeNull();
(client as any).sessionDifficultyTarget = Buffer.alloc(32);
const addSharesSpy = jest.spyOn((client as any).statistics, 'addShares');
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: 1234,
submissionHash: 'network-block-below-session-target',
hashBuffer: networkTarget,
});
emitMessage(MockRecording1.MINING_SUBMIT);
jest.useRealTimers();
await new Promise((r) => setTimeout(r, 1000));
expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
expect((client as any).write).toHaveBeenCalledWith(`{"id":5,"error":null,"result":true}\n`);
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
isBlockCandidate: true,
creditedDifficulty: 1234,
submissionDifficulty: 1234,
}));
expect(addSharesSpy).toHaveBeenCalledWith(expect.any(Object), 1234);
});
it('should reject duplicate submissions', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
@@ -520,6 +683,257 @@ describe('StratumV1Client', () => {
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[22,"Duplicate share",""]}\n`);
});
it('should accept a five-field submission without clearing advertised version bits', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
const baseVersion = (MockRecording1.BLOCK_TEMPLATE.version | 0x00002000) | 0;
newBlockEmitter.next({
...MockRecording1.BLOCK_TEMPLATE,
version: baseVersion,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(transaction => ({ ...transaction })),
});
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
const submission = JSON.parse(MockRecording1.MINING_SUBMIT);
submission.params.pop();
emitMessage(JSON.stringify(submission));
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
version: (baseVersion >>> 0).toString(16),
}));
});
it('should reject case-variant encodings of the same submission as duplicates', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
const variant = JSON.parse(MockRecording1.MINING_SUBMIT);
for (const index of [2, 3, 4, 5]) {
variant.params[index] = variant.params[index].toUpperCase();
}
emitMessage(JSON.stringify(variant));
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[22,"Duplicate share",""]}\n`);
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledTimes(1);
});
it.each([
{ payoutMode: 'pplns' as const, payoutIdentity: 'pplns\0foreign-snapshot' },
{ payoutMode: 'solo' as const, payoutIdentity: 'solo\0foreign-address' },
])('should reject a $payoutMode job not owned by the connection', async ({ payoutMode, payoutIdentity }) => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
const calculateDifficultySpy = jest.spyOn(client as any, 'calculateDifficulty');
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
const template = stratumV1JobsService.getLatestJobTemplate('solo');
const foreignJob = stratumV1JobsService.getOrCreateJob(
bitcoinjs.networks.testnet,
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
template,
payoutIdentity,
payoutMode,
);
const foreignSubmission = JSON.parse(MockRecording1.MINING_SUBMIT);
foreignSubmission.params[1] = foreignJob.jobId;
emitMessage(JSON.stringify(foreignSubmission));
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job not found",""]}\n`);
expect(calculateDifficultySpy).not.toHaveBeenCalled();
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
expect(bitcoinRpcService.SUBMIT_BLOCK).not.toHaveBeenCalled();
});
it('should accept a legacy PPLNS template with explicit outputs and no snapshot id', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
(client as any).payoutMode = 'pplns';
newBlockEmitter.next({
...MockRecording1.BLOCK_TEMPLATE,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(transaction => ({ ...transaction })),
payoutMode: 'all',
payoutSnapshotId: undefined,
payoutOutputs: [{
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
amountSats: MockRecording1.BLOCK_TEMPLATE.coinbasevalue,
}],
});
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
const ownedJob = stratumV1JobsService.getJobById('1');
expect(ownedJob.ownership).toEqual(expect.objectContaining({
payoutMode: 'pplns',
payoutIdentity: expect.stringMatching(/^pplns\0outputs\0/),
}));
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
payoutMode: 'pplns',
jobId: '1',
}));
});
it('should retain duplicate detection across a clean tip switch', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(socket, 'write').mockImplementation(() => true);
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(false);
const nextTip = {
...MockRecording1.BLOCK_TEMPLATE,
previousblockhash: '11'.repeat(32),
height: MockRecording1.BLOCK_TEMPLATE.height + 1,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
};
newBlockEmitter.next(nextTip);
client.broadcastMiningJob(stratumV1JobsService.getLatestJobTemplate('solo'));
expect((client as any).isDuplicateSubmission('old-tip-share')).toBe(true);
});
it('should bound duplicate tracking and expire entries by TTL', () => {
(configService.get as jest.Mock).mockImplementation((key: string) => {
if (key === 'STRATUM_SUBMISSION_DEDUP_TTL_MS') return '1000';
if (key === 'STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES') return '2';
if (key === 'NETWORK') return 'testnet';
return null;
});
expect((client as any).isDuplicateSubmission('one')).toBe(false);
expect((client as any).isDuplicateSubmission('two')).toBe(false);
expect((client as any).isDuplicateSubmission('three')).toBe(false);
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['two', 'three']);
jest.advanceTimersByTime(1001);
expect((client as any).isDuplicateSubmission('two')).toBe(false);
expect([...(client as any).miningSubmissionHashes.keys()]).toEqual(['two']);
});
it('should hash and reject stale non-block shares without accounting them', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(socket, 'write').mockImplementation(() => true);
const calculateDifficultySpy = jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: 1,
submissionHash: 'stale-share',
hashBuffer: Buffer.alloc(32, 0xff),
});
const buildHeaderSpy = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
newBlockEmitter.next({
...MockRecording1.BLOCK_TEMPLATE,
previousblockhash: '22'.repeat(32),
height: MockRecording1.BLOCK_TEMPLATE.height + 1,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
});
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect(buildHeaderSpy).toHaveBeenCalled();
expect(calculateDifficultySpy).toHaveBeenCalled();
expect(bitcoinRpcService.SUBMIT_BLOCK).not.toHaveBeenCalled();
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Stale share",""]}\n`);
});
it('should reconstruct and submit an exact-target candidate from a stale tip', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(socket, 'write').mockImplementation(() => true);
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: Number.POSITIVE_INFINITY,
submissionHash: 'stale-block',
hashBuffer: Buffer.alloc(32),
});
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
newBlockEmitter.next({
...MockRecording1.BLOCK_TEMPLATE,
previousblockhash: '33'.repeat(32),
height: MockRecording1.BLOCK_TEMPLATE.height + 1,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
});
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
expect(blocksService.save).toHaveBeenCalledWith(expect.objectContaining({
height: MockRecording1.BLOCK_TEMPLATE.height,
blockSubmissionResult: null,
}));
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
jobId: '1',
isBlockCandidate: true,
blockSubmissionResult: null,
}));
expect((client as any).write).lastCalledWith(`{"id":5,"error":null,"result":true}\n`);
});
it('should not credit a stale exact-target candidate rejected by Core', async () => {
(bitcoinRpcService.SUBMIT_BLOCK as jest.Mock).mockResolvedValue('stale-prevblk');
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
jest.spyOn(socket, 'write').mockImplementation(() => true);
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: Number.POSITIVE_INFINITY,
submissionHash: 'rejected-stale-block',
hashBuffer: Buffer.alloc(32),
});
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((r) => setTimeout(r, 100));
newBlockEmitter.next({
...MockRecording1.BLOCK_TEMPLATE,
previousblockhash: '44'.repeat(32),
height: MockRecording1.BLOCK_TEMPLATE.height + 1,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })),
});
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledWith(expect.any(String));
expect(blocksService.save).toHaveBeenCalledWith(expect.objectContaining({
blockSubmissionResult: 'stale-prevblk',
}));
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Stale share",""]}\n`);
});
it('should reject submissions for unknown jobs', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
const hashSpy = jest.spyOn(MiningSubmitMessage.prototype, 'hash');
@@ -550,7 +964,7 @@ describe('StratumV1Client', () => {
emitMessage(MockRecording1.MINING_SUBMIT);
await new Promise((r) => setTimeout(r, 100));
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job Template not found",""]}\n`);
expect((client as any).write).lastCalledWith(`{"id":5,"result":null,"error":[21,"Job not found",""]}\n`);
});
it('should reject low difficulty shares', async () => {
@@ -569,6 +983,62 @@ describe('StratumV1Client', () => {
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
});
it('rejects version rolling masks outside the negotiated BIP320 range', async () => {
jest.spyOn(client as any, 'write').mockImplementation(() => Promise.resolve(true));
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(`{"id": 4, "method": "mining.suggest_difficulty", "params": [0]}`);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise((resolve) => setTimeout(resolve, 100));
const invalid = JSON.parse(MockRecording1.MINING_SUBMIT);
invalid.params[5] = '20000000';
emitMessage(JSON.stringify(invalid));
jest.useRealTimers();
await new Promise((resolve) => setTimeout(resolve, 100));
expect((client as any).write).lastCalledWith(
`{"id":5,"result":null,"error":[20,"Invalid version mask",""]}\n`,
);
expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled();
});
it('reconstructs BIP310 replacement bits when the job version already has the bit set', async () => {
const versionBit = 0x00002000;
const baseVersion = (MockRecording1.BLOCK_TEMPLATE.version | versionBit) | 0;
newBlockEmitter.next({
...MockRecording1.BLOCK_TEMPLATE,
version: baseVersion,
transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(transaction => ({ ...transaction })),
});
jest.spyOn(client as any, 'write').mockResolvedValue(true);
jest.spyOn(client as any, 'calculateDifficulty').mockReturnValue({
submissionDifficulty: Number.POSITIVE_INFINITY,
submissionHash: 'bip310-candidate',
hashBuffer: Buffer.alloc(32),
});
const buildHeader = jest.spyOn(MiningJob.prototype, 'buildHeaderBuffer');
emitMessage(MockRecording1.MINING_SUBSCRIBE);
emitMessage(MockRecording1.MINING_AUTHORIZE);
await new Promise(resolve => setTimeout(resolve, 100));
const submission = JSON.parse(MockRecording1.MINING_SUBMIT);
submission.params[5] = versionBit.toString(16).padStart(8, '0');
emitMessage(JSON.stringify(submission));
await new Promise(resolve => setTimeout(resolve, 100));
const expectedVersion = baseVersion >>> 0;
const header = buildHeader.mock.results.at(-1).value as Buffer;
const submittedBlockHex = (bitcoinRpcService.SUBMIT_BLOCK as jest.Mock).mock.calls.at(-1)[0] as string;
expect(header.readUInt32LE(0)).toBe(expectedVersion);
expect(Buffer.from(submittedBlockHex.slice(0, 160), 'hex').readUInt32LE(0)).toBe(expectedVersion);
expect(submittedBlockHex.slice(0, 160)).toBe(header.toString('hex'));
expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({
version: expectedVersion.toString(16),
isBlockCandidate: true,
}));
});
it('should reject submissions with short extranonce2 values', async () => {
jest.spyOn(client as any, 'write').mockImplementation((data) => Promise.resolve(true));
+393 -146
View File
@@ -4,7 +4,7 @@ import { plainToInstance } from 'class-transformer';
import { validate, ValidationError, ValidatorOptions } from 'class-validator';
import * as crypto from 'crypto';
import { Socket } from 'net';
import { firstValueFrom, Subscription } from 'rxjs';
import { filter, firstValueFrom } from 'rxjs';
import { clearInterval } from 'timers';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
@@ -16,7 +16,12 @@ import { ShareAccountingService } from '../ORM/share-accounting/share-accounting
import { BitcoinRpcService } from '../services/bitcoin-rpc.service';
import { NotificationService } from '../services/notification.service';
import { RedisMessagingService } from '../services/redis-messaging.service';
import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service';
import {
createPayoutOutputIdentity,
IJobSubmissionContext,
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';
@@ -38,6 +43,16 @@ const BLOCKED_USER_AGENT_LOG_INTERVAL_MS = 60 * 1000;
const VALIDATION_ERROR_LOG_INTERVAL_MS = 60 * 1000;
const DEFAULT_MIN_DIFFICULTY = 0.001;
const DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS = 60 * 1000;
const DEFAULT_SUBMISSION_DEDUP_TTL_MS = 5 * 60 * 1000;
const DEFAULT_SUBMISSION_DEDUP_MAX_ENTRIES = 10_000;
const DEFAULT_MAX_SOCKET_BUFFER_BYTES = 256 * 1024;
const VERSION_ROLLING_MASK = 0x1fffe000;
export interface MiningJobBroadcastResult {
status: 'written' | 'backpressured' | 'skipped' | 'closed' | 'error';
bytes: number;
bufferedBytes: number;
}
export class StratumV1Client {
private static blockedUserAgentLogState = new Map<string, { nextLogAt: number, suppressed: number }>();
@@ -47,7 +62,6 @@ export class StratumV1Client {
private clientConfiguration: ConfigurationMessage;
private clientAuthorization: AuthorizationMessage;
private clientSuggestedDifficulty: SuggestDifficulty;
private stratumSubscription: Subscription;
private backgroundWork: NodeJS.Timeout[] = [];
private readonly socketDataHandler: (data: Buffer) => void;
private destroyPromise: Promise<void> | null = null;
@@ -69,9 +83,12 @@ export class StratumV1Client {
private buffer: string = '';
private connectionClosed = false;
private lastSentMiningJobTimestamp: number = null;
private lastSentMiningJobSignature: string = null;
private lastHashRatePersistedAt = 0;
private readonly network: bitcoinjs.Network;
private readonly maxSocketBufferBytes: number;
private miningSubmissionHashes = new Set<string>()
private miningSubmissionHashes = new Map<string, number>();
constructor(
public readonly socket: Socket,
@@ -93,6 +110,8 @@ export class StratumV1Client {
void this.handleSocketData(data);
};
this.socket.on('data', this.socketDataHandler);
this.network = this.getNetwork();
this.maxSocketBufferBytes = this.readMaxSocketBufferBytes();
}
@@ -111,11 +130,6 @@ export class StratumV1Client {
this.socket.removeListener('data', this.socketDataHandler);
this.buffer = '';
if (this.stratumSubscription != null) {
this.stratumSubscription.unsubscribe();
this.stratumSubscription = null;
}
for (const work of this.backgroundWork) {
clearInterval(work);
}
@@ -405,7 +419,6 @@ export class StratumV1Client {
}
private async initStratum() {
this.stratumInitialized = true;
this.socket.setTimeout(0);
if (this.isBlockedUserAgent(this.clientSubscription.userAgent)) {
@@ -423,17 +436,10 @@ export class StratumV1Client {
}
}
this.stratumSubscription = this.stratumV1JobsService.newMiningJob$.subscribe(async (jobTemplate) => {
try {
if(jobTemplate.blockData.clearJobs){
this.miningSubmissionHashes.clear();
}
await this.sendNewMiningJob(jobTemplate);
} catch (e) {
await this.socket.end();
console.error(e);
}
});
await this.ensureClientEntity();
this.stratumInitialized = true;
const latestJobTemplate = await this.getLatestPayoutJobTemplate();
this.broadcastMiningJob(latestJobTemplate);
this.backgroundWork.push(
setInterval(async () => {
@@ -448,10 +454,42 @@ export class StratumV1Client {
// );
}
private async sendNewMiningJob(jobTemplate: IJobTemplate) {
await this.ensureClientEntity();
public isReadyForMiningJobs(): boolean {
return this.stratumInitialized
&& !this.connectionClosed
&& !this.socket.destroyed
&& !this.socket.writableEnded;
}
let payoutInformation = this.getPayoutInformation(jobTemplate, this.clientAuthorization.address);
public broadcastMiningJob(jobTemplate: IJobTemplate, force = false): MiningJobBroadcastResult {
if (!this.isReadyForMiningJobs()) {
return { status: 'skipped', bytes: 0, bufferedBytes: this.socket.writableLength ?? 0 };
}
if (jobTemplate.blockData.payoutMode !== 'all'
&& jobTemplate.blockData.payoutMode !== this.payoutMode) {
return { status: 'skipped', bytes: 0, bufferedBytes: this.socket.writableLength ?? 0 };
}
const signature = [
jobTemplate.blockData.id,
jobTemplate.block.timestamp,
jobTemplate.blockData.clearJobs,
].join(':');
if (!force && signature === this.lastSentMiningJobSignature) {
return { status: 'skipped', bytes: 0, bufferedBytes: this.socket.writableLength ?? 0 };
}
const maximumBufferedBytes = this.maxSocketBufferBytes;
const bufferedBeforeBuild = this.socket.writableLength ?? 0;
if (bufferedBeforeBuild >= maximumBufferedBytes) {
this.closeSocket();
return { status: 'closed', bytes: 0, bufferedBytes: bufferedBeforeBuild };
}
const payoutInformation = this.getPayoutInformation(jobTemplate, this.clientAuthorization.address);
if (payoutInformation == null) {
return { status: 'skipped', bytes: 0, bufferedBytes: this.socket.writableLength ?? 0 };
}
// const devFeeAddress = this.configService.get('DEV_FEE_ADDRESS');
// //50Th/s
// this.noFee = false;
@@ -474,38 +512,46 @@ export class StratumV1Client {
// ];
// }
const networkConfig = this.configService.get('NETWORK');
let network;
if (networkConfig === 'mainnet') {
network = bitcoinjs.networks.bitcoin;
} else if (networkConfig === 'testnet') {
network = bitcoinjs.networks.testnet;
} else if (networkConfig === 'regtest') {
network = bitcoinjs.networks.regtest;
} else {
throw new Error('Invalid network configuration');
}
const job = new MiningJob(
network,
this.stratumV1JobsService.getNextId(),
const job = this.stratumV1JobsService.getOrCreateJob(
this.network,
payoutInformation,
jobTemplate
jobTemplate,
this.getPayoutIdentity(jobTemplate),
this.payoutMode,
);
this.stratumV1JobsService.addJob(job);
const success = await this.write(job.response(jobTemplate));
if (!success) {
return;
const payload = job.responseBuffer(jobTemplate);
const bufferedBeforeWrite = this.socket.writableLength ?? 0;
if (bufferedBeforeWrite >= maximumBufferedBytes
|| payload.length >= maximumBufferedBytes - bufferedBeforeWrite) {
this.closeSocket();
return { status: 'closed', bytes: 0, bufferedBytes: bufferedBeforeWrite };
}
try {
const accepted = this.socket.write(payload);
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
this.lastSentMiningJobSignature = signature;
const bufferedAfterWrite = this.socket.writableLength ?? 0;
if (bufferedAfterWrite >= maximumBufferedBytes) {
this.closeSocket();
return {
status: 'closed',
bytes: payload.length,
bufferedBytes: bufferedAfterWrite,
};
}
return {
status: accepted ? 'written' : 'backpressured',
bytes: payload.length,
bufferedBytes: bufferedAfterWrite,
};
} catch (error) {
this.closeSocket();
return {
status: 'error',
bytes: 0,
bufferedBytes: this.socket.writableLength ?? 0,
};
}
this.lastSentMiningJobTimestamp = jobTemplate.block.timestamp;
//console.log(`Sent new job to ${this.clientAuthorization.worker}.${this.extraNonceAndSessionId}. (clearJobs: ${jobTemplate.blockData.clearJobs}, fee?: ${!this.noFee})`)
}
@@ -533,10 +579,10 @@ export class StratumV1Client {
private async handleMiningSubmission(submission: MiningSubmitMessage) {
const job = this.stratumV1JobsService.getJobById(submission.jobId);
const submissionContext = this.stratumV1JobsService.getSubmissionContext(submission.jobId);
// a miner may submit a job that doesn't exist anymore if it was removed by a new block notification (or expired, 5 min)
if (job == null) {
if (submissionContext == null || !this.isOwnedSubmissionContext(submissionContext)) {
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.JobNotFound,
@@ -549,29 +595,60 @@ export class StratumV1Client {
return false;
}
const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId);
const { job, jobTemplate, status } = submissionContext;
if (jobTemplate == null) {
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.JobNotFound,
'Job Template not found').response();
//console.log(err);
const success = await this.write(err);
if (!success) {
return false;
}
const versionBits = this.parseUint32Hex(submission.versionMask);
const nonce = this.parseUint32Hex(submission.nonce);
const timestamp = this.parseUint32Hex(submission.ntime);
if (versionBits == null || nonce == null || timestamp == null) {
await this.writeSubmissionError(
submission,
eStratumErrorCode.OtherUnknown,
'Invalid mining submit hexadecimal field',
);
return false;
}
const unsignedVersionBits = versionBits >>> 0;
if (!Number.isInteger(versionBits)
|| versionBits < 0
|| versionBits > 0xffffffff
|| (unsignedVersionBits & (~VERSION_ROLLING_MASK >>> 0)) !== 0) {
await this.writeSubmissionError(
submission,
eStratumErrorCode.OtherUnknown,
'Invalid version mask',
);
return false;
}
// The optional BIP310 field contains replacement bits, not an XOR
// delta. A legacy five-field submission leaves the advertised version
// unchanged, including any bits already set inside the rolling mask.
const effectiveVersionBits = submission.params.length >= 6
? unsignedVersionBits
: (jobTemplate.block.version >>> 0) & (VERSION_ROLLING_MASK >>> 0);
const submittedVersion = MiningJob.applyVersionRolling(
jobTemplate.block.version,
effectiveVersionBits,
VERSION_ROLLING_MASK,
);
const requiredVersionBits = jobTemplate.blockData.requiredVersionBits ?? 0;
if ((submittedVersion & requiredVersionBits) !== requiredVersionBits) {
await this.writeSubmissionError(
submission,
eStratumErrorCode.OtherUnknown,
'Invalid version mask',
);
return false;
}
const submissionHash = [
submission.jobId,
submission.extraNonce2,
submission.ntime,
submission.nonce,
submission.versionMask ?? ''
job.jobId,
submission.extraNonce2.toLowerCase(),
timestamp.toString(16).padStart(8, '0'),
nonce.toString(16).padStart(8, '0'),
submittedVersion.toString(16).padStart(8, '0'),
].join(':');
if (this.miningSubmissionHashes.has(submissionHash)) {
if (this.isDuplicateSubmission(submissionHash)) {
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.DuplicateShare,
@@ -581,76 +658,82 @@ export class StratumV1Client {
return false;
}
return false;
} else {
this.miningSubmissionHashes.add(submissionHash);
}
const versionMask = parseInt(submission.versionMask, 16);
const nonce = parseInt(submission.nonce, 16);
const timestamp = parseInt(submission.ntime, 16);
const header = job.buildHeaderBuffer(
jobTemplate,
versionMask,
effectiveVersionBits,
nonce,
this.extraNonceAndSessionId,
submission.extraNonce2,
timestamp
timestamp,
VERSION_ROLLING_MASK,
);
const { submissionDifficulty, hashBuffer } = this.calculateDifficulty(header);
//console.log(`DIFF: ${submissionDifficulty} of ${this.sessionDifficulty} from ${this.clientAuthorization.worker + '.' + this.extraNonceAndSessionId}`);
const isBlockCandidate = DifficultyUtils.meetsCompactTarget(
hashBuffer,
jobTemplate.block.bits,
);
if (status === 'stale' && !isBlockCandidate) {
await this.writeSubmissionError(
submission,
eStratumErrorCode.JobNotFound,
'Stale share',
);
return false;
}
if (DifficultyUtils.meetsTarget(hashBuffer, this.sessionDifficultyTarget)) {
const meetsSessionTarget = DifficultyUtils.meetsTarget(hashBuffer, this.sessionDifficultyTarget);
if (status === 'current' && !isBlockCandidate && !meetsSessionTarget) {
await this.writeSubmissionError(
submission,
eStratumErrorCode.LowDifficultyShare,
'Difficulty too low',
);
return false;
}
const creditedDifficulty = isBlockCandidate && !meetsSessionTarget
? Math.min(this.sessionDifficulty, submissionDifficulty)
: this.sessionDifficulty;
let blockSubmissionResult: string = null;
if (status === 'stale') {
blockSubmissionResult = await this.submitBlockCandidate(
job,
jobTemplate,
submission,
effectiveVersionBits,
nonce,
timestamp,
);
if (!this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
await this.writeSubmissionError(
submission,
eStratumErrorCode.JobNotFound,
'Stale share',
);
return false;
}
}
{
const success = await this.write(JSON.stringify(submission.response()) + '\n');
if (!success) {
return false;
}
let blockSubmissionResult: string = null;
const isBlockCandidate = DifficultyUtils.meetsTarget(
hashBuffer,
DifficultyUtils.difficultyToTarget(jobTemplate.blockData.networkDifficulty),
);
if (isBlockCandidate) {
console.log('!!! BLOCK FOUND !!!');
const updatedJobBlock = job.copyAndUpdateBlock(
if (status === 'current' && isBlockCandidate) {
blockSubmissionResult = await this.submitBlockCandidate(
job,
jobTemplate,
versionMask,
submission,
effectiveVersionBits,
nonce,
this.extraNonceAndSessionId,
submission.extraNonce2,
timestamp
timestamp,
);
const blockHex = updatedJobBlock.toHex(false);
blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
await this.blocksService.save({
height: jobTemplate.blockData.height,
minerAddress: this.clientAuthorization.address,
worker: this.clientAuthorization.worker,
sessionId: this.extraNonceAndSessionId,
blockData: blockHex,
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
if (this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
await this.addressSettingsService.resetBestDifficultyAndShares();
}
}
await this.ensureClientEntity();
try {
@@ -664,19 +747,17 @@ export class StratumV1Client {
jobId: job.jobId,
jobTemplateId: job.jobTemplateId,
blockHeight: jobTemplate.blockData.height,
creditedDifficulty: this.sessionDifficulty,
creditedDifficulty,
submissionDifficulty,
networkDifficulty: jobTemplate.blockData.networkDifficulty,
nonce: submission.nonce,
ntime: submission.ntime,
version: Number.isFinite(versionMask)
? (jobTemplate.block.version ^ versionMask).toString(16)
: jobTemplate.block.version.toString(16),
version: submittedVersion.toString(16),
extraNonce2: submission.extraNonce2,
isBlockCandidate,
blockSubmissionResult,
});
await this.statistics.addShares(this.clientEntity, this.sessionDifficulty);
await this.statistics.addShares(this.clientEntity, creditedDifficulty);
const now = new Date();
this.clientEntity.updatedAt = now;
this.clientEntity.hashRate = this.statistics.hashRate;
@@ -691,21 +772,6 @@ export class StratumV1Client {
this.clientEntity.bestDifficulty = submissionDifficulty;
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
}
} else {
const err = new StratumErrorMessage(
submission.id,
eStratumErrorCode.LowDifficultyShare,
'Difficulty too low').response();
const success = await this.write(err);
if (!success) {
return false;
}
return false;
}
//await this.checkDifficulty();
@@ -713,6 +779,71 @@ export class StratumV1Client {
}
private async submitBlockCandidate(
job: MiningJob,
jobTemplate: IJobTemplate,
submission: MiningSubmitMessage,
versionBits: number,
nonce: number,
timestamp: number,
): Promise<string> {
console.log('!!! BLOCK FOUND !!!');
const updatedJobBlock = job.copyAndUpdateBlock(
jobTemplate,
versionBits,
nonce,
this.extraNonceAndSessionId,
submission.extraNonce2,
timestamp,
VERSION_ROLLING_MASK,
);
const blockHex = updatedJobBlock.toHex(false);
const blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex);
await this.blocksService.save({
height: jobTemplate.blockData.height,
minerAddress: this.clientAuthorization.address,
worker: this.clientAuthorization.worker,
sessionId: this.extraNonceAndSessionId,
blockData: blockHex,
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,
);
if (this.isSuccessfulBlockSubmission(blockSubmissionResult)) {
await this.addressSettingsService.resetBestDifficultyAndShares();
}
return blockSubmissionResult;
}
private async writeSubmissionError(
submission: MiningSubmitMessage,
code: eStratumErrorCode,
message: string,
): Promise<boolean> {
return this.write(new StratumErrorMessage(
submission.id,
code,
message,
).response());
}
private async checkDifficulty() {
const targetDiff = this.clampDifficulty(this.statistics.getSuggestedDifficulty(this.sessionDifficulty));
if (targetDiff == null) {
@@ -733,7 +864,7 @@ export class StratumV1Client {
await this.socket.write(data);
const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$);
const jobTemplate = await this.getLatestPayoutJobTemplate();
const nextTimestamp = Math.max(
jobTemplate.block.timestamp,
Math.floor(Date.now() / 1000),
@@ -748,7 +879,7 @@ export class StratumV1Client {
}),
blockData: { ...jobTemplate.blockData, clearJobs: true }
};
await this.sendNewMiningJob(refreshedJobTemplate);
this.broadcastMiningJob(refreshedJobTemplate, true);
}
}
@@ -860,6 +991,67 @@ export class StratumV1Client {
return DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS;
}
private isDuplicateSubmission(submissionHash: string): boolean {
const now = Date.now();
const existingExpiry = this.miningSubmissionHashes.get(submissionHash);
if (existingExpiry != null && existingExpiry > now) {
return true;
}
if (existingExpiry != null) {
this.miningSubmissionHashes.delete(submissionHash);
}
for (const [hash, expiresAt] of this.miningSubmissionHashes) {
if (expiresAt <= now) {
this.miningSubmissionHashes.delete(hash);
}
}
this.miningSubmissionHashes.set(
submissionHash,
now + this.getSubmissionDedupTtlMs(),
);
const maxEntries = this.getSubmissionDedupMaxEntries();
while (this.miningSubmissionHashes.size > maxEntries) {
const oldestHash = this.miningSubmissionHashes.keys().next().value;
if (oldestHash == null) {
break;
}
this.miningSubmissionHashes.delete(oldestHash);
}
return false;
}
private getSubmissionDedupTtlMs(): number {
const configured = Number(
this.configService.get<string>('STRATUM_SUBMISSION_DEDUP_TTL_MS')
?? process.env.STRATUM_SUBMISSION_DEDUP_TTL_MS,
);
return Number.isInteger(configured) && configured > 0
? configured
: DEFAULT_SUBMISSION_DEDUP_TTL_MS;
}
private getSubmissionDedupMaxEntries(): number {
const configured = Number(
this.configService.get<string>('STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES')
?? process.env.STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES,
);
return Number.isInteger(configured) && configured > 0
? configured
: DEFAULT_SUBMISSION_DEDUP_MAX_ENTRIES;
}
private readMaxSocketBufferBytes(): number {
const configured = Number(
this.configService.get<string>('STRATUM_MAX_SOCKET_BUFFER_BYTES')
?? process.env.STRATUM_MAX_SOCKET_BUFFER_BYTES,
);
return Number.isSafeInteger(configured) && configured > 0
? configured
: DEFAULT_MAX_SOCKET_BUFFER_BYTES;
}
private getValidationErrorSignature(errors: ValidationError[]): string {
if (errors.length === 0) {
return 'unknown';
@@ -905,14 +1097,69 @@ export class StratumV1Client {
return Number.isFinite(configured) && configured > 0 ? configured : null;
}
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] {
if (this.payoutMode === 'pplns' && jobTemplate.blockData.payoutOutputs?.length > 0) {
return jobTemplate.blockData.payoutOutputs;
private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] | null {
if (this.payoutMode === 'pplns') {
return jobTemplate.blockData.payoutOutputs?.length > 0
? jobTemplate.blockData.payoutOutputs
: null;
}
return [{ address: fallbackAddress, percent: 100 }];
}
private async getLatestPayoutJobTemplate(): Promise<IJobTemplate> {
return this.stratumV1JobsService.getLatestJobTemplate(this.payoutMode)
?? firstValueFrom((
this.stratumV1JobsService.sv1MiningJob$
?? this.stratumV1JobsService.newMiningJob$
).pipe(
filter(template => template.blockData.payoutMode === 'all'
|| template.blockData.payoutMode === this.payoutMode),
));
}
private getPayoutIdentity(jobTemplate: IJobTemplate): string | undefined {
if (this.payoutMode === 'solo') {
return `solo\0${this.clientAuthorization.address}`;
}
if (jobTemplate.blockData.payoutSnapshotId != null) {
return `pplns\0${jobTemplate.blockData.payoutSnapshotId}`;
}
return jobTemplate.blockData.payoutOutputs?.length > 0
? createPayoutOutputIdentity('pplns', jobTemplate.blockData.payoutOutputs)
: undefined;
}
private isOwnedSubmissionContext(context: IJobSubmissionContext): boolean {
const templatePayoutMode = context.jobTemplate.blockData.payoutMode;
const expectedPayoutIdentity = this.getPayoutIdentity(context.jobTemplate);
return (templatePayoutMode === 'all' || templatePayoutMode === this.payoutMode)
&& expectedPayoutIdentity != null
&& context.job.ownership?.payoutMode === this.payoutMode
&& context.job.ownership.payoutIdentity === expectedPayoutIdentity;
}
private parseUint32Hex(value: string | null | undefined): number | null {
if (typeof value !== 'string' || !/^[0-9a-fA-F]{8}$/.test(value)) {
return null;
}
return Number.parseInt(value, 16);
}
private getNetwork(): bitcoinjs.Network {
const networkConfig = this.configService.get('NETWORK');
if (networkConfig === 'mainnet') {
return bitcoinjs.networks.bitcoin;
}
if (networkConfig === 'testnet') {
return bitcoinjs.networks.testnet;
}
if (networkConfig === 'regtest') {
return bitcoinjs.networks.regtest;
}
throw new Error('Invalid network configuration');
}
private isSuccessfulBlockSubmission(result?: string | null): boolean {
return result == null || result === 'SUCCESS!';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13
View File
@@ -1,3 +1,5 @@
import type { PayoutMode } from '../../types/payout-mode';
export interface IBlockTemplateTx {
data: string; //'hex', // (string) transaction data encoded in hexadecimal (byte-for-byte)
txid: string; //'hex', // (string) transaction id encoded in little-endian hexadecimal
@@ -44,5 +46,16 @@ export interface IBlockTemplate {
capabilities: string[]
payoutSnapshotId?: string;
payoutOutputs?: IBlockTemplatePayoutOutput[];
/** Force miners to switch even when the previous block hash is unchanged. */
forceCleanJobs?: boolean;
/** Distinguishes normal fee-paying templates from the subsidy-only bridge. */
jobType?: 'full' | 'empty';
/** Limits this template to the payout path whose coinbase data is ready. */
payoutMode?: PayoutMode | 'all';
/** Correlates master detection, Redis delivery, and socket fan-out traces. */
notificationEventId?: string;
notificationPublishedAtMs?: number;
/** Timestamp of the rolling PPLNS snapshot seed used by an empty bridge. */
payoutBridgeSeedCreatedAtMs?: number;
}
@@ -62,6 +62,30 @@ describe('MiningSubmitMessage', () => {
expect(errors.some(error => error.property === 'extraNonce2')).toBe(true);
});
it.each([
{ field: 'extraNonce2', index: 2, value: '990200000000000g' },
{ field: 'ntime', index: 3, value: '64b1f10ftrailing' },
{ field: 'nonce', index: 4, value: '2402812g' },
{ field: 'versionMask', index: 5, value: '00006000trailing' },
])('should reject a non-canonical hexadecimal $field', async ({ field, index, value }) => {
const parsed = JSON.parse(MINING_SUBMIT_MESSAGE);
parsed.params[index] = value;
const invalidMessage = plainToInstance(MiningSubmitMessage, parsed);
const errors = await validate(invalidMessage);
expect(errors.some(error => error.property === field)).toBe(true);
});
it('should normalize an omitted version mask to eight zeroes', async () => {
const parsed = JSON.parse(MINING_SUBMIT_MESSAGE);
parsed.params.pop();
const noVersionMask = plainToInstance(MiningSubmitMessage, parsed);
expect(noVersionMask.versionMask).toBe('00000000');
await expect(validate(noVersionMask)).resolves.toEqual([]);
});
});
@@ -1,5 +1,5 @@
import { Expose, Transform } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, Length } from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, Length, Matches } from 'class-validator';
import { eRequestMethod } from '../enums/eRequestMethod';
import { EXTRANONCE2_SIZE_BYTES } from '../stratum.constants';
@@ -29,18 +29,23 @@ export class MiningSubmitMessage extends StratumBaseMessage {
@Expose()
@IsString()
@Length(EXTRANONCE2_SIZE_BYTES * 2, EXTRANONCE2_SIZE_BYTES * 2)
@Matches(/^[0-9a-fA-F]+$/)
@Transform(({ value, key, obj, type }) => {
return obj.params[2];
})
public extraNonce2: string;
@Expose()
@IsString()
@Length(8, 8)
@Matches(/^[0-9a-fA-F]{8}$/)
@Transform(({ value, key, obj, type }) => {
return obj.params[3];
})
public ntime: string;
@Expose()
@IsString()
@Length(8, 8)
@Matches(/^[0-9a-fA-F]{8}$/)
@Transform(({ value, key, obj, type }) => {
return obj.params[4];
})
@@ -48,8 +53,10 @@ export class MiningSubmitMessage extends StratumBaseMessage {
@Expose()
@IsString()
@Length(8, 8)
@Matches(/^[0-9a-fA-F]{8}$/)
@Transform(({ value, key, obj, type }) => {
return obj.params[5] == null ? '0' : obj.params[5];
return obj.params[5] == null ? '00000000' : obj.params[5];
})
public versionMask?: string | null;
+68 -1
View File
@@ -1,4 +1,7 @@
import { Sv2ExtranonceManager } from './sv2-extranonce-manager';
import {
resolveSv2ProcessNamespace,
Sv2ExtranonceManager,
} from './sv2-extranonce-manager';
describe('Sv2ExtranonceManager', () => {
@@ -114,4 +117,68 @@ describe('Sv2ExtranonceManager', () => {
expect(mgr.allocatedCount).toBe(0);
});
it('partitions prefixes deterministically across PM2 workers and reload generations', () => {
const worker0 = resolveSv2ProcessNamespace({
NODE_APP_INSTANCE: '0',
restart_time: '0',
});
const worker1 = resolveSv2ProcessNamespace({
NODE_APP_INSTANCE: '1',
restart_time: '0',
});
const worker0Reload = resolveSv2ProcessNamespace({
NODE_APP_INSTANCE: '0',
restart_time: '1',
});
const prefixes = [worker0, worker1, worker0Reload].map(namespace => (
new Sv2ExtranonceManager(4, 14, namespace).allocate(1).toString('hex')
));
expect([worker0, worker1, worker0Reload]).toEqual([0, 2, 1]);
expect(new Set(prefixes).size).toBe(3);
expect(prefixes).toEqual(['00000001', '02000001', '01000001']);
});
it('offsets PM2 worker lanes with an explicit deployment namespace base', () => {
expect(resolveSv2ProcessNamespace({
SV2_EXTRANONCE_NAMESPACE_BASE: '32',
NODE_APP_INSTANCE: '3',
restart_time: '5',
})).toBe(39);
});
it('falls back to pm_id when NODE_APP_INSTANCE is unavailable', () => {
expect(resolveSv2ProcessNamespace({
pm_id: '7',
restart_time: '2',
})).toBe(14);
});
it('fails closed for managed workers without a unique process identifier', () => {
expect(() => resolveSv2ProcessNamespace({ PM2_ENABLED: 'true' }))
.toThrow('cannot allocate collision-free extranonces');
});
it('fails when the worker namespace cannot fit in the four-byte prefix', () => {
expect(() => resolveSv2ProcessNamespace({
NODE_APP_INSTANCE: '128',
restart_time: '0',
})).toThrow('does not fit in one byte');
expect(() => new Sv2ExtranonceManager(4, 14, 256))
.toThrow('must fit in one byte');
});
it('rejects prefix and total sizes that cannot preserve the namespace', () => {
expect(() => new Sv2ExtranonceManager(1, 14, 0))
.toThrow('between 2 and 4 bytes');
expect(() => new Sv2ExtranonceManager(4, 3, 0))
.toThrow('must include the pool prefix');
});
it('rejects channel identifiers that cannot be represented by SV2', () => {
const mgr = new Sv2ExtranonceManager();
expect(() => mgr.allocate(0)).toThrow('unsigned non-zero 32-bit integer');
expect(() => mgr.allocate(0x1_0000_0000)).toThrow('unsigned non-zero 32-bit integer');
});
});
+105 -21
View File
@@ -6,12 +6,12 @@ import { SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES } from '../stratum.constants';
export class Sv2ExtranonceManager {
private nextPrefix: number;
private readonly workerOffset: number;
private readonly maxPrefix: number;
private allocatedPrefixes = new Map<number, number>(); // channelId → prefix
private usedPrefixes = new Set<number>();
private readonly prefixSize: number;
private readonly totalExtranonceSize: number;
private readonly processNamespace: number;
/**
* @param prefixSize Bytes used for pool-assigned prefix (default 4)
@@ -19,13 +19,25 @@ export class Sv2ExtranonceManager {
* SV2 extended jobs patch the coinbase script length when the
* negotiated total differs from the SV1-compatible template slot.
*/
constructor(prefixSize = 4, totalExtranonceSize = SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES) {
constructor(
prefixSize = 4,
totalExtranonceSize = SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES,
processNamespace = 0,
) {
if (!Number.isInteger(prefixSize) || prefixSize < 2 || prefixSize > 4) {
throw new Error('SV2 extranonce prefix size must be between 2 and 4 bytes');
}
if (!Number.isInteger(totalExtranonceSize) || totalExtranonceSize < prefixSize) {
throw new Error('SV2 total extranonce size must include the pool prefix');
}
if (!Number.isInteger(processNamespace) || processNamespace < 0 || processNamespace > 0xff) {
throw new Error('SV2 process namespace must fit in one byte');
}
this.prefixSize = prefixSize;
this.totalExtranonceSize = totalExtranonceSize;
this.processNamespace = processNamespace;
const workerId = 0;
const bitsPerWorker = (prefixSize - 1) * 8;
this.workerOffset = workerId * Math.pow(2, bitsPerWorker);
this.maxPrefix = Math.pow(2, bitsPerWorker) - 1; // max within this worker's slice
this.nextPrefix = 1;
}
@@ -43,6 +55,9 @@ export class Sv2ExtranonceManager {
* Returns a Buffer of `prefixSize` bytes.
*/
allocate(channelId: number): Buffer {
if (!Number.isInteger(channelId) || channelId < 1 || channelId > 0xffffffff) {
throw new Error('SV2 channel ID must be an unsigned non-zero 32-bit integer');
}
// Check if already allocated
if (this.allocatedPrefixes.has(channelId)) {
const existing = this.allocatedPrefixes.get(channelId)!;
@@ -52,19 +67,17 @@ export class Sv2ExtranonceManager {
// Find next available prefix within this worker's partition
let localPrefix = this.nextPrefix;
let attempts = 0;
const globalPrefix = () => this.workerOffset + localPrefix;
while (this.usedPrefixes.has(globalPrefix()) && attempts <= this.maxPrefix) {
while (this.usedPrefixes.has(localPrefix) && attempts <= this.maxPrefix) {
localPrefix = localPrefix + 1;
if (localPrefix > this.maxPrefix) localPrefix = 1; // Wrap within partition, skip 0
attempts++;
}
if (this.usedPrefixes.has(globalPrefix())) {
if (this.usedPrefixes.has(localPrefix)) {
throw new Error('Extranonce prefix space exhausted');
}
const prefix = globalPrefix();
const prefix = localPrefix;
this.allocatedPrefixes.set(channelId, prefix);
this.usedPrefixes.add(prefix);
this.nextPrefix = localPrefix + 1;
@@ -99,19 +112,90 @@ export class Sv2ExtranonceManager {
private prefixToBuffer(prefix: number): Buffer {
const buf = Buffer.alloc(this.prefixSize);
// Write as big-endian so prefix 1 = 0x00000001
if (this.prefixSize === 4) {
buf.writeUInt32BE(prefix, 0);
} else if (this.prefixSize === 2) {
buf.writeUInt16BE(prefix, 0);
} else {
// Generic: write big-endian
let val = prefix;
for (let i = this.prefixSize - 1; i >= 0; i--) {
buf[i] = val & 0xff;
val = val >>> 8;
}
buf[0] = this.processNamespace;
// The remaining bytes are a process-local, big-endian allocation. Prefix
// zero stays reserved so an all-zero pool prefix is never issued.
let val = prefix;
for (let i = this.prefixSize - 1; i >= 1; i--) {
buf[i] = val % 0x100;
val = Math.floor(val / 0x100);
}
return buf;
}
}
export const SV2_MAX_PROCESS_NAMESPACE = 0xff;
/**
* Resolve the byte that namespaces every pool-assigned SV2 extranonce prefix.
* PM2 can overlap old and new workers during reload, so each worker receives
* two lanes selected by restart-generation parity.
*/
export function resolveSv2ProcessNamespace(
env: NodeJS.ProcessEnv = process.env,
clusterWorkerIndex?: number,
): number {
const base = parseNamespaceInteger(
'SV2_EXTRANONCE_NAMESPACE_BASE',
env.SV2_EXTRANONCE_NAMESPACE_BASE,
true,
) ?? 0;
const nodeAppInstance = parseNamespaceInteger(
'NODE_APP_INSTANCE',
env.NODE_APP_INSTANCE,
true,
);
const pmId = parseNamespaceInteger('pm_id', env.pm_id, true);
const workerIndex = nodeAppInstance ?? pmId ?? clusterWorkerIndex;
if (workerIndex == null) {
if (env.PM2_ENABLED?.toLowerCase() === 'true') {
throw new Error(
'PM2 SV2 worker has no NODE_APP_INSTANCE or pm_id; cannot allocate collision-free extranonces',
);
}
assertNamespaceFits(base);
return base;
}
if (!Number.isInteger(workerIndex) || workerIndex < 0) {
throw new Error('SV2 worker index must be a non-negative integer');
}
const restartGeneration = parseNamespaceInteger(
'restart_time',
env.restart_time,
true,
) ?? 0;
const namespace = base + (workerIndex * 2) + (restartGeneration % 2);
assertNamespaceFits(namespace);
return namespace;
}
function parseNamespaceInteger(
name: string,
value: string | undefined,
optional: boolean,
): number | null {
if (value == null || value.trim().length === 0) {
if (optional) {
return null;
}
throw new Error(`${name} is required`);
}
if (!/^\d+$/.test(value.trim())) {
throw new Error(`${name} must be a non-negative integer`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${name} must be a safe integer`);
}
return parsed;
}
function assertNamespaceFits(namespace: number): void {
if (!Number.isInteger(namespace) || namespace < 0 || namespace > SV2_MAX_PROCESS_NAMESPACE) {
throw new Error(
`SV2 extranonce namespace ${namespace} does not fit in one byte; reduce worker count or namespace base`,
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,292 @@
import * as bitcoinjs from 'bitcoinjs-lib';
import * as crypto from 'crypto';
import * as merkle from 'merkle-lib';
import * as merkleProof from 'merkle-lib/proof';
import { MockRecording1 } from '../../test/models/MockRecording1';
import type { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { hash256 } from '../utils/hash.utils';
import {
createPreparedMiningJob,
PREPARED_JOB_BODY_CHECKSUM_ALGORITHM,
} from './prepared-mining-job.factory';
describe('prepared mining job factory', () => {
it('prepares a compact full job from authoritative GBT fields', () => {
const template = cloneTemplate();
const prepared = createPreparedMiningJob(template);
const parsedTransactions = template.transactions.map((transaction) =>
bitcoinjs.Transaction.fromHex(transaction.data),
);
const expectedBody = checksumTemplateTransactions(template.transactions);
expect(prepared).toEqual(
expect.objectContaining({
tipKey: `${template.height}:${template.previousblockhash}`,
height: template.height,
jobType: 'full',
payoutMode: 'all',
forceCleanJobs: false,
}),
);
expect(prepared.header).toEqual({
previousBlockHash: template.previousblockhash,
previousBlockHashLE: Buffer.from(template.previousblockhash, 'hex')
.reverse()
.toString('hex'),
version: template.version,
bits: parseInt(template.bits, 16),
bitsHex: template.bits,
minTime: template.mintime,
currentTime: template.curtime,
});
expect(prepared.coinbase).toEqual({
valueSats: template.coinbasevalue,
merkleBranch: expect.any(Array),
witnessCommitmentHash: template.default_witness_commitment.slice(12, 76),
payoutSnapshotId: undefined,
payoutOutputs: undefined,
});
expect(prepared.body).toEqual({
reference: `sha256:${expectedBody.checksum}`,
checksum: expectedBody.checksum,
checksumAlgorithm: PREPARED_JOB_BODY_CHECKSUM_ALGORITHM,
transactionCount: parsedTransactions.length,
byteLength: expectedBody.byteLength,
});
expect(prepared).not.toHaveProperty('transactions');
});
it('matches a bitcoinjs/merkle-lib branch and root for the full fixture', () => {
const template = cloneTemplate();
const prepared = createPreparedMiningJob(template);
const coinbase = createPlaceholderCoinbase();
const parsedTransactions = template.transactions.map((transaction) =>
bitcoinjs.Transaction.fromHex(transaction.data),
);
const expected = buildBitcoinJsMerkleProof(coinbase, parsedTransactions);
expect(prepared.coinbase.merkleBranch).toEqual(expected.branch);
expect(
applyCoinbaseMerkleBranch(
coinbase.getHash(false),
prepared.coinbase.merkleBranch,
),
).toEqual(expected.root);
expect(expected.root).toEqual(
bitcoinjs.Block.calculateMerkleRoot(
[coinbase, ...parsedTransactions],
false,
),
);
expect(
template.transactions.map((transaction) => transaction.txid),
).toEqual(parsedTransactions.map((transaction) => transaction.getId()));
});
it('prepares an empty template with an empty branch and coinbase-only root', () => {
const witnessCommitmentHash = hash256(Buffer.alloc(64)).toString('hex');
const template = cloneTemplate({
transactions: [],
coinbasevalue: 312_500_000,
default_witness_commitment: `6a24aa21a9ed${witnessCommitmentHash}`,
forceCleanJobs: true,
jobType: 'empty',
payoutMode: 'solo',
});
const prepared = createPreparedMiningJob(template);
const coinbase = createPlaceholderCoinbase();
const emptyChecksum = crypto.createHash('sha256').digest('hex');
expect(prepared.coinbase.merkleBranch).toEqual([]);
expect(prepared.coinbase.witnessCommitmentHash).toBe(witnessCommitmentHash);
expect(prepared.body).toEqual({
reference: `sha256:${emptyChecksum}`,
checksum: emptyChecksum,
checksumAlgorithm: PREPARED_JOB_BODY_CHECKSUM_ALGORITHM,
transactionCount: 0,
byteLength: 0,
});
expect(prepared.jobType).toBe('empty');
expect(prepared.payoutMode).toBe('solo');
expect(prepared.forceCleanJobs).toBe(true);
expect(
applyCoinbaseMerkleBranch(
coinbase.getHash(false),
prepared.coinbase.merkleBranch,
),
).toEqual(bitcoinjs.Block.calculateMerkleRoot([coinbase], false));
});
it('builds the branch from txid even when data is not a parseable transaction', () => {
const txid = '0123456789abcdef'.repeat(4);
const template = cloneTemplate({
transactions: [
{
...MockRecording1.BLOCK_TEMPLATE.transactions[0],
data: '00',
txid,
},
],
});
expect(() => bitcoinjs.Transaction.fromHex('00')).toThrow();
expect(createPreparedMiningJob(template).coinbase.merkleBranch).toEqual([
Buffer.from(txid, 'hex').reverse().toString('hex'),
]);
});
it('preserves payout and job modes while copying payout output objects', () => {
const payoutOutputs = [
{ address: 'bc1qprepared-a', amountSats: 200_000_000 },
{ address: 'bc1qprepared-b', amountSats: 112_500_000 },
];
const template = cloneTemplate({
payoutMode: 'pplns',
payoutSnapshotId: 'snapshot-42',
payoutOutputs,
jobType: 'empty',
forceCleanJobs: true,
});
const prepared = createPreparedMiningJob(template);
expect(prepared.payoutMode).toBe('pplns');
expect(prepared.jobType).toBe('empty');
expect(prepared.coinbase.payoutSnapshotId).toBe('snapshot-42');
expect(prepared.coinbase.payoutOutputs).toEqual(payoutOutputs);
expect(prepared.coinbase.payoutOutputs).not.toBe(payoutOutputs);
expect(prepared.coinbase.payoutOutputs?.[0]).not.toBe(payoutOutputs[0]);
});
it('rejects malformed or incorrectly sized authoritative txids', () => {
const original = MockRecording1.BLOCK_TEMPLATE.transactions[0];
for (const txid of ['11'.repeat(31), '11'.repeat(33), 'zz'.repeat(32)]) {
const template = cloneTemplate({
transactions: [{ ...original, txid }],
});
expect(() => createPreparedMiningJob(template)).toThrow(
/transactions\[0\]\.txid/,
);
}
});
it('validates and parses the BIP141 witness commitment length and prefix', () => {
const commitmentHash = 'ab'.repeat(32);
const commitment = `6a24aa21a9ed${commitmentHash}`;
expect(
createPreparedMiningJob(
cloneTemplate({
default_witness_commitment: `${commitment}cafe`,
}),
).coinbase.witnessCommitmentHash,
).toBe(commitmentHash);
for (const defaultWitnessCommitment of [
`6a24aa21a9ed${'ab'.repeat(31)}`,
`6a24aa21a9ee${commitmentHash}`,
'zz'.repeat(38),
]) {
expect(() =>
createPreparedMiningJob(
cloneTemplate({
default_witness_commitment: defaultWitnessCommitment,
}),
),
).toThrow(/default_witness_commitment/);
}
});
it('defers raw body hex parsing while still requiring a non-empty byte-aligned body', () => {
const original = MockRecording1.BLOCK_TEMPLATE.transactions[0];
for (const data of ['', '0']) {
expect(() =>
createPreparedMiningJob(
cloneTemplate({
transactions: [{ ...original, data }],
}),
),
).toThrow(/transactions\[0\]\.data/);
}
expect(() => createPreparedMiningJob(cloneTemplate({
transactions: [{ ...original, data: 'zz' }],
}))).not.toThrow();
});
});
function cloneTemplate(
overrides: Partial<IBlockTemplate> = {},
): IBlockTemplate {
const template = MockRecording1.BLOCK_TEMPLATE;
return {
...template,
...overrides,
rules: [...(overrides.rules ?? template.rules)],
vbavailable: { ...(overrides.vbavailable ?? template.vbavailable) },
coinbaseaux: { ...(overrides.coinbaseaux ?? template.coinbaseaux) },
mutable: [...(overrides.mutable ?? template.mutable)],
capabilities: [...(overrides.capabilities ?? template.capabilities)],
transactions: (overrides.transactions ?? template.transactions).map(
(transaction) => ({
...transaction,
depends: [...transaction.depends],
}),
),
payoutOutputs: (overrides.payoutOutputs ?? template.payoutOutputs)?.map(
(output) => ({ ...output }),
),
};
}
function createPlaceholderCoinbase(): bitcoinjs.Transaction {
const coinbase = new bitcoinjs.Transaction();
coinbase.version = 2;
coinbase.addInput(Buffer.alloc(32), 0xffffffff, 0xffffffff);
coinbase.ins[0].witness = [Buffer.alloc(32)];
return coinbase;
}
function buildBitcoinJsMerkleProof(
coinbase: bitcoinjs.Transaction,
transactions: bitcoinjs.Transaction[],
): { branch: string[]; root: Buffer } {
const hashes = [
coinbase.getHash(false),
...transactions.map((transaction) => transaction.getHash(false)),
];
const tree = merkle(hashes, hash256);
const proof = merkleProof(tree, hashes[0]).filter(
(hash): hash is Buffer => hash != null,
);
return {
branch: proof.slice(1, -1).map((hash) => hash.toString('hex')),
root: proof[proof.length - 1],
};
}
function applyCoinbaseMerkleBranch(
coinbaseHash: Buffer,
branch: readonly string[],
): Buffer {
return branch.reduce(
(root, sibling) =>
hash256(Buffer.concat([root, Buffer.from(sibling, 'hex')])),
coinbaseHash,
);
}
function checksumTemplateTransactions(
transactions: IBlockTemplate['transactions'],
): {
checksum: string;
byteLength: number;
} {
const checksum = crypto.createHash('sha256');
let byteLength = 0;
for (const transaction of transactions) {
checksum.update(Buffer.from(transaction.txid, 'hex'));
byteLength += transaction.data.length / 2;
}
return { checksum: checksum.digest('hex'), byteLength };
}
+238
View File
@@ -0,0 +1,238 @@
import * as crypto from 'crypto';
import type {
IBlockTemplate,
IBlockTemplatePayoutOutput,
IBlockTemplateTx,
} from '../models/bitcoin-rpc/IBlockTemplate';
import type { PayoutMode } from '../types/payout-mode';
import { hash256 } from '../utils/hash.utils';
const WITNESS_COMMITMENT_PREFIX = '6a24aa21a9ed';
const HASH_BYTES = 32;
const UINT32_MAX = 0xffffffff;
const INT32_MIN = -0x80000000;
const INT32_MAX = 0x7fffffff;
export const PREPARED_JOB_BODY_CHECKSUM_ALGORITHM =
'sha256-txids-v1' as const;
export interface PreparedMiningJobHeader {
/** Display/RPC byte order, normalized to lowercase hex. */
previousBlockHash: string;
/** Internal header byte order, normalized to lowercase hex. */
previousBlockHashLE: string;
version: number;
bits: number;
bitsHex: string;
minTime: number;
currentTime: number;
}
export interface PreparedMiningJobCoinbase {
valueSats: number;
/** Internal-byte-order hashes, ready for the Stratum coinbase Merkle path. */
merkleBranch: readonly string[];
/** The 32-byte hash following the BIP141 commitment header. */
witnessCommitmentHash: string;
payoutSnapshotId?: string;
payoutOutputs?: ReadonlyArray<Readonly<IBlockTemplatePayoutOutput>>;
}
export interface PreparedMiningJobBodyReference {
reference: string;
checksum: string;
checksumAlgorithm: typeof PREPARED_JOB_BODY_CHECKSUM_ALGORITHM;
transactionCount: number;
byteLength: number;
}
export interface PreparedMiningJob {
tipKey: string;
height: number;
header: Readonly<PreparedMiningJobHeader>;
coinbase: Readonly<PreparedMiningJobCoinbase>;
body: Readonly<PreparedMiningJobBodyReference>;
jobType: 'full' | 'empty';
payoutMode: PayoutMode | 'all';
forceCleanJobs: boolean;
}
export function createPreparedMiningJob(
template: Readonly<IBlockTemplate>,
): PreparedMiningJob {
validateTemplateNumbers(template);
const previousBlockHash = parseHex(
template.previousblockhash,
'previousblockhash',
HASH_BYTES,
);
const bits = parseHex(template.bits, 'bits', 4);
const witnessCommitmentHash = parseWitnessCommitment(
template.default_witness_commitment,
);
const body = prepareBodyReference(template.transactions);
const merkleBranch = buildCoinbaseMerkleBranch(template.transactions);
const normalizedPreviousBlockHash = previousBlockHash.toString('hex');
return {
tipKey: `${template.height}:${normalizedPreviousBlockHash}`,
height: template.height,
header: {
previousBlockHash: normalizedPreviousBlockHash,
previousBlockHashLE: Buffer.from(previousBlockHash)
.reverse()
.toString('hex'),
version: template.version,
bits: bits.readUInt32BE(0),
bitsHex: bits.toString('hex'),
minTime: template.mintime,
currentTime: template.curtime,
},
coinbase: {
valueSats: template.coinbasevalue,
merkleBranch,
witnessCommitmentHash,
payoutSnapshotId: template.payoutSnapshotId,
payoutOutputs: template.payoutOutputs?.map((output) => ({ ...output })),
},
body,
jobType: template.jobType ?? 'full',
payoutMode: template.payoutMode ?? 'all',
forceCleanJobs: template.forceCleanJobs === true,
};
}
function buildCoinbaseMerkleBranch(
transactions: readonly IBlockTemplateTx[],
): string[] {
let level: Buffer[] = [
Buffer.alloc(HASH_BYTES),
...transactions.map((transaction, index) =>
Buffer.from(
parseHex(transaction.txid, `transactions[${index}].txid`, HASH_BYTES),
).reverse(),
),
];
const branch: string[] = [];
while (level.length > 1) {
if (level.length % 2 !== 0) {
level.push(Buffer.from(level[level.length - 1]));
}
branch.push(level[1].toString('hex'));
const nextLevel: Buffer[] = [];
for (let index = 0; index < level.length; index += 2) {
nextLevel.push(hash256(Buffer.concat([level[index], level[index + 1]])));
}
level = nextLevel;
}
return branch;
}
function parseWitnessCommitment(commitment: string): string {
const script = parseHex(commitment, 'default_witness_commitment');
const minimumBytes = WITNESS_COMMITMENT_PREFIX.length / 2 + HASH_BYTES;
if (script.length < minimumBytes) {
throw new Error(
`default_witness_commitment must be at least ${minimumBytes} bytes`,
);
}
if (
!script
.subarray(0, WITNESS_COMMITMENT_PREFIX.length / 2)
.equals(Buffer.from(WITNESS_COMMITMENT_PREFIX, 'hex'))
) {
throw new Error('default_witness_commitment has an invalid BIP141 prefix');
}
return script
.subarray(WITNESS_COMMITMENT_PREFIX.length / 2, minimumBytes)
.toString('hex');
}
function prepareBodyReference(
transactions: readonly IBlockTemplateTx[],
): PreparedMiningJobBodyReference {
const checksum = crypto.createHash('sha256');
let byteLength = 0;
transactions.forEach((transaction, index) => {
const txid = parseHex(
transaction.txid,
`transactions[${index}].txid`,
HASH_BYTES,
);
if (
typeof transaction.data !== 'string' ||
transaction.data.length === 0 ||
transaction.data.length % 2 !== 0
) {
throw new Error(`transactions[${index}].data must not be empty`);
}
const transactionBytes = transaction.data.length / 2;
if (transactionBytes > UINT32_MAX) {
throw new Error(`transactions[${index}].data exceeds the checksum frame`);
}
checksum.update(txid);
byteLength += transactionBytes;
});
const checksumHex = checksum.digest('hex');
return {
reference: `sha256:${checksumHex}`,
checksum: checksumHex,
checksumAlgorithm: PREPARED_JOB_BODY_CHECKSUM_ALGORITHM,
transactionCount: transactions.length,
byteLength,
};
}
function parseHex(value: string, field: string, exactBytes?: number): Buffer {
if (
typeof value !== 'string' ||
value.length % 2 !== 0 ||
!/^[0-9a-f]*$/i.test(value)
) {
throw new Error(`${field} must be valid hexadecimal`);
}
const bytes = Buffer.from(value, 'hex');
if (exactBytes != null && bytes.length !== exactBytes) {
throw new Error(`${field} must be exactly ${exactBytes} bytes`);
}
return bytes;
}
function validateTemplateNumbers(template: Readonly<IBlockTemplate>): void {
if (
!Number.isInteger(template.version) ||
template.version < INT32_MIN ||
template.version > INT32_MAX
) {
throw new Error('version must be a signed 32-bit integer');
}
validateUnsignedInteger(template.height, 'height', Number.MAX_SAFE_INTEGER);
validateUnsignedInteger(
template.coinbasevalue,
'coinbasevalue',
Number.MAX_SAFE_INTEGER,
);
validateUnsignedInteger(template.mintime, 'mintime', UINT32_MAX);
validateUnsignedInteger(template.curtime, 'curtime', UINT32_MAX);
}
function validateUnsignedInteger(
value: number,
field: string,
maximum: number,
): void {
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
throw new Error(
`${field} must be an unsigned integer no greater than ${maximum}`,
);
}
}
+241 -6
View File
@@ -14,6 +14,7 @@ describe('RedisMessagingService', () => {
beforeEach(() => {
store.clear();
sets.clear();
subscriptions.clear();
clientsByRole.publisher = null;
clientsByRole.subscriber = null;
clients = [createRedisClient(), createRedisClient()];
@@ -31,11 +32,62 @@ describe('RedisMessagingService', () => {
await service.connect();
await service.setLatestMiningInfo({ blocks: 900000 } as any);
await service.setBlockTemplate(900000, { height: 900000, transactions: [] } as any);
await service.setBlockTemplate(900000, {
height: 900001,
previousblockhash: 'aa'.repeat(32),
payoutMode: 'solo',
transactions: [],
} as any);
expect(await service.getLatestMiningInfo()).toEqual({ blocks: 900000 });
expect(await service.getBlockTemplate(900000)).toEqual({ height: 900000, transactions: [] });
expect(await service.getLatestBlockTemplate()).toEqual({ height: 900000, transactions: [] });
expect(await service.getBlockTemplate(900000)).toEqual(expect.objectContaining({
height: 900001,
payoutMode: 'solo',
}));
expect(await service.getLatestBlockTemplate()).toEqual(expect.objectContaining({
height: 900001,
payoutMode: 'solo',
}));
});
it('keeps the legacy latest key as JSON and writes it only when explicitly ready', async () => {
await service.connect();
const template = {
height: 900001,
previousblockhash: 'ab'.repeat(32),
payoutMode: 'pplns',
payoutSnapshotId: 'safe-snapshot',
transactions: [],
} as any;
await service.setBlockTemplate(900000, template);
expect(store.has('block-template:latest')).toBe(false);
expect(store.has('block-template:900000')).toBe(false);
await service.setLegacyBlockTemplate(900000, template);
expect(JSON.parse(store.get('block-template:latest')!)).toEqual(template);
expect(JSON.parse(store.get('block-template:900000')!)).toEqual(template);
});
it('never uses a tagged legacy template for the wrong payout mode', async () => {
await service.connect();
const solo = {
height: 900001,
previousblockhash: 'ac'.repeat(32),
payoutMode: 'solo',
transactions: [],
} as any;
await service.setLegacyBlockTemplate(900000, solo);
expect(await service.getBlockTemplate(900000, 'solo')).toEqual(solo);
expect(await service.getBlockTemplate(900000, 'pplns')).toBeNull();
const oldCombined = { ...solo };
delete oldCombined.payoutMode;
await service.setLegacyBlockTemplate(900000, oldCombined);
expect(await service.getBlockTemplate(900000, 'solo')).toEqual(oldCombined);
expect(await service.getBlockTemplate(900000, 'pplns')).toEqual(oldCombined);
});
it('should publish and subscribe to mining info updates', async () => {
@@ -54,18 +106,201 @@ describe('RedisMessagingService', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
await service.subscribeMiningInfoUpdates(handler);
await clientsByRole.subscriber.callback('{bad json');
await subscriptions.get('mining-info.updated')!('{bad json');
expect(handler).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid Redis mining info update'));
consoleSpy.mockRestore();
});
it('stores payout variants and same-height reorg templates under distinct tip keys', async () => {
await service.connect();
const firstHash = '11'.repeat(32);
const reorgHash = '22'.repeat(32);
await service.setBlockTemplate(900000, {
height: 900001,
previousblockhash: firstHash,
payoutMode: 'solo',
transactions: [],
} as any);
await service.setBlockTemplate(900000, {
height: 900001,
previousblockhash: firstHash,
payoutMode: 'pplns',
payoutSnapshotId: '7',
transactions: [],
} as any);
await service.setBlockTemplate(900000, {
height: 900001,
previousblockhash: reorgHash,
payoutMode: 'solo',
transactions: [],
} as any);
expect((await service.getBlockTemplate(900000, 'solo', firstHash))?.previousblockhash).toBe(firstHash);
expect((await service.getBlockTemplate(900000, 'solo'))?.previousblockhash).toBe(reorgHash);
expect((await service.getBlockTemplate(900000, 'pplns', firstHash))?.payoutSnapshotId).toBe('7');
expect((await service.getLatestBlockTemplate('pplns'))?.payoutMode).toBe('pplns');
});
it('publishes validated block template update envelopes', async () => {
await service.connect();
const handler = jest.fn().mockResolvedValue(undefined);
const update = {
schemaVersion: 1 as const,
eventId: 'new-block:1',
height: 900001,
previousBlockHash: '33'.repeat(32),
payoutMode: 'solo' as const,
publishedAtMs: 123,
};
await service.subscribeBlockTemplateUpdates(handler);
await service.publishBlockTemplateUpdate(update);
expect(handler).toHaveBeenCalledWith(update);
});
it('ignores malformed block template update envelopes', async () => {
await service.connect();
const handler = jest.fn();
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
await service.subscribeBlockTemplateUpdates(handler);
await subscriptions.get('block-template.updated')!(JSON.stringify({ schemaVersion: 2 }));
expect(handler).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid Redis block template update'));
consoleSpy.mockRestore();
});
it('stores, publishes, and replays compact SV1 bridge updates', async () => {
await service.connect();
const handler = jest.fn().mockResolvedValue(undefined);
const update = {
schemaVersion: 1 as const,
type: 'subsidy-bridge' as const,
eventId: 'bridge:900001:hash',
publishedAtMs: 123,
template: {
height: 900001,
previousblockhash: '44'.repeat(32),
payoutMode: 'solo' as const,
jobType: 'empty' as const,
transactions: [],
} as any,
};
await service.subscribeSv1BridgeUpdates(handler);
await service.publishSv1BridgeUpdate(update);
expect(handler).toHaveBeenCalledWith(update);
expect(await service.getLatestSv1BridgeUpdate()).toEqual(update);
});
it('stores and replays the latest SV1 bridge independently by payout mode', async () => {
await service.connect();
const solo = createBridgeUpdate('solo', 'solo-bridge');
const pplns = createBridgeUpdate('pplns', 'pplns-bridge');
await service.publishSv1BridgeUpdate(solo);
await service.publishSv1BridgeUpdate(pplns);
expect(await service.getLatestSv1BridgeUpdate('solo')).toEqual(solo);
expect(await service.getLatestSv1BridgeUpdate('pplns')).toEqual(pplns);
expect(store.get('sv1-bridge:latest:solo')).toBe(JSON.stringify(solo));
expect(store.get('sv1-bridge:latest:pplns')).toBe(JSON.stringify(pplns));
});
it('rejects PPLNS bridges without an explicit snapshot and fixed-value outputs', async () => {
await service.connect();
const valid = createBridgeUpdate('pplns', 'pplns-valid');
const invalidUpdates = [
{
...valid,
template: { ...valid.template, payoutSnapshotId: undefined },
},
{
...valid,
template: { ...valid.template, payoutOutputs: [] },
},
{
...valid,
template: {
...valid.template,
payoutOutputs: [{ address: 'bc1qpercentage', percent: 100 }],
},
},
{
...valid,
template: {
...valid.template,
payoutOutputs: [{ address: 'bc1qpartial', amountSats: 1 }],
},
},
];
for (const update of invalidUpdates) {
await expect(service.publishSv1BridgeUpdate(update as any)).rejects.toThrow(
'unsupported SV1 bridge update',
);
}
expect(await service.getLatestSv1BridgeUpdate('pplns')).toBeNull();
});
it('rejects non-empty bridge templates from the shared bridge channel', async () => {
await service.connect();
const handler = jest.fn();
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
await service.subscribeSv1BridgeUpdates(handler);
await subscriptions.get('sv1-bridge.updated')!(JSON.stringify({
schemaVersion: 1,
type: 'subsidy-bridge',
eventId: 'bad',
template: {
height: 1,
previousblockhash: '00'.repeat(32),
payoutMode: 'pplns',
jobType: 'empty',
transactions: [{ data: '00' }],
},
}));
expect(handler).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid Redis SV1 bridge update'));
consoleSpy.mockRestore();
});
});
function createBridgeUpdate(payoutMode: 'solo' | 'pplns', eventId: string) {
const isPplns = payoutMode === 'pplns';
return {
schemaVersion: 1 as const,
type: 'subsidy-bridge' as const,
eventId,
publishedAtMs: 123,
template: {
height: 900001,
previousblockhash: '55'.repeat(32),
payoutMode,
jobType: 'empty' as const,
transactions: [],
coinbasevalue: 312_500_000,
payoutSnapshotId: isPplns ? 'snapshot-55' : undefined,
payoutOutputs: isPplns
? [{ address: 'bc1qpplns', amountSats: 312_500_000 }]
: undefined,
} as any,
};
}
const store = new Map<string, string>();
const sets = new Map<string, Set<string>>();
const clientsByRole: { publisher?: any; subscriber?: any } = {};
const subscriptions = new Map<string, (message: string) => Promise<void>>();
function createRedisClient() {
const client = {
@@ -116,11 +351,11 @@ function createRedisClient() {
}
}),
publish: jest.fn((channel: string, value: string) => {
void clientsByRole.subscriber?.callback(value);
void subscriptions.get(channel)?.(value);
return Promise.resolve(1);
}),
subscribe: jest.fn((channel: string, callback: (message: string) => Promise<void>) => {
clientsByRole.subscriber.callback = callback;
subscriptions.set(channel, callback);
return Promise.resolve();
}),
};
+272 -9
View File
@@ -4,13 +4,46 @@ import { createClient, RedisClientType } from 'redis';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
import { PayoutMode } from '../types/payout-mode';
const MINING_INFO_CHANNEL = 'mining-info.updated';
const BLOCK_TEMPLATE_CHANNEL = 'block-template.updated';
const SV1_BRIDGE_CHANNEL = 'sv1-bridge.updated';
const MINING_INFO_KEY = 'mining-info:latest';
const BLOCK_TEMPLATE_LATEST_KEY = 'block-template:latest';
const blockTemplateKey = (height: number) => `block-template:${height}`;
const SV1_BRIDGE_LATEST_KEY = 'sv1-bridge:latest';
const BLOCK_TEMPLATE_CACHE_TTL_SECONDS = 60 * 60;
const sv1BridgeLatestKey = (payoutMode: PayoutMode) =>
`${SV1_BRIDGE_LATEST_KEY}:${payoutMode}`;
const blockTemplateLegacyKey = (height: number) => `block-template:${height}`;
const blockTemplateKey = (
height: number,
previousBlockHash: string,
payoutMode: PayoutMode,
) => `block-template:${height}:${previousBlockHash}:${payoutMode}`;
const blockTemplateHeightPointerKey = (height: number, payoutMode: PayoutMode) =>
`block-template:${height}:${payoutMode}:latest`;
const blockTemplateLatestPointerKey = (payoutMode: PayoutMode) =>
`${BLOCK_TEMPLATE_LATEST_KEY}:${payoutMode}`;
const jsonCacheKey = (key: string) => `json-cache:${key}`;
export interface BlockTemplateUpdate {
schemaVersion: 1;
eventId: string;
height: number;
previousBlockHash: string;
payoutMode: PayoutMode;
publishedAtMs: number;
}
export interface Sv1BridgeUpdate {
schemaVersion: 1;
type: 'subsidy-bridge';
eventId: string;
template: IBlockTemplate;
publishedAtMs: number;
}
@Injectable()
export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
private publisher: RedisClientType;
@@ -75,6 +108,99 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
});
}
public async publishBlockTemplateUpdate(update: BlockTemplateUpdate): Promise<void> {
if (!await this.ensureConnected()) {
return;
}
await this.publisher.publish(BLOCK_TEMPLATE_CHANNEL, JSON.stringify(update));
}
public async subscribeBlockTemplateUpdates(handler: (update: BlockTemplateUpdate) => Promise<void>): Promise<void> {
if (!await this.ensureConnected()) {
return;
}
await this.subscriber.subscribe(BLOCK_TEMPLATE_CHANNEL, async message => {
try {
const update = JSON.parse(message) as Partial<BlockTemplateUpdate>;
if (update.schemaVersion !== 1
|| !Number.isInteger(update.height)
|| typeof update.eventId !== 'string'
|| typeof update.previousBlockHash !== 'string'
|| (update.payoutMode !== 'solo' && update.payoutMode !== 'pplns')) {
throw new Error('unsupported block template update');
}
await handler(update as BlockTemplateUpdate);
} catch (error) {
console.error(`Invalid Redis block template update: ${error.message}`);
}
});
}
public async publishSv1BridgeUpdate(update: Sv1BridgeUpdate): Promise<void> {
if (!await this.ensureConnected()) {
return;
}
const serialized = JSON.stringify(update);
const validated = this.parseSv1BridgeUpdate(serialized);
const payoutMode = validated.template.payoutMode === 'pplns' ? 'pplns' : 'solo';
// The bridge envelope is self-contained. Deliver it before spending a
// second Redis round trip on best-effort replay metadata.
await this.publisher.publish(SV1_BRIDGE_CHANNEL, serialized);
void Promise.all([
this.publisher.setEx(
sv1BridgeLatestKey(payoutMode),
BLOCK_TEMPLATE_CACHE_TTL_SECONDS,
serialized,
),
...(payoutMode === 'solo'
? [this.publisher.setEx(
SV1_BRIDGE_LATEST_KEY,
BLOCK_TEMPLATE_CACHE_TTL_SECONDS,
serialized,
)]
: []),
]).catch(error => {
console.error(`Unable to cache latest SV1 bridge: ${error.message}`);
});
}
public async subscribeSv1BridgeUpdates(handler: (update: Sv1BridgeUpdate) => Promise<void>): Promise<void> {
if (!await this.ensureConnected()) {
return;
}
await this.subscriber.subscribe(SV1_BRIDGE_CHANNEL, async message => {
try {
await handler(this.parseSv1BridgeUpdate(message));
} catch (error) {
console.error(`Invalid Redis SV1 bridge update: ${error.message}`);
}
});
}
public async getLatestSv1BridgeUpdate(
payoutMode: PayoutMode = 'solo',
): Promise<Sv1BridgeUpdate | null> {
if (!await this.ensureConnected()) {
return null;
}
let value = await this.publisher.get(sv1BridgeLatestKey(payoutMode)) as string | null;
if (value == null && payoutMode === 'solo') {
// Backward-compatible replay for solo bridges published before
// latest pointers were split by payout mode.
value = await this.publisher.get(SV1_BRIDGE_LATEST_KEY) as string | null;
}
if (value == null) {
return null;
}
try {
const update = this.parseSv1BridgeUpdate(value);
return update.template.payoutMode === payoutMode ? update : null;
} catch (error) {
console.error(`Invalid Redis latest SV1 bridge update: ${error.message}`);
return null;
}
}
public async setLatestMiningInfo(miningInfo: IMiningInfo) {
if (!await this.ensureConnected()) {
return;
@@ -90,31 +216,100 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
return value == null ? null : JSON.parse(value as string);
}
public async setBlockTemplate(height: number, blockTemplate: IBlockTemplate) {
public async setBlockTemplate(height: number, blockTemplate: IBlockTemplate): Promise<string | null> {
if (!await this.ensureConnected()) {
return null;
}
const payoutMode = blockTemplate.payoutMode === 'pplns' ? 'pplns' : 'solo';
const key = blockTemplateKey(height, blockTemplate.previousblockhash, payoutMode);
const serialized = JSON.stringify(blockTemplate);
await Promise.all([
this.publisher.setEx(key, BLOCK_TEMPLATE_CACHE_TTL_SECONDS, serialized),
this.publisher.setEx(
blockTemplateHeightPointerKey(height, payoutMode),
BLOCK_TEMPLATE_CACHE_TTL_SECONDS,
key,
),
this.publisher.set(blockTemplateLatestPointerKey(payoutMode), key),
]);
return key;
}
/**
* Write the payload shape understood by pre-channel workers. Call this only
* once the PPLNS-safe compatibility template is ready, then publish the
* mining-info notification that wakes those workers.
*/
public async setLegacyBlockTemplate(height: number, blockTemplate: IBlockTemplate): Promise<void> {
if (!await this.ensureConnected()) {
return;
}
const serialized = JSON.stringify(blockTemplate);
await Promise.all([
this.publisher.set(blockTemplateKey(height), serialized),
this.publisher.setEx(blockTemplateLegacyKey(height), 15 * 60, serialized),
// Old workers JSON.parse this key directly, so it must remain a
// template payload rather than one of the new pointer values.
this.publisher.set(BLOCK_TEMPLATE_LATEST_KEY, serialized),
]);
}
public async getBlockTemplate(height: number): Promise<IBlockTemplate | null> {
public async getBlockTemplate(
height: number,
payoutMode: PayoutMode = 'solo',
previousBlockHash?: string,
): Promise<IBlockTemplate | null> {
if (!await this.ensureConnected()) {
return null;
}
const value = await this.publisher.get(blockTemplateKey(height));
return value == null ? null : JSON.parse(value as string);
if (previousBlockHash != null) {
return this.readBlockTemplateValue(
await this.publisher.get(blockTemplateKey(height, previousBlockHash, payoutMode)) as string | null,
);
}
const pointer = await this.publisher.get(blockTemplateHeightPointerKey(height, payoutMode)) as string | null;
const pointedTemplate = await this.readBlockTemplatePointer(pointer);
if (pointedTemplate != null) {
return pointedTemplate;
}
// Backward-compatible replay for templates written before tip-specific keys.
const legacyTemplate = this.readBlockTemplateValue(
await this.publisher.get(blockTemplateLegacyKey(height)) as string | null,
);
return this.matchesPayoutMode(legacyTemplate, payoutMode)
? legacyTemplate
: null;
}
public async getLatestBlockTemplate(): Promise<IBlockTemplate | null> {
public async getLatestBlockTemplate(payoutMode: PayoutMode = 'solo'): Promise<IBlockTemplate | null> {
if (!await this.ensureConnected()) {
return null;
}
const value = await this.publisher.get(BLOCK_TEMPLATE_LATEST_KEY);
return value == null ? null : JSON.parse(value as string);
const modeValue = await this.publisher.get(blockTemplateLatestPointerKey(payoutMode)) as string | null;
if (modeValue != null) {
return this.readBlockTemplatePointer(modeValue);
}
const value = await this.publisher.get(BLOCK_TEMPLATE_LATEST_KEY) as string | null;
if (value == null) {
return null;
}
if (/^\d+$/.test(value as string)) {
return this.getBlockTemplate(Number(value), payoutMode);
}
const legacyTemplate = await this.readBlockTemplatePointer(value);
return this.matchesPayoutMode(legacyTemplate, payoutMode)
? legacyTemplate
: null;
}
public async getLatestBlockTemplates(): Promise<IBlockTemplate[]> {
const [solo, pplns] = await Promise.all([
this.getLatestBlockTemplate('solo'),
this.getLatestBlockTemplate('pplns'),
]);
return [solo, pplns].filter((template): template is IBlockTemplate => template != null);
}
public async getJsonCache<T>(key: string): Promise<T | null> {
@@ -148,6 +343,74 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
);
}
private async readBlockTemplatePointer(value: string | null): Promise<IBlockTemplate | null> {
if (value == null) {
return null;
}
if (value.startsWith('block-template:')) {
return this.readBlockTemplateValue(await this.publisher.get(value) as string | null);
}
return this.readBlockTemplateValue(value);
}
private readBlockTemplateValue(value: string | null): IBlockTemplate | null {
if (value == null) {
return null;
}
try {
const parsed = JSON.parse(value) as IBlockTemplate;
return parsed != null && typeof parsed === 'object' ? parsed : null;
} catch (error) {
console.error(`Invalid Redis block template: ${error.message}`);
return null;
}
}
private matchesPayoutMode(
template: IBlockTemplate | null,
payoutMode: PayoutMode,
): template is IBlockTemplate {
return template != null
&& (template.payoutMode == null
|| template.payoutMode === 'all'
|| template.payoutMode === payoutMode);
}
private parseSv1BridgeUpdate(message: string): Sv1BridgeUpdate {
const update = JSON.parse(message) as Partial<Sv1BridgeUpdate>;
const template = update.template as Partial<IBlockTemplate> | undefined;
const payoutMode = template?.payoutMode;
const payoutOutputs = template?.payoutOutputs;
const hasExplicitPplnsPayout = payoutMode !== 'pplns' || (
typeof template?.payoutSnapshotId === 'string'
&& template.payoutSnapshotId.trim().length > 0
&& Array.isArray(payoutOutputs)
&& payoutOutputs.length > 0
&& Number.isSafeInteger(template.coinbasevalue)
&& payoutOutputs.every(output => (
typeof output.address === 'string'
&& output.address.trim().length > 0
&& Number.isSafeInteger(output.amountSats)
&& output.amountSats >= 0
))
&& payoutOutputs.reduce((sum, output) => sum + output.amountSats, 0)
=== template.coinbasevalue
);
if (update.schemaVersion !== 1
|| update.type !== 'subsidy-bridge'
|| typeof update.eventId !== 'string'
|| template?.jobType !== 'empty'
|| (payoutMode !== 'solo' && payoutMode !== 'pplns')
|| !Array.isArray(template?.transactions)
|| template.transactions.length !== 0
|| typeof template.previousblockhash !== 'string'
|| !Number.isInteger(template.height)
|| !hasExplicitPplnsPayout) {
throw new Error('unsupported SV1 bridge update');
}
return update as Sv1BridgeUpdate;
}
private async ensureConnected(): Promise<boolean> {
if (!this.connected) {
try {
+369 -9
View File
@@ -1,12 +1,15 @@
import { BehaviorSubject, firstValueFrom, skip } from 'rxjs';
import * as bitcoinjs from 'bitcoinjs-lib';
import { BehaviorSubject, firstValueFrom, skip, Subject } from 'rxjs';
import { MockRecording1 } from '../../test/models/MockRecording1';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { createPreparedMiningJob } from './prepared-mining-job.factory';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
describe('StratumV1JobsService', () => {
let blockTemplate$: BehaviorSubject<IBlockTemplate>;
let bitcoinRpcService: { newBlockTemplate$: any, miningInfo: { blocks: number } };
let bridgeTemplate$: Subject<IBlockTemplate>;
let bitcoinRpcService: { newBlockTemplate$: any, newSv1BridgeTemplate$: any, miningInfo: { blocks: number } };
let service: StratumV1JobsService;
let consoleLogSpy: jest.SpyInstance;
@@ -22,8 +25,10 @@ describe('StratumV1JobsService', () => {
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
blockTemplate$ = new BehaviorSubject(createTemplate());
bridgeTemplate$ = new Subject<IBlockTemplate>();
bitcoinRpcService = {
newBlockTemplate$: blockTemplate$.asObservable(),
newSv1BridgeTemplate$: bridgeTemplate$.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height }
};
service = new StratumV1JobsService(bitcoinRpcService as any);
@@ -36,33 +41,289 @@ describe('StratumV1JobsService', () => {
it('should create job templates from block templates', async () => {
const jobTemplate = await firstValueFrom(service.newMiningJob$);
const sourceTemplate = createTemplate();
const prepared = createPreparedMiningJob(sourceTemplate);
expect(jobTemplate.blockData).toEqual(expect.objectContaining({
id: '1',
height: MockRecording1.BLOCK_TEMPLATE.height,
clearJobs: true,
coinbasevalue: MockRecording1.BLOCK_TEMPLATE.coinbasevalue
coinbasevalue: MockRecording1.BLOCK_TEMPLATE.coinbasevalue,
transactions: sourceTemplate.transactions,
bodyReference: prepared.body,
}));
expect(jobTemplate.merkle_branch.length).toBeGreaterThan(0);
expect(jobTemplate.block.transactions).toHaveLength(1);
expect(jobTemplate.merkle_branch).toEqual(prepared.coinbase.merkleBranch);
expect(jobTemplate.block.witnessCommit.toString('hex'))
.toBe(prepared.coinbase.witnessCommitmentHash);
expect(jobTemplate.block.transactions[0].ins[0].witness[0]).toHaveLength(32);
expect(service.getJobTemplateById('1')).toBe(jobTemplate);
});
it('should clear jobs when the block height changes', async () => {
it('does not parse transaction bodies while preparing a mining job', async () => {
const fromHexSpy = jest.spyOn(bitcoinjs.Transaction, 'fromHex');
const reorderedTemplate = createTemplate();
reorderedTemplate.transactions = reorderedTemplate.transactions.slice().reverse();
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(reorderedTemplate);
const jobTemplate = await nextTemplate;
expect(fromHexSpy).not.toHaveBeenCalled();
expect(jobTemplate.block.transactions).toHaveLength(1);
expect(jobTemplate.blockData.transactions).toHaveLength(reorderedTemplate.transactions.length);
expect(jobTemplate.merkle_branch)
.toEqual(createPreparedMiningJob(reorderedTemplate).coinbase.merkleBranch);
});
it('should mark a new previous block hash clean while retaining old jobs for late submissions', async () => {
const firstTemplate = await firstValueFrom(service.newMiningJob$);
service.addJob({ jobId: 'old-job', creation: Date.now() } as any);
bitcoinRpcService.miningInfo.blocks = MockRecording1.BLOCK_TEMPLATE.height + 1;
const changedTipTemplate = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
changedTipTemplate.previousblockhash = '11'.repeat(32);
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1));
blockTemplate$.next(changedTipTemplate);
const jobTemplate = await nextTemplate;
expect(jobTemplate.blockData.clearJobs).toBe(true);
expect(service.getJobById('old-job')).toBeUndefined();
expect(service.getJobTemplateById(firstTemplate.blockData.id)).toBeUndefined();
expect(jobTemplate.blockData.isNewBlock).toBe(true);
expect(service.getJobById('old-job')).toBeDefined();
expect(service.getJobTemplateById(firstTemplate.blockData.id)).toBe(firstTemplate);
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
});
it('should force a clean miner switch without treating the same tip as a new block', async () => {
await firstValueFrom(service.newMiningJob$);
service.addJob({ jobId: 'bridge-job', creation: Date.now() } as any);
const fullTemplate = createTemplate();
fullTemplate.forceCleanJobs = true;
fullTemplate.transactions = fullTemplate.transactions.slice(0, -1);
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(fullTemplate);
const jobTemplate = await nextTemplate;
expect(jobTemplate.blockData.clearJobs).toBe(true);
expect(jobTemplate.blockData.isNewBlock).toBe(false);
expect(service.getJobById('bridge-job')).toBeDefined();
});
it('emits the subsidy bridge only on the ordered SV1 stream, followed by canonical full work', async () => {
await firstValueFrom(service.newMiningJob$);
const bridgeSource = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
bridgeSource.previousblockhash = '12'.repeat(32);
bridgeSource.transactions = [];
bridgeSource.jobType = 'empty';
bridgeSource.payoutMode = 'solo';
bridgeSource.forceCleanJobs = true;
const bridgeResult = firstValueFrom(service.sv1MiningJob$.pipe(skip(1)));
bridgeTemplate$.next(bridgeSource);
const bridge = await bridgeResult;
expect(bridge.blockData.jobType).toBe('empty');
expect(bridge.blockData.clearJobs).toBe(true);
expect(bridge.merkle_branch).toEqual([]);
const fullSource = createTemplate(bridgeSource.height);
fullSource.previousblockhash = bridgeSource.previousblockhash;
fullSource.payoutMode = 'solo';
fullSource.forceCleanJobs = true;
const fullResult = firstValueFrom(service.sv1MiningJob$.pipe(skip(1)));
blockTemplate$.next(fullSource);
const full = await fullResult;
expect(full.blockData.jobType).toBe('full');
expect(full.blockData.isNewBlock).toBe(false);
expect(full.blockData.clearJobs).toBe(true);
expect(full.blockData.tipKey).toBe(bridge.blockData.tipKey);
expect(full.merkle_branch.length).toBeGreaterThan(0);
});
it('should keep same-tip bridge and full jobs current, then mark both stale on the next tip', async () => {
const bridgeTemplate = await firstValueFrom(service.newMiningJob$);
const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }];
const bridgeJob = service.getOrCreateJob(bitcoinjs.networks.testnet, payout, bridgeTemplate);
const fullSource = createTemplate();
fullSource.forceCleanJobs = true;
fullSource.transactions = fullSource.transactions.slice(0, -1);
const fullResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(fullSource);
const fullTemplate = await fullResult;
const fullJob = service.getOrCreateJob(bitcoinjs.networks.testnet, payout, fullTemplate);
expect(bridgeJob.tipKey).toBe(fullJob.tipKey);
expect(service.getSubmissionContext(bridgeJob.jobId)?.status).toBe('current');
expect(service.getSubmissionContext(fullJob.jobId)?.status).toBe('current');
const nextTipSource = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
nextTipSource.previousblockhash = '22'.repeat(32);
const nextTipResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(nextTipSource);
const nextTipTemplate = await nextTipResult;
const nextTipJob = service.getOrCreateJob(bitcoinjs.networks.testnet, payout, nextTipTemplate);
expect(service.getSubmissionContext(bridgeJob.jobId)?.status).toBe('stale');
expect(service.getSubmissionContext(fullJob.jobId)?.status).toBe('stale');
expect(service.getSubmissionContext(nextTipJob.jobId)?.status).toBe('current');
});
it('keeps a new solo tip independent from delayed old-tip PPLNS work and pinning', async () => {
const previousRetention = process.env.STRATUM_JOB_RETENTION_MS;
process.env.STRATUM_JOB_RETENTION_MS = '1000';
try {
const sharedTemplate = await firstValueFrom(service.newMiningJob$);
const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }];
const oldSoloJob = service.getOrCreateJob(
bitcoinjs.networks.testnet,
payout,
sharedTemplate,
'solo-old-tip',
'solo',
);
const oldPplnsJob = service.getOrCreateJob(
bitcoinjs.networks.testnet,
payout,
sharedTemplate,
'pplns-old-tip',
'pplns',
);
const newSoloSource = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
newSoloSource.previousblockhash = '44'.repeat(32);
newSoloSource.payoutMode = 'solo';
const newSoloResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(newSoloSource);
const newSoloTemplate = await newSoloResult;
const newSoloJob = service.getOrCreateJob(
bitcoinjs.networks.testnet,
payout,
newSoloTemplate,
'solo-new-tip',
'solo',
);
expect(service.getSubmissionContext(oldSoloJob.jobId)?.status).toBe('stale');
expect(service.getSubmissionContext(oldPplnsJob.jobId)?.status).toBe('current');
expect(service.getSubmissionContext(newSoloJob.jobId)?.status).toBe('current');
sharedTemplate.blockData.creation = Date.now() - 1001;
oldSoloJob.creation = Date.now() - 1001;
oldPplnsJob.creation = Date.now() - 1001;
const delayedPplnsSource = createTemplate();
delayedPplnsSource.payoutMode = 'pplns';
delayedPplnsSource.payoutSnapshotId = 'delayed-old-tip';
delayedPplnsSource.payoutOutputs = [{
address: payout[0].address,
amountSats: delayedPplnsSource.coinbasevalue,
}];
const delayedPplnsResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(delayedPplnsSource);
const delayedPplnsTemplate = await delayedPplnsResult;
const delayedPplnsJob = service.getOrCreateJob(
bitcoinjs.networks.testnet,
delayedPplnsSource.payoutOutputs,
delayedPplnsTemplate,
'pplns-delayed-old-tip',
'pplns',
);
expect(delayedPplnsTemplate.blockData.isNewBlock).toBe(false);
expect(delayedPplnsTemplate.blockData.clearJobs).toBe(false);
expect(service.getJobById(oldSoloJob.jobId)).toBeUndefined();
expect(service.getSubmissionContext(oldPplnsJob.jobId)?.status).toBe('current');
expect(service.getSubmissionContext(delayedPplnsJob.jobId)?.status).toBe('current');
expect(service.getSubmissionContext(newSoloJob.jobId)?.status).toBe('current');
expect(service.getJobTemplateById(sharedTemplate.blockData.id)).toBe(sharedTemplate);
delayedPplnsTemplate.blockData.creation = Date.now() - 1001;
delayedPplnsJob.creation = Date.now() - 1001;
const alignedPplnsSource = createTemplate(newSoloSource.height);
alignedPplnsSource.previousblockhash = newSoloSource.previousblockhash;
alignedPplnsSource.payoutMode = 'pplns';
alignedPplnsSource.payoutSnapshotId = 'aligned-new-tip';
alignedPplnsSource.payoutOutputs = [{
address: payout[0].address,
amountSats: alignedPplnsSource.coinbasevalue,
}];
const alignedPplnsResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(alignedPplnsSource);
const alignedPplnsTemplate = await alignedPplnsResult;
const alignedPplnsJob = service.getOrCreateJob(
bitcoinjs.networks.testnet,
alignedPplnsSource.payoutOutputs,
alignedPplnsTemplate,
'pplns-aligned-new-tip',
'pplns',
);
expect(alignedPplnsTemplate.blockData.isNewBlock).toBe(true);
expect(service.getJobById(oldPplnsJob.jobId)).toBeUndefined();
expect(service.getJobById(delayedPplnsJob.jobId)).toBeUndefined();
expect(service.getJobTemplateById(sharedTemplate.blockData.id)).toBeUndefined();
expect(service.getJobTemplateById(delayedPplnsTemplate.blockData.id)).toBeUndefined();
expect(service.getSubmissionContext(newSoloJob.jobId)?.status).toBe('current');
expect(service.getSubmissionContext(alignedPplnsJob.jobId)?.status).toBe('current');
} finally {
if (previousRetention == null) {
delete process.env.STRATUM_JOB_RETENTION_MS;
} else {
process.env.STRATUM_JOB_RETENTION_MS = previousRetention;
}
}
});
it('classifies concurrent startup replays independently in either mode order', async () => {
const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }];
const soloSource = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
soloSource.previousblockhash = '55'.repeat(32);
soloSource.payoutMode = 'solo';
soloSource.forceCleanJobs = true;
const pplnsSource = createTemplate();
pplnsSource.payoutMode = 'pplns';
pplnsSource.forceCleanJobs = true;
pplnsSource.payoutSnapshotId = 'startup-pplns';
pplnsSource.payoutOutputs = [{
address: payout[0].address,
amountSats: pplnsSource.coinbasevalue,
}];
const sources = { solo: soloSource, pplns: pplnsSource };
for (const canonicalMode of ['solo', 'pplns'] as const) {
const bridgeMode = canonicalMode === 'solo' ? 'pplns' : 'solo';
const canonical$ = new BehaviorSubject(sources[canonicalMode]);
const bridge$ = new BehaviorSubject(sources[bridgeMode]);
const startupService = new StratumV1JobsService({
newBlockTemplate$: canonical$.asObservable(),
newSv1BridgeTemplate$: bridge$.asObservable(),
miningInfo: { blocks: MockRecording1.BLOCK_TEMPLATE.height },
} as any);
await firstValueFrom(startupService.sv1MiningJob$);
const soloTemplate = startupService.getLatestJobTemplate('solo');
const pplnsTemplate = startupService.getLatestJobTemplate('pplns');
const soloJob = startupService.getOrCreateJob(
bitcoinjs.networks.testnet,
payout,
soloTemplate,
`startup-solo-${canonicalMode}`,
'solo',
);
const pplnsJob = startupService.getOrCreateJob(
bitcoinjs.networks.testnet,
pplnsSource.payoutOutputs,
pplnsTemplate,
`startup-pplns-${canonicalMode}`,
'pplns',
);
expect(startupService.getSubmissionContext(soloJob.jobId)?.status).toBe('current');
expect(startupService.getSubmissionContext(pplnsJob.jobId)?.status).toBe('current');
}
});
it('should skip identical non-clean template refreshes', async () => {
await firstValueFrom(service.newMiningJob$);
@@ -129,6 +390,69 @@ describe('StratumV1JobsService', () => {
expect(service.getJobTemplateById(jobTemplate.blockData.id)).toBe(jobTemplate);
});
it('should honor the configured job retention window', async () => {
const previousRetention = process.env.STRATUM_JOB_RETENTION_MS;
process.env.STRATUM_JOB_RETENTION_MS = '1000';
try {
const oldTemplate = await firstValueFrom(service.newMiningJob$);
const oldJob = service.getOrCreateJob(
bitcoinjs.networks.testnet,
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
oldTemplate,
);
oldTemplate.blockData.creation = Date.now() - 1001;
oldJob.creation = Date.now() - 1001;
const nextTip = createTemplate(MockRecording1.BLOCK_TEMPLATE.height + 1);
nextTip.previousblockhash = '33'.repeat(32);
const nextTemplate = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(nextTip);
await nextTemplate;
expect(service.getJobById(oldJob.jobId)).toBeUndefined();
expect(service.getJobTemplateById(oldTemplate.blockData.id)).toBeUndefined();
} finally {
if (previousRetention == null) {
delete process.env.STRATUM_JOB_RETENTION_MS;
} else {
process.env.STRATUM_JOB_RETENTION_MS = previousRetention;
}
}
});
it('keeps clean current-tip work reconstructable beyond the normal retention window', async () => {
const previousRetention = process.env.STRATUM_JOB_RETENTION_MS;
process.env.STRATUM_JOB_RETENTION_MS = '1000';
try {
const cleanTemplate = await firstValueFrom(service.newMiningJob$);
const job = service.getOrCreateJob(
bitcoinjs.networks.testnet,
[{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }],
cleanTemplate,
);
cleanTemplate.blockData.creation = Date.now() - 10_000;
job.creation = Date.now() - 10_000;
const refresh = createTemplate();
refresh.transactions = refresh.transactions.slice(0, -1);
const refreshResult = firstValueFrom(service.newMiningJob$.pipe(skip(1)));
blockTemplate$.next(refresh);
await refreshResult;
expect(service.getSubmissionContext(job.jobId)).toEqual(expect.objectContaining({
status: 'current',
job,
jobTemplate: cleanTemplate,
}));
} finally {
if (previousRetention == null) {
delete process.env.STRATUM_JOB_RETENTION_MS;
} else {
process.env.STRATUM_JOB_RETENTION_MS = previousRetention;
}
}
});
it('should increment job ids when jobs are added', () => {
expect(service.getNextId()).toBe('1');
@@ -137,4 +461,40 @@ describe('StratumV1JobsService', () => {
expect(service.getNextId()).toBe('2');
expect(service.getJobById('1')).toEqual(expect.objectContaining({ jobId: '1' }));
});
it('should cache one mining job per template and payout identity', async () => {
const template = await firstValueFrom(service.newMiningJob$);
const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }];
const first = service.getOrCreateJob(bitcoinjs.networks.testnet, payout, template);
const second = service.getOrCreateJob(bitcoinjs.networks.testnet, [{ ...payout[0] }], template);
expect(second).toBe(first);
expect(first.ownership).toEqual(expect.objectContaining({ payoutMode: 'solo' }));
expect(Object.keys(service.jobs)).toEqual([first.jobId]);
});
it('should keep identical payout identities isolated by connection payout mode', async () => {
const template = await firstValueFrom(service.newMiningJob$);
const payout = [{ address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', percent: 100 }];
const solo = service.getOrCreateJob(
bitcoinjs.networks.testnet,
payout,
template,
'shared-identity',
'solo',
);
const pplns = service.getOrCreateJob(
bitcoinjs.networks.testnet,
payout,
template,
'shared-identity',
'pplns',
);
expect(pplns).not.toBe(solo);
expect(solo.ownership).toEqual({ payoutMode: 'solo', payoutIdentity: 'shared-identity' });
expect(pplns.ownership).toEqual({ payoutMode: 'pplns', payoutIdentity: 'shared-identity' });
});
});
+272 -87
View File
@@ -1,13 +1,16 @@
import { Injectable } from '@nestjs/common';
import * as bitcoinjs from 'bitcoinjs-lib';
import * as merkle from 'merkle-lib';
import * as merkleProof from 'merkle-lib/proof';
import { combineLatest, delay, filter, from, interval, map, Observable, shareReplay, startWith, switchMap, tap } from 'rxjs';
import * as crypto from 'crypto';
import { EMPTY, filter, map, merge, Observable, shareReplay, tap } from 'rxjs';
import { IBlockTemplateTx } from '../models/bitcoin-rpc/IBlockTemplate';
import { IBlockTemplate, IBlockTemplateTx } from '../models/bitcoin-rpc/IBlockTemplate';
import { AddressObject, MiningJob } from '../models/MiningJob';
import { hash256 } from '../utils/hash.utils';
import { PayoutMode } from '../types/payout-mode';
import { BitcoinRpcService } from './bitcoin-rpc.service';
import {
createPreparedMiningJob,
PreparedMiningJobBodyReference,
} from './prepared-mining-job.factory';
export interface IJobTemplate {
@@ -19,116 +22,164 @@ export interface IJobTemplate {
coinbasevalue: number;
networkDifficulty: number;
height: number;
tipKey: string;
clearJobs: boolean;
isNewBlock: boolean;
jobType: 'full' | 'empty';
payoutMode: PayoutMode | 'all';
notificationEventId?: string;
notificationPublishedAtMs?: number;
payoutSnapshotId?: string;
payoutOutputs?: AddressObject[];
transactions?: IBlockTemplateTx[];
bodyReference?: PreparedMiningJobBodyReference;
sigoplimit?: number;
sizelimit?: number;
weightlimit?: number;
requiredVersionBits?: number;
};
}
export interface IJobSubmissionContext {
job: MiningJob;
jobTemplate: IJobTemplate;
status: 'current' | 'stale';
}
const DEFAULT_JOB_RETENTION_MS = 5 * 60 * 1000;
const PAYOUT_MODES: readonly PayoutMode[] = ['solo', 'pplns'];
export function createPayoutOutputIdentity(
payoutMode: PayoutMode,
payoutInformation: readonly AddressObject[],
): string {
const fingerprint = crypto.createHash('sha256')
.update(JSON.stringify(payoutInformation.map(output => ({
address: output.address,
amountSats: output.amountSats ?? null,
percent: output.percent ?? null,
}))))
.digest('hex');
return `${payoutMode}\0outputs\0${fingerprint}`;
}
@Injectable()
export class StratumV1JobsService {
public newMiningJob$: Observable<IJobTemplate>;
/** Ordered SV1-only stream: subsidy bridge first, canonical full job second. */
public sv1MiningJob$: Observable<IJobTemplate>;
public latestJobId: number = 1;
public latestJobTemplateId: number = 1;
public jobs: { [jobId: string]: MiningJob } = {};
public blocks: { [id: number]: IJobTemplate } = {};
private lastBlockHeight = 0;
private lastWorkSignature: string;
private readonly lastPreviousBlockHashes = new Map<PayoutMode, string>();
private readonly lastWorkSignatures = new Map<PayoutMode, string>();
private readonly currentTipKeys = new Map<PayoutMode, string>();
private readonly cachedJobs = new Map<string, MiningJob>();
private readonly latestJobTemplates = new Map<PayoutMode | 'all', IJobTemplate>();
private readonly pinnedCurrentTipTemplateIds = new Map<PayoutMode, Set<string>>([
['solo', new Set<string>()],
['pplns', new Set<string>()],
]);
constructor(
private readonly bitcoinRpcService: BitcoinRpcService
) {
this.newMiningJob$ = this.bitcoinRpcService.newBlockTemplate$.pipe(
const createMiningJobStream = (blockTemplates: Observable<IBlockTemplate>) => blockTemplates.pipe(
map((blockTemplate) => {
if (process.env.MASTER == 'true') {
console.log('Updating block template');
}
let clearJobs = false;
const currentBlockHeight = this.bitcoinRpcService.miningInfo.blocks;
const prepared = createPreparedMiningJob(blockTemplate);
const payoutModes = this.getAffectedPayoutModes(prepared.payoutMode);
const isNewBlock = payoutModes.some(payoutMode =>
this.lastPreviousBlockHashes.get(payoutMode) == null
|| this.lastPreviousBlockHashes.get(payoutMode) !== prepared.header.previousBlockHash,
);
const clearJobs = isNewBlock || prepared.forceCleanJobs;
if(this.lastBlockHeight == 0 || this.lastBlockHeight != currentBlockHeight){
if(isNewBlock){
console.log('New template is new block, clearing jobs');
clearJobs = true;
this.lastBlockHeight = currentBlockHeight;
payoutModes.forEach(payoutMode => {
this.lastPreviousBlockHashes.set(
payoutMode,
prepared.header.previousBlockHash,
);
});
}
const currentTime = Math.floor(new Date().getTime() / 1000);
const timestamp = blockTemplate.mintime > currentTime ? blockTemplate.mintime : currentTime;
const timestamp = Math.max(
prepared.header.minTime,
currentTime,
);
const workSignature = [
blockTemplate.previousblockhash,
blockTemplate.version,
blockTemplate.bits,
prepared.tipKey,
prepared.header.version,
prepared.header.bitsHex,
timestamp,
blockTemplate.height,
blockTemplate.coinbasevalue,
blockTemplate.payoutSnapshotId ?? '',
...(blockTemplate.payoutOutputs ?? []).map(output => [
prepared.coinbase.valueSats,
prepared.coinbase.payoutSnapshotId ?? '',
...(prepared.coinbase.payoutOutputs ?? []).map(output => [
output.address,
output.amountSats ?? '',
output.percent ?? ''
].join(':')),
...blockTemplate.transactions.map(tx => tx.hash ?? tx.txid ?? tx.data)
prepared.body.reference,
...prepared.coinbase.merkleBranch,
].join('|');
if (!clearJobs && workSignature === this.lastWorkSignature) {
if (!clearJobs && payoutModes.every(payoutMode =>
this.lastWorkSignatures.get(payoutMode) === workSignature,
)) {
return null;
}
this.lastWorkSignature = workSignature;
payoutModes.forEach(payoutMode => {
this.lastWorkSignatures.set(payoutMode, workSignature);
});
return {
version: blockTemplate.version,
bits: parseInt(blockTemplate.bits, 16),
prevHash: this.convertToLittleEndian(blockTemplate.previousblockhash),
transactions: blockTemplate.transactions.map(t => bitcoinjs.Transaction.fromHex(t.data)),
coinbasevalue: blockTemplate.coinbasevalue,
prepared,
timestamp,
networkDifficulty: this.calculateNetworkDifficulty(parseInt(blockTemplate.bits, 16)),
networkDifficulty: this.calculateNetworkDifficulty(prepared.header.bits),
clearJobs,
height: blockTemplate.height,
payoutSnapshotId: blockTemplate.payoutSnapshotId,
payoutOutputs: blockTemplate.payoutOutputs,
isNewBlock,
notificationEventId: blockTemplate.notificationEventId,
notificationPublishedAtMs: blockTemplate.notificationPublishedAtMs,
rawTransactions: blockTemplate.transactions,
sigoplimit: blockTemplate.sigoplimit,
sizelimit: blockTemplate.sizelimit,
weightlimit: blockTemplate.weightlimit,
requiredVersionBits: blockTemplate.vbrequired >>> 0,
};
}),
filter(next => next != null),
map(({ version, bits, prevHash, transactions, timestamp, coinbasevalue, networkDifficulty, clearJobs, height, payoutSnapshotId, payoutOutputs, rawTransactions, sigoplimit, sizelimit, weightlimit }) => {
map(({ prepared, timestamp, networkDifficulty, clearJobs, isNewBlock, notificationEventId, notificationPublishedAtMs, rawTransactions, sigoplimit, sizelimit, weightlimit, requiredVersionBits }) => {
const block = new bitcoinjs.Block();
//create an empty coinbase tx
// Keep only a placeholder coinbase on the hot path. The full raw body
// remains in blockData and is parsed only for a block candidate.
const tempCoinbaseTx = new bitcoinjs.Transaction();
tempCoinbaseTx.version = 2;
tempCoinbaseTx.addInput(Buffer.alloc(32, 0), 0xffffffff, 0xffffffff);
tempCoinbaseTx.ins[0].witness = [Buffer.alloc(32, 0)];
transactions.unshift(tempCoinbaseTx);
const merkle_branch = [...prepared.coinbase.merkleBranch];
const transactionBuffers = transactions.map(tx => tx.getHash(false));
const merkleTree = merkle(transactionBuffers, hash256);
const merkleBranches: Buffer[] = merkleProof(merkleTree, transactionBuffers[0]).filter(h => h != null);
block.merkleRoot = merkleBranches.pop();
// remove the first (coinbase) and last (root) element from the branch
const merkle_branch = merkleBranches.slice(1, merkleBranches.length).map(b => b.toString('hex'))
block.prevHash = prevHash;
block.version = version;
block.bits = bits;
block.prevHash = Buffer.from(prepared.header.previousBlockHashLE, 'hex');
block.version = prepared.header.version;
block.bits = prepared.header.bits;
block.timestamp = timestamp;
block.transactions = transactions;
block.witnessCommit = bitcoinjs.Block.calculateMerkleRoot(transactions, true);
block.transactions = [tempCoinbaseTx];
block.merkleRoot = MiningJob.calculateMerkleRootFromCoinbaseHash(
tempCoinbaseTx.getHash(false),
merkle_branch.map(branch => Buffer.from(branch, 'hex')),
);
block.witnessCommit = Buffer.from(prepared.coinbase.witnessCommitmentHash, 'hex');
const id = this.getNextTemplateId();
this.latestJobTemplateId++;
@@ -138,50 +189,61 @@ export class StratumV1JobsService {
blockData: {
id,
creation: new Date().getTime(),
coinbasevalue,
coinbasevalue: prepared.coinbase.valueSats,
networkDifficulty,
height,
height: prepared.height,
tipKey: prepared.tipKey,
clearJobs,
payoutSnapshotId,
payoutOutputs,
isNewBlock,
jobType: prepared.jobType,
payoutMode: prepared.payoutMode,
notificationEventId,
notificationPublishedAtMs,
payoutSnapshotId: prepared.coinbase.payoutSnapshotId,
payoutOutputs: prepared.coinbase.payoutOutputs?.map(output => ({ ...output })),
transactions: rawTransactions,
bodyReference: prepared.body,
sigoplimit,
sizelimit,
weightlimit,
requiredVersionBits,
}
}
}),
tap((data) => {
if (data.blockData.clearJobs) {
this.blocks = {};
this.jobs = {};
}else{
let templatesDeleted = 0;
let jobsDeleted = 0;
const now = new Date().getTime();
// Delete old templates (5 minutes)
for(const templateId in this.blocks){
if(now - this.blocks[templateId].blockData.creation > (1000 * 60 * 5)){
delete this.blocks[templateId];
templatesDeleted++;
}
}
// Delete old jobs (5 minutes)
for (const jobId in this.jobs) {
if(now - this.jobs[jobId].creation > (1000 * 60 * 5)){
delete this.jobs[jobId];
jobsDeleted++;
}
}
//console.log(`Deleted ${templatesDeleted} templates and ${jobsDeleted} jobs.`)
}
this.blocks[data.blockData.id] = data;
this.getAffectedPayoutModes(data.blockData.payoutMode).forEach(payoutMode => {
const pinnedTemplateIds = this.pinnedCurrentTipTemplateIds.get(payoutMode);
if (this.currentTipKeys.get(payoutMode) !== data.blockData.tipKey) {
pinnedTemplateIds.clear();
}
this.currentTipKeys.set(payoutMode, data.blockData.tipKey);
if (data.blockData.clearJobs) {
// ESP-class miners only switch on clean work. Keep each mode's
// bridge and clean full replacement reconstructable for its tip.
pinnedTemplateIds.add(data.blockData.id);
}
});
this.latestJobTemplates.set(data.blockData.payoutMode, data);
if (data.blockData.payoutMode === 'all') {
this.latestJobTemplates.set('solo', data);
this.latestJobTemplates.set('pplns', data);
}
this.cleanupExpiredJobsAndTemplates();
}),
shareReplay({ refCount: true, bufferSize: 1 })
)
);
if (process.env.API_ONLY !== 'true') {
this.newMiningJob$.subscribe();
this.newMiningJob$ = createMiningJobStream(this.bitcoinRpcService.newBlockTemplate$);
const bridgeMiningJob$ = createMiningJobStream(
this.bitcoinRpcService.newSv1BridgeTemplate$ ?? EMPTY,
);
this.sv1MiningJob$ = merge(bridgeMiningJob$, this.newMiningJob$).pipe(
shareReplay({ refCount: true, bufferSize: 1 }),
);
if (process.env.API_ONLY !== 'true' && process.env.MASTER !== 'true') {
this.sv1MiningJob$.subscribe();
}
}
@@ -197,25 +259,83 @@ export class StratumV1JobsService {
return difficulty;
}
private convertToLittleEndian(hash: string): Buffer {
const bytes = Buffer.from(hash, 'hex');
Array.prototype.reverse.call(bytes);
return bytes;
}
public getJobTemplateById(jobTemplateId: string): IJobTemplate | null {
return this.blocks[jobTemplateId];
}
public getLatestJobTemplate(payoutMode: PayoutMode): IJobTemplate | null {
return this.latestJobTemplates.get(payoutMode)
?? this.latestJobTemplates.get('all')
?? null;
}
public addJob(job: MiningJob) {
this.jobs[job.jobId] = job;
this.latestJobId++;
}
public getOrCreateJob(
network: bitcoinjs.networks.Network,
payoutInformation: AddressObject[],
jobTemplate: IJobTemplate,
payoutIdentity?: string,
payoutMode?: PayoutMode,
): MiningJob {
const effectivePayoutMode = payoutMode
?? (jobTemplate.blockData.payoutMode === 'pplns' ? 'pplns' : 'solo');
const effectivePayoutIdentity = payoutIdentity
?? createPayoutOutputIdentity(effectivePayoutMode, payoutInformation);
const cacheKey = [
jobTemplate.blockData.id,
jobTemplate.block.timestamp,
jobTemplate.blockData.clearJobs,
effectivePayoutMode,
effectivePayoutIdentity,
].join(':');
const cached = this.cachedJobs.get(cacheKey);
if (cached != null) {
return cached;
}
const job = new MiningJob(
network,
this.getNextId(),
payoutInformation,
jobTemplate,
{
payoutMode: effectivePayoutMode,
payoutIdentity: effectivePayoutIdentity,
},
);
this.addJob(job);
this.cachedJobs.set(cacheKey, job);
return job;
}
public getJobById(jobId: string) {
return this.jobs[jobId];
}
public getSubmissionContext(jobId: string): IJobSubmissionContext | null {
const job = this.jobs[jobId];
if (job == null) {
return null;
}
const jobTemplate = this.blocks[job.jobTemplateId];
if (jobTemplate == null) {
return null;
}
return {
job,
jobTemplate,
status: this.getJobPayoutModes(job, jobTemplate).some(payoutMode =>
job.tipKey === this.currentTipKeys.get(payoutMode),
) ? 'current' : 'stale',
};
}
public getNextTemplateId() {
return this.latestJobTemplateId.toString(16);
}
@@ -223,4 +343,69 @@ export class StratumV1JobsService {
return this.latestJobId.toString(16);
}
private cleanupExpiredJobsAndTemplates() {
const cutoff = Date.now() - this.getJobRetentionMs();
const protectedTemplateIds = new Set<string>();
for (const payoutMode of PAYOUT_MODES) {
this.pinnedCurrentTipTemplateIds.get(payoutMode)
.forEach(templateId => protectedTemplateIds.add(templateId));
const latestTemplate = this.latestJobTemplates.get(payoutMode);
if (latestTemplate != null) {
protectedTemplateIds.add(latestTemplate.blockData.id);
}
}
for (const jobId in this.jobs) {
if (this.jobs[jobId].creation < cutoff
&& !this.isJobProtected(this.jobs[jobId], protectedTemplateIds)) {
delete this.jobs[jobId];
}
}
const retainedTemplateIds = new Set(
Object.values(this.jobs).map(job => job.jobTemplateId),
);
protectedTemplateIds.forEach(templateId => retainedTemplateIds.add(templateId));
for (const templateId in this.blocks) {
if (this.blocks[templateId].blockData.creation < cutoff
&& !retainedTemplateIds.has(templateId)) {
delete this.blocks[templateId];
}
}
for (const [cacheKey, job] of this.cachedJobs.entries()) {
if (this.jobs[job.jobId] !== job || this.blocks[job.jobTemplateId] == null) {
this.cachedJobs.delete(cacheKey);
}
}
}
private getAffectedPayoutModes(payoutMode: PayoutMode | 'all'): readonly PayoutMode[] {
return payoutMode === 'all' ? PAYOUT_MODES : [payoutMode];
}
private getJobPayoutModes(job: MiningJob, jobTemplate: IJobTemplate): readonly PayoutMode[] {
if (job.ownership?.payoutMode != null) {
return [job.ownership.payoutMode];
}
return this.getAffectedPayoutModes(jobTemplate.blockData.payoutMode);
}
private isJobProtected(job: MiningJob, legacyProtectedTemplateIds: ReadonlySet<string>): boolean {
const payoutMode = job.ownership?.payoutMode;
if (payoutMode == null) {
return legacyProtectedTemplateIds.has(job.jobTemplateId);
}
return this.pinnedCurrentTipTemplateIds.get(payoutMode).has(job.jobTemplateId)
|| this.latestJobTemplates.get(payoutMode)?.blockData.id === job.jobTemplateId;
}
private getJobRetentionMs(): number {
const configured = Number(process.env.STRATUM_JOB_RETENTION_MS);
return Number.isInteger(configured) && configured > 0
? configured
: DEFAULT_JOB_RETENTION_MS;
}
}
+107 -1
View File
@@ -1,3 +1,5 @@
import { Subject } from 'rxjs';
import { StratumV1Service } from './stratum-v1.service';
describe('StratumV1Service', () => {
@@ -16,6 +18,7 @@ describe('StratumV1Service', () => {
let userAgentReportService;
let stratumV2Service;
let redisMessagingService;
let miningJobs: Subject<any>;
let consoleLogSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
@@ -32,13 +35,14 @@ describe('StratumV1Service', () => {
createClient: jest.fn()
};
redisMessagingService = {};
miningJobs = new Subject();
service = new StratumV1Service(
{} as any,
clientService,
{} as any,
{} as any,
{} as any,
{} as any,
{ newMiningJob$: miningJobs.asObservable() } as any,
{} as any,
stratumV2Service as any,
userAgentReportService as any,
@@ -101,6 +105,108 @@ describe('StratumV1Service', () => {
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(14333, 'pplns');
});
it('should fan out each job once through the worker broadcaster', async () => {
process.env.MASTER = 'false';
process.env.STRATUM_PORTS = '';
process.env.STRATUM_SECURE = 'false';
const client = {
broadcastMiningJob: jest.fn().mockReturnValue({
status: 'written',
bytes: 500,
bufferedBytes: 0,
}),
};
(service as any).clients.add(client);
await service.onModuleInit();
const job = {
blockData: {
id: 'a',
height: 900001,
jobType: 'full',
isNewBlock: true,
clearJobs: true,
},
};
miningJobs.next(job);
expect(client.broadcastMiningJob).toHaveBeenCalledTimes(1);
expect(client.broadcastMiningJob).toHaveBeenCalledWith(job);
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('stratum_job_fanout'));
service.onModuleDestroy();
});
it('enqueues a 100,000-client fanout without per-client async serialization', () => {
const clientCount = 100_000;
let writes = 0;
const broadcastMiningJob = () => {
writes++;
return { status: 'written', bytes: 256, bufferedBytes: 0 };
};
for (let index = 0; index < clientCount; index++) {
(service as any).clients.add({ broadcastMiningJob });
}
const job = {
blockData: {
id: 'load',
height: 900001,
jobType: 'empty',
isNewBlock: true,
clearJobs: true,
notificationEventId: 'load-test',
notificationPublishedAtMs: Date.now(),
},
};
(service as any).broadcastMiningJob(job);
const trace = JSON.parse(consoleLogSpy.mock.calls.at(-1)?.[0]);
expect(writes).toBe(clientCount);
expect(trace).toEqual(expect.objectContaining({
event: 'stratum_job_fanout',
eventId: 'load-test',
clients: clientCount,
written: clientCount,
}));
expect(trace.milestoneMs).toEqual(expect.objectContaining({
p50: expect.any(Number),
p95: expect.any(Number),
p99: expect.any(Number),
}));
expect(trace.totalMs).toBeLessThan(1_000);
});
it('reports a buffer-limited client as closed without counting an unwritten job', () => {
(service as any).clients.add({
broadcastMiningJob: jest.fn().mockReturnValue({
status: 'closed',
bytes: 0,
bufferedBytes: 262_144,
}),
});
const job = {
blockData: {
id: 'buffer-limit',
height: 900001,
jobType: 'empty',
isNewBlock: true,
clearJobs: true,
},
};
(service as any).broadcastMiningJob(job);
const trace = JSON.parse(consoleLogSpy.mock.calls.at(-1)?.[0]);
expect(trace).toEqual(expect.objectContaining({
clients: 1,
closed: 1,
written: 0,
backpressured: 0,
bytesQueued: 0,
maxBufferedBytes: 262_144,
}));
});
it('should pause listeners when worker backpressure is high', () => {
const close = jest.fn((callback?: (error?: Error) => void) => callback?.());
(service as any).listeners.push({
+103 -3
View File
@@ -1,7 +1,8 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Server, Socket } from 'net';
import { monitorEventLoopDelay } from 'perf_hooks';
import { Subscription } from 'rxjs';
import { StratumV1Client } from '../models/StratumV1Client';
import { StratumV2Client } from '../models/StratumV2Client';
@@ -44,7 +45,7 @@ const DEFAULT_TCP_KEEPALIVE_INITIAL_DELAY_MS = 1000 * 60;
@Injectable()
export class StratumV1Service implements OnModuleInit {
export class StratumV1Service implements OnModuleInit, OnModuleDestroy {
private socketTimeout = 0;
private emptySocket = 0;
@@ -54,6 +55,8 @@ export class StratumV1Service implements OnModuleInit {
private readonly eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
private backpressureMonitor: NodeJS.Timeout | null = null;
private healthyBackpressureChecks = 0;
private readonly clients = new Set<StratumV1Client>();
private jobBroadcastSubscription: Subscription | null = null;
constructor(
private readonly bitcoinRpcService: BitcoinRpcService,
@@ -86,6 +89,14 @@ export class StratumV1Service implements OnModuleInit {
return;
}
this.jobBroadcastSubscription = (
this.stratumV1JobsService.sv1MiningJob$
?? this.stratumV1JobsService.newMiningJob$
).subscribe({
next: jobTemplate => this.broadcastMiningJob(jobTemplate),
error: error => console.error(`SV1 job broadcast subscription failed: ${error.message}`),
});
// wait for all the other processes to init for an even connection distribution
setTimeout(() => {
parsePayoutModePorts(
@@ -116,6 +127,15 @@ export class StratumV1Service implements OnModuleInit {
}
public onModuleDestroy(): void {
this.jobBroadcastSubscription?.unsubscribe();
this.jobBroadcastSubscription = null;
if (this.backpressureMonitor != null) {
clearInterval(this.backpressureMonitor);
this.backpressureMonitor = null;
}
}
private startSocketServer(port: number, payoutMode: PayoutMode) {
const listener: StratumListenerState = {
port,
@@ -132,6 +152,7 @@ export class StratumV1Service implements OnModuleInit {
const server = new Server(async (socket: Socket) => {
socket.setTimeout(this.getSocketTimeoutMs());
socket.setKeepAlive(true, this.getTcpKeepAliveInitialDelayMs());
socket.setNoDelay(true);
let client: StratumV1Client | StratumV2Client = null;
let protocol: 'v1' | 'v2' | null = null;
@@ -152,6 +173,9 @@ export class StratumV1Service implements OnModuleInit {
const initializedClient = protocol === 'v2'
|| (currentClient as StratumV1Client).extraNonceAndSessionId != null;
await currentClient.destroy();
if (protocol === 'v1') {
this.clients.delete(currentClient as StratumV1Client);
}
if (initializedClient) {
if (reason == 'Error') {
this.errorClosure++;
@@ -229,7 +253,7 @@ export class StratumV1Service implements OnModuleInit {
}
private createV1Client(socket: Socket, accountingProtocol: 'sv1' | 'sv1_tls', payoutMode: PayoutMode): StratumV1Client {
return new StratumV1Client(
const client = new StratumV1Client(
socket,
this.stratumV1JobsService,
this.bitcoinRpcService,
@@ -244,6 +268,8 @@ export class StratumV1Service implements OnModuleInit {
accountingProtocol,
payoutMode,
);
this.clients.add(client);
return client;
}
private startSecureSocketServer(port: number, payoutMode: PayoutMode) {
@@ -273,6 +299,7 @@ export class StratumV1Service implements OnModuleInit {
const server = createServer(tlsOptions, async (socket: TLSSocket) => {
socket.setTimeout(this.getSocketTimeoutMs());
socket.setKeepAlive(true, this.getTcpKeepAliveInitialDelayMs());
socket.setNoDelay(true);
const client = this.createV1Client(socket, 'sv1_tls', payoutMode);
let cleanedUp = false;
@@ -286,6 +313,7 @@ export class StratumV1Service implements OnModuleInit {
try {
const initializedClient = client.extraNonceAndSessionId != null;
await client.destroy();
this.clients.delete(client);
if (initializedClient) {
if (reason === 'Error') {
this.errorClosure++;
@@ -430,6 +458,78 @@ export class StratumV1Service implements OnModuleInit {
return Math.round(this.eventLoopDelay.percentile(95) / 1e6);
}
private broadcastMiningJob(jobTemplate: import('./stratum-v1-jobs.service').IJobTemplate): void {
const startedAt = process.hrtime.bigint();
const totalClients = this.clients.size;
const milestoneIndexes = {
p50: Math.max(1, Math.ceil(totalClients * 0.5)),
p95: Math.max(1, Math.ceil(totalClients * 0.95)),
p99: Math.max(1, Math.ceil(totalClients * 0.99)),
};
const milestoneMs: { p50?: number; p95?: number; p99?: number } = {};
let visited = 0;
let written = 0;
let skipped = 0;
let backpressured = 0;
let closed = 0;
let errors = 0;
let bytesQueued = 0;
let maxBufferedBytes = 0;
const elapsedMs = () => Number(process.hrtime.bigint() - startedAt) / 1e6;
for (const client of this.clients) {
visited++;
try {
const result = client.broadcastMiningJob(jobTemplate);
bytesQueued += result.bytes;
maxBufferedBytes = Math.max(maxBufferedBytes, result.bufferedBytes);
switch (result.status) {
case 'written': written++; break;
case 'backpressured': backpressured++; break;
case 'closed': closed++; break;
case 'error': errors++; break;
default: skipped++; break;
}
} catch (error) {
errors++;
void client.destroy();
}
if (milestoneMs.p50 == null && visited >= milestoneIndexes.p50) {
milestoneMs.p50 = elapsedMs();
}
if (milestoneMs.p95 == null && visited >= milestoneIndexes.p95) {
milestoneMs.p95 = elapsedMs();
}
if (milestoneMs.p99 == null && visited >= milestoneIndexes.p99) {
milestoneMs.p99 = elapsedMs();
}
}
console.log(JSON.stringify({
event: 'stratum_job_fanout',
eventId: jobTemplate.blockData.notificationEventId,
redisToFanoutStartMs: jobTemplate.blockData.notificationPublishedAtMs == null
? undefined
: Date.now() - jobTemplate.blockData.notificationPublishedAtMs,
templateId: jobTemplate.blockData.id,
height: jobTemplate.blockData.height,
jobType: jobTemplate.blockData.jobType,
isNewBlock: jobTemplate.blockData.isNewBlock,
cleanJobs: jobTemplate.blockData.clearJobs,
clients: totalClients,
written,
skipped,
backpressured,
closed,
errors,
bytesQueued,
maxBufferedBytes,
milestoneMs,
totalMs: elapsedMs(),
}));
}
private isBackpressureDisabled() {
return process.env.STRATUM_BACKPRESSURE_ENABLED?.toLowerCase() === 'false';
}
+355
View File
@@ -0,0 +1,355 @@
import { ConfigService } from '@nestjs/config';
import * as bitcoinjs from 'bitcoinjs-lib';
import { Socket } from 'net';
import { Observable, Subject } from 'rxjs';
import { StratumV2Client } from '../models/StratumV2Client';
import { IJobTemplate } from './stratum-v1-jobs.service';
import { StratumV2Service } from './stratum-v2.service';
describe('StratumV2Service canonical job broadcaster', () => {
const originalApiOnly = process.env.API_ONLY;
const originalMaster = process.env.MASTER;
const originalNamespaceEnv = {
NODE_APP_INSTANCE: process.env.NODE_APP_INSTANCE,
pm_id: process.env.pm_id,
restart_time: process.env.restart_time,
PM2_ENABLED: process.env.PM2_ENABLED,
SV2_EXTRANONCE_NAMESPACE_BASE: process.env.SV2_EXTRANONCE_NAMESPACE_BASE,
};
beforeEach(() => {
delete process.env.API_ONLY;
delete process.env.MASTER;
for (const key of Object.keys(originalNamespaceEnv)) {
delete process.env[key];
}
jest.spyOn(console, 'error').mockImplementation(() => undefined);
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
jest.spyOn(console, 'log').mockImplementation(() => undefined);
});
afterEach(() => {
process.env.API_ONLY = originalApiOnly;
process.env.MASTER = originalMaster;
for (const [key, value] of Object.entries(originalNamespaceEnv)) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
jest.restoreAllMocks();
});
it('owns one canonical subscription and fans out without awaiting slow clients', async () => {
const { service, templates, subscribeSpy } = createService();
await service.onModuleInit();
await service.onModuleInit();
let releaseSlow: () => void;
const slowGate = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
const slowClient = createClientMock(jest.fn(() => slowGate));
const fastClient = createClientMock();
service.registerClient(slowClient);
service.registerClient(fastClient);
templates.next(createJobTemplate('1', 'all'));
expect(subscribeSpy).toHaveBeenCalledTimes(1);
expect(slowClient.enqueueCanonicalJob).toHaveBeenCalledTimes(1);
expect(fastClient.enqueueCanonicalJob).toHaveBeenCalledTimes(1);
releaseSlow();
await slowGate;
await service.onModuleDestroy();
});
it('isolates a failed client and continues the same fanout', async () => {
const { service, templates } = createService();
await service.onModuleInit();
const failedClient = createClientMock(
jest.fn(() => {
throw new Error('socket failed');
}),
);
const healthyClient = createClientMock();
service.registerClient(failedClient);
service.registerClient(healthyClient);
templates.next(createJobTemplate('2', 'all'));
expect(failedClient.destroy).toHaveBeenCalledTimes(1);
expect(healthyClient.enqueueCanonicalJob).toHaveBeenCalledTimes(1);
expect((service as any).clients.has(failedClient)).toBe(false);
await service.onModuleDestroy();
});
it('removes an asynchronously failed client without blocking later broadcasts', async () => {
const { service, templates } = createService();
await service.onModuleInit();
const failedClient = createClientMock(
jest.fn().mockRejectedValue(new Error('async socket failure')),
);
const healthyClient = createClientMock();
service.registerClient(failedClient);
service.registerClient(healthyClient);
templates.next(createJobTemplate('async-failure', 'all'));
await Promise.resolve();
await Promise.resolve();
expect(failedClient.destroy).toHaveBeenCalledTimes(1);
expect((service as any).clients.has(failedClient)).toBe(false);
expect(healthyClient.enqueueCanonicalJob).toHaveBeenCalledTimes(1);
templates.next(createJobTemplate('after-failure', 'all'));
expect(healthyClient.enqueueCanonicalJob).toHaveBeenCalledTimes(2);
await service.onModuleDestroy();
});
it('tracks the latest canonical template independently by payout mode', async () => {
const { service, templates } = createService();
await service.onModuleInit();
const shared = createJobTemplate('shared', 'all');
const solo = createJobTemplate('solo', 'solo');
const pplns = createJobTemplate('pplns', 'pplns');
templates.next(shared);
expect(service.getLatestCanonicalJob('solo')).toBe(shared);
expect(service.getLatestCanonicalJob('pplns')).toBe(shared);
templates.next(solo);
templates.next(pplns);
expect(service.getLatestCanonicalJob('solo')).toBe(solo);
expect(service.getLatestCanonicalJob('pplns')).toBe(pplns);
await service.onModuleDestroy();
});
it('owns one activation subscription, deduplicates payout envelopes, and fans out without awaiting', async () => {
const {
service,
activations,
activationSubscribeSpy,
} = createService();
await service.onModuleInit();
await service.onModuleInit();
let releaseSlow: () => void;
const slowGate = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
const slowClient = createClientMock(undefined, jest.fn(() => slowGate));
const fastClient = createClientMock();
service.registerClient(slowClient);
service.registerClient(fastClient);
const solo = createActivationTemplate(900_001, '11'.repeat(32), 'solo');
activations.next(solo);
activations.next({ ...solo, payoutMode: 'pplns' });
expect(activationSubscribeSpy).toHaveBeenCalledTimes(1);
expect(slowClient.enqueueWorkActivation).toHaveBeenCalledTimes(1);
expect(fastClient.enqueueWorkActivation).toHaveBeenCalledTimes(1);
expect(service.getLatestWorkActivationTemplate()).toBe(solo);
releaseSlow();
await slowGate;
await service.onModuleDestroy();
});
it('registers created clients and unregisters them during client destruction', async () => {
const { service } = createService();
(service as any).noiseConfig = createNoiseConfig();
const socket = createSocket();
const client = service.createClient(socket, Buffer.alloc(0));
expect((service as any).clients.has(client)).toBe(true);
await client.destroy();
expect((service as any).clients.has(client)).toBe(false);
});
it('allocates one collision-free prefix space across channel types, workers, and reloads', () => {
const allocateBothChannelTypes = (service: StratumV2Service): Buffer[] => {
const standardChannelId = service.getNextChannelId();
const standard = service.generateExtranoncePrefix(standardChannelId);
const extendedChannelId = service.getNextChannelId();
const extended = service.allocateExtendedExtranoncePrefix(extendedChannelId);
expect(service.getExtendedMinerExtranonceSize()).toBe(10);
expect(service.getExtendedTotalExtranonceSize()).toBe(14);
return [standard, extended];
};
process.env.NODE_APP_INSTANCE = '0';
process.env.restart_time = '0';
const worker0 = allocateBothChannelTypes(createService().service);
process.env.NODE_APP_INSTANCE = '1';
process.env.restart_time = '0';
const worker1 = allocateBothChannelTypes(createService().service);
process.env.NODE_APP_INSTANCE = '0';
process.env.restart_time = '1';
const reloadedWorker0 = allocateBothChannelTypes(createService().service);
const prefixes = [...worker0, ...worker1, ...reloadedWorker0];
expect(prefixes.every(prefix => prefix.length === 4)).toBe(true);
expect(new Set(prefixes.map(prefix => prefix.toString('hex'))).size).toBe(prefixes.length);
expect(prefixes.map(prefix => prefix[0])).toEqual([0, 0, 2, 2, 1, 1]);
});
it('fails worker initialization when PM2 exposes no representable process identity', async () => {
process.env.PM2_ENABLED = 'true';
const { service } = createService();
await expect(service.onModuleInit())
.rejects.toThrow('cannot allocate collision-free extranonces');
});
it('unsubscribes and destroys all registered clients on module shutdown', async () => {
const { service } = createService();
await service.onModuleInit();
const first = createClientMock();
const second = createClientMock();
service.registerClient(first);
service.registerClient(second);
const subscription = (service as any).canonicalJobSubscription;
const activationSubscription = (service as any).workActivationSubscription;
await service.onModuleDestroy();
expect(subscription.closed).toBe(true);
expect(activationSubscription.closed).toBe(true);
expect(first.destroy).toHaveBeenCalledTimes(1);
expect(second.destroy).toHaveBeenCalledTimes(1);
expect((service as any).clients.size).toBe(0);
});
});
function createService(): {
service: StratumV2Service;
templates: Subject<IJobTemplate>;
subscribeSpy: jest.Mock;
activations: Subject<any>;
activationSubscribeSpy: jest.Mock;
} {
const templates = new Subject<IJobTemplate>();
const subscribeSpy = jest.fn((observer) => templates.subscribe(observer));
const activations = new Subject<any>();
const activationSubscribeSpy = jest.fn((observer) => activations.subscribe(observer));
const jobsService = {
newMiningJob$: new Observable((subscriber) => subscribeSpy(subscriber)),
getLatestJobTemplate: jest.fn().mockReturnValue(null),
};
const configService = {
get: jest.fn((key: string) => (key === 'NETWORK' ? 'testnet' : null)),
};
const clientService = {
delete: jest.fn().mockResolvedValue(undefined),
};
const bitcoinRpcService = {
workActivationTemplate$: new Observable((subscriber) => activationSubscribeSpy(subscriber)),
};
const service = new StratumV2Service(
bitcoinRpcService as any,
clientService as any,
{} as any,
{} as any,
configService as unknown as ConfigService,
jobsService as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
);
return { service, templates, subscribeSpy, activations, activationSubscribeSpy };
}
function createClientMock(
enqueue = jest.fn().mockResolvedValue(undefined),
enqueueActivation = jest.fn().mockResolvedValue(undefined),
): StratumV2Client & {
enqueueCanonicalJob: jest.Mock;
enqueueWorkActivation: jest.Mock;
destroy: jest.Mock;
} {
return {
enqueueCanonicalJob: enqueue,
enqueueWorkActivation: enqueueActivation,
destroy: jest.fn().mockResolvedValue(undefined),
} as any;
}
function createActivationTemplate(
height: number,
previousblockhash: string,
payoutMode: 'solo' | 'pplns',
) {
return {
height,
previousblockhash,
payoutMode,
jobType: 'empty',
version: 0x20000000,
vbrequired: 0,
bits: '1d00ffff',
mintime: 1_700_000_001,
} as any;
}
function createJobTemplate(
id: string,
payoutMode: 'solo' | 'pplns' | 'all',
): IJobTemplate {
const block = new bitcoinjs.Block();
block.timestamp = 1_700_000_000;
return {
block,
merkle_branch: [],
blockData: {
id,
creation: Date.now(),
coinbasevalue: 312_500_000,
networkDifficulty: 1,
height: 900_000,
tipKey: `900000:${'00'.repeat(32)}`,
clearJobs: true,
isNewBlock: true,
jobType: 'full',
payoutMode,
},
};
}
function createNoiseConfig() {
return {
staticKeypair: {
privateKey: Buffer.alloc(32, 1),
publicKey: Buffer.alloc(64, 2),
},
certificateMessage: {
version: 0,
validFrom: 0,
notValidAfter: 1,
signature: Buffer.alloc(64),
},
};
}
function createSocket(): Socket {
const socket: any = {
destroyed: false,
writableEnded: false,
on: jest.fn().mockReturnThis(),
write: jest.fn((_data, callback) => {
callback?.();
return true;
}),
destroy: jest.fn(function destroy() {
socket.destroyed = true;
return socket;
}),
};
return socket as Socket;
}
+161 -15
View File
@@ -1,7 +1,8 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
import { Server, Socket } from 'net';
import { Subscription } from 'rxjs';
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
import { BlocksService } from '../ORM/blocks/blocks.service';
@@ -9,8 +10,16 @@ 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 { StratumV2Client } from '../models/StratumV2Client';
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import { encodeSv2AuthorityPublicKey } from '../models/sv2/sv2-authority-key';
import { Sv2ExtranonceManager } from '../models/sv2/sv2-extranonce-manager';
import {
resolveSv2ProcessNamespace,
Sv2ExtranonceManager,
} from '../models/sv2/sv2-extranonce-manager';
import {
EXTRANONCE1_SIZE_BYTES,
SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES,
} from '../models/stratum.constants';
import {
createSignatureNoiseMessage,
generateServerKeypair,
@@ -22,7 +31,7 @@ import { BitcoinRpcService } from './bitcoin-rpc.service';
import { CustomWorkService } from './custom-work.service';
import { NotificationService } from './notification.service';
import { RedisMessagingService } from './redis-messaging.service';
import { StratumV1JobsService } from './stratum-v1-jobs.service';
import { IJobTemplate, StratumV1JobsService } from './stratum-v1-jobs.service';
import { Sv2JobDeclarationRegistryService } from './sv2-job-declaration-registry.service';
import { parsePayoutModePorts, PayoutMode } from '../types/payout-mode';
@@ -30,15 +39,21 @@ const DEFAULT_SOCKET_TIMEOUT_MS = 1000 * 60 * 60;
const DEFAULT_TCP_KEEPALIVE_INITIAL_DELAY_MS = 1000 * 60;
@Injectable()
export class StratumV2Service implements OnModuleInit {
export class StratumV2Service implements OnModuleInit, OnModuleDestroy {
private readonly servers: Server[] = [];
private readonly clients = new Set<StratumV2Client>();
private readonly latestCanonicalJobs = new Map<PayoutMode | 'all', IJobTemplate>();
private canonicalJobSubscription: Subscription = null;
private workActivationSubscription: Subscription = null;
private latestWorkActivationTemplate: IBlockTemplate = null;
private latestWorkActivationKey: string = null;
private authorityPrivKey: Buffer;
private authorityPublicKeyXOnly: Buffer;
private authorityKeyConfigured = false;
private serverKeypair: Sv2ServerKeypair;
private noiseConfig: Sv2NoiseConfig;
private channelIdCounter = 1;
private readonly extranonceManager = new Sv2ExtranonceManager();
private extranonceManager: Sv2ExtranonceManager = null;
constructor(
private readonly bitcoinRpcService: BitcoinRpcService,
@@ -65,6 +80,10 @@ export class StratumV2Service implements OnModuleInit {
return;
}
this.getExtranonceManager();
this.startCanonicalJobBroadcaster();
this.startWorkActivationBroadcaster();
const ports = this.getPorts();
if (ports.length === 0) {
return;
@@ -74,6 +93,23 @@ export class StratumV2Service implements OnModuleInit {
ports.forEach(({ port, payoutMode }) => this.startSocketServer(port, payoutMode));
}
public async onModuleDestroy(): Promise<void> {
this.canonicalJobSubscription?.unsubscribe();
this.canonicalJobSubscription = null;
this.workActivationSubscription?.unsubscribe();
this.workActivationSubscription = null;
const clients = Array.from(this.clients);
this.clients.clear();
await Promise.allSettled(clients.map(client => client.destroy()));
for (const server of this.servers) {
if (server.listening) {
server.close();
}
}
}
public async ensureInitialized(): Promise<void> {
if (this.noiseConfig != null) {
return;
@@ -87,7 +123,7 @@ export class StratumV2Service implements OnModuleInit {
throw new Error('Stratum V2 service is not initialized');
}
return new StratumV2Client(
const client = new StratumV2Client(
socket,
firstChunk,
this,
@@ -105,6 +141,26 @@ export class StratumV2Service implements OnModuleInit {
this.payoutSnapshotService,
payoutMode,
);
this.registerClient(client);
return client;
}
public registerClient(client: StratumV2Client): void {
this.clients.add(client);
}
public unregisterClient(client: StratumV2Client): void {
this.clients.delete(client);
}
public getLatestCanonicalJob(payoutMode: PayoutMode): IJobTemplate | null {
return this.latestCanonicalJobs.get(payoutMode)
?? this.latestCanonicalJobs.get('all')
?? this.stratumV1JobsService.getLatestJobTemplate(payoutMode);
}
public getLatestWorkActivationTemplate(): IBlockTemplate | null {
return this.latestWorkActivationTemplate;
}
public getNoiseConfig(): Sv2NoiseConfig {
@@ -121,30 +177,120 @@ export class StratumV2Service implements OnModuleInit {
}
public getNextChannelId(): number {
if (this.channelIdCounter > 0xffffffff) {
throw new Error('SV2 channel ID space exhausted');
}
return this.channelIdCounter++;
}
public generateExtranoncePrefix(): Buffer {
const prefix = Buffer.alloc(4);
prefix.writeUInt16BE(this.channelIdCounter & 0xffff, 0);
crypto.randomBytes(2).copy(prefix, 2);
return prefix;
public generateExtranoncePrefix(channelId: number): Buffer {
return this.getExtranonceManager().allocate(channelId);
}
public allocateExtendedExtranoncePrefix(channelId: number): Buffer {
return this.extranonceManager.allocate(channelId);
return this.getExtranonceManager().allocate(channelId);
}
public releaseExtranoncePrefix(channelId: number): void {
this.extranonceManager?.release(channelId);
}
public releaseExtendedExtranoncePrefix(channelId: number): void {
this.extranonceManager.release(channelId);
this.releaseExtranoncePrefix(channelId);
}
public getExtendedMinerExtranonceSize(): number {
return this.extranonceManager.minerExtranonceSize;
return this.getExtranonceManager().minerExtranonceSize;
}
public getExtendedTotalExtranonceSize(): number {
return this.extranonceManager.totalSize;
return this.getExtranonceManager().totalSize;
}
private getExtranonceManager(): Sv2ExtranonceManager {
if (this.extranonceManager == null) {
const clusterWorkerId = (require('cluster') as { worker?: { id?: number } }).worker?.id;
const clusterWorkerIndex = clusterWorkerId == null
? undefined
: clusterWorkerId - 1;
const processNamespace = resolveSv2ProcessNamespace(
process.env,
clusterWorkerIndex,
);
this.extranonceManager = new Sv2ExtranonceManager(
EXTRANONCE1_SIZE_BYTES,
SV2_EXTENDED_TOTAL_EXTRANONCE_SIZE_BYTES,
processNamespace,
);
console.log(`SV2 extranonce namespace ${processNamespace} initialized`);
}
return this.extranonceManager;
}
private startCanonicalJobBroadcaster(): void {
if (this.canonicalJobSubscription != null) {
return;
}
this.canonicalJobSubscription = this.stratumV1JobsService.newMiningJob$.subscribe({
next: jobTemplate => this.broadcastCanonicalJob(jobTemplate),
error: error => console.error(`SV2 canonical job subscription failed: ${error.message}`),
});
}
private broadcastCanonicalJob(jobTemplate: IJobTemplate): void {
this.latestCanonicalJobs.set(jobTemplate.blockData.payoutMode, jobTemplate);
if (jobTemplate.blockData.payoutMode === 'all') {
this.latestCanonicalJobs.set('solo', jobTemplate);
this.latestCanonicalJobs.set('pplns', jobTemplate);
}
for (const client of this.clients) {
try {
void client.enqueueCanonicalJob(jobTemplate).catch(error => {
console.error(`SV2 canonical job enqueue failed: ${error.message}`);
this.unregisterClient(client);
void client.destroy();
});
} catch (error) {
console.error(`SV2 canonical job enqueue failed: ${error.message}`);
this.unregisterClient(client);
void client.destroy();
}
}
}
private startWorkActivationBroadcaster(): void {
if (this.workActivationSubscription != null || this.bitcoinRpcService.workActivationTemplate$ == null) {
return;
}
this.workActivationSubscription = this.bitcoinRpcService.workActivationTemplate$.subscribe({
next: template => this.broadcastWorkActivation(template),
error: error => console.error(`SV2 work activation subscription failed: ${error.message}`),
});
}
private broadcastWorkActivation(template: IBlockTemplate): void {
const activationKey = `${template.height}:${template.previousblockhash}`;
if (activationKey === this.latestWorkActivationKey) {
return;
}
this.latestWorkActivationKey = activationKey;
this.latestWorkActivationTemplate = template;
for (const client of this.clients) {
try {
void client.enqueueWorkActivation(template).catch(error => {
console.error(`SV2 work activation enqueue failed: ${error.message}`);
this.unregisterClient(client);
void client.destroy();
});
} catch (error) {
console.error(`SV2 work activation enqueue failed: ${error.message}`);
this.unregisterClient(client);
void client.destroy();
}
}
}
private async initializeNoiseConfig(): Promise<void> {
@@ -0,0 +1,194 @@
import type { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
import {
calculateBlockSubsidySats,
createSubsidyOnlyBlockTemplate,
EMPTY_DEFAULT_WITNESS_COMMITMENT,
EMPTY_WITNESS_COMMITMENT_HASH,
} from './subsidy-only-template.factory';
describe('subsidy-only template factory', () => {
it('calculates mainnet subsidy at halving boundaries', () => {
expect(calculateBlockSubsidySats(0, 'mainnet')).toBe(5_000_000_000);
expect(calculateBlockSubsidySats(209_999, 'mainnet')).toBe(5_000_000_000);
expect(calculateBlockSubsidySats(210_000, 'mainnet')).toBe(2_500_000_000);
expect(calculateBlockSubsidySats(840_000, 'mainnet')).toBe(312_500_000);
expect(calculateBlockSubsidySats(210_000 * 64, 'mainnet')).toBe(0);
});
it('uses the Bitcoin Core regtest halving interval', () => {
expect(calculateBlockSubsidySats(149, 'regtest')).toBe(5_000_000_000);
expect(calculateBlockSubsidySats(150, 'regtest')).toBe(2_500_000_000);
expect(calculateBlockSubsidySats(300, 'regtest')).toBe(1_250_000_000);
});
it('accepts an explicit halving interval for custom chains', () => {
expect(calculateBlockSubsidySats(20, 'mainnet', 10)).toBe(1_250_000_000);
});
it('rejects invalid candidate heights and halving intervals', () => {
expect(() => calculateBlockSubsidySats(-1, 'mainnet')).toThrow(
'Candidate height',
);
expect(() => calculateBlockSubsidySats(1.5, 'mainnet')).toThrow(
'Candidate height',
);
expect(() => calculateBlockSubsidySats(1, 'mainnet', 0)).toThrow(
'halving interval',
);
});
it('builds a solo empty template while preserving authoritative header fields', () => {
const authoritativeTemplate = createAuthoritativeTemplate();
const originalTransaction = authoritativeTemplate.transactions[0];
const result = createSubsidyOnlyBlockTemplate({
authoritativeTemplate,
network: 'mainnet',
payoutMode: 'solo',
});
expect(result).toEqual(
expect.objectContaining({
version: authoritativeTemplate.version,
bits: authoritativeTemplate.bits,
previousblockhash: authoritativeTemplate.previousblockhash,
mintime: authoritativeTemplate.mintime,
height: authoritativeTemplate.height,
coinbasevalue: 312_500_000,
transactions: [],
default_witness_commitment: EMPTY_DEFAULT_WITNESS_COMMITMENT,
forceCleanJobs: true,
jobType: 'empty',
payoutMode: 'solo',
payoutSnapshotId: undefined,
payoutOutputs: undefined,
}),
);
expect(authoritativeTemplate.transactions).toEqual([originalTransaction]);
expect(authoritativeTemplate.coinbasevalue).toBe(312_512_345);
});
it('computes the empty-set BIP141 witness commitment', () => {
expect(EMPTY_WITNESS_COMMITMENT_HASH).toBe(
'e2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf9',
);
expect(EMPTY_DEFAULT_WITNESS_COMMITMENT).toBe(
'6a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf9',
);
});
it('requires explicit PPLNS subsidy payout data instead of synthesizing a fallback', () => {
expect(() =>
createSubsidyOnlyBlockTemplate({
authoritativeTemplate: createAuthoritativeTemplate(),
network: 'mainnet',
payoutMode: 'pplns',
}),
).toThrow('explicit payout snapshot');
});
it('uses only the explicitly supplied PPLNS subsidy snapshot', () => {
const authoritativeTemplate = createAuthoritativeTemplate();
const result = createSubsidyOnlyBlockTemplate({
authoritativeTemplate,
network: 'mainnet',
payoutMode: 'pplns',
payoutSnapshot: {
id: 'subsidy-snapshot',
payoutOutputs: [
{ address: 'bc1qfirst', amountSats: 200_000_000 },
{ address: 'bc1qsecond', amountSats: 112_500_000 },
],
},
});
expect(result.payoutSnapshotId).toBe('subsidy-snapshot');
expect(result.payoutOutputs).toEqual([
{ address: 'bc1qfirst', amountSats: 200_000_000 },
{ address: 'bc1qsecond', amountSats: 112_500_000 },
]);
expect(result.payoutOutputs).not.toBe(authoritativeTemplate.payoutOutputs);
expect(result.payoutMode).toBe('pplns');
});
it('rejects PPLNS outputs that are not subsidy-valued fixed amounts', () => {
const authoritativeTemplate = createAuthoritativeTemplate();
expect(() =>
createSubsidyOnlyBlockTemplate({
authoritativeTemplate,
network: 'mainnet',
payoutMode: 'pplns',
payoutSnapshot: {
id: 'percentage-snapshot',
payoutOutputs: [{ address: 'bc1qpercentage', percent: 100 }],
},
}),
).toThrow('amountSats');
expect(() =>
createSubsidyOnlyBlockTemplate({
authoritativeTemplate,
network: 'mainnet',
payoutMode: 'pplns',
payoutSnapshot: {
id: 'overpaying-snapshot',
payoutOutputs: [{ address: 'bc1qoverpaid', amountSats: 312_500_001 }],
},
}),
).toThrow('entire block subsidy');
expect(() =>
createSubsidyOnlyBlockTemplate({
authoritativeTemplate,
network: 'mainnet',
payoutMode: 'pplns',
payoutSnapshot: {
id: 'underpaying-snapshot',
payoutOutputs: [{ address: 'bc1qunderpaid', amountSats: 312_499_999 }],
},
}),
).toThrow('entire block subsidy');
});
});
function createAuthoritativeTemplate(): IBlockTemplate {
return {
version: 0x20000000,
rules: ['segwit'],
vbavailable: {},
vbrequired: 0,
previousblockhash: '00'.repeat(32),
transactions: [
{
data: '00',
txid: '11'.repeat(32),
hash: '22'.repeat(32),
depends: [],
fee: 12_345,
sigops: 1,
weight: 400,
},
],
coinbaseaux: {},
coinbasevalue: 312_512_345,
longpollid: 'longpoll',
target: '00'.repeat(32),
mintime: 1_700_000_000,
mutable: ['time', 'transactions', 'prevblock'],
noncerange: '00000000ffffffff',
sigoplimit: 80_000,
sizelimit: 4_000_000,
weightlimit: 4_000_000,
curtime: 1_700_000_001,
bits: '170fffff',
height: 840_000,
default_witness_commitment: `6a24aa21a9ed${'33'.repeat(32)}`,
capabilities: ['proposal'],
payoutSnapshotId: 'full-template-snapshot',
payoutOutputs: [{ address: 'bc1qfulltemplate', amountSats: 312_512_345 }],
forceCleanJobs: false,
jobType: 'full',
payoutMode: 'all',
};
}
@@ -0,0 +1,146 @@
import type {
IBlockTemplate,
IBlockTemplatePayoutOutput,
} from '../models/bitcoin-rpc/IBlockTemplate';
import type { PayoutMode } from '../types/payout-mode';
import { hash256 } from '../utils/hash.utils';
export type BitcoinNetworkName = 'mainnet' | 'testnet' | 'regtest';
export interface SubsidyOnlyPayoutSnapshot {
id: string;
payoutOutputs: IBlockTemplatePayoutOutput[];
}
export interface CreateSubsidyOnlyTemplateInput {
authoritativeTemplate: Readonly<IBlockTemplate>;
network: BitcoinNetworkName;
payoutMode: PayoutMode;
/** Overrides the network default, for custom chains and explicit test fixtures. */
halvingInterval?: number;
/** Required for PPLNS so the factory can never invent a miner-address fallback. */
payoutSnapshot?: SubsidyOnlyPayoutSnapshot;
}
const SATOSHIS_PER_BITCOIN = 100_000_000n;
const INITIAL_BLOCK_SUBSIDY_SATS = 50n * SATOSHIS_PER_BITCOIN;
const MAX_SUBSIDY_HALVINGS = 64;
export const NETWORK_SUBSIDY_HALVING_INTERVALS: Readonly<
Record<BitcoinNetworkName, number>
> = {
mainnet: 210_000,
testnet: 210_000,
regtest: 150,
};
export const EMPTY_WITNESS_COMMITMENT_HASH = hash256(Buffer.alloc(64)).toString(
'hex',
);
export const EMPTY_DEFAULT_WITNESS_COMMITMENT = `6a24aa21a9ed${EMPTY_WITNESS_COMMITMENT_HASH}`;
export function calculateBlockSubsidySats(
candidateHeight: number,
network: BitcoinNetworkName,
halvingInterval = NETWORK_SUBSIDY_HALVING_INTERVALS[network],
): number {
if (!Number.isSafeInteger(candidateHeight) || candidateHeight < 0) {
throw new Error('Candidate height must be a non-negative safe integer');
}
if (!Number.isSafeInteger(halvingInterval) || halvingInterval <= 0) {
throw new Error('Subsidy halving interval must be a positive safe integer');
}
const halvings = Math.floor(candidateHeight / halvingInterval);
if (halvings >= MAX_SUBSIDY_HALVINGS) {
return 0;
}
return Number(INITIAL_BLOCK_SUBSIDY_SATS >> BigInt(halvings));
}
export function createSubsidyOnlyBlockTemplate(
input: CreateSubsidyOnlyTemplateInput,
): IBlockTemplate {
const {
authoritativeTemplate,
network,
payoutMode,
halvingInterval,
payoutSnapshot,
} = input;
const subsidySats = calculateBlockSubsidySats(
authoritativeTemplate.height,
network,
halvingInterval,
);
const payout = resolvePayout(payoutMode, payoutSnapshot, subsidySats);
const authoritativeFields = { ...authoritativeTemplate };
delete authoritativeFields.payoutSnapshotId;
delete authoritativeFields.payoutOutputs;
return {
...authoritativeFields,
rules: [...authoritativeTemplate.rules],
vbavailable: { ...authoritativeTemplate.vbavailable },
coinbaseaux: { ...authoritativeTemplate.coinbaseaux },
mutable: [...authoritativeTemplate.mutable],
capabilities: [...authoritativeTemplate.capabilities],
transactions: [],
coinbasevalue: subsidySats,
default_witness_commitment: EMPTY_DEFAULT_WITNESS_COMMITMENT,
forceCleanJobs: true,
jobType: 'empty',
payoutMode,
...payout,
};
}
function resolvePayout(
payoutMode: PayoutMode,
payoutSnapshot: SubsidyOnlyPayoutSnapshot | undefined,
subsidySats: number,
): Pick<IBlockTemplate, 'payoutSnapshotId' | 'payoutOutputs'> {
if (payoutMode === 'solo') {
return {
payoutSnapshotId: undefined,
payoutOutputs: undefined,
};
}
if (payoutSnapshot == null || payoutSnapshot.id.trim().length === 0) {
throw new Error(
'PPLNS subsidy-only templates require an explicit payout snapshot',
);
}
if (payoutSnapshot.payoutOutputs.length === 0) {
throw new Error('PPLNS subsidy-only templates require payout outputs');
}
let allocatedSats = 0;
const payoutOutputs = payoutSnapshot.payoutOutputs.map((output) => {
if (output.address.trim().length === 0) {
throw new Error('PPLNS payout output address must not be empty');
}
if (!Number.isSafeInteger(output.amountSats) || output.amountSats < 0) {
throw new Error(
'PPLNS subsidy-only payout outputs require non-negative integer amountSats',
);
}
allocatedSats += output.amountSats;
return {
address: output.address,
amountSats: output.amountSats,
};
});
if (allocatedSats !== subsidySats) {
throw new Error('PPLNS payout outputs must allocate the entire block subsidy');
}
return {
payoutSnapshotId: payoutSnapshot.id,
payoutOutputs,
};
}
+1 -1
View File
@@ -104,7 +104,7 @@ export class TemplateProviderService implements OnModuleInit {
) {}
public onModuleInit(): void {
if (this.subscription != null) {
if (this.subscription != null || process.env.API_ONLY === 'true' || process.env.MASTER === 'true') {
return;
}
this.subscription = this.jobsService.newMiningJob$.subscribe(template => {
+141
View File
@@ -1,4 +1,5 @@
import { DifficultyUtils } from './difficulty.utils';
import { hash256 } from './hash.utils';
describe('DifficultyUtils', () => {
function incrementLe256(target: Buffer): Buffer {
@@ -36,6 +37,146 @@ describe('DifficultyUtils', () => {
expect(DifficultyUtils.meetsTarget(easier, target)).toBe(false);
});
it('decodes compact targets from Bitcoin mainnet examples into little-endian bytes', () => {
const examples = [
{
nBits: 0x1d00ffff,
target: '00000000ffff0000000000000000000000000000000000000000000000000000',
},
{
nBits: 0x181bc330,
target: '00000000000000001bc330000000000000000000000000000000000000000000',
},
{
nBits: 0x1a44b9f2,
target: '00000000000044b9f20000000000000000000000000000000000000000000000',
},
];
for (const example of examples) {
const target = DifficultyUtils.compactToTarget(example.nBits);
expect(target).not.toBeNull();
expect(Buffer.from(target).reverse().toString('hex')).toBe(example.target);
}
});
it('matches Bitcoin Core compact decoding for exponents at or below three', () => {
const examples = [
{ nBits: 0x01123456, target: 0x12 },
{ nBits: 0x02008000, target: 0x80 },
{ nBits: 0x03009234, target: 0x9234 },
{ nBits: 0x05009234, target: 0x92340000 },
];
for (const example of examples) {
const expected = Buffer.alloc(32);
expected.writeUInt32LE(example.target, 0);
expect(DifficultyUtils.compactToTarget(example.nBits)).toEqual(expected);
}
});
it('matches known Bitcoin mainnet header hashes using internal hash byte order', () => {
const examples = [
{
nBits: 0x1d00ffff,
header: [
'01000000',
'0000000000000000000000000000000000000000000000000000000000000000',
'3ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a',
'29ab5f49',
'ffff001d',
'1dac2b7c',
].join(''),
internalHash: '6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000',
},
{
nBits: 0x1a44b9f2,
header: [
'01000000',
'81cd02ab7e569e8bcd9317e2fe99f2de44d49ab2b8851ba4a308000000000000',
'e320b6c2fffc8d750423db8b1eb942ae710e951ed797f7affc8892b0f1fc122b',
'c7f5d74d',
'f2b9441a',
'42a14695',
].join(''),
internalHash: '1dbd981fe6985776b644b173a4d0385ddc1aa2a829688d1e0000000000000000',
},
];
for (const example of examples) {
const hash = hash256(Buffer.from(example.header, 'hex'));
expect(hash.toString('hex')).toBe(example.internalHash);
expect(DifficultyUtils.meetsCompactTarget(hash, example.nBits)).toBe(true);
expect(DifficultyUtils.meetsCompactTarget(Buffer.from(hash).reverse(), example.nBits)).toBe(false);
}
});
it('compares compact targets exactly at the little-endian boundary', () => {
const nBits = 0x1d00ffff;
const target = DifficultyUtils.compactToTarget(nBits);
expect(target).not.toBeNull();
expect(DifficultyUtils.meetsCompactTarget(target, nBits)).toBe(true);
expect(DifficultyUtils.meetsCompactTarget(incrementLe256(target), nBits)).toBe(false);
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(true);
});
it('rejects negative compact targets using Bitcoin Core sign semantics', () => {
for (const nBits of [0x04923456, 0x1d80ffff]) {
expect(DifficultyUtils.compactToTarget(nBits)).toBeNull();
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(false);
}
});
it('rejects all compact target overflow forms at the 256-bit boundary', () => {
for (const nBits of [
0x23000001,
0x22000100,
0x21010000,
]) {
expect(DifficultyUtils.compactToTarget(nBits)).toBeNull();
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(false);
}
const size34Boundary = DifficultyUtils.compactToTarget(0x220000ff);
const size33Boundary = DifficultyUtils.compactToTarget(0x2100ffff);
expect(size34Boundary).not.toBeNull();
expect(size33Boundary).not.toBeNull();
expect(Buffer.from(size34Boundary).reverse().toString('hex')).toBe(`ff${'00'.repeat(31)}`);
expect(Buffer.from(size33Boundary).reverse().toString('hex')).toBe(`ffff${'00'.repeat(30)}`);
});
it('rejects zero targets and values outside an unsigned 32-bit nBits field', () => {
for (const nBits of [
0,
0x01003456,
-1,
0x1_0000_0000,
1.5,
Number.NaN,
]) {
expect(DifficultyUtils.compactToTarget(nBits)).toBeNull();
expect(DifficultyUtils.meetsCompactTarget(Buffer.alloc(32), nBits)).toBe(false);
}
});
it('rejects ambiguous hash or target buffer lengths', () => {
expect(() => DifficultyUtils.meetsTarget(
Buffer.alloc(31),
Buffer.alloc(32),
)).toThrow('Hash must be 32 bytes');
expect(() => DifficultyUtils.meetsTarget(
Buffer.alloc(32),
Buffer.alloc(31),
)).toThrow('Target must be 32 bytes');
expect(() => DifficultyUtils.meetsCompactTarget(
Buffer.alloc(33),
0x1d00ffff,
)).toThrow('Hash must be 32 bytes');
});
it('calculates difficulty consistently with the returned hash buffer', () => {
const header = Buffer.alloc(80, 1);
const result = DifficultyUtils.calculateDifficulty(header);
+53
View File
@@ -19,9 +19,62 @@ export class DifficultyUtils {
}
public static meetsTarget(hashBuffer: Buffer, target: Buffer): boolean {
if (hashBuffer.length !== 32) {
throw new Error('Hash must be 32 bytes');
}
if (target.length !== 32) {
throw new Error('Target must be 32 bytes');
}
return DifficultyUtils.compareLe256(hashBuffer, target) <= 0;
}
/**
* Decode Bitcoin's compact nBits value to the 32-byte little-endian target
* representation used by raw double-SHA256 digests in this service.
* Returns null for zero, negative, overflowing, or non-uint32 encodings.
*/
public static compactToTarget(nBits: number): Buffer | null {
if (!Number.isInteger(nBits) || nBits < 0 || nBits > 0xffffffff) {
return null;
}
const size = nBits >>> 24;
let word = nBits & 0x007fffff;
if (size <= 3) {
word >>>= 8 * (3 - size);
}
const negative = word !== 0 && (nBits & 0x00800000) !== 0;
const overflow = word !== 0 && (
size > 34
|| (word > 0xff && size > 33)
|| (word > 0xffff && size > 32)
);
if (word === 0 || negative || overflow) {
return null;
}
const target = Buffer.alloc(32);
const offset = size <= 3 ? 0 : size - 3;
for (let byte = 0; byte < 3 && offset + byte < target.length; byte++) {
target[offset + byte] = (word >>> (byte * 8)) & 0xff;
}
return target;
}
/**
* Compare a raw, internal-byte-order block hash against a compact target.
* Invalid nBits never match.
*/
public static meetsCompactTarget(hashBuffer: Buffer, nBits: number): boolean {
if (hashBuffer.length !== 32) {
throw new Error('Hash must be 32 bytes');
}
const target = DifficultyUtils.compactToTarget(nBits);
return target != null && DifficultyUtils.compareLe256(hashBuffer, target) <= 0;
}
public static difficultyToTarget(difficulty: number): Buffer {
if (!Number.isFinite(difficulty) || difficulty <= 0) {
return Buffer.alloc(32, 0xff);