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
+32
View File
@@ -10,6 +10,12 @@ BITCOIN_RPC_PASSWORD=
BITCOIN_RPC_PORT=8332
BITCOIN_RPC_TIMEOUT=10000
# Keep an authoritative getblocktemplate longpoll outstanding so new-tip work
# does not wait for the ZMQ -> RPC round trip. The normal ZMQ feed remains a watchdog.
BLOCK_TEMPLATE_LONGPOLL_TIMEOUT_MS=600000
BLOCK_TEMPLATE_RETRY_INITIAL_MS=25
BLOCK_TEMPLATE_RETRY_MAX_MS=1000
# Enable in bitcoin.conf with:
# zmqpubrawblock=tcp://0.0.0.0:3000
BITCOIN_ZMQ_HOST=tcp://192.168.1.100:3000
@@ -32,9 +38,28 @@ STRATUM_PORTS=3333,3332,3331,3330
#PPLNS_STRATUM_PORTS=
STRATUM_WORKERS=2
STRATUM_WORKER_MAX_MEMORY_RESTART=4096M
# High-byte base for pool-assigned SV2 extranonce prefixes. PM2 reserves two
# values per NODE_APP_INSTANCE so an old/new reload pair cannot overlap. Keep
# base + (2 * worker index) + reload parity <= 255. Separate pool containers
# sharing work must use non-overlapping ranges of 2 * STRATUM_WORKERS values.
SV2_EXTRANONCE_NAMESPACE_BASE=0
STRATUM_MIN_DIFFICULTY=1
STRATUM_SOCKET_TIMEOUT_MS=3600000
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS=60000
# Disconnect slow/non-reading SV1 clients before their per-socket write queue
# can grow without bound. Includes bytes already buffered by Node.js.
STRATUM_MAX_SOCKET_BUFFER_BYTES=262144
# Immediately publish a consensus-valid, subsidy-only solo job before the full
# transaction template. SV2 uses the same activation to select its pre-staged
# future job; the full job then follows on the active tip.
SV1_SUBSIDY_BRIDGE_ENABLED=true
# Comma-separated: solo,pplns. PPLNS requires a fresh precomputed snapshot;
# solo remains the safe default and is always published first when both are set.
SV1_SUBSIDY_BRIDGE_PAYOUT_MODES=solo
SV1_SUBSIDY_BRIDGE_PPLNS_SEED_MAX_AGE_MS=300000
STRATUM_JOB_RETENTION_MS=300000
STRATUM_SUBMISSION_DEDUP_TTL_MS=300000
STRATUM_SUBMISSION_DEDUP_MAX_ENTRIES=10000
# Optional additional SV2-only mining ports. Usually unnecessary when STRATUM_PORTS are exposed.
#STRATUM_V2_PORTS=
@@ -43,6 +68,13 @@ STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS=60000
#SV2_TARGET_SHARES_PER_MINUTE=2
#SV2_DIFFICULTY_CHECK_INTERVAL_MS=60000
#STRATUM_V2_SOCKET_TIMEOUT_MS=3600000
# Per-client lifecycle bounds. Obsolete queued jobs are coalesced; clients are
# disconnected instead of dropping still-recoverable candidates or buffering forever.
SV2_JOB_RETENTION_MS=300000
SV2_MAX_RETAINED_JOBS_PER_CHANNEL=16
SV2_MAX_QUEUED_JOB_OPERATIONS=4
SV2_MAX_SOCKET_BUFFER_BYTES=262144
SV2_SOCKET_WRITE_TIMEOUT_MS=2000
#SV2_AUTHORITY_PRIVKEY=
#SV2_JDP_PORTS=
#PPLNS_SV2_JDP_PORTS=
+63
View File
@@ -63,6 +63,61 @@ Cluster-mode connection dropping requires Node.js `22.12.0` or newer.
Size it using the busiest port: `worker count * limit`. For example, 28 workers
with the default limit of `10000` allow up to `280000` connections on one port.
SV2 pool-assigned extranonces reserve the first of their four prefix bytes for
a process namespace and share the remaining 24-bit allocation space across
standard and extended channels. Under PM2, the namespace uses
`NODE_APP_INSTANCE` (falling back to `pm_id`) plus `restart_time` parity, which
keeps workers and overlapping zero-downtime reload generations disjoint without
changing the advertised prefix or total extranonce sizes. The effective value is
`SV2_EXTRANONCE_NAMESPACE_BASE + 2 * worker + (restart_time % 2)` and must be at
most `255` (up to 128 worker lanes at base zero). Startup fails if a PM2 worker
identity is missing or the namespace cannot fit. Independent containers or PM2
worker apps that share mining work must be assigned non-overlapping base ranges;
reserve two namespace values per configured Stratum worker.
### New-block notification path
The master keeps an authoritative Bitcoin Core `getblocktemplate` longpoll open;
rawblock ZMQ remains a watchdog and duplicate results are discarded. On a new tip,
the master publishes a compact subsidy-only SV1 job before serializing the full
transaction template. Workers fan that job out through one process-level socket
broadcaster, then issue the full fee-paying job as a second clean switch. Payout
snapshot creation and Postgres persistence run after the immediate solo publish.
SV2 solo channels pre-stage a native subsidy-only future job for the next height.
When the authoritative header arrives, the pool activates that job with only
`SetNewPrevHash`; the same-tip full job follows without a second prevhash switch.
Standard and extended candidates retain exact header/body reconstruction, and
late network-target candidates remain recoverable without crediting stale shares.
Pending SV2 canonical jobs are coalesced per client, while a new-tip activation
is moved ahead of any not-yet-started canonical work for that tip. The finite
defaults are 16 retained jobs per channel, four queued operations, 256 KiB of
outstanding socket writes, and a two-second write-callback deadline. A client is
disconnected if `SV2_MAX_RETAINED_JOBS_PER_CHANNEL`,
`SV2_MAX_QUEUED_JOB_OPERATIONS`, `SV2_MAX_SOCKET_BUFFER_BYTES`, or
`SV2_SOCKET_WRITE_TIMEOUT_MS` is exceeded; `SV2_JOB_RETENTION_MS` controls how
long stale network candidates remain reconstructable.
`SV1_SUBSIDY_BRIDGE_ENABLED=true` enables the solo bridge (the default). PPLNS is
never allowed to fall back to a miner-address coinbase; optional PPLNS bridge
support requires a precomputed subsidy-valued payout snapshot. Retained jobs are
kept for `STRATUM_JOB_RETENTION_MS` so a late network-target candidate can still
be reconstructed and submitted, while ordinary old-tip shares are rejected.
PPLNS seeds use a non-active snapshot status and are skipped if the next
authoritative `nBits` differs from their preparation basis.
The Redis protocol remains rolling-deploy compatible: new workers retain the
legacy mining-info reload path, while the master writes the historical latest
key as JSON only after a PPLNS-safe compatibility template is ready. Deploying
workers before the master is still the preferred rollout order. When any PPLNS
listener is configured and snapshot preparation fails, legacy workers are held
on their prior job instead of being woken with a miner-address fallback job.
Two structured log events expose the end-to-end timing:
- `block_notification_trace` reports Core, bridge, Redis, PPLNS, and persistence stages, separated by payout mode and job type.
- `stratum_job_fanout` reports client count, bytes, backpressure, and p50/p95/p99/last enqueue time, correlated by `eventId`.
## Docker
The default compose stack includes Public Pool, TimescaleDB, and Redis. TimescaleDB
@@ -163,3 +218,11 @@ Integration tests against real TimescaleDB and Redis:
```bash
$ docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
```
With the full-setup regtest Bitcoin Core running, validate reconstructed empty
and full blocks through BIP23 proposal mode:
```bash
$ RUN_BITCOIN_REGTEST_INTEGRATION=true \
npm run test:integration -- bitcoin-regtest-proposal
```
@@ -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);
@@ -0,0 +1,96 @@
import axios from 'axios';
import * as bitcoinjs from 'bitcoinjs-lib';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { MiningJob } from '../src/models/MiningJob';
import { IBlockTemplate } from '../src/models/bitcoin-rpc/IBlockTemplate';
import { createSubsidyOnlyBlockTemplate } from '../src/services/subsidy-only-template.factory';
import { StratumV1JobsService } from '../src/services/stratum-v1-jobs.service';
const describeRegtest = process.env.RUN_BITCOIN_REGTEST_INTEGRATION === 'true'
? describe
: describe.skip;
describeRegtest('Bitcoin Core regtest block proposals', () => {
const rpcUrl = process.env.BITCOIN_REGTEST_RPC_URL ?? 'http://127.0.0.1:28332';
const rpcUser = process.env.BITCOIN_REGTEST_RPC_USER ?? 'bitcoin';
const rpcPassword = process.env.BITCOIN_REGTEST_RPC_PASSWORD ?? 'bitcoin';
let rpcId = 0;
it('accepts reconstructed subsidy-only and full jobs in proposal mode', async () => {
const authoritative = await rpc<IBlockTemplate>('getblocktemplate', [{
mode: 'template',
rules: ['segwit'],
capabilities: ['proposal'],
}]);
const empty = createSubsidyOnlyBlockTemplate({
authoritativeTemplate: authoritative,
network: 'regtest',
payoutMode: 'solo',
});
const emptyBlock = await reconstruct(empty);
const fullBlock = await reconstruct({
...authoritative,
payoutMode: 'solo',
jobType: 'full',
forceCleanJobs: true,
});
expect(emptyBlock.transactions).toHaveLength(1);
expect(fullBlock.transactions).toHaveLength(authoritative.transactions.length + 1);
await expect(propose(emptyBlock)).resolves.toBeNull();
await expect(propose(fullBlock)).resolves.toBeNull();
});
async function reconstruct(template: IBlockTemplate): Promise<bitcoinjs.Block> {
const source = new BehaviorSubject(template);
const jobs = new StratumV1JobsService({
newBlockTemplate$: source.asObservable(),
miningInfo: { blocks: template.height - 1 },
} as any);
const jobTemplate = await firstValueFrom(jobs.newMiningJob$);
const payoutAddress = bitcoinjs.payments.p2wpkh({
hash: Buffer.alloc(20, 1),
network: bitcoinjs.networks.regtest,
}).address!;
const job = new MiningJob(
bitcoinjs.networks.regtest,
'1',
[{ address: payoutAddress, percent: 100 }],
jobTemplate,
);
return job.copyAndUpdateBlock(
jobTemplate,
0,
0,
'01020304',
'05060708090a0b0c',
Math.max(template.mintime, template.curtime, Math.floor(Date.now() / 1000)),
);
}
async function propose(block: bitcoinjs.Block): Promise<string | null> {
return rpc<string | null>('getblocktemplate', [{
mode: 'proposal',
data: block.toHex(false),
rules: ['segwit'],
}]);
}
async function rpc<T>(method: string, params: unknown[]): Promise<T> {
const response = await axios.post(rpcUrl, {
jsonrpc: '1.0',
id: ++rpcId,
method,
params,
}, {
auth: { username: rpcUser, password: rpcPassword },
timeout: 30_000,
});
if (response.data.error != null) {
throw new Error(response.data.error.message ?? JSON.stringify(response.data.error));
}
return response.data.result as T;
}
});