diff --git a/.env.example b/.env.example index c3b8bd8..78353c9 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 4dc5562..3bf4b5b 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/src/ORM/payout-snapshot/payout-snapshot.entity.ts b/src/ORM/payout-snapshot/payout-snapshot.entity.ts index e777319..ef9e524 100644 --- a/src/ORM/payout-snapshot/payout-snapshot.entity.ts +++ b/src/ORM/payout-snapshot/payout-snapshot.entity.ts @@ -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; diff --git a/src/ORM/payout-snapshot/payout-snapshot.service.spec.ts b/src/ORM/payout-snapshot/payout-snapshot.service.spec.ts index 1bbd99a..1183e72 100644 --- a/src/ORM/payout-snapshot/payout-snapshot.service.spec.ts +++ b/src/ORM/payout-snapshot/payout-snapshot.service.spec.ts @@ -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; + let resolveFirstInsert: () => void = () => { + throw new Error('First insert did not start'); + }; + manager.query + .mockImplementationOnce(() => new Promise(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' }]) diff --git a/src/ORM/payout-snapshot/payout-snapshot.service.ts b/src/ORM/payout-snapshot/payout-snapshot.service.ts index 39a9579..452751e 100644 --- a/src/ORM/payout-snapshot/payout-snapshot.service.ts +++ b/src/ORM/payout-snapshot/payout-snapshot.service.ts @@ -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 { 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 { 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 { - 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 { + 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[] { diff --git a/src/models/MiningJob.spec.ts b/src/models/MiningJob.spec.ts index 73f906d..c609243 100644 --- a/src/models/MiningJob.spec.ts +++ b/src/models/MiningJob.spec.ts @@ -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); + }); }); diff --git a/src/models/MiningJob.ts b/src/models/MiningJob.ts index 1ecaa7c..56432ee 100644 --- a/src/models/MiningJob.ts +++ b/src/models/MiningJob.ts @@ -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(); + 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; } diff --git a/src/models/StratumV1Client.spec.ts b/src/models/StratumV1Client.spec.ts index 7c48e98..3ad3307 100644 --- a/src/models/StratumV1Client.spec.ts +++ b/src/models/StratumV1Client.spec.ts @@ -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 = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE); + let newBlockEmitter: BehaviorSubject; 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(); 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)); diff --git a/src/models/StratumV1Client.ts b/src/models/StratumV1Client.ts index 4987c1e..56b2e0e 100644 --- a/src/models/StratumV1Client.ts +++ b/src/models/StratumV1Client.ts @@ -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(); @@ -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 | 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() + private miningSubmissionHashes = new Map(); 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 { + 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 { + 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('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('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('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 { + 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!'; } diff --git a/src/models/StratumV2Client.spec.ts b/src/models/StratumV2Client.spec.ts index 2c9b272..5048960 100644 --- a/src/models/StratumV2Client.spec.ts +++ b/src/models/StratumV2Client.spec.ts @@ -7,6 +7,7 @@ import { MockRecording1 } from '../../test/models/MockRecording1'; import { ClientEntity } from '../ORM/client/client.entity'; import { CustomWorkService } from '../services/custom-work.service'; import { StratumV1JobsService } from '../services/stratum-v1-jobs.service'; +import { DifficultyUtils } from '../utils/difficulty.utils'; import { hash256 } from '../utils/hash.utils'; import { BufferReader } from './sv2/sv2-binary-codec'; import { SV2_CHANNEL_MSG_FLAG, Sv2MiningSetupFlags, Sv2MsgType, Sv2Protocol } from './sv2/sv2-constants'; @@ -16,7 +17,17 @@ import { serializeOpenExtendedMiningChannel, serializeSubmitSharesExtended, } from './sv2/sv2-extended-messages'; -import { deserializeSetNewPrevHash, deserializeSubmitSharesError, serializeSetupConnection } from './sv2/sv2-messages'; +import { + deserializeNewMiningJob, + deserializeSetNewPrevHash, + deserializeSubmitSharesError, + deserializeSubmitSharesSuccess, + serializeCloseChannel, + serializeOpenStandardMiningChannel, + serializeSetupConnection, + serializeSubmitSharesStandard, + serializeUpdateChannel, +} from './sv2/sv2-messages'; import { deserializeSetCustomMiningJobError, deserializeSetCustomMiningJobSuccess, @@ -45,6 +56,22 @@ describe('StratumV2Client extended channels', () => { jest.useRealTimers(); }); + it('creates standard canonical and future jobs without parsing the full transaction body', async () => { + const fromHex = jest.spyOn(bitcoinjs.Transaction, 'fromHex'); + const { client, sentFrames } = await createClient(); + + await (client as any).handleOpenStandardMiningChannel(serializeOpenStandardMiningChannel({ + requestId: 1, + user_identity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + })); + + expect(sentFrames.filter(frame => frame.msgType === Sv2MsgType.NEW_MINING_JOB)) + .toHaveLength(2); + expect(fromHex).not.toHaveBeenCalled(); + }); + it('opens an extended channel and sends initial extended work', async () => { const { client, sentFrames } = await createClient(); const maxTarget = Buffer.alloc(32, 0xff); @@ -59,9 +86,11 @@ describe('StratumV2Client extended channels', () => { const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL_SUCCESS); const jobFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB); - const jobFrame = jobFrames[jobFrames.length - 1]; const prevHashFrames = sentFrames.filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH); const prevHashFrame = prevHashFrames[prevHashFrames.length - 1]; + const prevHash = deserializeSetNewPrevHash(new BufferReader(prevHashFrame.payload)); + const jobFrame = jobFrames.find(frame => + deserializeNewExtendedMiningJob(new BufferReader(frame.payload)).jobId === prevHash.jobId); expect(successFrame?.extensionType).toBe(0); const success = deserializeOpenExtendedMiningChannelSuccess(new BufferReader(successFrame.payload)); @@ -79,9 +108,56 @@ describe('StratumV2Client extended channels', () => { expect(job.merklePath.length).toBeGreaterThan(0); expect(prevHashFrame?.extensionType).toBe(SV2_CHANNEL_MSG_FLAG); - const prevHash = deserializeSetNewPrevHash(new BufferReader(prevHashFrame.payload)); expect(prevHash.channelId).toBe(1); expect(prevHash.jobId).toBe(job.jobId); + const futureJob = deserializeNewExtendedMiningJob(new BufferReader(jobFrames.at(-1).payload)); + expect(futureJob.jobId).not.toBe(job.jobId); + expect(futureJob.minNtime).toBeNull(); + expect(futureJob.merklePath).toHaveLength(0); + }); + + it('keeps PPLNS channels on explicit payout work during legacy all-mode rollout', async () => { + const { client, sentFrames, jobTemplate } = await createClient('pplns'); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + + expect(jobTemplate.blockData.payoutMode).toBe('all'); + expect(jobTemplate.blockData.payoutOutputs).toBeUndefined(); + expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB)).toBe(false); + const channel = (client as any).channels.get(1); + expect(channel.extendedJobs.size).toBe(0); + + const explicitPplnsTemplate = { + ...jobTemplate, + block: Object.assign(new bitcoinjs.Block(), jobTemplate.block), + blockData: { + ...jobTemplate.blockData, + id: 'explicit-pplns', + payoutMode: 'pplns' as const, + payoutSnapshotId: 'snapshot-1', + payoutOutputs: [{ + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + amountSats: jobTemplate.blockData.coinbasevalue, + }], + }, + }; + sentFrames.length = 0; + await client.enqueueCanonicalJob(explicitPplnsTemplate); + const activation = deserializeSetNewPrevHash(new BufferReader(sentFrames + .find(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH).payload)); + const activeJob = channel.extendedJobs.get(activation.jobId); + expect(activeJob.retiredAt).toBeUndefined(); + + sentFrames.length = 0; + await client.enqueueCanonicalJob(jobTemplate); + expect(sentFrames).toHaveLength(0); + expect(channel.extendedJobs.size).toBe(1); + expect(activeJob.retiredAt).toBeUndefined(); }); it('rejects extended shares with the wrong negotiated extranonce size', async () => { @@ -116,6 +192,108 @@ describe('StratumV2Client extended channels', () => { expect(error.errorCode).toBe('invalid-extranonce-size'); }); + it('submits standard network candidates that miss a harder channel target', async () => { + const { client, sentFrames, bitcoinRpcService, shareAccountingService } = await createClient(); + await (client as any).handleOpenStandardMiningChannel(serializeOpenStandardMiningChannel({ + requestId: 1, + user_identity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + })); + const activation = deserializeSetNewPrevHash(new BufferReader(sentFrames + .filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH) + .at(-1).payload)); + const channel = (client as any).channels.get(1); + const job = channel.standardJobs.get(activation.jobId); + channel.jobIdToDifficulty.set(activation.jobId, 1000); + + const calculateDifficulty = jest.spyOn(DifficultyUtils, 'calculateDifficulty').mockReturnValue({ + submissionDifficulty: 123, + submissionHash: '00'.repeat(32), + hashBuffer: Buffer.alloc(32), + }); + const meetsCompactTarget = jest.spyOn(DifficultyUtils, 'meetsCompactTarget').mockReturnValue(true); + const meetsTarget = jest.spyOn(DifficultyUtils, 'meetsTarget').mockReturnValue(false); + sentFrames.length = 0; + try { + await (client as any).handleSubmitSharesStandard(serializeSubmitSharesStandard({ + channelId: 1, + sequenceNumber: 41, + jobId: activation.jobId, + nonce: 1, + ntime: activation.minNtime, + version: job.expectedVersion, + })); + } finally { + calculateDifficulty.mockRestore(); + meetsCompactTarget.mockRestore(); + meetsTarget.mockRestore(); + } + + expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_ERROR)).toBe(false); + const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_SUCCESS); + const success = deserializeSubmitSharesSuccess(new BufferReader(successFrame.payload)); + expect(success.newSharesSum).toBe(123n); + expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledTimes(1); + expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({ + creditedDifficulty: 123, + submissionDifficulty: 123, + isBlockCandidate: true, + })); + }); + + it('submits extended network candidates that miss a harder channel target', async () => { + const { client, sentFrames, bitcoinRpcService, shareAccountingService } = await createClient(); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + const activation = deserializeSetNewPrevHash(new BufferReader(sentFrames + .filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH) + .at(-1).payload)); + const channel = (client as any).channels.get(1); + const job = channel.extendedJobs.get(activation.jobId); + channel.jobIdToDifficulty.set(activation.jobId, 1000); + + const calculateDifficulty = jest.spyOn(DifficultyUtils, 'calculateDifficulty').mockReturnValue({ + submissionDifficulty: 123, + submissionHash: '00'.repeat(32), + hashBuffer: Buffer.alloc(32), + }); + const meetsCompactTarget = jest.spyOn(DifficultyUtils, 'meetsCompactTarget').mockReturnValue(true); + const meetsTarget = jest.spyOn(DifficultyUtils, 'meetsTarget').mockReturnValue(false); + sentFrames.length = 0; + try { + await (client as any).handleSubmitSharesExtended(serializeSubmitSharesExtended({ + channelId: 1, + sequenceNumber: 42, + jobId: activation.jobId, + nonce: 1, + ntime: activation.minNtime, + version: job.expectedVersion, + extranonce: Buffer.alloc(channel.extranonceSize, 0x42), + })); + } finally { + calculateDifficulty.mockRestore(); + meetsCompactTarget.mockRestore(); + meetsTarget.mockRestore(); + } + + expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_ERROR)).toBe(false); + const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_SUCCESS); + const success = deserializeSubmitSharesSuccess(new BufferReader(successFrame.payload)); + expect(success.newSharesSum).toBe(123n); + expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledTimes(1); + expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({ + creditedDifficulty: 123, + submissionDifficulty: 123, + isBlockCandidate: true, + })); + }); + it('reconstructs extended-channel block candidates with a matching merkle root', async () => { const { client, sentFrames } = await createClient(); await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ @@ -125,9 +303,12 @@ describe('StratumV2Client extended channels', () => { maxTarget: Buffer.alloc(32, 0xff), minExtranonceSize: 8, })); + const activeJobId = deserializeSetNewPrevHash(new BufferReader(sentFrames + .filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH) + .at(-1).payload)).jobId; const jobFrame = sentFrames .filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB) - .at(-1); + .find(frame => deserializeNewExtendedMiningJob(new BufferReader(frame.payload)).jobId === activeJobId); const job = deserializeNewExtendedMiningJob(new BufferReader(jobFrame.payload)); const channel = (client as any).channels.get(1); const extendedJob = channel.extendedJobs.get(job.jobId); @@ -157,7 +338,10 @@ describe('StratumV2Client extended channels', () => { ); expect(block.merkleRoot.equals(bitcoinjs.Block.calculateMerkleRoot(block.transactions, false))).toBe(true); - expect(block.transactions[0].toBuffer()).toEqual(coinbaseTxBytes); + expect(block.transactions[0].ins[0].witness).toEqual([Buffer.alloc(32)]); + const strippedCoinbase = bitcoinjs.Transaction.fromBuffer(block.transactions[0].toBuffer()); + strippedCoinbase.ins[0].witness = []; + expect(strippedCoinbase.toBuffer()).toEqual(coinbaseTxBytes); }); it('accepts SV2 work-selection setup and SetCustomMiningJob without sending pool work', async () => { @@ -214,7 +398,90 @@ describe('StratumV2Client extended channels', () => { const success = deserializeSetCustomMiningJobSuccess(new BufferReader(successFrame.payload)); expect(success.channelId).toBe(1); expect(success.requestId).toBe(99); - expect((client as any).channels.get(1).extendedJobs.get(success.jobId).workProtocol).toBe('sv2_jdp'); + const customJob = (client as any).channels.get(1).extendedJobs.get(success.jobId); + expect(customJob.workProtocol).toBe('sv2_jdp'); + expect(customJob.headerContext.activationMinNtime).toBe(parseInt(MockRecording1.TIME, 16)); + expect(typeof customJob.headerContext.activatedAtMonotonicNs).toBe('bigint'); + expect((client as any).isSubmissionHeaderValid( + customJob.headerContext, + customJob.expectedVersion, + customJob.headerContext.minNtime + 1, + )).toBe(true); + + const calculateDifficulty = jest.spyOn(DifficultyUtils, 'calculateDifficulty').mockReturnValue({ + submissionDifficulty: 123, + submissionHash: '00'.repeat(32), + hashBuffer: Buffer.alloc(32), + }); + const meetsCompactTarget = jest.spyOn(DifficultyUtils, 'meetsCompactTarget').mockReturnValue(true); + const meetsTarget = jest.spyOn(DifficultyUtils, 'meetsTarget').mockReturnValue(false); + sentFrames.length = 0; + try { + const channel = (client as any).channels.get(1); + await (client as any).handleSubmitSharesExtended(serializeSubmitSharesExtended({ + channelId: 1, + sequenceNumber: 100, + jobId: success.jobId, + nonce: 1, + ntime: customJob.headerContext.minNtime, + version: customJob.expectedVersion, + extranonce: Buffer.alloc(channel.extranonceSize), + })); + } finally { + calculateDifficulty.mockRestore(); + meetsCompactTarget.mockRestore(); + meetsTarget.mockRestore(); + } + const shareError = deserializeSubmitSharesError(new BufferReader(sentFrames + .find(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_ERROR).payload)); + expect(shareError.errorCode).toBe('difficulty-too-low'); + }); + + it('rejects PPLNS custom jobs when only a legacy template without payout outputs is available', async () => { + const { client, sentFrames } = await createClient('pplns'); + await (client as any).handleSetupConnection(serializeSetupConnection({ + protocol: Sv2Protocol.MINING, + minVersion: 2, + maxVersion: 2, + flags: Sv2MiningSetupFlags.REQUIRES_WORK_SELECTION | Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING, + endpoint_host: 'localhost', + endpoint_port: 3333, + vendor: 'jd-client', + hardwareVersion: '', + firmwareVersion: '', + deviceId: '', + })); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + + sentFrames.length = 0; + await (client as any).handleSetCustomMiningJob(serializeSetCustomMiningJob({ + channelId: 1, + requestId: 101, + token: Buffer.from('aa', 'hex'), + version: MockRecording1.BLOCK_TEMPLATE.version, + prevHash: Buffer.alloc(32, 1), + minNtime: parseInt(MockRecording1.TIME, 16), + nBits: parseInt(MockRecording1.BLOCK_TEMPLATE.bits, 16), + coinbaseTxVersion: 2, + coinbasePrefix: Buffer.from('51', 'hex'), + coinbaseTxInputNSequence: 0xffffffff, + coinbaseTxOutputs: Buffer.from('00', 'hex'), + coinbaseTxLocktime: 0, + merklePath: [], + })); + + const errorFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR); + const error = deserializeSetCustomMiningJobError(new BufferReader(errorFrame.payload)); + expect(error.channelId).toBe(1); + expect(error.requestId).toBe(101); + expect(error.errorCode).toBe('template-not-found'); + expect((client as any).channels.get(1).extendedJobs.size).toBe(0); }); it('rejects SV2 work-selection custom jobs that do not pay the pool coinbase outputs', async () => { @@ -414,7 +681,774 @@ describe('StratumV2Client extended channels', () => { expect(addressSettingsService.resetBestDifficultyAndShares).toHaveBeenCalled(); }); - async function createClient(): Promise<{ + it('serializes queued canonical clean jobs in arrival order', async () => { + const { client, jobTemplate } = await createClient(); + const firstTemplate = { + ...jobTemplate, + blockData: { ...jobTemplate.blockData, id: '100', clearJobs: true }, + }; + const secondTemplate = { + ...jobTemplate, + blockData: { ...jobTemplate.blockData, id: '101', clearJobs: true }, + }; + const operations: string[] = []; + let releaseFirst: () => void; + const firstGate = new Promise(resolve => { releaseFirst = resolve; }); + jest.spyOn(client as any, 'applyCanonicalJob').mockImplementation(async (template: any) => { + operations.push(`start:${template.blockData.id}`); + if (template.blockData.id === '100') { + await firstGate; + } + operations.push(`end:${template.blockData.id}`); + }); + + const first = client.enqueueCanonicalJob(firstTemplate); + const second = client.enqueueCanonicalJob(secondTemplate); + await Promise.resolve(); + await Promise.resolve(); + + expect(operations).toEqual(['start:100']); + releaseFirst(); + await Promise.all([first, second]); + expect(operations).toEqual(['start:100', 'end:100', 'start:101', 'end:101']); + }); + + it('collapses pending canonical work and prioritizes new-tip activation before its full job', async () => { + const { client, jobTemplate } = await createClient(); + const activation = createActivationTemplate(jobTemplate); + const firstTemplate = { + ...jobTemplate, + blockData: { ...jobTemplate.blockData, id: 'active-old-tip' }, + }; + const obsoleteTemplate = { + ...jobTemplate, + blockData: { ...jobTemplate.blockData, id: 'pending-old-tip' }, + }; + const fullTemplate = createCanonicalFollowup(jobTemplate, activation); + const operations: string[] = []; + let releaseFirst: () => void; + const firstGate = new Promise(resolve => { releaseFirst = resolve; }); + jest.spyOn(client as any, 'applyCanonicalJob').mockImplementation(async (template: any) => { + operations.push(`canonical:${template.blockData.id}`); + if (template.blockData.id === 'active-old-tip') { + await firstGate; + } + }); + jest.spyOn(client as any, 'applyWorkActivation').mockImplementation(async (template: any) => { + operations.push(`activation:${template.height}:${template.previousblockhash}`); + }); + + const active = client.enqueueCanonicalJob(firstTemplate); + const obsolete = client.enqueueCanonicalJob(obsoleteTemplate); + const full = client.enqueueCanonicalJob(fullTemplate); + const activate = client.enqueueWorkActivation(activation as any); + + expect((client as any).jobOperationQueue).toHaveLength(2); + releaseFirst(); + await Promise.all([active, obsolete, full, activate]); + + expect(operations).toEqual([ + 'canonical:active-old-tip', + `activation:${activation.height}:${activation.previousblockhash}`, + 'canonical:canonical-followup', + ]); + }); + + it('disconnects when non-coalescible SV2 operations exceed the queue bound', async () => { + const { client } = await createClient(); + const unregisterClient = (client as any).stratumV2Service.unregisterClient as jest.Mock; + (client as any).maxQueuedJobOperations = 2; + let releaseActive: () => void; + const activeGate = new Promise(resolve => { releaseActive = resolve; }); + + const active = (client as any).enqueueJobOperation(async () => activeGate); + const first = (client as any).enqueueJobOperation(async () => undefined); + const second = (client as any).enqueueJobOperation(async () => undefined); + const overflow = (client as any).enqueueJobOperation(async () => undefined); + + expect((client as any).socket.destroyed).toBe(true); + expect(unregisterClient).toHaveBeenCalledWith(client); + expect((client as any).jobOperationQueue).toHaveLength(0); + + releaseActive(); + await Promise.all([active, first, second, overflow]); + }); + + it('filters canonical jobs by payout mode before queueing work', async () => { + const { client, jobTemplate } = await createClient(); + const applySpy = jest.spyOn(client as any, 'applyCanonicalJob'); + const pplnsTemplate = { + ...jobTemplate, + blockData: { ...jobTemplate.blockData, payoutMode: 'pplns' }, + }; + + await client.enqueueCanonicalJob(pplnsTemplate); + + expect(applySpy).not.toHaveBeenCalled(); + }); + + it('does not send canonical work before channel-open success is written', async () => { + const { client, sentFrames, jobTemplate } = await createClient(); + const latestTemplate = { + ...jobTemplate, + blockData: { ...jobTemplate.blockData, id: 'latest' }, + }; + (client as any).stratumV2Service.getLatestCanonicalJob = () => latestTemplate; + let releaseOpen: () => void; + const openGate = new Promise(resolve => { releaseOpen = resolve; }); + (client as any).sendFrame.mockImplementation((msgType: number, payload: Buffer, extensionType = 0) => { + sentFrames.push({ msgType, payload, extensionType }); + return msgType === Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL_SUCCESS + ? openGate + : Promise.resolve(); + }); + + const opening = (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + await Promise.resolve(); + await Promise.resolve(); + + await client.enqueueCanonicalJob(latestTemplate); + expect(sentFrames.map(frame => frame.msgType)).toEqual([ + Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ]); + + releaseOpen(); + await opening; + expect(sentFrames.map(frame => frame.msgType)).toEqual([ + Sv2MsgType.OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + Sv2MsgType.NEW_EXTENDED_MINING_JOB, + Sv2MsgType.SET_NEW_PREV_HASH, + Sv2MsgType.NEW_EXTENDED_MINING_JOB, + ]); + }); + + it('sends the cached canonical job only to a newly opened second channel', async () => { + const { client, sentFrames } = await createClient(); + const openMessage = (requestId: number) => serializeOpenExtendedMiningChannel({ + requestId, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + }); + await (client as any).handleOpenExtendedMiningChannel(openMessage(1)); + sentFrames.length = 0; + + await (client as any).handleOpenExtendedMiningChannel(openMessage(2)); + + const jobs = sentFrames + .filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB) + .map(frame => deserializeNewExtendedMiningJob(new BufferReader(frame.payload))); + expect(jobs).toHaveLength(2); + expect(jobs.every(job => job.channelId === 2)).toBe(true); + }); + + it('can force replacement work after a target change even when the template signature matches', async () => { + const { client, sentFrames, jobTemplate } = await createClient(); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + sentFrames.length = 0; + + await client.enqueueCanonicalJob(jobTemplate); + expect(sentFrames).toHaveLength(0); + + await (client as any).applyCanonicalJob(jobTemplate, true); + expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB)).toBe(true); + expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH)).toBe(false); + }); + + it('activates a staged extended future with only SetNewPrevHash, then sends full same-tip work as active', async () => { + const { client, sentFrames, jobTemplate } = await createClient(); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + const future = deserializeNewExtendedMiningJob(new BufferReader(sentFrames + .filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB) + .at(-1).payload)); + const activation = createActivationTemplate(jobTemplate); + const channel = (client as any).channels.get(1); + let boundBeforeWrite = false; + sentFrames.length = 0; + (client as any).sendFrame.mockImplementation((msgType: number, payload: Buffer, extensionType = 0) => { + if (msgType === Sv2MsgType.SET_NEW_PREV_HASH) { + const record = channel.extendedJobs.get(future.jobId); + boundBeforeWrite = record.headerContext?.tipKey === `${activation.height}:${activation.previousblockhash}`; + } + sentFrames.push({ msgType, payload, extensionType }); + return Promise.resolve(); + }); + + await client.enqueueWorkActivation(activation as any); + + expect(sentFrames.map(frame => frame.msgType)).toEqual([Sv2MsgType.SET_NEW_PREV_HASH]); + const activated = deserializeSetNewPrevHash(new BufferReader(sentFrames[0].payload)); + expect(activated.jobId).toBe(future.jobId); + expect(activated.prevHash).toEqual(Buffer.from(activation.previousblockhash, 'hex').reverse()); + expect(activated.nBits).toBe(parseInt(activation.bits, 16)); + expect(boundBeforeWrite).toBe(true); + const activationContext = channel.extendedJobs.get(future.jobId).headerContext; + + sentFrames.length = 0; + await client.enqueueCanonicalJob(createCanonicalFollowup(jobTemplate, activation)); + const followups = sentFrames + .filter(frame => frame.msgType === Sv2MsgType.NEW_EXTENDED_MINING_JOB) + .map(frame => deserializeNewExtendedMiningJob(new BufferReader(frame.payload))); + expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH)).toBe(false); + expect(followups[0].minNtime).not.toBeNull(); + expect(followups.at(-1).minNtime).toBeNull(); + expect(channel.extendedJobs.get(future.jobId).retiredAt).toBeUndefined(); + const fullContext = channel.extendedJobs.get(followups[0].jobId).headerContext; + expect(fullContext.activationMinNtime).toBe(activationContext.minNtime); + expect(fullContext.activatedAtMonotonicNs).toBe(activationContext.activatedAtMonotonicNs); + expect((client as any).isSubmissionHeaderValid( + fullContext, + fullContext.baseVersion, + fullContext.minNtime, + )).toBe(true); + expect((client as any).isSubmissionHeaderValid( + fullContext, + fullContext.baseVersion, + fullContext.minNtime + 1, + )).toBe(true); + }); + + it('activates a staged standard future and rejects incompatible activation versions', async () => { + const { client, sentFrames, jobTemplate } = await createClient(); + await (client as any).handleOpenStandardMiningChannel(serializeOpenStandardMiningChannel({ + requestId: 1, + user_identity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + })); + const jobs = sentFrames + .filter(frame => frame.msgType === Sv2MsgType.NEW_MINING_JOB) + .map(frame => deserializeNewMiningJob(new BufferReader(frame.payload))); + const future = jobs.at(-1); + const activation = createActivationTemplate(jobTemplate); + sentFrames.length = 0; + + await client.enqueueWorkActivation({ ...activation, height: activation.height + 1 } as any); + expect(sentFrames).toHaveLength(0); + await client.enqueueWorkActivation({ ...activation, version: activation.version ^ 1 } as any); + expect(sentFrames).toHaveLength(0); + expect((client as any).channels.get(1).stagedFutureJobId).toBe(future.jobId); + + await client.enqueueWorkActivation({ ...activation, version: activation.version ^ 0x2000 } as any); + expect(sentFrames.map(frame => frame.msgType)).toEqual([Sv2MsgType.SET_NEW_PREV_HASH]); + const activated = deserializeSetNewPrevHash(new BufferReader(sentFrames[0].payload)); + expect(activated.jobId).toBe(future.jobId); + expect((client as any).channels.get(1).standardJobs.get(future.jobId).headerContext.nBits) + .toBe(parseInt(activation.bits, 16)); + }); + + it('validates fixed/rolling versions, required bits, and the advertised minimum nTime', async () => { + const { client } = await createClient(); + const now = process.hrtime.bigint(); + const context = { + tipKey: 'tip', + prevHash: Buffer.alloc(32), + nBits: 0x1d00ffff, + minNtime: 100, + height: 1, + networkDifficulty: 1, + baseVersion: 0x20000004, + requiredVersionBits: 0x4, + activatedAtMonotonicNs: now, + }; + + expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 100)).toBe(true); + expect((client as any).isSubmissionHeaderValid(context, 0x20002004, 100)).toBe(false); + expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 99)).toBe(false); + expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 101)).toBe(true); + expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 0xffffffff)).toBe(true); + expect((client as any).isSubmissionHeaderValid(context, 0x20000004, 0x1_0000_0000)).toBe(false); + + (client as any).versionRollingEnabled = true; + expect((client as any).isSubmissionHeaderValid(context, 0x20002004, 100)).toBe(true); + expect((client as any).isSubmissionHeaderValid(context, 0x20000000, 100)).toBe(false); + expect((client as any).isSubmissionHeaderValid(context, 0x20000005, 100)).toBe(false); + + const elapsedContext = { + ...context, + activatedAtMonotonicNs: now - 2_000_000_000n, + }; + expect((client as any).isSubmissionHeaderValid(elapsedContext, 0x20000004, 10_000)).toBe(true); + }); + + it('binds canonical standard jobs to the SetNewPrevHash clock and reuses it for same-tip followups', async () => { + const { client, sentFrames, jobTemplate } = await createClient(); + await (client as any).handleOpenStandardMiningChannel(serializeOpenStandardMiningChannel({ + requestId: 1, + user_identity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + })); + const activation = deserializeSetNewPrevHash(new BufferReader(sentFrames + .filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH) + .at(-1).payload)); + const channel = (client as any).channels.get(1); + const initialContext = channel.standardJobs.get(activation.jobId).headerContext; + expect(initialContext.activationMinNtime).toBe(activation.minNtime); + expect(typeof initialContext.activatedAtMonotonicNs).toBe('bigint'); + + sentFrames.length = 0; + await (client as any).handleSubmitSharesStandard(serializeSubmitSharesStandard({ + channelId: 1, + sequenceNumber: 1, + jobId: activation.jobId, + nonce: 0, + ntime: activation.minNtime + 1, + version: channel.standardJobs.get(activation.jobId).expectedVersion, + })); + const error = deserializeSubmitSharesError(new BufferReader(sentFrames + .find(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_ERROR).payload)); + expect(error.errorCode).toBe('difficulty-too-low'); + + sentFrames.length = 0; + const followup = { + ...jobTemplate, + block: Object.assign(new bitcoinjs.Block(), jobTemplate.block), + blockData: { + ...jobTemplate.blockData, + id: 'same-tip-ntime-followup', + clearJobs: false, + }, + }; + await client.enqueueCanonicalJob(followup); + const followupJob = deserializeNewMiningJob(new BufferReader(sentFrames + .filter(frame => frame.msgType === Sv2MsgType.NEW_MINING_JOB) + .at(-1).payload)); + const followupContext = channel.standardJobs.get(followupJob.jobId).headerContext; + expect(followupJob.minNtime).not.toBeNull(); + expect(followupContext.activationMinNtime).toBe(initialContext.activationMinNtime); + expect(followupContext.activatedAtMonotonicNs).toBe(initialContext.activatedAtMonotonicNs); + }); + + it('binds canonical extended jobs to the SetNewPrevHash clock', async () => { + const { client, sentFrames } = await createClient(); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + const activation = deserializeSetNewPrevHash(new BufferReader(sentFrames + .filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH) + .at(-1).payload)); + const context = (client as any).channels.get(1).extendedJobs.get(activation.jobId).headerContext; + expect(context.activationMinNtime).toBe(activation.minNtime); + expect(typeof context.activatedAtMonotonicNs).toBe('bigint'); + expect((client as any).isSubmissionHeaderValid( + context, + context.baseVersion, + activation.minNtime + 1, + )).toBe(true); + }); + + it('updates only the queued future difficulty when SetTarget changes', async () => { + const { client } = await createClient(); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + const channel = (client as any).channels.get(1); + const activeJobId = Array.from(channel.extendedJobs.entries()) + .find(([, job]: any) => job.headerContext != null)[0]; + const futureJobId = channel.stagedFutureJobId; + const activeDifficulty = channel.jobIdToDifficulty.get(activeJobId); + + await (client as any).handleUpdateChannel(serializeUpdateChannel({ + channelId: 1, + nominalHashRate: 1_000_000_000_000_000, + maximumTarget: Buffer.alloc(32, 0xff), + })); + + expect(channel.jobIdToDifficulty.get(activeJobId)).toBe(activeDifficulty); + expect(channel.jobIdToDifficulty.get(futureJobId)).toBe(channel.sessionDifficulty); + expect(channel.jobIdToDifficulty.get(futureJobId)).not.toBe(activeDifficulty); + }); + + it('expires superseded same-tip jobs while preserving active, latest, and staged future jobs', async () => { + const { client, jobTemplate } = await createClient(); + await (client as any).handleOpenStandardMiningChannel(serializeOpenStandardMiningChannel({ + requestId: 1, + user_identity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + })); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 2, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + (client as any).jobRetentionMs = 1000; + const channels = Array.from((client as any).channels.values()) as any[]; + const activeJobIds = channels.map(channel => channel.activePrevHashJobId); + const futureJobIds = channels.map(channel => channel.stagedFutureJobId); + const makeRefresh = (id: string) => ({ + ...jobTemplate, + block: Object.assign(new bitcoinjs.Block(), jobTemplate.block), + blockData: { + ...jobTemplate.blockData, + id, + clearJobs: false, + }, + }); + + jest.advanceTimersByTime(100); + await client.enqueueCanonicalJob(makeRefresh('same-tip-1')); + const supersededIds = channels.map(channel => Math.max(...[ + ...channel.standardJobs.keys(), + ...channel.extendedJobs.keys(), + ])); + + jest.advanceTimersByTime(1100); + await client.enqueueCanonicalJob(makeRefresh('same-tip-2')); + + channels.forEach((channel, index) => { + const jobs = channel.channelType === 'standard' + ? channel.standardJobs + : channel.extendedJobs; + expect(jobs.has(activeJobIds[index])).toBe(true); + expect(jobs.has(futureJobIds[index])).toBe(true); + expect(jobs.has(supersededIds[index])).toBe(false); + expect(jobs.size).toBe(3); + expect(channel.jobIdToDifficulty.size).toBe(jobs.size); + if (channel.channelType === 'standard') { + expect(channel.jobIdToMerkleRoot.size).toBe(jobs.size); + } + }); + }); + + it('disconnects instead of evicting jobs still inside the candidate-rescue window', async () => { + const { client, jobTemplate } = await createClient(); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + (client as any).maxRetainedJobsPerChannel = 3; + const makeRefresh = (id: string) => ({ + ...jobTemplate, + block: Object.assign(new bitcoinjs.Block(), jobTemplate.block), + blockData: { + ...jobTemplate.blockData, + id, + clearJobs: false, + }, + }); + + await client.enqueueCanonicalJob(makeRefresh('within-retention-1')); + expect((client as any).socket.destroyed).toBe(false); + await client.enqueueCanonicalJob(makeRefresh('within-retention-2')); + + expect((client as any).socket.destroyed).toBe(true); + expect((client as any).stratumV2Service.unregisterClient).toHaveBeenCalledWith(client); + }); + + it('accepts and accounts a successfully submitted stale extended candidate while rejecting ordinary stale shares', async () => { + const { client, sentFrames, bitcoinRpcService, shareAccountingService } = await createClient(); + bitcoinRpcService.SUBMIT_BLOCK.mockResolvedValue('SUCCESS!'); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + const activeJobId = deserializeSetNewPrevHash(new BufferReader(sentFrames + .filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH) + .at(-1).payload)).jobId; + const channel = (client as any).channels.get(1); + channel.extendedJobs.get(activeJobId).retiredAt = Date.now(); + channel.activeTipKey = 'new-tip'; + jest.spyOn(DifficultyUtils, 'calculateDifficulty').mockReturnValue({ + submissionDifficulty: 1, + submissionHash: '00'.repeat(32), + hashBuffer: Buffer.alloc(32), + }); + jest.spyOn(DifficultyUtils, 'meetsCompactTarget') + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + sentFrames.length = 0; + + const submit = (nonce: number) => (client as any).handleSubmitSharesExtended(serializeSubmitSharesExtended({ + channelId: 1, + sequenceNumber: nonce, + jobId: activeJobId, + nonce, + ntime: parseInt(MockRecording1.TIME, 16), + version: channel.extendedJobs.get(activeJobId).expectedVersion, + extranonce: Buffer.alloc(channel.extranonceSize, 0x42), + })); + await submit(1); + await submit(2); + + expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledTimes(1); + expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledTimes(1); + expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({ + protocol: 'sv2', + isBlockCandidate: true, + blockSubmissionResult: 'SUCCESS!', + })); + const successes = sentFrames + .filter(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_SUCCESS) + .map(frame => deserializeSubmitSharesSuccess(new BufferReader(frame.payload))); + expect(successes).toHaveLength(1); + expect(successes[0].lastSequenceNumber).toBe(1); + const errors = sentFrames + .filter(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_ERROR) + .map(frame => deserializeSubmitSharesError(new BufferReader(frame.payload))); + expect(errors.map(error => error.errorCode)).toEqual(['stale-share']); + + (client as any).jobRetentionMs = 10; + jest.advanceTimersByTime(11); + (client as any).cleanupRetiredJobs(channel); + expect(channel.extendedJobs.has(activeJobId)).toBe(false); + expect(channel.jobIdToDifficulty.has(activeJobId)).toBe(false); + }); + + it('accepts and accounts a successfully submitted stale standard candidate', async () => { + const { client, sentFrames, bitcoinRpcService, shareAccountingService } = await createClient(); + bitcoinRpcService.SUBMIT_BLOCK.mockResolvedValue('SUCCESS!'); + await (client as any).handleOpenStandardMiningChannel(serializeOpenStandardMiningChannel({ + requestId: 1, + user_identity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + })); + const activeJobId = deserializeSetNewPrevHash(new BufferReader(sentFrames + .filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH) + .at(-1).payload)).jobId; + const channel = (client as any).channels.get(1); + const staleJob = channel.standardJobs.get(activeJobId); + staleJob.retiredAt = Date.now(); + channel.activeTipKey = 'new-tip'; + jest.spyOn(DifficultyUtils, 'calculateDifficulty').mockReturnValue({ + submissionDifficulty: 1, + submissionHash: '00'.repeat(32), + hashBuffer: Buffer.alloc(32), + }); + jest.spyOn(DifficultyUtils, 'meetsCompactTarget').mockReturnValue(true); + sentFrames.length = 0; + + await (client as any).handleSubmitSharesStandard(serializeSubmitSharesStandard({ + channelId: 1, + sequenceNumber: 7, + jobId: activeJobId, + nonce: 1, + ntime: parseInt(MockRecording1.TIME, 16), + version: staleJob.expectedVersion, + })); + + expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledTimes(1); + expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({ + protocol: 'sv2', + isBlockCandidate: true, + blockSubmissionResult: 'SUCCESS!', + })); + const successFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_SUCCESS); + expect(deserializeSubmitSharesSuccess(new BufferReader(successFrame.payload)).lastSequenceNumber).toBe(7); + expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_ERROR)).toBe(false); + }); + + it('keeps a rejected stale block candidate on the stale-share error path', async () => { + const { client, sentFrames, bitcoinRpcService, shareAccountingService } = await createClient(); + bitcoinRpcService.SUBMIT_BLOCK.mockResolvedValue('stale-prevblk'); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + const activeJobId = deserializeSetNewPrevHash(new BufferReader(sentFrames + .filter(frame => frame.msgType === Sv2MsgType.SET_NEW_PREV_HASH) + .at(-1).payload)).jobId; + const channel = (client as any).channels.get(1); + const staleJob = channel.extendedJobs.get(activeJobId); + staleJob.retiredAt = Date.now(); + channel.activeTipKey = 'new-tip'; + jest.spyOn(DifficultyUtils, 'calculateDifficulty').mockReturnValue({ + submissionDifficulty: 1, + submissionHash: '00'.repeat(32), + hashBuffer: Buffer.alloc(32), + }); + jest.spyOn(DifficultyUtils, 'meetsCompactTarget').mockReturnValue(true); + sentFrames.length = 0; + + await (client as any).handleSubmitSharesExtended(serializeSubmitSharesExtended({ + channelId: 1, + sequenceNumber: 9, + jobId: activeJobId, + nonce: 1, + ntime: parseInt(MockRecording1.TIME, 16), + version: staleJob.expectedVersion, + extranonce: Buffer.alloc(channel.extranonceSize, 0x42), + })); + + expect(bitcoinRpcService.SUBMIT_BLOCK).toHaveBeenCalledTimes(1); + expect(shareAccountingService.recordAcceptedShare).not.toHaveBeenCalled(); + const errorFrame = sentFrames.find(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_ERROR); + expect(deserializeSubmitSharesError(new BufferReader(errorFrame.payload)).errorCode) + .toBe('stale-share'); + expect(sentFrames.some(frame => frame.msgType === Sv2MsgType.SUBMIT_SHARES_SUCCESS)).toBe(false); + }); + + it('orders channel target mutations and SetTarget after already-queued canonical work', async () => { + const { client, jobTemplate } = await createClient(); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 1, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + const channel = (client as any).channels.get(1); + const originalDifficulty = channel.sessionDifficulty; + const operations: string[] = []; + let releaseJob: () => void; + const jobGate = new Promise(resolve => { releaseJob = resolve; }); + jest.spyOn(client as any, 'sendNewExtendedMiningJob').mockImplementation(async () => { + operations.push(`job:${channel.sessionDifficulty}`); + await jobGate; + }); + jest.spyOn(client as any, 'sendSetTarget').mockImplementation(async () => { + operations.push(`target:${channel.sessionDifficulty}`); + }); + const nextTemplate = { + ...jobTemplate, + blockData: { ...jobTemplate.blockData, id: 'next' }, + }; + + const canonicalJob = client.enqueueCanonicalJob(nextTemplate); + await Promise.resolve(); + await Promise.resolve(); + const targetUpdate = (client as any).handleUpdateChannel(serializeUpdateChannel({ + channelId: 1, + nominalHashRate: 1_000_000_000_000_000, + maximumTarget: Buffer.alloc(32, 0xff), + })); + + expect(channel.sessionDifficulty).toBe(originalDifficulty); + expect(operations).toEqual([`job:${originalDifficulty}`]); + + releaseJob(); + await Promise.all([canonicalJob, targetUpdate]); + expect(channel.sessionDifficulty).not.toBe(originalDifficulty); + expect(operations).toEqual([ + `job:${originalDifficulty}`, + `target:${channel.sessionDifficulty}`, + ]); + }); + + it('closes and unregisters a client when its queued job fails', async () => { + const { client, jobTemplate } = await createClient(); + const unregisterClient = (client as any).stratumV2Service.unregisterClient as jest.Mock; + jest.spyOn(client as any, 'applyCanonicalJob').mockRejectedValue(new Error('write failed')); + + await client.enqueueCanonicalJob(jobTemplate); + + expect((client as any).socket.destroyed).toBe(true); + expect(unregisterClient).toHaveBeenCalledWith(client); + }); + + it('bounds outstanding SV2 socket bytes across unresolved write callbacks', async () => { + const { client } = await createClient(); + const socket = (client as any).socket; + (client as any).maxSocketBufferBytes = 8; + let finishFirstWrite: (error?: Error) => void; + socket.write.mockImplementation((_data: Buffer, callback: (error?: Error) => void) => { + finishFirstWrite = callback; + return false; + }); + + const firstWrite = (client as any).writeRaw(Buffer.alloc(5)); + await expect((client as any).writeRaw(Buffer.alloc(4))) + .rejects.toThrow('SV2 socket buffer would reach 9 bytes'); + + expect(socket.destroyed).toBe(true); + finishFirstWrite(); + await firstWrite; + }); + + it('disconnects when an SV2 socket write callback never completes', async () => { + const { client } = await createClient(); + const socket = (client as any).socket; + (client as any).socketWriteTimeoutMs = 25; + socket.write.mockImplementation(() => false); + + const write = (client as any).writeRaw(Buffer.alloc(1)); + jest.advanceTimersByTime(26); + + await expect(write).rejects.toThrow('SV2 socket write callback exceeded 25ms'); + expect(socket.destroyed).toBe(true); + expect((client as any).stratumV2Service.unregisterClient).toHaveBeenCalledWith(client); + }); + + it('releases shared extranonce allocations for standard and extended channels', async () => { + const { client } = await createClient(); + await (client as any).handleOpenStandardMiningChannel(serializeOpenStandardMiningChannel({ + requestId: 1, + user_identity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + })); + await (client as any).handleOpenExtendedMiningChannel(serializeOpenExtendedMiningChannel({ + requestId: 2, + userIdentity: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4.worker', + nominalHashRate: 0, + maxTarget: Buffer.alloc(32, 0xff), + minExtranonceSize: 8, + })); + const release = (client as any).stratumV2Service.releaseExtranoncePrefix as jest.Mock; + + (client as any).handleCloseChannel(serializeCloseChannel({ + channelId: 1, + reasonCode: 'test-close', + })); + expect(release).toHaveBeenCalledWith(1); + + await client.destroy(); + expect(release).toHaveBeenCalledWith(2); + expect(release).toHaveBeenCalledTimes(2); + }); + + it('unregisters from the centralized broadcaster exactly once on destroy', async () => { + const { client } = await createClient(); + const unregisterClient = (client as any).stratumV2Service.unregisterClient as jest.Mock; + + await client.destroy(); + await client.destroy(); + + expect(unregisterClient).toHaveBeenCalledTimes(1); + expect(unregisterClient).toHaveBeenCalledWith(client); + }); + + async function createClient(payoutMode: 'solo' | 'pplns' = 'solo'): Promise<{ client: StratumV2Client; sentFrames: any[]; bitcoinRpcService: { SUBMIT_BLOCK: jest.Mock }; @@ -499,11 +1533,14 @@ describe('StratumV2Client extended channels', () => { }, }), getNextChannelId: () => nextChannelId++, - generateExtranoncePrefix: () => Buffer.from('00000002', 'hex'), + generateExtranoncePrefix: (_channelId: number) => Buffer.from('00000002', 'hex'), allocateExtendedExtranoncePrefix: jest.fn(() => Buffer.from('00000001', 'hex')), + releaseExtranoncePrefix: jest.fn(), releaseExtendedExtranoncePrefix: jest.fn(), getExtendedMinerExtranonceSize: () => 10, getExtendedTotalExtranonceSize: () => 14, + getLatestCanonicalJob: () => jobTemplate, + unregisterClient: jest.fn(), } as any, stratumV1JobsService, bitcoinRpcService as any, @@ -533,6 +1570,8 @@ describe('StratumV2Client extended channels', () => { } as any, shareAccountingService as any, redisMessagingService as any, + undefined, + payoutMode, ); (client as any).sendFrame = jest.fn((msgType: number, payload: Buffer, extensionType = 0) => { sentFrames.push({ msgType, payload, extensionType }); @@ -569,3 +1608,44 @@ function serializeCoinbaseOutputs(outputs: { address: string; amountSats: number }), ]); } + +function createActivationTemplate(jobTemplate: any) { + return { + ...MockRecording1.BLOCK_TEMPLATE, + height: jobTemplate.blockData.height + 1, + previousblockhash: '11'.repeat(32), + version: jobTemplate.block.version, + vbrequired: 0, + bits: MockRecording1.BLOCK_TEMPLATE.bits, + mintime: parseInt(MockRecording1.TIME, 16) + 1, + curtime: parseInt(MockRecording1.TIME, 16) + 1, + transactions: [], + jobType: 'empty' as const, + payoutMode: 'solo' as const, + }; +} + +function createCanonicalFollowup(jobTemplate: any, activation: any) { + const block = Object.assign(new bitcoinjs.Block(), jobTemplate.block, { + prevHash: Buffer.from(activation.previousblockhash, 'hex').reverse(), + bits: parseInt(activation.bits, 16), + timestamp: activation.mintime + 1, + version: activation.version, + }); + block.transactions = jobTemplate.block.transactions.map((transaction: bitcoinjs.Transaction) => + Object.assign(new bitcoinjs.Transaction(), transaction)); + return { + ...jobTemplate, + block, + blockData: { + ...jobTemplate.blockData, + id: 'canonical-followup', + height: activation.height, + tipKey: `${activation.height}:${activation.previousblockhash}`, + clearJobs: true, + isNewBlock: true, + jobType: 'full' as const, + payoutMode: 'solo' as const, + }, + }; +} diff --git a/src/models/StratumV2Client.ts b/src/models/StratumV2Client.ts index f310991..d3f1689 100644 --- a/src/models/StratumV2Client.ts +++ b/src/models/StratumV2Client.ts @@ -3,7 +3,6 @@ import { getAddressInfo } from 'bitcoin-address-validation'; import * as bitcoinjs from 'bitcoinjs-lib'; import * as crypto from 'crypto'; import { Socket } from 'net'; -import { firstValueFrom, Subscription } from 'rxjs'; import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; import { BlocksService } from '../ORM/blocks/blocks.service'; @@ -17,6 +16,11 @@ import { NotificationService } from '../services/notification.service'; import { RedisMessagingService } from '../services/redis-messaging.service'; import { StratumV2Service } from '../services/stratum-v2.service'; import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service'; +import { + BitcoinNetworkName, + calculateBlockSubsidySats, + EMPTY_WITNESS_COMMITMENT_HASH, +} from '../services/subsidy-only-template.factory'; import { Sv2JobDeclarationRegistryService } from '../services/sv2-job-declaration-registry.service'; import { patchCoinbasePrefixVarint } from '../utils/coinbase-prefix.utils'; import { DifficultyUtils } from '../utils/difficulty.utils'; @@ -24,6 +28,7 @@ import { hash256 } from '../utils/hash.utils'; import { AddressObject, MiningJob } from './MiningJob'; import { StratumV1ClientStatistics } from './StratumV1ClientStatistics'; import { PayoutMode } from '../types/payout-mode'; +import { IBlockTemplate } from './bitcoin-rpc/IBlockTemplate'; import { BufferReader } from './sv2/sv2-binary-codec'; import { SV2_CHANNEL_MSG_FLAG, @@ -72,8 +77,50 @@ const DEFAULT_TARGET_SHARES_PER_MINUTE = 2; const DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS = 60 * 1000; const DEFAULT_CLIENT_HASHRATE_PERSIST_INTERVAL_MS = 60 * 1000; const FIXED_STANDARD_EXTRANONCE2 = '0000000000000000'; -const RETIRED_EXTENDED_JOB_RETENTION_MS = 5 * 60 * 1000; +const DEFAULT_SV2_JOB_RETENTION_MS = 5 * 60 * 1000; +const DEFAULT_SV2_MAX_RETAINED_JOBS_PER_CHANNEL = 16; +const DEFAULT_SV2_MAX_QUEUED_JOB_OPERATIONS = 4; +const DEFAULT_SV2_MAX_SOCKET_BUFFER_BYTES = 256 * 1024; +const DEFAULT_SV2_SOCKET_WRITE_TIMEOUT_MS = 2 * 1000; const SV2_AUTH_FAILURE_LOG_INTERVAL_MS = 60 * 1000; +const BIP320_CONSENSUS_VERSION_MASK = 0xe0001fff; + +type Sv2JobOperationKind = 'canonical' | 'activation' | 'ordered'; + +interface QueuedSv2JobOperation { + kind: Sv2JobOperationKind; + operation: () => Promise; + resolve: () => void; + tipKey?: string; +} + +interface Sv2HeaderContext { + tipKey: string; + prevHash: Buffer; + nBits: number; + minNtime: number; + height: number; + networkDifficulty: number; + baseVersion: number; + requiredVersionBits: number; + activationMinNtime?: number; + activatedAtMonotonicNs?: bigint; +} + +interface Sv2WorkActivation extends Sv2HeaderContext { + version: number; +} + +interface StandardJobData { + merkleRoot: Buffer; + jobTemplate: IJobTemplate; + miningJob: MiningJob; + expectedHeight: number; + expectedVersion: number; + headerContext?: Sv2HeaderContext; + retiredAt?: number; + creation: number; +} interface ExtendedJobData { coinbasePrefix: Buffer; @@ -84,6 +131,9 @@ interface ExtendedJobData { minNtime: number; jobTemplate: IJobTemplate; miningJob: MiningJob; + expectedHeight: number; + expectedVersion: number; + headerContext?: Sv2HeaderContext; retiredAt?: number; creation: number; workProtocol?: 'pool' | 'sv2_jdp'; @@ -98,12 +148,21 @@ interface ChannelState { declaredMaxTarget: Buffer; jobIdToDifficulty: Map; jobIdToMerkleRoot: Map; + standardJobs: Map; extendedJobs: Map; latestExtendedPrevHash: Buffer; latestExtendedNBits: number; latestExtendedMinNtime: number; miningSubmissionHashes: Set; acceptedShareCount: number; + readyForCanonicalJobs: boolean; + lastCanonicalJobSignature?: string; + stagedFutureJobId?: number; + activeTipKey?: string; + activatedFutureTipKey?: string; + activePrevHashMinNtime?: number; + activePrevHashAtMonotonicNs?: bigint; + activePrevHashJobId?: number; } export class StratumV2Client { @@ -117,12 +176,22 @@ export class StratumV2Client { private readonly network: bitcoinjs.networks.Network; private readonly targetSharesPerMinute: number; private readonly difficultyCheckIntervalMs: number; + private readonly jobRetentionMs: number; + private readonly maxRetainedJobsPerChannel: number; + private readonly maxQueuedJobOperations: number; + private readonly maxSocketBufferBytes: number; + private readonly socketWriteTimeoutMs: number; private handshakeBuffer = Buffer.alloc(0); private handshakeComplete = false; private processingHandshake = false; private destroyed = false; - private stratumSubscription: Subscription = null; + private jobOperationQueue: QueuedSv2JobOperation[] = []; + private drainingJobOperations = false; + private activeJobOperation: QueuedSv2JobOperation = null; + private pendingSocketWriteBytes = 0; + private futureTemplateCounter = 0; + private nextMiningJobId = 1; private difficultyTimer: NodeJS.Timeout = null; private channels = new Map(); private primaryChannelId: number = null; @@ -134,6 +203,7 @@ export class StratumV2Client { private creatingEntity: Promise = null; private readonly firstChunkSummary: string; private workSelectionEnabled = false; + private versionRollingEnabled = false; private lastHashRatePersistedAt = 0; constructor( @@ -159,6 +229,26 @@ export class StratumV2Client { this.sessionDifficulty = this.getInitialDifficulty(); this.targetSharesPerMinute = this.getTargetSharesPerMinute(); this.difficultyCheckIntervalMs = this.getDifficultyCheckIntervalMs(); + this.jobRetentionMs = this.getPositiveIntegerConfig( + 'SV2_JOB_RETENTION_MS', + this.getPositiveIntegerConfig('STRATUM_JOB_RETENTION_MS', DEFAULT_SV2_JOB_RETENTION_MS), + ); + this.maxRetainedJobsPerChannel = this.getPositiveIntegerConfig( + 'SV2_MAX_RETAINED_JOBS_PER_CHANNEL', + DEFAULT_SV2_MAX_RETAINED_JOBS_PER_CHANNEL, + ); + this.maxQueuedJobOperations = this.getPositiveIntegerConfig( + 'SV2_MAX_QUEUED_JOB_OPERATIONS', + DEFAULT_SV2_MAX_QUEUED_JOB_OPERATIONS, + ); + this.maxSocketBufferBytes = this.getPositiveIntegerConfig( + 'SV2_MAX_SOCKET_BUFFER_BYTES', + DEFAULT_SV2_MAX_SOCKET_BUFFER_BYTES, + ); + this.socketWriteTimeoutMs = this.getPositiveIntegerConfig( + 'SV2_SOCKET_WRITE_TIMEOUT_MS', + DEFAULT_SV2_SOCKET_WRITE_TIMEOUT_MS, + ); this.statistics = new StratumV1ClientStatistics(this.getMinimumDifficulty()); this.statistics.targetSubmitShareEveryNSeconds = 60 / this.targetSharesPerMinute; this.network = this.getNetwork(); @@ -175,19 +265,15 @@ export class StratumV2Client { return; } this.destroyed = true; + this.settleQueuedJobOperations(); + this.stratumV2Service.unregisterClient?.(this); - if (this.stratumSubscription != null) { - this.stratumSubscription.unsubscribe(); - this.stratumSubscription = null; - } if (this.difficultyTimer != null) { clearInterval(this.difficultyTimer); this.difficultyTimer = null; } for (const channel of this.channels.values()) { - if (channel.channelType === 'extended') { - this.stratumV2Service.releaseExtendedExtranoncePrefix(channel.channelId); - } + this.stratumV2Service.releaseExtranoncePrefix(channel.channelId); } this.channels.clear(); if (this.clientEntity?.id != null) { @@ -333,6 +419,7 @@ export class StratumV2Client { } const versionRolling = (message.flags & Sv2MiningSetupFlags.REQUIRES_VERSION_ROLLING) !== 0; + this.versionRollingEnabled = versionRolling; this.workSelectionEnabled = (message.flags & Sv2MiningSetupFlags.REQUIRES_WORK_SELECTION) !== 0; const successFlags = versionRolling ? 0 : Sv2MiningSetupSuccessFlags.REQUIRES_FIXED_VERSION; await this.sendFrame( @@ -371,7 +458,7 @@ export class StratumV2Client { } const channelId = this.stratumV2Service.getNextChannelId(); - const extranoncePrefix = this.stratumV2Service.generateExtranoncePrefix(); + const extranoncePrefix = this.stratumV2Service.generateExtranoncePrefix(channelId); let channelDifficulty = this.sessionDifficulty; if (Number.isFinite(message.nominalHashRate) && message.nominalHashRate > 0) { const calculatedDifficulty = DifficultyUtils.hashRateToDifficulty( @@ -394,12 +481,14 @@ export class StratumV2Client { declaredMaxTarget: Buffer.from(message.maxTarget), jobIdToDifficulty: new Map(), jobIdToMerkleRoot: new Map(), + standardJobs: new Map(), extendedJobs: new Map(), latestExtendedPrevHash: Buffer.alloc(32), latestExtendedNBits: 0, latestExtendedMinNtime: 0, miningSubmissionHashes: new Set(), acceptedShareCount: 0, + readyForCanonicalJobs: false, }; this.channels.set(channelId, channel); if (this.primaryChannelId == null) { @@ -418,14 +507,26 @@ export class StratumV2Client { groupChannelId: 0, }), ); + channel.readyForCanonicalJobs = true; if (!this.workSelectionEnabled) { - const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$); - await this.sendNewMiningJob(channel, jobTemplate, true); + const jobTemplate = this.stratumV2Service.getLatestCanonicalJob(this.payoutMode); + if (jobTemplate != null) { + await this.enqueueCanonicalJob(jobTemplate); + if (this.destroyed) { + return; + } + } + const activationTemplate = this.stratumV2Service.getLatestWorkActivationTemplate?.(); + if (activationTemplate != null) { + await this.enqueueWorkActivation(activationTemplate); + if (this.destroyed) { + return; + } + } } if (isFirstChannel && !this.workSelectionEnabled) { - this.subscribeToJobs(); this.startDifficultyTimer(); } } @@ -493,12 +594,14 @@ export class StratumV2Client { declaredMaxTarget: Buffer.from(message.maxTarget), jobIdToDifficulty: new Map(), jobIdToMerkleRoot: new Map(), + standardJobs: new Map(), extendedJobs: new Map(), latestExtendedPrevHash: Buffer.alloc(32), latestExtendedNBits: 0, latestExtendedMinNtime: 0, miningSubmissionHashes: new Set(), acceptedShareCount: 0, + readyForCanonicalJobs: false, }; this.channels.set(channelId, channel); if (this.primaryChannelId == null) { @@ -518,14 +621,26 @@ export class StratumV2Client { groupChannelId: 0, }), ); + channel.readyForCanonicalJobs = true; if (!this.workSelectionEnabled) { - const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$); - await this.sendNewExtendedMiningJob(channel, jobTemplate, true); + const jobTemplate = this.stratumV2Service.getLatestCanonicalJob(this.payoutMode); + if (jobTemplate != null) { + await this.enqueueCanonicalJob(jobTemplate); + if (this.destroyed) { + return; + } + } + const activationTemplate = this.stratumV2Service.getLatestWorkActivationTemplate?.(); + if (activationTemplate != null) { + await this.enqueueWorkActivation(activationTemplate); + if (this.destroyed) { + return; + } + } } if (isFirstChannel && !this.workSelectionEnabled) { - this.subscribeToJobs(); this.startDifficultyTimer(); } } @@ -553,43 +668,114 @@ export class StratumV2Client { channel.miningSubmissionHashes.clear(); } - const jobIdHex = submission.jobId.toString(16); - const job = this.stratumV1JobsService.getJobById(jobIdHex); - const sentMerkleRoot = channel.jobIdToMerkleRoot.get(submission.jobId); - if (job == null || sentMerkleRoot == null) { + const standardJob = channel.standardJobs.get(submission.jobId); + if (standardJob == null) { await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-job-id'); return; } - - const jobTemplate = this.stratumV1JobsService.getJobTemplateById(job.jobTemplateId); - if (jobTemplate == null) { - await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-job-id'); + if (standardJob.headerContext == null) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share'); + return; + } + const headerContext = standardJob.headerContext; + if (!this.isSubmissionHeaderValid(headerContext, submission.version, submission.ntime)) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share'); return; } - const header = this.buildHeader(jobTemplate, sentMerkleRoot, submission.version, submission.ntime, submission.nonce); + const header = this.buildHeader( + headerContext, + standardJob.merkleRoot, + submission.version, + submission.ntime, + submission.nonce, + ); const { submissionDifficulty, hashBuffer } = DifficultyUtils.calculateDifficulty(header); const jobDifficulty = channel.jobIdToDifficulty.get(submission.jobId) ?? channel.sessionDifficulty; const target = DifficultyUtils.difficultyToTarget(jobDifficulty); + const isBlockCandidate = DifficultyUtils.meetsCompactTarget(hashBuffer, headerContext.nBits); + const meetsJobTarget = DifficultyUtils.meetsTarget(hashBuffer, target); + const isStale = standardJob.retiredAt != null + || headerContext.tipKey !== channel.activeTipKey; + if (isStale) { + if (!isBlockCandidate) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share'); + return; + } - if (!DifficultyUtils.meetsTarget(hashBuffer, target)) { - await this.sendShareError(submission.channelId, submission.sequenceNumber, 'difficulty-too-low'); + const candidate = this.reconstructStandardBlock({ + jobTemplate: standardJob.jobTemplate, + miningJob: standardJob.miningJob, + headerContext, + }, submission, channel.extranoncePrefix); + const blockSubmissionResult = await this.submitBlockCandidate( + standardJob.jobTemplate, + candidate, + ); + if (!this.isSuccessfulBlockSubmission(blockSubmissionResult)) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share'); + return; + } + + const creditedDifficulty = this.getCreditedDifficulty( + jobDifficulty, + submissionDifficulty, + meetsJobTarget, + ); + await this.sendShareSuccess( + submission.channelId, + submission.sequenceNumber, + creditedDifficulty, + ); + channel.acceptedShareCount++; + await this.recordAcceptedShare( + submissionDifficulty, + creditedDifficulty, + standardJob.jobTemplate, + null, + { + jobId: standardJob.miningJob.jobId, + nonce: submission.nonce, + ntime: submission.ntime, + version: submission.version, + extraNonce2: FIXED_STANDARD_EXTRANONCE2, + }, + { + blockCandidateResult: blockSubmissionResult, + isBlockCandidate: true, + }, + ); return; } - await this.sendFrame( - Sv2MsgType.SUBMIT_SHARES_SUCCESS, - serializeSubmitSharesSuccess({ - channelId: submission.channelId, - lastSequenceNumber: submission.sequenceNumber, - newSubmitsAcceptedCount: 1, - newSharesSum: BigInt(Math.round(jobDifficulty)), - }), - SV2_CHANNEL_MSG_FLAG, + if (!isBlockCandidate && !meetsJobTarget) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'difficulty-too-low'); + return; + } + const creditedDifficulty = this.getCreditedDifficulty( + jobDifficulty, + submissionDifficulty, + meetsJobTarget, + ); + + await this.sendShareSuccess( + submission.channelId, + submission.sequenceNumber, + creditedDifficulty, ); channel.acceptedShareCount++; - await this.handleAcceptedShare(submission, channel, job, jobTemplate, submissionDifficulty, jobDifficulty, hashBuffer); + await this.handleAcceptedShare( + submission, + channel, + standardJob.miningJob, + standardJob.jobTemplate, + submissionDifficulty, + creditedDifficulty, + hashBuffer, + headerContext, + isBlockCandidate, + ); } private async handleSubmitSharesExtended(payload: Buffer): Promise { @@ -622,12 +808,16 @@ export class StratumV2Client { await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-job-id'); return; } - if (extendedJob.retiredAt != null) { + if (submission.extranonce.length !== channel.extranonceSize) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-extranonce-size'); + return; + } + if (extendedJob.headerContext == null) { await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share'); return; } - if (submission.extranonce.length !== channel.extranonceSize) { - await this.sendShareError(submission.channelId, submission.sequenceNumber, 'invalid-extranonce-size'); + if (!this.isSubmissionHeaderValid(extendedJob.headerContext, submission.version, submission.ntime)) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share'); return; } @@ -646,51 +836,110 @@ export class StratumV2Client { } const header = this.buildHeader( - { - ...extendedJob.jobTemplate, - block: Object.assign(new bitcoinjs.Block(), extendedJob.jobTemplate.block, { - prevHash: extendedJob.prevHash, - bits: extendedJob.nBits, - }), - }, + extendedJob.headerContext, merkleRoot, submission.version, submission.ntime, submission.nonce, ); const { submissionDifficulty, hashBuffer } = DifficultyUtils.calculateDifficulty(header); - const target = DifficultyUtils.difficultyToTarget(jobDifficulty); + const isStale = extendedJob.retiredAt != null + || (extendedJob.workProtocol !== 'sv2_jdp' + && extendedJob.headerContext.tipKey !== channel.activeTipKey); + if (isStale) { + const isBlockCandidate = extendedJob.workProtocol !== 'sv2_jdp' + && DifficultyUtils.meetsCompactTarget(hashBuffer, extendedJob.headerContext.nBits); + if (!isBlockCandidate) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share'); + return; + } - if (!DifficultyUtils.meetsTarget(hashBuffer, target)) { + const target = DifficultyUtils.difficultyToTarget(jobDifficulty); + const meetsJobTarget = DifficultyUtils.meetsTarget(hashBuffer, target); + const candidate = this.reconstructExtendedBlock( + extendedJob, + submission, + merkleRoot, + channel.extranoncePrefix, + ); + const blockSubmissionResult = await this.submitBlockCandidate( + extendedJob.jobTemplate, + candidate, + ); + if (!this.isSuccessfulBlockSubmission(blockSubmissionResult)) { + await this.sendShareError(submission.channelId, submission.sequenceNumber, 'stale-share'); + return; + } + + const creditedDifficulty = this.getCreditedDifficulty( + jobDifficulty, + submissionDifficulty, + meetsJobTarget, + ); + await this.sendShareSuccess( + submission.channelId, + submission.sequenceNumber, + creditedDifficulty, + ); + channel.acceptedShareCount++; + await this.recordAcceptedShare( + submissionDifficulty, + creditedDifficulty, + extendedJob.jobTemplate, + null, + { + jobId: submission.jobId.toString(16), + nonce: submission.nonce, + ntime: submission.ntime, + version: submission.version, + extraNonce2: submission.extranonce.toString('hex'), + }, + { + workProtocol: 'pool', + blockCandidateResult: blockSubmissionResult, + isBlockCandidate: true, + }, + ); + return; + } + const target = DifficultyUtils.difficultyToTarget(jobDifficulty); + const isBlockCandidate = DifficultyUtils.meetsCompactTarget( + hashBuffer, + extendedJob.headerContext.nBits, + ); + // Pool jobs bind nBits to the canonical template. A work-selection + // client supplies its own nBits and submits its solution through JDP, + // so that untrusted value must not bypass this channel's share target. + const canBypassJobTarget = extendedJob.workProtocol !== 'sv2_jdp' && isBlockCandidate; + const meetsJobTarget = DifficultyUtils.meetsTarget(hashBuffer, target); + + if (!canBypassJobTarget && !meetsJobTarget) { await this.sendShareError(submission.channelId, submission.sequenceNumber, 'difficulty-too-low'); return; } + const creditedDifficulty = this.getCreditedDifficulty( + jobDifficulty, + submissionDifficulty, + meetsJobTarget, + ); - await this.sendFrame( - Sv2MsgType.SUBMIT_SHARES_SUCCESS, - serializeSubmitSharesSuccess({ - channelId: submission.channelId, - lastSequenceNumber: submission.sequenceNumber, - newSubmitsAcceptedCount: 1, - newSharesSum: BigInt(Math.round(jobDifficulty)), - }), - SV2_CHANNEL_MSG_FLAG, + await this.sendShareSuccess( + submission.channelId, + submission.sequenceNumber, + creditedDifficulty, ); channel.acceptedShareCount++; let updatedJobBlock: bitcoinjs.Block = null; let blockCandidateResult: string | null = null; - if (DifficultyUtils.meetsTarget( - hashBuffer, - DifficultyUtils.difficultyToTarget(extendedJob.jobTemplate.blockData.networkDifficulty), - )) { + if (isBlockCandidate) { if (extendedJob.workProtocol === 'sv2_jdp') { blockCandidateResult = 'sv2-jdp-client-submit-expected'; } else { updatedJobBlock = this.reconstructExtendedBlock(extendedJob, submission, merkleRoot, channel.extranoncePrefix); } } - await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, extendedJob.jobTemplate, updatedJobBlock, { + await this.recordAcceptedShare(submissionDifficulty, creditedDifficulty, extendedJob.jobTemplate, updatedJobBlock, { jobId: submission.jobId.toString(16), nonce: submission.nonce, ntime: submission.ntime, @@ -762,9 +1011,31 @@ export class StratumV2Client { return; } - const latestTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$); - const jobIdHex = this.stratumV1JobsService.getNextId(); - const jobId = parseInt(jobIdHex, 16); + const latestTemplate = this.stratumV2Service.getLatestCanonicalJob(this.payoutMode); + if (latestTemplate == null) { + await this.sendFrame( + Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR, + serializeSetCustomMiningJobError({ + channelId: msg.channelId, + requestId: msg.requestId, + errorCode: 'template-not-found', + }), + ); + return; + } + const payoutInformation = this.getPayoutInformation(latestTemplate, this.address); + if (payoutInformation == null) { + await this.sendFrame( + Sv2MsgType.SET_CUSTOM_MINING_JOB_ERROR, + serializeSetCustomMiningJobError({ + channelId: msg.channelId, + requestId: msg.requestId, + errorCode: 'template-not-found', + }), + ); + return; + } + const { jobId, jobIdHex } = this.allocateMiningJobId(); const split = this.customWorkService.buildSv2CoinbaseSplit( msg, channel.extranoncePrefix.length + channel.extranonceSize, @@ -776,7 +1047,7 @@ export class StratumV2Client { split.coinbaseSuffix, ]), latestTemplate, - this.getPayoutInformation(latestTemplate, this.address), + payoutInformation, ); if (!payoutValidation.valid) { await this.sendFrame( @@ -792,11 +1063,10 @@ export class StratumV2Client { const placeholderJob = new MiningJob( this.network, jobIdHex, - this.getPayoutInformation(latestTemplate, this.address), + payoutInformation, latestTemplate, ); - this.stratumV1JobsService.addJob(placeholderJob); - + const activatedAtMonotonicNs = process.hrtime.bigint(); channel.jobIdToDifficulty.set(jobId, channel.sessionDifficulty); channel.extendedJobs.set(jobId, { coinbasePrefix: split.coinbasePrefix, @@ -815,6 +1085,22 @@ export class StratumV2Client { }), }, miningJob: placeholderJob, + expectedHeight: latestTemplate.blockData.height, + expectedVersion: msg.version, + headerContext: { + tipKey: `sv2-jdp:${msg.prevHash.toString('hex')}`, + prevHash: Buffer.from(msg.prevHash), + nBits: msg.nBits, + minNtime: msg.minNtime, + height: latestTemplate.blockData.height, + networkDifficulty: DifficultyUtils.targetToDifficulty( + DifficultyUtils.compactToTarget(msg.nBits) ?? Buffer.alloc(32, 0xff), + ), + baseVersion: msg.version, + requiredVersionBits: 0, + activationMinNtime: msg.minNtime, + activatedAtMonotonicNs, + }, creation: Date.now(), workProtocol: 'sv2_jdp', }); @@ -829,6 +1115,25 @@ export class StratumV2Client { ); } + private reconstructStandardBlock( + jobData: Pick & { headerContext: Sv2HeaderContext }, + submission: { nonce: number; ntime: number; version: number }, + extranoncePrefix: Buffer, + ): bitcoinjs.Block { + const versionMask = submission.version ^ jobData.jobTemplate.block.version; + const block = jobData.miningJob.copyAndUpdateBlock( + jobData.jobTemplate, + versionMask, + submission.nonce, + extranoncePrefix.toString('hex'), + FIXED_STANDARD_EXTRANONCE2, + submission.ntime, + ); + block.prevHash = Buffer.from(jobData.headerContext.prevHash); + block.bits = jobData.headerContext.nBits; + return block; + } + private async handleAcceptedShare( submission: { nonce: number; ntime: number; version: number }, channel: ChannelState, @@ -837,21 +1142,16 @@ export class StratumV2Client { submissionDifficulty: number, jobDifficulty: number, hashBuffer: Buffer, + headerContext: Sv2HeaderContext = this.createHeaderContext(jobTemplate), + isBlockCandidate = DifficultyUtils.meetsCompactTarget(hashBuffer, headerContext.nBits), ): Promise { let updatedJobBlock: bitcoinjs.Block = null; - if (DifficultyUtils.meetsTarget( - hashBuffer, - DifficultyUtils.difficultyToTarget(jobTemplate.blockData.networkDifficulty), - )) { - const versionMask = submission.version ^ jobTemplate.block.version; - updatedJobBlock = job.copyAndUpdateBlock( + if (isBlockCandidate) { + updatedJobBlock = this.reconstructStandardBlock({ jobTemplate, - versionMask, - submission.nonce, - channel.extranoncePrefix.toString('hex'), - FIXED_STANDARD_EXTRANONCE2, - submission.ntime, - ); + miningJob: job, + headerContext, + }, submission, channel.extranoncePrefix); } await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, jobTemplate, updatedJobBlock, { @@ -863,50 +1163,63 @@ export class StratumV2Client { }); } + private async submitBlockCandidate( + jobTemplate: IJobTemplate, + updatedJobBlock: bitcoinjs.Block, + ): Promise { + await this.ensureClientEntity(); + console.log(`[SV2 ${this.sessionId}] BLOCK FOUND at height ${jobTemplate.blockData.height}`); + const blockHex = updatedJobBlock.toHex(false); + const blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex); + await this.blocksService.save({ + height: jobTemplate.blockData.height, + minerAddress: this.address, + worker: this.workerName, + sessionId: this.sessionId, + 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.address, + jobTemplate.blockData.height, + updatedJobBlock, + blockSubmissionResult, + ); + if (this.isSuccessfulBlockSubmission(blockSubmissionResult)) { + await this.addressSettingsService.resetBestDifficultyAndShares(); + } + return blockSubmissionResult; + } + private async recordAcceptedShare( submissionDifficulty: number, jobDifficulty: number, jobTemplate: IJobTemplate, updatedJobBlock: bitcoinjs.Block | null, share: { jobId: string; nonce: number; ntime: number; version: number; extraNonce2: string }, - metadata: { workProtocol?: 'pool' | 'sv2_jdp'; blockCandidateResult?: string | null } = {}, + metadata: { + workProtocol?: 'pool' | 'sv2_jdp'; + blockCandidateResult?: string | null; + isBlockCandidate?: boolean; + } = {}, ): Promise { await this.ensureClientEntity(); let blockSubmissionResult: string = metadata.blockCandidateResult ?? null; if (updatedJobBlock != null) { - console.log(`[SV2 ${this.sessionId}] BLOCK FOUND at height ${jobTemplate.blockData.height}`); - const blockHex = updatedJobBlock.toHex(false); - blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex); - await this.blocksService.save({ - height: jobTemplate.blockData.height, - minerAddress: this.address, - worker: this.workerName, - sessionId: this.sessionId, - 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.address, - jobTemplate.blockData.height, - updatedJobBlock, - blockSubmissionResult, - ); - if (this.isSuccessfulBlockSubmission(blockSubmissionResult)) { - await this.addressSettingsService.resetBestDifficultyAndShares(); - } + blockSubmissionResult = await this.submitBlockCandidate(jobTemplate, updatedJobBlock); } await this.shareAccountingService?.recordAcceptedShare({ @@ -928,7 +1241,9 @@ export class StratumV2Client { ntime: share.ntime, version: share.version, extraNonce2: share.extraNonce2, - isBlockCandidate: updatedJobBlock != null || metadata.blockCandidateResult != null, + isBlockCandidate: updatedJobBlock != null + || metadata.isBlockCandidate === true + || metadata.blockCandidateResult != null, blockSubmissionResult, }); await this.statistics.addShares(this.clientEntity, jobDifficulty); @@ -956,7 +1271,13 @@ export class StratumV2Client { ): bitcoinjs.Block { const jobTemplate = extendedJob.jobTemplate; const testBlock = Object.assign(new bitcoinjs.Block(), jobTemplate.block); - testBlock.transactions = jobTemplate.block.transactions.map(tx => Object.assign(new bitcoinjs.Transaction(), tx)); + testBlock.transactions = [ + Object.assign(new bitcoinjs.Transaction(), jobTemplate.block.transactions[0]), + ...(jobTemplate.blockData.transactions == null + ? jobTemplate.block.transactions.slice(1).map(tx => + Object.assign(new bitcoinjs.Transaction(), tx)) + : jobTemplate.blockData.transactions.map(tx => bitcoinjs.Transaction.fromHex(tx.data))), + ]; const coinbaseTx = bitcoinjs.Transaction.fromBuffer(Buffer.concat([ extendedJob.coinbasePrefix, @@ -964,11 +1285,14 @@ export class StratumV2Client { submission.extranonce, extendedJob.coinbaseSuffix, ])); + coinbaseTx.ins[0].witness = [Buffer.alloc(32)]; testBlock.transactions[0] = coinbaseTx; - testBlock.version = submission.version; + testBlock.version = submission.version | 0; testBlock.nonce = submission.nonce; testBlock.timestamp = submission.ntime; testBlock.merkleRoot = merkleRoot; + testBlock.prevHash = Buffer.from(extendedJob.headerContext.prevHash); + testBlock.bits = extendedJob.headerContext.nBits; return testBlock; } @@ -979,27 +1303,36 @@ export class StratumV2Client { return; } - channel.declaredMaxTarget = Buffer.from(message.maximumTarget); - if (Number.isFinite(message.nominalHashRate) && message.nominalHashRate > 0) { - const nextDifficulty = DifficultyUtils.hashRateToDifficulty( - message.nominalHashRate, - this.targetSharesPerMinute, - ); - if (Number.isFinite(nextDifficulty) && nextDifficulty > 0) { - channel.sessionDifficulty = this.clampDifficulty(DifficultyUtils.clampDifficultyToMaxTarget( - nextDifficulty, - channel.declaredMaxTarget, - )); - await this.sendSetTarget(channel); + await this.enqueueJobOperation(async () => { + if (this.channels.get(channel.channelId) !== channel) { + return; } - } + + channel.declaredMaxTarget = Buffer.from(message.maximumTarget); + if (Number.isFinite(message.nominalHashRate) && message.nominalHashRate > 0) { + const nextDifficulty = DifficultyUtils.hashRateToDifficulty( + message.nominalHashRate, + this.targetSharesPerMinute, + ); + if (Number.isFinite(nextDifficulty) && nextDifficulty > 0) { + channel.sessionDifficulty = this.clampDifficulty(DifficultyUtils.clampDifficultyToMaxTarget( + nextDifficulty, + channel.declaredMaxTarget, + )); + if (this.channels.get(channel.channelId) === channel) { + this.updateStagedFutureDifficulty(channel); + await this.sendSetTarget(channel); + } + } + } + }); } private handleCloseChannel(payload: Buffer): void { const message = deserializeCloseChannel(new BufferReader(payload)); const channel = this.channels.get(message.channelId); - if (channel?.channelType === 'extended') { - this.stratumV2Service.releaseExtendedExtranoncePrefix(message.channelId); + if (channel != null) { + this.stratumV2Service.releaseExtranoncePrefix(message.channelId); } this.channels.delete(message.channelId); if (this.primaryChannelId === message.channelId) { @@ -1010,48 +1343,354 @@ export class StratumV2Client { } } - private subscribeToJobs(): void { - if (this.stratumSubscription != null) { + public enqueueCanonicalJob(jobTemplate: IJobTemplate): Promise { + if (!this.shouldReceiveCanonicalJob(jobTemplate)) { + return Promise.resolve(); + } + + return this.enqueueJobOperation( + () => this.applyCanonicalJob(jobTemplate), + 'canonical', + jobTemplate.blockData.tipKey, + ); + } + + public enqueueWorkActivation(template: IBlockTemplate): Promise { + if (this.destroyed || this.workSelectionEnabled || this.payoutMode !== 'solo') { + return Promise.resolve(); + } + + const tipKey = this.getQueuedActivationTipKey(template); + return this.enqueueJobOperation( + () => this.applyWorkActivation(template), + tipKey == null ? 'ordered' : 'activation', + tipKey, + ); + } + + private enqueueJobOperation( + operation: () => Promise, + kind: Sv2JobOperationKind = 'ordered', + tipKey?: string, + ): Promise { + if (this.destroyed) { + return Promise.resolve(); + } + + return new Promise(resolve => { + const queuedOperation: QueuedSv2JobOperation = { + kind, + operation, + resolve, + tipKey, + }; + + if (kind === 'canonical') { + const activeActivation = this.activeJobOperation?.kind === 'activation' + ? this.activeJobOperation + : null; + const pendingActivation = [...this.jobOperationQueue] + .reverse() + .find(queued => queued.kind === 'activation'); + const activationTipKey = pendingActivation?.tipKey ?? activeActivation?.tipKey; + if (activationTipKey != null && activationTipKey !== tipKey) { + // A late old-tip refresh must not be replayed after a newer + // activation. A matching full job is retained behind it. + resolve(); + return; + } + + const retainedOperations: QueuedSv2JobOperation[] = []; + for (const queued of this.jobOperationQueue) { + if (queued.kind === 'canonical') { + // Only the newest not-yet-started canonical job can be + // useful to this client. Resolve superseded waiters and + // retain the replacement at its newest queue position. + queued.resolve(); + } else { + retainedOperations.push(queued); + } + } + this.jobOperationQueue = retainedOperations; + this.jobOperationQueue.push(queuedOperation); + } else if (kind === 'activation') { + const retainedOperations: QueuedSv2JobOperation[] = []; + let matchingCanonical: QueuedSv2JobOperation = null; + for (const queued of this.jobOperationQueue) { + if (queued.kind === 'activation') { + queued.resolve(); + continue; + } + if (queued.kind === 'canonical') { + if (queued.tipKey === tipKey) { + matchingCanonical?.resolve(); + matchingCanonical = queued; + } else { + queued.resolve(); + } + continue; + } + retainedOperations.push(queued); + } + + // Target/difficulty mutations keep FIFO order. Obsolete jobs + // are removed, then the activation runs before a matching full + // job even if that full job arrived first. + this.jobOperationQueue = retainedOperations; + this.jobOperationQueue.push(queuedOperation); + if (matchingCanonical != null) { + this.jobOperationQueue.push(matchingCanonical); + } + } else { + this.jobOperationQueue.push(queuedOperation); + } + + if (this.jobOperationQueue.length > this.maxQueuedJobOperations) { + console.warn( + `[SV2 ${this.sessionId}] Closing slow client with ${this.jobOperationQueue.length} queued operations ` + + `(limit ${this.maxQueuedJobOperations})`, + ); + this.settleQueuedJobOperations(); + this.closeSocket(); + return; + } + + this.startJobOperationDrain(); + }); + } + + private startJobOperationDrain(): void { + if (this.drainingJobOperations) { + return; + } + this.drainingJobOperations = true; + void this.drainJobOperations(); + } + + private async drainJobOperations(): Promise { + while (!this.destroyed && this.jobOperationQueue.length > 0) { + const queuedOperation = this.jobOperationQueue.shift(); + this.activeJobOperation = queuedOperation; + try { + await queuedOperation.operation(); + } catch (error) { + if (!this.destroyed) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[SV2 ${this.sessionId}] Failed to send mining job: ${message}`); + this.closeSocket(); + } + } finally { + queuedOperation.resolve(); + this.activeJobOperation = null; + } + } + + if (this.destroyed) { + this.settleQueuedJobOperations(); + } + this.drainingJobOperations = false; + } + + private settleQueuedJobOperations(): void { + const queuedOperations = this.jobOperationQueue.splice(0); + for (const queuedOperation of queuedOperations) { + queuedOperation.resolve(); + } + } + + private getQueuedActivationTipKey(template: IBlockTemplate): string | undefined { + return Number.isSafeInteger(template?.height) + && typeof template?.previousblockhash === 'string' + && /^[0-9a-fA-F]{64}$/.test(template.previousblockhash) + ? `${template.height}:${template.previousblockhash}` + : undefined; + } + + private allocateMiningJobId(): { jobId: number; jobIdHex: string } { + for (let attempt = 0; attempt < 0xffffffff; attempt++) { + const jobId = this.nextMiningJobId >>> 0; + this.nextMiningJobId = (jobId + 1) >>> 0; + if (this.nextMiningJobId === 0) { + this.nextMiningJobId = 1; + } + const isInUse = Array.from(this.channels.values()).some(channel => + channel.standardJobs.has(jobId) || channel.extendedJobs.has(jobId)); + if (!isInUse) { + return { jobId, jobIdHex: jobId.toString(16) }; + } + } + throw new Error('No available SV2 mining job IDs'); + } + + private async applyCanonicalJob(jobTemplate: IJobTemplate, force = false): Promise { + if (!this.shouldReceiveCanonicalJob(jobTemplate)) { return; } - this.stratumSubscription = this.stratumV1JobsService.newMiningJob$.subscribe({ - next: async (jobTemplate) => { - try { - if (jobTemplate.blockData.clearJobs) { - this.channels.forEach(channel => { - if (channel.channelType === 'extended') { - const now = Date.now(); - channel.extendedJobs.forEach(job => { - if (job.retiredAt == null) { - job.retiredAt = now; - } - }); - this.cleanupRetiredExtendedJobs(channel); - } else { - channel.jobIdToDifficulty.clear(); - channel.jobIdToMerkleRoot.clear(); - } - channel.miningSubmissionHashes.clear(); - }); - } - for (const channel of this.channels.values()) { - if (channel.channelType === 'extended') { - await this.sendNewExtendedMiningJob(channel, jobTemplate, jobTemplate.blockData.clearJobs); - } else { - await this.sendNewMiningJob(channel, jobTemplate, jobTemplate.blockData.clearJobs); - } - } - } catch (error) { - console.error(`[SV2 ${this.sessionId}] Failed to send mining job: ${error.message}`); - this.closeSocket(); - } - }, - error: error => { - console.error(`[SV2 ${this.sessionId}] Job subscription failed: ${error.message}`); - this.closeSocket(); - }, + const signature = this.getCanonicalJobSignature(jobTemplate); + const channels = Array.from(this.channels.values()) + .filter(channel => channel.readyForCanonicalJobs) + .filter(channel => force || channel.lastCanonicalJobSignature !== signature); + for (const channel of channels) { + if (this.destroyed || this.channels.get(channel.channelId) !== channel) { + continue; + } + + const headerMatchesActiveTip = this.canonicalHeaderMatchesActiveTip(channel, jobTemplate); + const followsActivatedFuture = channel.activatedFutureTipKey === jobTemplate.blockData.tipKey + && jobTemplate.blockData.jobType === 'full' + && headerMatchesActiveTip; + const sendPrevHash = channel.activeTipKey !== jobTemplate.blockData.tipKey + || !headerMatchesActiveTip; + + if (sendPrevHash) { + this.retireChannelJobs(channel); + } else if (jobTemplate.blockData.clearJobs && !followsActivatedFuture) { + // A second SetNewPrevHash would invalidate the already-advertised + // next-height future at the miner. Retire only current server-side + // jobs and keep that future valid while sending active Some(ntime). + this.retireChannelJobs(channel, channel.stagedFutureJobId); + } + if (channel.channelType === 'extended') { + await this.sendNewExtendedMiningJob(channel, jobTemplate, sendPrevHash); + } else { + await this.sendNewMiningJob(channel, jobTemplate, sendPrevHash); + } + channel.activeTipKey = jobTemplate.blockData.tipKey; + if (channel.activatedFutureTipKey === jobTemplate.blockData.tipKey) { + channel.activatedFutureTipKey = undefined; + } + channel.lastCanonicalJobSignature = signature; + await this.stageSubsidyFutureJob(channel, jobTemplate); + this.cleanupRetiredJobs(channel); + } + } + + private async applyWorkActivation(template: IBlockTemplate): Promise { + const activation = this.parseWorkActivation(template); + if (activation == null) { + return; + } + + const activated: Array<{ + channel: ChannelState; + jobId: number; + }> = []; + for (const channel of this.channels.values()) { + if (!channel.readyForCanonicalJobs || channel.stagedFutureJobId == null) { + continue; + } + + const jobId = channel.stagedFutureJobId; + const jobData = channel.channelType === 'extended' + ? channel.extendedJobs.get(jobId) + : channel.standardJobs.get(jobId); + if ( + jobData == null + || jobData.retiredAt != null + || jobData.headerContext != null + || jobData.expectedHeight !== activation.height + || !this.isFutureVersionCompatible(jobData.expectedVersion, template) + ) { + continue; + } + + const activatedTemplate = this.createActivatedFutureTemplate(jobData.jobTemplate, template, activation); + this.retireChannelJobs(channel, jobId); + jobData.jobTemplate = activatedTemplate; + jobData.headerContext = { + ...this.cloneHeaderContext(activation), + baseVersion: jobData.expectedVersion, + }; + jobData.miningJob.tipKey = activation.tipKey; + jobData.miningJob.networkDifficulty = activation.networkDifficulty; + if (channel.channelType === 'extended') { + const extendedJob = jobData as ExtendedJobData; + extendedJob.prevHash = Buffer.from(activation.prevHash); + extendedJob.nBits = activation.nBits; + extendedJob.minNtime = activation.minNtime; + channel.latestExtendedPrevHash = Buffer.from(activation.prevHash); + channel.latestExtendedNBits = activation.nBits; + channel.latestExtendedMinNtime = activation.minNtime; + } + channel.stagedFutureJobId = undefined; + channel.activeTipKey = activation.tipKey; + channel.activatedFutureTipKey = activation.tipKey; + channel.activePrevHashMinNtime = activation.minNtime; + channel.activePrevHashAtMonotonicNs = activation.activatedAtMonotonicNs; + channel.activePrevHashJobId = jobId; + activated.push({ channel, jobId }); + } + + // All matching jobs are bound before the first socket write, so a share + // arriving immediately after SetNewPrevHash always sees the new header. + for (const { channel, jobId } of activated) { + await this.sendFrame( + Sv2MsgType.SET_NEW_PREV_HASH, + serializeSetNewPrevHash({ + channelId: channel.channelId, + jobId, + prevHash: Buffer.from(activation.prevHash), + minNtime: activation.minNtime, + nBits: activation.nBits, + }), + SV2_CHANNEL_MSG_FLAG, + ); + } + } + + private retireChannelJobs(channel: ChannelState, keepJobId?: number): void { + const now = Date.now(); + channel.standardJobs.forEach((job, jobId) => { + if (jobId !== keepJobId && job.retiredAt == null) { + job.retiredAt = now; + } }); + channel.extendedJobs.forEach((job, jobId) => { + if (jobId !== keepJobId && job.retiredAt == null) { + job.retiredAt = now; + } + }); + if (channel.stagedFutureJobId !== keepJobId) { + channel.stagedFutureJobId = undefined; + } + this.cleanupRetiredJobs(channel); + } + + private canonicalHeaderMatchesActiveTip(channel: ChannelState, jobTemplate: IJobTemplate): boolean { + const activeJobs = channel.channelType === 'extended' + ? channel.extendedJobs.values() + : channel.standardJobs.values(); + for (const job of activeJobs) { + if ( + job.retiredAt == null + && job.headerContext?.tipKey === jobTemplate.blockData.tipKey + && job.headerContext.nBits === jobTemplate.block.bits + && job.headerContext.prevHash.equals(jobTemplate.block.prevHash) + ) { + return true; + } + } + return false; + } + + private shouldReceiveCanonicalJob(jobTemplate: IJobTemplate): boolean { + return !this.destroyed + && !this.workSelectionEnabled + && (jobTemplate.blockData.payoutMode === 'all' + || jobTemplate.blockData.payoutMode === this.payoutMode) + && (this.payoutMode !== 'pplns' + || jobTemplate.blockData.payoutOutputs?.length > 0); + } + + private getCanonicalJobSignature(jobTemplate: IJobTemplate): string { + return [ + jobTemplate.blockData.id, + jobTemplate.block.timestamp, + jobTemplate.blockData.clearJobs, + jobTemplate.blockData.jobType, + jobTemplate.blockData.payoutMode, + ].join(':'); } private startDifficultyTimer(): void { @@ -1070,65 +1709,78 @@ export class StratumV2Client { return; } - this.sessionDifficulty = targetDiff; - for (const channel of this.channels.values()) { - channel.sessionDifficulty = this.clampDifficulty(DifficultyUtils.clampDifficultyToMaxTarget( - targetDiff, - channel.declaredMaxTarget, - )); - await this.sendSetTarget(channel); - } - - const jobTemplate = await firstValueFrom(this.stratumV1JobsService.newMiningJob$); - const refreshedTemplate: IJobTemplate = { - ...jobTemplate, - block: Object.assign(new bitcoinjs.Block(), jobTemplate.block, { - timestamp: Math.max(jobTemplate.block.timestamp, Math.floor(Date.now() / 1000)), - }), - blockData: { ...jobTemplate.blockData, clearJobs: true }, - }; - for (const channel of this.channels.values()) { - if (channel.channelType === 'extended') { - await this.sendNewExtendedMiningJob(channel, refreshedTemplate, true); - } else { - await this.sendNewMiningJob(channel, refreshedTemplate, true); + await this.enqueueJobOperation(async () => { + this.sessionDifficulty = targetDiff; + for (const channel of this.channels.values()) { + channel.sessionDifficulty = this.clampDifficulty(DifficultyUtils.clampDifficultyToMaxTarget( + targetDiff, + channel.declaredMaxTarget, + )); + this.updateStagedFutureDifficulty(channel); + await this.sendSetTarget(channel); } - } + + const jobTemplate = this.stratumV2Service.getLatestCanonicalJob(this.payoutMode); + if (jobTemplate != null) { + const refreshedTemplate: IJobTemplate = { + ...jobTemplate, + block: Object.assign(new bitcoinjs.Block(), jobTemplate.block, { + timestamp: Math.max(jobTemplate.block.timestamp, Math.floor(Date.now() / 1000)), + }), + blockData: { ...jobTemplate.blockData, clearJobs: true }, + }; + await this.applyCanonicalJob(refreshedTemplate, true); + } + }); } - private async sendNewMiningJob(channel: ChannelState, jobTemplate: IJobTemplate, sendPrevHash: boolean): Promise { + private async sendNewMiningJob( + channel: ChannelState, + jobTemplate: IJobTemplate, + sendPrevHash: boolean, + isFuture = false, + ): Promise { if (this.address == null) { - return; + return null; } - const jobIdHex = this.stratumV1JobsService.getNextId(); - const jobId = parseInt(jobIdHex, 16); + const payoutInformation = this.getPayoutInformation(jobTemplate, this.address); + if (payoutInformation == null) { + return null; + } + const { jobId, jobIdHex } = this.allocateMiningJobId(); const job = new MiningJob( this.network, jobIdHex, - this.getPayoutInformation(jobTemplate, this.address), + payoutInformation, jobTemplate, ); - this.stratumV1JobsService.addJob(job); - - const jobSideBlock = job.copyAndUpdateBlock( - jobTemplate, - 0, - 0, + // The standard job needs only its coinbase-derived Merkle root. Parsing + // and cloning the full transaction body here made canonical fanout O(body + // size * channel count); retain that work solely for a block candidate. + const merkleRoot = job.buildCoinbaseMerkleRoot( channel.extranoncePrefix.toString('hex'), FIXED_STANDARD_EXTRANONCE2, - jobTemplate.block.timestamp, ); - const merkleRoot = Buffer.from(jobSideBlock.merkleRoot); channel.jobIdToDifficulty.set(jobId, channel.sessionDifficulty); channel.jobIdToMerkleRoot.set(jobId, merkleRoot); + const headerContext = isFuture ? undefined : this.createHeaderContext(jobTemplate, channel); + channel.standardJobs.set(jobId, { + merkleRoot, + jobTemplate, + miningJob: job, + expectedHeight: jobTemplate.blockData.height, + expectedVersion: jobTemplate.block.version, + headerContext, + creation: Date.now(), + }); await this.sendFrame( Sv2MsgType.NEW_MINING_JOB, serializeNewMiningJob({ channelId: channel.channelId, jobId, - minNtime: sendPrevHash ? null : jobTemplate.block.timestamp, + minNtime: sendPrevHash || isFuture ? null : jobTemplate.block.timestamp, version: jobTemplate.block.version, merkleRoot, }), @@ -1136,6 +1788,13 @@ export class StratumV2Client { ); if (sendPrevHash) { + const activatedAtMonotonicNs = process.hrtime.bigint(); + headerContext.activationMinNtime = jobTemplate.block.timestamp; + headerContext.activatedAtMonotonicNs = activatedAtMonotonicNs; + channel.activePrevHashMinNtime = jobTemplate.block.timestamp; + channel.activePrevHashAtMonotonicNs = activatedAtMonotonicNs; + channel.activePrevHashJobId = jobId; + channel.activeTipKey = jobTemplate.blockData.tipKey; await this.sendFrame( Sv2MsgType.SET_NEW_PREV_HASH, serializeSetNewPrevHash({ @@ -1148,48 +1807,41 @@ export class StratumV2Client { SV2_CHANNEL_MSG_FLAG, ); } + return jobId; } - private async sendNewExtendedMiningJob(channel: ChannelState, jobTemplate: IJobTemplate, sendPrevHash: boolean): Promise { + private async sendNewExtendedMiningJob( + channel: ChannelState, + jobTemplate: IJobTemplate, + sendPrevHash: boolean, + isFuture = false, + ): Promise { if (this.address == null) { - return; + return null; } - const jobIdHex = this.stratumV1JobsService.getNextId(); - const jobId = parseInt(jobIdHex, 16); + const payoutInformation = this.getPayoutInformation(jobTemplate, this.address); + if (payoutInformation == null) { + return null; + } + const { jobId, jobIdHex } = this.allocateMiningJobId(); const job = new MiningJob( this.network, jobIdHex, - this.getPayoutInformation(jobTemplate, this.address), + payoutInformation, jobTemplate, ); - this.stratumV1JobsService.addJob(job); - const merklePath = jobTemplate.merkle_branch.map(branch => Buffer.from(branch, 'hex')); const totalExtranonceSize = channel.extranoncePrefix.length + channel.extranonceSize; const coinbasePrefix = patchCoinbasePrefixVarint(job.getCoinbasePrefixBuffer(), totalExtranonceSize); const coinbaseSuffix = job.getCoinbaseSuffixBuffer(); - await this.sendFrame( - Sv2MsgType.NEW_EXTENDED_MINING_JOB, - serializeNewExtendedMiningJob({ - channelId: channel.channelId, - jobId, - minNtime: sendPrevHash ? null : jobTemplate.block.timestamp, - version: jobTemplate.block.version, - versionRollingAllowed: true, - merklePath, - coinbasePrefix, - coinbaseSuffix, - }), - SV2_CHANNEL_MSG_FLAG, - ); - - const prevHash = sendPrevHash - ? Buffer.from(jobTemplate.block.prevHash) - : Buffer.from(channel.latestExtendedPrevHash); - const nBits = sendPrevHash ? jobTemplate.block.bits : channel.latestExtendedNBits; - const minNtime = sendPrevHash ? jobTemplate.block.timestamp : channel.latestExtendedMinNtime; + const headerContext = isFuture ? undefined : this.createHeaderContext(jobTemplate, channel); + const prevHash = headerContext == null + ? Buffer.alloc(32) + : Buffer.from(headerContext.prevHash); + const nBits = headerContext?.nBits ?? 0; + const minNtime = headerContext?.minNtime ?? 0; channel.jobIdToDifficulty.set(jobId, channel.sessionDifficulty); channel.extendedJobs.set(jobId, { @@ -1201,13 +1853,38 @@ export class StratumV2Client { minNtime, jobTemplate, miningJob: job, + expectedHeight: jobTemplate.blockData.height, + expectedVersion: jobTemplate.block.version, + headerContext, creation: Date.now(), }); + await this.sendFrame( + Sv2MsgType.NEW_EXTENDED_MINING_JOB, + serializeNewExtendedMiningJob({ + channelId: channel.channelId, + jobId, + minNtime: sendPrevHash || isFuture ? null : jobTemplate.block.timestamp, + version: jobTemplate.block.version, + versionRollingAllowed: this.versionRollingEnabled, + merklePath, + coinbasePrefix, + coinbaseSuffix, + }), + SV2_CHANNEL_MSG_FLAG, + ); + if (sendPrevHash) { + const activatedAtMonotonicNs = process.hrtime.bigint(); + headerContext.activationMinNtime = minNtime; + headerContext.activatedAtMonotonicNs = activatedAtMonotonicNs; + channel.activePrevHashMinNtime = minNtime; + channel.activePrevHashAtMonotonicNs = activatedAtMonotonicNs; + channel.activePrevHashJobId = jobId; channel.latestExtendedPrevHash = prevHash; channel.latestExtendedNBits = nBits; channel.latestExtendedMinNtime = minNtime; + channel.activeTipKey = jobTemplate.blockData.tipKey; await this.sendFrame( Sv2MsgType.SET_NEW_PREV_HASH, serializeSetNewPrevHash({ @@ -1220,31 +1897,306 @@ export class StratumV2Client { SV2_CHANNEL_MSG_FLAG, ); } + return jobId; } - private cleanupRetiredExtendedJobs(channel: ChannelState): void { - const cutoff = Date.now() - RETIRED_EXTENDED_JOB_RETENTION_MS; - for (const [jobId, job] of channel.extendedJobs.entries()) { - if (job.retiredAt != null && job.retiredAt < cutoff) { - channel.extendedJobs.delete(jobId); - channel.jobIdToDifficulty.delete(jobId); + private async stageSubsidyFutureJob(channel: ChannelState, sourceTemplate: IJobTemplate): Promise { + if ( + this.payoutMode !== 'solo' + || this.workSelectionEnabled + || !channel.readyForCanonicalJobs + || !Number.isSafeInteger(sourceTemplate.blockData.height) + || sourceTemplate.blockData.height < 0 + || sourceTemplate.blockData.height >= Number.MAX_SAFE_INTEGER + ) { + return; + } + + const expectedHeight = sourceTemplate.blockData.height + 1; + const expectedVersion = sourceTemplate.block.version; + if (channel.stagedFutureJobId != null) { + const staged = channel.channelType === 'extended' + ? channel.extendedJobs.get(channel.stagedFutureJobId) + : channel.standardJobs.get(channel.stagedFutureJobId); + if ( + staged != null + && staged.retiredAt == null + && staged.headerContext == null + && staged.expectedHeight === expectedHeight + && (staged.expectedVersion >>> 0) === (expectedVersion >>> 0) + ) { + return; } + if (staged != null && staged.retiredAt == null) { + staged.retiredAt = Date.now(); + } + channel.stagedFutureJobId = undefined; + } + + const futureTemplate = this.createSubsidyFutureTemplate(expectedHeight, expectedVersion); + const jobId = channel.channelType === 'extended' + ? await this.sendNewExtendedMiningJob(channel, futureTemplate, false, true) + : await this.sendNewMiningJob(channel, futureTemplate, false, true); + if (jobId != null) { + channel.stagedFutureJobId = jobId; + } + this.cleanupRetiredJobs(channel); + } + + private createSubsidyFutureTemplate(height: number, version: number): IJobTemplate { + const block = new bitcoinjs.Block(); + const placeholderCoinbase = new bitcoinjs.Transaction(); + placeholderCoinbase.version = 2; + placeholderCoinbase.addInput(Buffer.alloc(32), 0xffffffff, 0xffffffff); + placeholderCoinbase.ins[0].witness = [Buffer.alloc(32)]; + block.version = version; + block.prevHash = Buffer.alloc(32); + block.bits = 0; + block.timestamp = Math.floor(Date.now() / 1000); + block.transactions = [placeholderCoinbase]; + block.merkleRoot = placeholderCoinbase.getHash(false); + block.witnessCommit = Buffer.from(EMPTY_WITNESS_COMMITMENT_HASH, 'hex'); + + const halvingInterval = this.getOptionalPositiveIntegerConfig('SUBSIDY_HALVING_INTERVAL'); + const coinbasevalue = calculateBlockSubsidySats( + height, + this.getNetworkName(), + halvingInterval, + ); + const id = `sv2-future:${this.sessionId}:${height}:${++this.futureTemplateCounter}`; + return { + block, + merkle_branch: [], + blockData: { + id, + creation: Date.now(), + coinbasevalue, + networkDifficulty: 0, + height, + tipKey: `future:${height}:${version >>> 0}`, + clearJobs: true, + isNewBlock: true, + jobType: 'empty', + payoutMode: 'solo', + transactions: [], + }, + }; + } + + private parseWorkActivation(template: IBlockTemplate): Sv2WorkActivation | null { + if ( + template?.jobType !== 'empty' + || (template.payoutMode !== 'solo' && template.payoutMode !== 'pplns') + || !Number.isSafeInteger(template.height) + || template.height < 0 + || !Number.isInteger(template.version) + || !Number.isInteger(template.vbrequired) + || template.vbrequired < 0 + || template.vbrequired > 0xffffffff + || !Number.isInteger(template.mintime) + || template.mintime < 0 + || template.mintime > 0xffffffff + || !/^[0-9a-fA-F]{64}$/.test(template.previousblockhash ?? '') + || !/^[0-9a-fA-F]{8}$/.test(template.bits ?? '') + ) { + return null; + } + + const nBits = Number.parseInt(template.bits, 16); + const target = DifficultyUtils.compactToTarget(nBits); + if (target == null) { + return null; + } + return { + tipKey: `${template.height}:${template.previousblockhash}`, + prevHash: Buffer.from(template.previousblockhash, 'hex').reverse(), + nBits, + minNtime: template.mintime, + height: template.height, + version: template.version, + networkDifficulty: DifficultyUtils.targetToDifficulty(target), + baseVersion: template.version, + requiredVersionBits: template.vbrequired >>> 0, + activationMinNtime: template.mintime, + activatedAtMonotonicNs: process.hrtime.bigint(), + }; + } + + private createActivatedFutureTemplate( + stagedTemplate: IJobTemplate, + rawTemplate: IBlockTemplate, + activation: Sv2WorkActivation, + ): IJobTemplate { + const block = Object.assign(new bitcoinjs.Block(), stagedTemplate.block, { + prevHash: Buffer.from(activation.prevHash), + bits: activation.nBits, + timestamp: activation.minNtime, + // The future job's version was already sent to the miner. GBT may + // change BIP320 general-purpose bits without making it incompatible. + version: stagedTemplate.block.version, + }); + block.transactions = stagedTemplate.block.transactions.map(transaction => + Object.assign(new bitcoinjs.Transaction(), transaction)); + return { + ...stagedTemplate, + block, + blockData: { + ...stagedTemplate.blockData, + networkDifficulty: activation.networkDifficulty, + height: activation.height, + tipKey: activation.tipKey, + clearJobs: true, + isNewBlock: true, + jobType: 'empty', + payoutMode: 'solo', + notificationEventId: rawTemplate.notificationEventId, + notificationPublishedAtMs: rawTemplate.notificationPublishedAtMs, + }, + }; + } + + private createHeaderContext( + jobTemplate: IJobTemplate, + channel?: ChannelState, + ): Sv2HeaderContext { + return { + tipKey: jobTemplate.blockData.tipKey, + prevHash: Buffer.from(jobTemplate.block.prevHash), + nBits: jobTemplate.block.bits, + minNtime: jobTemplate.block.timestamp, + height: jobTemplate.blockData.height, + networkDifficulty: jobTemplate.blockData.networkDifficulty, + baseVersion: jobTemplate.block.version, + requiredVersionBits: jobTemplate.blockData.requiredVersionBits ?? 0, + activationMinNtime: channel?.activePrevHashMinNtime, + activatedAtMonotonicNs: channel?.activePrevHashAtMonotonicNs, + }; + } + + private isFutureVersionCompatible(stagedVersion: number, template: IBlockTemplate): boolean { + const staged = stagedVersion >>> 0; + const authoritative = template.version >>> 0; + const required = template.vbrequired >>> 0; + return ((staged ^ authoritative) & BIP320_CONSENSUS_VERSION_MASK) === 0 + && (staged & required) === required; + } + + private isSubmissionHeaderValid( + headerContext: Sv2HeaderContext, + submittedVersion: number, + submittedNtime: number, + ): boolean { + const submitted = submittedVersion >>> 0; + const base = headerContext.baseVersion >>> 0; + if (this.versionRollingEnabled) { + if (((submitted ^ base) & BIP320_CONSENSUS_VERSION_MASK) !== 0) { + return false; + } + } else if (submitted !== base) { + return false; + } + if ((submitted & headerContext.requiredVersionBits) !== headerContext.requiredVersionBits) { + return false; + } + return Number.isInteger(submittedNtime) + && submittedNtime >= headerContext.minNtime + && submittedNtime <= 0xffffffff; + } + + private cloneHeaderContext(context: Sv2HeaderContext): Sv2HeaderContext { + return { + ...context, + prevHash: Buffer.from(context.prevHash), + }; + } + + private updateStagedFutureDifficulty(channel: ChannelState): void { + if (channel.stagedFutureJobId != null) { + channel.jobIdToDifficulty.set(channel.stagedFutureJobId, channel.sessionDifficulty); } } + private cleanupRetiredJobs(channel: ChannelState): void { + const cutoff = Date.now() - this.jobRetentionMs; + const jobs = channel.channelType === 'extended' + ? channel.extendedJobs + : channel.standardJobs; + let latestActiveJobId: number = null; + let latestActiveCreation = Number.NEGATIVE_INFINITY; + for (const [jobId, job] of jobs.entries()) { + if ( + job.retiredAt == null + && job.headerContext != null + && job.creation >= latestActiveCreation + ) { + latestActiveJobId = jobId; + latestActiveCreation = job.creation; + } + } + const protectedJobIds = new Set(); + if (channel.stagedFutureJobId != null) { + protectedJobIds.add(channel.stagedFutureJobId); + } + if (latestActiveJobId != null) { + protectedJobIds.add(latestActiveJobId); + } + const activePrevHashJob = channel.activePrevHashJobId == null + ? null + : jobs.get(channel.activePrevHashJobId); + if (activePrevHashJob?.retiredAt == null) { + protectedJobIds.add(channel.activePrevHashJobId); + } + + for (const [jobId, job] of channel.standardJobs.entries()) { + const expiredRetiredJob = job.retiredAt != null && job.retiredAt <= cutoff; + const expiredSameTipJob = job.retiredAt == null + && job.headerContext != null + && job.creation <= cutoff + && !protectedJobIds.has(jobId); + if (expiredRetiredJob || expiredSameTipJob) { + this.deleteChannelJob(channel, jobId); + } + } + for (const [jobId, job] of channel.extendedJobs.entries()) { + const expiredRetiredJob = job.retiredAt != null && job.retiredAt <= cutoff; + const expiredSameTipJob = job.retiredAt == null + && job.headerContext != null + && job.creation <= cutoff + && !protectedJobIds.has(jobId); + if (expiredRetiredJob || expiredSameTipJob) { + this.deleteChannelJob(channel, jobId); + } + } + + const retainedJobCount = channel.standardJobs.size + channel.extendedJobs.size; + if (retainedJobCount > this.maxRetainedJobsPerChannel) { + throw new Error( + `SV2 channel ${channel.channelId} retained ${retainedJobCount} jobs ` + + `(limit ${this.maxRetainedJobsPerChannel})`, + ); + } + } + + private deleteChannelJob(channel: ChannelState, jobId: number): void { + channel.standardJobs.delete(jobId); + channel.extendedJobs.delete(jobId); + channel.jobIdToDifficulty.delete(jobId); + channel.jobIdToMerkleRoot.delete(jobId); + } + private buildHeader( - jobTemplate: IJobTemplate, + headerContext: Sv2HeaderContext, merkleRoot: Buffer, version: number, timestamp: number, nonce: number, ): Buffer { const header = Buffer.alloc(80); - header.writeInt32LE(version, 0); - jobTemplate.block.prevHash.copy(header, 4); + header.writeUInt32LE(version >>> 0, 0); + headerContext.prevHash.copy(header, 4); merkleRoot.copy(header, 36); header.writeUInt32LE(timestamp >>> 0, 68); - header.writeUInt32LE(jobTemplate.block.bits >>> 0, 72); + header.writeUInt32LE(headerContext.nBits >>> 0, 72); header.writeUInt32LE(nonce >>> 0, 76); return header; } @@ -1279,6 +2231,23 @@ export class StratumV2Client { ); } + private async sendShareSuccess( + channelId: number, + sequenceNumber: number, + creditedDifficulty: number, + ): Promise { + await this.sendFrame( + Sv2MsgType.SUBMIT_SHARES_SUCCESS, + serializeSubmitSharesSuccess({ + channelId, + lastSequenceNumber: sequenceNumber, + newSubmitsAcceptedCount: 1, + newSharesSum: BigInt(Math.round(creditedDifficulty)), + }), + SV2_CHANNEL_MSG_FLAG, + ); + } + private async sendFrame(msgType: number, payload: Buffer, extensionType = 0): Promise { const data = this.frameWriter.writeFrame({ extensionType, @@ -1289,12 +2258,59 @@ export class StratumV2Client { } private async writeRaw(data: Buffer): Promise { - if (this.socket.destroyed || this.socket.writableEnded) { + if (this.destroyed || this.socket.destroyed || this.socket.writableEnded) { return; } - await new Promise((resolve, reject) => { - this.socket.write(data, error => error ? reject(error) : resolve()); - }); + + const socketBufferedBytes = Number.isFinite(this.socket.writableLength) + ? this.socket.writableLength + : 0; + const bufferedBytes = Math.max(socketBufferedBytes, this.pendingSocketWriteBytes); + if (bufferedBytes + data.length > this.maxSocketBufferBytes) { + const error = new Error( + `SV2 socket buffer would reach ${bufferedBytes + data.length} bytes ` + + `(limit ${this.maxSocketBufferBytes})`, + ); + console.warn(`[SV2 ${this.sessionId}] ${error.message}; closing slow client`); + this.closeSocket(); + throw error; + } + + this.pendingSocketWriteBytes += data.length; + try { + await new Promise((resolve, reject) => { + let completed = false; + const finish = (error?: Error): void => { + if (completed) { + return; + } + completed = true; + clearTimeout(timeout); + if (error != null) { + reject(error); + } else { + resolve(); + } + }; + const timeout = setTimeout(() => { + const error = new Error( + `SV2 socket write callback exceeded ${this.socketWriteTimeoutMs}ms`, + ); + console.warn(`[SV2 ${this.sessionId}] ${error.message}; closing slow client`); + this.closeSocket(); + finish(error); + }, this.socketWriteTimeoutMs); + timeout.unref?.(); + + try { + this.socket.write(data, error => finish(error ?? undefined)); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }); + } finally { + this.pendingSocketWriteBytes = Math.max(0, this.pendingSocketWriteBytes - data.length); + } } private async ensureClientEntity(): Promise { @@ -1432,14 +2448,37 @@ export class StratumV2Client { return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS; } - private getPayoutInformation(jobTemplate: IJobTemplate, fallbackAddress: string): AddressObject[] { - if (this.payoutMode === 'pplns' && jobTemplate.blockData.payoutOutputs?.length > 0) { - return jobTemplate.blockData.payoutOutputs; + private getOptionalPositiveIntegerConfig(key: string): number | undefined { + const configured = Number(this.configService.get(key)); + return Number.isSafeInteger(configured) && configured > 0 ? configured : undefined; + } + + private getPositiveIntegerConfig(key: string, fallback: number): number { + return this.getOptionalPositiveIntegerConfig(key) ?? fallback; + } + + 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 getCreditedDifficulty( + jobDifficulty: number, + submissionDifficulty: number, + meetsJobTarget: boolean, + ): number { + if (meetsJobTarget || !Number.isFinite(submissionDifficulty) || submissionDifficulty <= 0) { + return jobDifficulty; + } + + return Math.min(jobDifficulty, submissionDifficulty); + } + private validateCoinbasePayoutOutputs( coinbaseTxBytes: Buffer, jobTemplate: IJobTemplate, @@ -1510,7 +2549,7 @@ export class StratumV2Client { } private getNetwork(): bitcoinjs.networks.Network { - const networkConfig = this.configService.get('NETWORK'); + const networkConfig = this.getNetworkName(); if (networkConfig === 'mainnet') { return bitcoinjs.networks.bitcoin; } @@ -1523,6 +2562,14 @@ export class StratumV2Client { throw new Error('Invalid network configuration'); } + private getNetworkName(): BitcoinNetworkName { + const networkConfig = this.configService.get('NETWORK'); + if (networkConfig === 'mainnet' || networkConfig === 'testnet' || networkConfig === 'regtest') { + return networkConfig; + } + throw new Error('Invalid network configuration'); + } + private logProtocolError(error: Error): void { if (!this.isNoisyAuthFailure(error)) { console.error(`[SV2 ${this.sessionId}] ${error.message}`); diff --git a/src/models/bitcoin-rpc/IBlockTemplate.ts b/src/models/bitcoin-rpc/IBlockTemplate.ts index f060177..9b7dbda 100644 --- a/src/models/bitcoin-rpc/IBlockTemplate.ts +++ b/src/models/bitcoin-rpc/IBlockTemplate.ts @@ -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; } diff --git a/src/models/stratum-messages/MiningSubmitMessage.spec.ts b/src/models/stratum-messages/MiningSubmitMessage.spec.ts index e5f9fd9..91cc930 100644 --- a/src/models/stratum-messages/MiningSubmitMessage.spec.ts +++ b/src/models/stratum-messages/MiningSubmitMessage.spec.ts @@ -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([]); + }); }); diff --git a/src/models/stratum-messages/MiningSubmitMessage.ts b/src/models/stratum-messages/MiningSubmitMessage.ts index 28791e7..577845c 100644 --- a/src/models/stratum-messages/MiningSubmitMessage.ts +++ b/src/models/stratum-messages/MiningSubmitMessage.ts @@ -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; diff --git a/src/models/sv2/sv2-extranonce-manager.spec.ts b/src/models/sv2/sv2-extranonce-manager.spec.ts index 10eb6ba..98067c3 100644 --- a/src/models/sv2/sv2-extranonce-manager.spec.ts +++ b/src/models/sv2/sv2-extranonce-manager.spec.ts @@ -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'); + }); + }); diff --git a/src/models/sv2/sv2-extranonce-manager.ts b/src/models/sv2/sv2-extranonce-manager.ts index f7ede76..db6db5b 100644 --- a/src/models/sv2/sv2-extranonce-manager.ts +++ b/src/models/sv2/sv2-extranonce-manager.ts @@ -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(); // channelId → prefix private usedPrefixes = new Set(); 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`, + ); + } +} diff --git a/src/services/bitcoin-rpc.service.spec.ts b/src/services/bitcoin-rpc.service.spec.ts new file mode 100644 index 0000000..be6ee43 --- /dev/null +++ b/src/services/bitcoin-rpc.service.spec.ts @@ -0,0 +1,1310 @@ +import { ConfigService } from '@nestjs/config'; + +import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate'; +import { MockRecording1 } from '../../test/models/MockRecording1'; +import { BitcoinRpcService } from './bitcoin-rpc.service'; +import { calculateBlockSubsidySats } from './subsidy-only-template.factory'; + +describe('BitcoinRpcService template publication', () => { + const createTemplate = (): IBlockTemplate => ({ + ...MockRecording1.BLOCK_TEMPLATE, + transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })), + }); + + it('publishes solo work before an unresolved PPLNS snapshot and persistence', async () => { + const order: string[] = []; + let resolveSnapshot: (value: any) => void; + const snapshotPromise = new Promise(resolve => { resolveSnapshot = resolve; }); + let resolvePersistence: () => void; + const persistencePromise = new Promise(resolve => { resolvePersistence = resolve; }); + const redis = createRedisMock(order); + const rpcBlock = { + saveBlock: jest.fn(() => { + order.push('postgres:start'); + return persistencePromise; + }), + getSavedBlockTemplate: jest.fn(), + }; + const payoutSnapshots = { + createSnapshotForTemplate: jest.fn(() => { + order.push('snapshot:start'); + return snapshotPromise; + }), + }; + const service = new BitcoinRpcService( + { get: jest.fn() } as unknown as ConfigService, + rpcBlock as any, + redis as any, + payoutSnapshots as any, + ); + service.miningInfo = { blocks: createTemplate().height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(createTemplate()); + + await service.getAndBroadcastLatestTemplate('new_block'); + + expect(order.indexOf('redis:set:solo')).toBeLessThan(order.indexOf('redis:publish:solo')); + expect(order.indexOf('redis:publish:solo')).toBeLessThan(order.indexOf('snapshot:start')); + expect(redis.setBlockTemplate).toHaveBeenCalledTimes(1); + expect(redis.setBlockTemplate.mock.calls[0][1]).toEqual(expect.objectContaining({ + payoutMode: 'solo', + payoutSnapshotId: undefined, + })); + + resolveSnapshot!({ + id: '91', + payoutOutputs: [{ address: 'bc1qexample', amountSats: 1 }], + }); + await flushPromises(); + + expect(redis.setBlockTemplate).toHaveBeenCalledTimes(2); + expect(redis.setBlockTemplate.mock.calls[1][1]).toEqual(expect.objectContaining({ + payoutMode: 'pplns', + payoutSnapshotId: '91', + forceCleanJobs: true, + })); + expect(order.indexOf('snapshot:start')).toBeLessThan(order.indexOf('redis:set:pplns')); + expect(order.indexOf('redis:set:pplns')).toBeLessThan(order.indexOf('redis:set:legacy:pplns')); + expect(order.indexOf('redis:set:legacy:pplns')).toBeLessThan(order.indexOf('redis:publish:mining-info')); + + resolvePersistence!(); + await flushPromises(); + }); + + it.each(['null', 'error', 'empty'] as const)( + 'never wakes legacy PPLNS listeners with solo fallback work after a %s snapshot result', + async outcome => { + const redis = createRedisMock([]); + const template = createTemplate(); + const createSnapshotForTemplate = jest.fn(); + if (outcome === 'null') { + createSnapshotForTemplate.mockResolvedValue(null); + } else if (outcome === 'error') { + createSnapshotForTemplate.mockRejectedValue(new Error('snapshot unavailable')); + } else { + createSnapshotForTemplate.mockResolvedValue({ id: 'empty-snapshot', payoutOutputs: [] }); + } + const service = new BitcoinRpcService( + createConfig({ + SV1_SUBSIDY_BRIDGE_ENABLED: 'false', + PPLNS_STRATUM_PORTS: '13333', + }), + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { createSnapshotForTemplate } as any, + ); + service.miningInfo = { blocks: template.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + await service.getAndBroadcastLatestTemplate('new_block'); + await flushPromises(); + + expect(redis.setBlockTemplate).toHaveBeenCalledTimes(1); + expect(redis.setLegacyBlockTemplate).not.toHaveBeenCalled(); + expect(redis.setLatestMiningInfo).not.toHaveBeenCalled(); + expect(redis.publishMiningInfoUpdate).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }, + ); + + it('keeps the legacy solo fallback for rolling deployments with no PPLNS listeners', async () => { + const redis = createRedisMock([]); + const template = createTemplate(); + const service = new BitcoinRpcService( + createConfig({ SV1_SUBSIDY_BRIDGE_ENABLED: 'false' }), + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any, + ); + service.miningInfo = { blocks: template.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template); + + await service.getAndBroadcastLatestTemplate('new_block'); + await flushPromises(); + + expect(redis.setLegacyBlockTemplate).toHaveBeenCalledWith( + template.height - 1, + expect.objectContaining({ payoutMode: 'solo' }), + ); + expect(redis.publishMiningInfoUpdate).toHaveBeenCalledTimes(1); + }); + + it.each([ + 'PPLNS_STRATUM_PORTS', + 'PPLNS_SECURE_STRATUM_PORTS', + 'PPLNS_STRATUM_V2_PORTS', + 'PPLNS_SV2_JDP_PORTS', + 'PPLNS_SV2_TDP_PORTS', + 'PPLNS_DATUM_PORTS', + ])('recognizes %s as a legacy PPLNS rollout guard', key => { + const service = new BitcoinRpcService( + createConfig({ [key]: ' 13333 ' }), + {} as any, + {} as any, + ); + + expect((service as any).hasConfiguredPplnsListeners()).toBe(true); + }); + + it('stores periodic solo and PPLNS refreshes without forcing a clean switch', async () => { + const redis = createRedisMock([]); + const rpcBlock = { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() }; + const payoutSnapshots = { + createSnapshotForTemplate: jest.fn().mockResolvedValue({ + id: '92', + payoutOutputs: [{ address: 'bc1qperiodic', amountSats: 1 }], + }), + }; + const service = new BitcoinRpcService( + { get: jest.fn() } as unknown as ConfigService, + rpcBlock as any, + redis as any, + payoutSnapshots as any, + ); + const template = createTemplate(); + service.miningInfo = { blocks: template.height - 1 } as any; + (service as any).lastPublishedTipKey = `${template.height}:${template.previousblockhash}`; + (service as any).lastPublishedCandidateHeight = template.height; + jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template); + + await service.getAndBroadcastLatestTemplate('periodic'); + await flushPromises(); + + expect(redis.setBlockTemplate.mock.calls.map(call => call[1].forceCleanJobs)).toEqual([false, false]); + }); + + it('forces both payout modes clean when longpoll is first to report a new tip', async () => { + const redis = createRedisMock([]); + const template = createTemplate(); + const service = new BitcoinRpcService( + createConfig({ SV1_SUBSIDY_BRIDGE_ENABLED: 'false' }), + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { + createSnapshotForTemplate: jest.fn().mockResolvedValue({ + id: 'longpoll-pplns', + payoutOutputs: [{ address: 'bc1qlongpoll', amountSats: 1 }], + }), + } as any, + ); + service.miningInfo = { blocks: template.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template); + + await service.getAndBroadcastLatestTemplate('longpoll', template.longpollid); + await flushPromises(); + + expect(redis.setBlockTemplate.mock.calls.map(call => call[1].forceCleanJobs)) + .toEqual([true, true]); + }); + + it('publishes a validated compact subsidy bridge before serializing the full template', async () => { + const order: string[] = []; + const redis = createRedisMock(order); + const template = createTemplate(); + template.height = 840_000; + template.coinbasevalue = 312_500_000 + + template.transactions.reduce((sum, transaction) => sum + transaction.fee, 0); + const service = new BitcoinRpcService( + { + get: jest.fn((key: string) => key === 'NETWORK' ? 'mainnet' : undefined), + } as unknown as ConfigService, + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any, + ); + service.miningInfo = { blocks: template.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template); + + await service.getAndBroadcastLatestTemplate('new_block'); + + expect(redis.publishSv1BridgeUpdate).toHaveBeenCalledWith(expect.objectContaining({ + schemaVersion: 1, + type: 'subsidy-bridge', + template: expect.objectContaining({ + height: 840_000, + coinbasevalue: 312_500_000, + transactions: [], + payoutMode: 'solo', + jobType: 'empty', + forceCleanJobs: true, + }), + })); + expect(redis.publishSv1BridgeUpdate).toHaveBeenCalledTimes(1); + expect(order.indexOf('redis:publish:bridge:solo')).toBeLessThan(order.indexOf('redis:set:solo')); + expect(order.indexOf('redis:publish:bridge:solo')).toBeLessThan(order.indexOf('redis:publish:solo')); + }); + + it('precomputes an exact next-height PPLNS subsidy seed off the canonical path', async () => { + const order: string[] = []; + const canonical = createTemplateAtHeight(839_999, '66'); + let resolveSeed: (value: any) => void; + const seedPromise = new Promise(resolve => { resolveSeed = resolve; }); + const payoutSnapshots = { + createSnapshotForTemplate: jest.fn((input: { blockHeight: number; coinbaseValueSats: number }) => { + if (input.blockHeight === 840_000) { + order.push('seed:start'); + return seedPromise; + } + return Promise.resolve(null); + }), + }; + const redis = createRedisMock(order); + const service = new BitcoinRpcService( + createConfig({ + NETWORK: 'mainnet', + SV1_SUBSIDY_BRIDGE_PAYOUT_MODES: 'pplns', + }), + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + payoutSnapshots as any, + ); + service.miningInfo = { blocks: canonical.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(canonical); + + await service.getAndBroadcastLatestTemplate('periodic'); + await flushPromises(); + + expect(order.indexOf('redis:publish:solo')).toBeLessThan(order.indexOf('seed:start')); + expect(payoutSnapshots.createSnapshotForTemplate).toHaveBeenCalledWith({ + blockHeight: 840_000, + coinbaseValueSats: 312_500_000, + networkDifficulty: expect.any(Number), + visibility: 'bridge_seed', + }); + + resolveSeed!(createSeedSnapshot(840_000, 312_500_000)); + await (service as any).pplnsSeedPrecomputeTail; + const seeds = (service as any).pplnsSubsidyBridgeSeeds as Map; + expect(seeds.get(840_000)).toEqual(expect.objectContaining({ + candidateHeight: 840_000, + subsidySats: 312_500_000, + basisBits: canonical.bits, + payoutSnapshotId: 'seed-840000', + preparedAtMs: expect.any(Number), + })); + + (service as any).storePplnsSubsidyBridgeSeed({ + ...seeds.get(840_000), + candidateHeight: 840_001, + }); + (service as any).storePplnsSubsidyBridgeSeed({ + ...seeds.get(840_000), + candidateHeight: 840_002, + }); + expect([...seeds.keys()].sort()).toEqual([840_001, 840_002]); + }); + + it('coalesces slow PPLNS seed preparation to the newest pending template', async () => { + const older = createTemplateAtHeight(839_998, '67'); + const latest = createTemplateAtHeight(839_999, '68'); + let resolveOlder: (value: any) => void; + const olderSnapshot = new Promise(resolve => { resolveOlder = resolve; }); + const payoutSnapshots = { + createSnapshotForTemplate: jest.fn() + .mockReturnValueOnce(olderSnapshot) + .mockResolvedValueOnce(createSeedSnapshot(840_000, 312_500_000)), + }; + const service = new BitcoinRpcService( + createConfig({ + NETWORK: 'mainnet', + SV1_SUBSIDY_BRIDGE_PAYOUT_MODES: 'pplns', + }), + {} as any, + createRedisMock([]) as any, + payoutSnapshots as any, + ); + + (service as any).queuePplnsSubsidyBridgeSeedPrecompute(older); + (service as any).queuePplnsSubsidyBridgeSeedPrecompute(latest); + expect(payoutSnapshots.createSnapshotForTemplate).toHaveBeenCalledTimes(1); + resolveOlder!(createSeedSnapshot(839_999, 312_500_000)); + await (service as any).pplnsSeedPrecomputeTail; + + expect(payoutSnapshots.createSnapshotForTemplate).toHaveBeenCalledTimes(2); + expect([...((service as any).pplnsSubsidyBridgeSeeds as Map).keys()]) + .toEqual([840_000]); + }); + + it('coalesces active PPLNS snapshot publication to the newest pending template', async () => { + const older = createTemplateAtHeight(839_998, '69'); + const middle = createTemplateAtHeight(839_999, '6a'); + const latest = createTemplateAtHeight(840_000, '6b'); + let resolveOlder: (value: any) => void; + const olderSnapshot = new Promise(resolve => { resolveOlder = resolve; }); + const payoutSnapshots = { + createSnapshotForTemplate: jest.fn() + .mockReturnValueOnce(olderSnapshot) + .mockResolvedValueOnce(null), + }; + const service = new BitcoinRpcService( + createConfig({ SV1_SUBSIDY_BRIDGE_ENABLED: 'false' }), + {} as any, + createRedisMock([]) as any, + payoutSnapshots as any, + ); + const enqueue = (template: IBlockTemplate, eventId: string) => { + (service as any).queuePplnsTemplatePublication({ + blockTemplate: template, + soloTemplate: { ...template, payoutMode: 'solo' }, + tipHeight: template.height - 1, + forceCleanJobs: false, + tipKey: `${template.height}:${template.previousblockhash}`, + templateSignature: eventId, + trace: (service as any).startTrace('periodic'), + }); + }; + + enqueue(older, 'active-older'); + enqueue(middle, 'active-middle'); + enqueue(latest, 'active-latest'); + expect(payoutSnapshots.createSnapshotForTemplate).toHaveBeenCalledTimes(1); + + resolveOlder!(null); + await (service as any).pplnsTemplatePublicationPromise; + + expect(payoutSnapshots.createSnapshotForTemplate).toHaveBeenCalledTimes(2); + expect(payoutSnapshots.createSnapshotForTemplate.mock.calls.map(call => call[0].blockHeight)) + .toEqual([older.height, latest.height]); + }); + + it('preserves a required clean PPLNS switch when a same-tip refresh is coalesced', async () => { + const template = createTemplateAtHeight(840_000, '6c'); + const refreshed = { + ...template, + curtime: template.curtime + 1, + longpollid: `${template.longpollid}:refresh`, + }; + let resolveFirst: (value: any) => void; + const firstSnapshot = new Promise(resolve => { resolveFirst = resolve; }); + const service = new BitcoinRpcService( + createConfig({ SV1_SUBSIDY_BRIDGE_ENABLED: 'false' }), + {} as any, + createRedisMock([]) as any, + { + createSnapshotForTemplate: jest.fn() + .mockReturnValueOnce(firstSnapshot) + .mockResolvedValueOnce(null), + } as any, + ); + const enqueue = (blockTemplate: IBlockTemplate, forceCleanJobs: boolean) => { + (service as any).queuePplnsTemplatePublication({ + blockTemplate, + soloTemplate: { ...blockTemplate, payoutMode: 'solo' }, + tipHeight: blockTemplate.height - 1, + forceCleanJobs, + tipKey: `${blockTemplate.height}:${blockTemplate.previousblockhash}`, + templateSignature: blockTemplate.longpollid, + trace: (service as any).startTrace('periodic'), + }); + }; + + enqueue(template, true); + enqueue(refreshed, false); + + expect((service as any).pendingPplnsTemplatePublication.forceCleanJobs).toBe(true); + resolveFirst!(null); + await (service as any).pplnsTemplatePublicationPromise; + }); + + it('publishes solo then seeded PPLNS bridges before the full template', async () => { + const order: string[] = []; + const redis = createRedisMock(order); + const template = createTemplateAtHeight(840_000, '77'); + const service = new BitcoinRpcService( + createConfig({ + NETWORK: 'mainnet', + SV1_SUBSIDY_BRIDGE_PAYOUT_MODES: ' solo, PPLNS ', + }), + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any, + ); + const preparedAtMs = Date.now(); + (service as any).storePplnsSubsidyBridgeSeed({ + candidateHeight: template.height, + subsidySats: 312_500_000, + basisBits: template.bits, + payoutSnapshotId: 'seed-for-next-tip', + payoutOutputs: [ + { address: 'bc1qseed-a', amountSats: 200_000_000 }, + { address: 'bc1qseed-b', amountSats: 112_500_000 }, + ], + preparedAtMs, + }); + service.miningInfo = { blocks: template.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template); + + await service.getAndBroadcastLatestTemplate('new_block'); + + expect(order.indexOf('redis:publish:bridge:solo')).toBeLessThan( + order.indexOf('redis:publish:bridge:pplns'), + ); + expect(order.indexOf('redis:publish:bridge:pplns')).toBeLessThan( + order.indexOf('redis:set:solo'), + ); + const [soloUpdate, pplnsUpdate] = redis.publishSv1BridgeUpdate.mock.calls + .map(call => call[0]) as any[]; + expect(soloUpdate.template.notificationEventId).toBe(soloUpdate.eventId); + expect(pplnsUpdate).toEqual(expect.objectContaining({ + eventId: expect.stringContaining(':bridge:pplns'), + template: expect.objectContaining({ + payoutMode: 'pplns', + payoutSnapshotId: 'seed-for-next-tip', + payoutBridgeSeedCreatedAtMs: preparedAtMs, + payoutOutputs: [ + { address: 'bc1qseed-a', amountSats: 200_000_000 }, + { address: 'bc1qseed-b', amountSats: 112_500_000 }, + ], + transactions: [], + }), + })); + expect(pplnsUpdate.template.notificationEventId).toBe(pplnsUpdate.eventId); + }); + + it('never falls back to PPLNS outputs when precomputation returns no outputs', async () => { + const canonical = createTemplateAtHeight(840_000, '88'); + const payoutSnapshots = { + createSnapshotForTemplate: jest.fn().mockResolvedValue({ + ...createSeedSnapshot(840_001, 312_500_000), + payoutOutputs: [], + }), + }; + const redis = createRedisMock([]); + const service = new BitcoinRpcService( + createConfig({ + NETWORK: 'mainnet', + SV1_SUBSIDY_BRIDGE_PAYOUT_MODES: 'pplns', + }), + {} as any, + redis as any, + payoutSnapshots as any, + ); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + (service as any).storePplnsSubsidyBridgeSeed({ + candidateHeight: 840_001, + subsidySats: 312_500_000, + basisBits: canonical.bits, + payoutSnapshotId: 'old-seed-that-must-not-fallback', + payoutOutputs: [{ address: 'bc1qoldseed', amountSats: 312_500_000 }], + preparedAtMs: Date.now(), + }); + + (service as any).queuePplnsSubsidyBridgeSeedPrecompute(canonical); + await (service as any).pplnsSeedPrecomputeTail; + + expect((service as any).pplnsSubsidyBridgeSeeds.size).toBe(0); + const nextTemplate = createTemplateAtHeight(840_001, '89'); + const trace = (service as any).startTrace('new_block'); + await expect((service as any).publishPplnsSubsidyBridge( + nextTemplate, + trace, + )).resolves.toBe(false); + expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('rejects mismatched or stale PPLNS seeds', async () => { + const redis = createRedisMock([]); + const template = createTemplateAtHeight(840_001, '99'); + const service = new BitcoinRpcService( + createConfig({ + NETWORK: 'mainnet', + SV1_SUBSIDY_BRIDGE_PAYOUT_MODES: 'pplns', + SV1_SUBSIDY_BRIDGE_PPLNS_SEED_MAX_AGE_MS: '10', + }), + {} as any, + redis as any, + ); + const baseSeed = { + subsidySats: calculateBlockSubsidySats(template.height, 'mainnet'), + basisBits: template.bits, + payoutSnapshotId: 'seed-stale', + payoutOutputs: [{ + address: 'bc1qseed', + amountSats: calculateBlockSubsidySats(template.height, 'mainnet'), + }], + preparedAtMs: Date.now(), + }; + (service as any).storePplnsSubsidyBridgeSeed({ + ...baseSeed, + candidateHeight: template.height + 1, + }); + + const trace = (service as any).startTrace('new_block'); + await expect((service as any).publishPplnsSubsidyBridge( + template, + trace, + )).resolves.toBe(false); + + (service as any).storePplnsSubsidyBridgeSeed({ + ...baseSeed, + candidateHeight: template.height, + basisBits: '1d00ffff', + }); + await expect((service as any).publishPplnsSubsidyBridge( + template, + trace, + )).resolves.toBe(false); + + (service as any).storePplnsSubsidyBridgeSeed({ + ...baseSeed, + candidateHeight: template.height, + preparedAtMs: Date.now() - 11, + }); + await expect((service as any).publishPplnsSubsidyBridge( + template, + trace, + )).resolves.toBe(false); + expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled(); + }); + + it('keeps the solo bridge and full publication independent of PPLNS Redis failure', async () => { + const order: string[] = []; + const redis = createRedisMock(order); + const template = createTemplateAtHeight(840_000, 'aa'); + redis.publishSv1BridgeUpdate.mockImplementation(async (update: any) => { + order.push(`redis:publish:bridge:${update.template.payoutMode}`); + if (update.template.payoutMode === 'pplns') { + throw new Error('PPLNS Redis unavailable'); + } + }); + const service = new BitcoinRpcService( + createConfig({ + NETWORK: 'mainnet', + SV1_SUBSIDY_BRIDGE_PAYOUT_MODES: 'solo,pplns', + }), + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any, + ); + (service as any).storePplnsSubsidyBridgeSeed({ + candidateHeight: template.height, + subsidySats: 312_500_000, + basisBits: template.bits, + payoutSnapshotId: 'seed-redis-failure', + payoutOutputs: [{ address: 'bc1qseed', amountSats: 312_500_000 }], + preparedAtMs: Date.now(), + }); + service.miningInfo = { blocks: template.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await service.getAndBroadcastLatestTemplate('new_block'); + + expect(order).toEqual(expect.arrayContaining([ + 'redis:publish:bridge:solo', + 'redis:publish:bridge:pplns', + 'redis:set:solo', + 'redis:publish:solo', + ])); + expect(order.indexOf('redis:publish:bridge:solo')).toBeLessThan( + order.indexOf('redis:publish:bridge:pplns'), + ); + expect(redis.setBlockTemplate).toHaveBeenCalledWith( + template.height - 1, + expect.objectContaining({ payoutMode: 'solo' }), + ); + consoleSpy.mockRestore(); + }); + + it('passes Core longpollid with a dedicated long timeout and remembers the returned id', async () => { + const template = createTemplate(); + template.longpollid = 'next-longpoll-id'; + const post = jest.fn().mockResolvedValue({ data: { result: template, error: null } }); + const service = new BitcoinRpcService( + { + get: jest.fn((key: string) => key === 'BLOCK_TEMPLATE_LONGPOLL_TIMEOUT_MS' ? '123456' : undefined), + } as unknown as ConfigService, + {} as any, + {} as any, + ); + (service as any).client = { post }; + + const trace = (service as any).startTrace('longpoll'); + const result = await (service as any).fetchBlockTemplate(trace, 'previous-longpoll-id'); + + expect(result).toBe(template); + expect(post).toHaveBeenCalledWith('', expect.objectContaining({ + method: 'getblocktemplate', + params: [expect.objectContaining({ longpollid: 'previous-longpoll-id' })], + }), { timeout: 123456 }); + expect((service as any).latestLongpollId).toBe('next-longpoll-id'); + }); + + it('deduplicates the same authoritative template returned by ZMQ and longpoll', async () => { + const redis = createRedisMock([]); + const template = createTemplate(); + const service = new BitcoinRpcService( + { + get: jest.fn((key: string) => key === 'SV1_SUBSIDY_BRIDGE_ENABLED' ? 'false' : undefined), + } as unknown as ConfigService, + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any, + ); + service.miningInfo = { blocks: template.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template); + + await Promise.all([ + service.getAndBroadcastLatestTemplate('new_block'), + service.getAndBroadcastLatestTemplate('longpoll', template.longpollid), + ]); + + expect(redis.setBlockTemplate).toHaveBeenCalledTimes(1); + expect(redis.publishBlockTemplateUpdate).toHaveBeenCalledTimes(1); + }); + + it('publishes templates whose ordered transaction body changes even when endpoint fields match', async () => { + const redis = createRedisMock([]); + const first = createTemplate(); + const changed = { + ...first, + transactions: first.transactions.map((transaction, index) => index === 0 + ? { + ...transaction, + // Keep count, endpoint txid, longpollid, and every prior + // signature field unchanged. The full body checksum must + // still distinguish this template. + data: `${transaction.data.startsWith('00') ? '01' : '00'}${transaction.data.slice(2)}`, + } + : { ...transaction }), + }; + const service = new BitcoinRpcService( + createConfig({ SV1_SUBSIDY_BRIDGE_ENABLED: 'false' }), + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any, + ); + service.miningInfo = { blocks: first.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate') + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(changed); + + await service.getAndBroadcastLatestTemplate('periodic'); + await service.getAndBroadcastLatestTemplate('periodic'); + + expect(redis.setBlockTemplate).toHaveBeenCalledTimes(2); + expect(redis.publishBlockTemplateUpdate).toHaveBeenCalledTimes(2); + }); + + it('does not let a stalled bridge publish block canonical template publication', async () => { + const order: string[] = []; + const redis = createRedisMock(order); + let resolveBridge: () => void; + const stalledBridge = new Promise(resolve => { resolveBridge = resolve; }); + redis.publishSv1BridgeUpdate.mockImplementation(async (update: { template: IBlockTemplate }) => { + order.push(`redis:publish:bridge:${update.template.payoutMode}`); + await stalledBridge; + }); + const template = createTemplateAtHeight(840_000, '71'); + const service = new BitcoinRpcService( + createConfig({ NETWORK: 'mainnet' }), + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any, + ); + service.miningInfo = { blocks: template.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate').mockResolvedValue(template); + + await service.getAndBroadcastLatestTemplate('new_block'); + + expect(redis.publishSv1BridgeUpdate).toHaveBeenCalledTimes(1); + expect(redis.setBlockTemplate).toHaveBeenCalledWith( + template.height - 1, + expect.objectContaining({ payoutMode: 'solo', jobType: 'full' }), + ); + expect(redis.publishBlockTemplateUpdate).toHaveBeenCalledWith(expect.objectContaining({ + payoutMode: 'solo', + })); + expect(order.indexOf('redis:publish:bridge:solo')).toBeLessThan(order.indexOf('redis:set:solo')); + + resolveBridge!(); + await flushPromises(); + }); + + it('verifies same-height reorgs against Core and never switches back to an orphan', async () => { + const redis = createRedisMock([]); + const first = createTemplateAtHeight(840_000, '31'); + const reorg = createTemplateAtHeight(840_000, '32'); + const service = new BitcoinRpcService( + createConfig({ SV1_SUBSIDY_BRIDGE_ENABLED: 'false' }), + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any, + ); + service.miningInfo = { blocks: first.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate') + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(reorg) + .mockResolvedValueOnce(first); + const coreBestHash = jest.spyOn(service as any, 'callRpc') + .mockResolvedValue(reorg.previousblockhash); + + await service.getAndBroadcastLatestTemplate('periodic'); + await service.getAndBroadcastLatestTemplate('new_block'); + await service.getAndBroadcastLatestTemplate('longpoll'); + + expect(coreBestHash).toHaveBeenCalledTimes(2); + expect(redis.setBlockTemplate).toHaveBeenCalledTimes(2); + expect(redis.setBlockTemplate.mock.calls.at(-1)[1].previousblockhash) + .toBe(reorg.previousblockhash); + }); + + it('accepts a Core-verified lower-height higher-work reorg and fences delayed old responses', async () => { + const redis = createRedisMock([]); + const original = createTemplateAtHeight(840_000, '51'); + const lowerReorg = createTemplateAtHeight(839_999, '52'); + const newBranch = createTemplateAtHeight(840_000, '53'); + const service = new BitcoinRpcService( + createConfig({ SV1_SUBSIDY_BRIDGE_ENABLED: 'false' }), + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any, + ); + service.miningInfo = { blocks: original.height - 1 } as any; + jest.spyOn(service as any, 'fetchBlockTemplate') + .mockResolvedValueOnce(original) + .mockResolvedValueOnce(lowerReorg) + .mockResolvedValueOnce(newBranch) + .mockResolvedValueOnce(original); + const coreBestHash = jest.spyOn(service as any, 'callRpc') + .mockResolvedValueOnce(lowerReorg.previousblockhash) + .mockResolvedValueOnce(newBranch.previousblockhash) + .mockResolvedValueOnce(newBranch.previousblockhash); + + await service.getAndBroadcastLatestTemplate('periodic'); + await service.getAndBroadcastLatestTemplate('new_block'); + await service.getAndBroadcastLatestTemplate('new_block'); + await service.getAndBroadcastLatestTemplate('longpoll'); + + expect(coreBestHash).toHaveBeenCalledTimes(3); + expect(redis.setBlockTemplate).toHaveBeenCalledTimes(3); + expect(redis.setBlockTemplate.mock.calls.at(-1)[1].previousblockhash) + .toBe(newBranch.previousblockhash); + }); + + it('single-flights periodic GBT refreshes during an outage or slow response', async () => { + const redis = createRedisMock([]); + const service = new BitcoinRpcService( + createConfig({ SV1_SUBSIDY_BRIDGE_ENABLED: 'false' }), + { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, + redis as any, + { createSnapshotForTemplate: jest.fn().mockResolvedValue(null) } as any, + ); + service.miningInfo = { blocks: createTemplate().height - 1 } as any; + let resolveFetch: (template: IBlockTemplate) => void; + const fetch = jest.spyOn(service as any, 'fetchBlockTemplate').mockReturnValue( + new Promise(resolve => { resolveFetch = resolve; }), + ); + + const first = service.getAndBroadcastLatestTemplate('periodic'); + const second = service.getAndBroadcastLatestTemplate('periodic'); + expect(fetch).toHaveBeenCalledTimes(1); + resolveFetch!(createTemplate()); + await Promise.all([first, second]); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(redis.setBlockTemplate).toHaveBeenCalledTimes(1); + }); + + it('bounds detached persistence to the in-flight item plus the latest pending envelope', async () => { + let resolveFirstSave: () => void; + const saveBlock = jest.fn() + .mockReturnValueOnce(new Promise(resolve => { resolveFirstSave = resolve; })) + .mockResolvedValue(undefined); + const service = new BitcoinRpcService( + createConfig({}), + { saveBlock } as any, + {} as any, + ); + const trace = (service as any).startTrace('periodic'); + const first = { ...createTemplate(), notificationEventId: 'persist-first' }; + const middle = { ...createTemplate(), notificationEventId: 'persist-middle' }; + const latest = { ...createTemplate(), notificationEventId: 'persist-latest' }; + + (service as any).queueTemplatePersistence(1, [first], trace); + (service as any).queueTemplatePersistence(2, [middle], trace); + (service as any).queueTemplatePersistence(3, [latest], trace); + const drain = (service as any).persistenceDrainPromise; + expect(saveBlock).toHaveBeenCalledTimes(1); + resolveFirstSave!(); + await drain; + + expect(saveBlock).toHaveBeenCalledTimes(2); + expect(saveBlock.mock.calls[1][0]).toBe(3); + expect(JSON.parse(saveBlock.mock.calls[1][1]).templates[0].notificationEventId) + .toBe('persist-latest'); + }); + + it('ignores stale bridge replays but lets newer canonical work override a conflicting bridge', async () => { + const service = new BitcoinRpcService( + createConfig({}), + {} as any, + {} as any, + ); + service.miningInfo = { blocks: 900_000 } as any; + const bridges: IBlockTemplate[] = []; + const canonicals: IBlockTemplate[] = []; + service.newSv1BridgeTemplate$.subscribe(template => bridges.push(template)); + service.newBlockTemplate$.subscribe(template => canonicals.push(template)); + const bridgeTemplate = { + ...createTemplate(), + height: 900_001, + previousblockhash: '41'.repeat(32), + payoutMode: 'solo' as const, + jobType: 'empty' as const, + transactions: [], + }; + + await (service as any).handleSv1BridgeUpdate({ + schemaVersion: 1, + type: 'subsidy-bridge', + eventId: 'stale-height', + publishedAtMs: 100, + template: { ...bridgeTemplate, height: 899_999 }, + }); + await (service as any).handleSv1BridgeUpdate({ + schemaVersion: 1, + type: 'subsidy-bridge', + eventId: 'fresh-bridge', + publishedAtMs: 200, + template: bridgeTemplate, + }); + + const canonical = { + ...createTemplate(), + height: bridgeTemplate.height, + previousblockhash: '42'.repeat(32), + payoutMode: 'solo' as const, + jobType: 'full' as const, + notificationEventId: 'canonical-newer', + notificationPublishedAtMs: 300, + }; + (service as any).emitCanonicalTemplate(canonical); + (service as any).emitCanonicalTemplate(canonical); + (service as any).emitCanonicalTemplate({ + ...canonical, + previousblockhash: '43'.repeat(32), + notificationEventId: 'canonical-older', + notificationPublishedAtMs: 250, + }); + + expect(bridges).toEqual([expect.objectContaining({ previousblockhash: bridgeTemplate.previousblockhash })]); + expect(canonicals).toEqual([expect.objectContaining({ previousblockhash: canonical.previousblockhash })]); + }); + + it('rejects live bridge updates older than or redundant with active canonical work', async () => { + const service = new BitcoinRpcService( + createConfig({}), + {} as any, + {} as any, + ); + service.miningInfo = { blocks: 900_000 } as any; + const bridges: IBlockTemplate[] = []; + service.newSv1BridgeTemplate$.subscribe(template => bridges.push(template)); + const canonical = { + ...createTemplate(), + height: 900_001, + previousblockhash: '71'.repeat(32), + payoutMode: 'solo' as const, + jobType: 'full' as const, + notificationEventId: 'canonical-active', + notificationPublishedAtMs: 300, + }; + (service as any).emitCanonicalTemplate(canonical); + + const sendBridge = (eventId: string, previousblockhash: string, publishedAtMs: number) => ( + (service as any).handleSv1BridgeUpdate({ + schemaVersion: 1, + type: 'subsidy-bridge', + eventId, + publishedAtMs, + template: { + ...canonical, + previousblockhash, + payoutMode: 'solo', + jobType: 'empty', + transactions: [], + }, + }) + ); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + await sendBridge('redundant-same-tip', canonical.previousblockhash, 400); + await sendBridge('older-conflicting-tip', '72'.repeat(32), 300); + await sendBridge('newer-conflicting-tip', '73'.repeat(32), 301); + + (service as any).emitCanonicalTemplate({ + ...canonical, + previousblockhash: '74'.repeat(32), + notificationEventId: 'legacy-canonical-without-time', + notificationPublishedAtMs: undefined, + }); + await sendBridge('unproven-after-legacy-canonical', '75'.repeat(32), 999); + + expect(bridges).toEqual([ + expect.objectContaining({ previousblockhash: '73'.repeat(32) }), + ]); + warnSpy.mockRestore(); + }); + + it('loads the legacy height key when an old master publishes only mining-info updates', async () => { + const previousMaster = process.env.MASTER; + const previousApiOnly = process.env.API_ONLY; + process.env.MASTER = 'false'; + process.env.API_ONLY = 'false'; + let miningInfoHandler: (miningInfo: any) => Promise; + const redis = { + getLatestMiningInfo: jest.fn().mockResolvedValue({ blocks: 10 }), + subscribeSv1BridgeUpdates: jest.fn().mockResolvedValue(undefined), + subscribeBlockTemplateUpdates: jest.fn().mockResolvedValue(undefined), + getLatestSv1BridgeUpdate: jest.fn().mockResolvedValue(null), + getBlockTemplate: jest.fn().mockResolvedValue(null), + getLatestBlockTemplates: jest.fn().mockResolvedValue([]), + subscribeMiningInfoUpdates: jest.fn(async (handler: typeof miningInfoHandler) => { + miningInfoHandler = handler; + }), + }; + const service = new BitcoinRpcService( + createConfig({}), + { getSavedBlockTemplate: jest.fn().mockResolvedValue(null) } as any, + redis as any, + ); + + try { + await service.onModuleInit(); + expect(redis.subscribeMiningInfoUpdates.mock.invocationCallOrder[0]) + .toBeLessThan(redis.getBlockTemplate.mock.invocationCallOrder[0]); + await miningInfoHandler!({ blocks: 11 }); + + expect(redis.getBlockTemplate).toHaveBeenLastCalledWith(11, 'solo', undefined); + expect(redis.getLatestSv1BridgeUpdate).not.toHaveBeenCalled(); + } finally { + if (previousMaster == null) delete process.env.MASTER; + else process.env.MASTER = previousMaster; + if (previousApiOnly == null) delete process.env.API_ONLY; + else process.env.API_ONLY = previousApiOnly; + } + }); + + it('replays a newer solo latest pointer before stale mining-info fallback and rejects a later lower legacy replay', async () => { + const previousMaster = process.env.MASTER; + const previousApiOnly = process.env.API_ONLY; + process.env.MASTER = 'false'; + process.env.API_ONLY = 'false'; + const latestSolo = { + ...createTemplate(), + payoutMode: 'solo' as const, + notificationEventId: 'new-solo-pointer', + notificationPublishedAtMs: 200, + }; + const staleTipHeight = latestSolo.height - 2; + const staleLegacy = { + ...latestSolo, + height: latestSolo.height - 1, + previousblockhash: '76'.repeat(32), + payoutMode: undefined, + notificationEventId: undefined, + notificationPublishedAtMs: undefined, + }; + let miningInfoHandler: (miningInfo: any) => Promise; + const getBlockTemplate = jest.fn().mockResolvedValue(null); + const redis = { + getLatestMiningInfo: jest.fn().mockResolvedValue({ blocks: staleTipHeight }), + getLatestBlockTemplates: jest.fn().mockResolvedValue([latestSolo]), + subscribeSv1BridgeUpdates: jest.fn().mockResolvedValue(undefined), + subscribeBlockTemplateUpdates: jest.fn().mockResolvedValue(undefined), + subscribeMiningInfoUpdates: jest.fn(async (handler: typeof miningInfoHandler) => { + miningInfoHandler = handler; + }), + getBlockTemplate, + }; + const service = new BitcoinRpcService( + createConfig({}), + { getSavedBlockTemplate: jest.fn().mockResolvedValue(null) } as any, + redis as any, + ); + const emitted: IBlockTemplate[] = []; + const subscription = service.newBlockTemplate$.subscribe(template => emitted.push(template)); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + await service.onModuleInit(); + + expect(redis.subscribeSv1BridgeUpdates.mock.invocationCallOrder[0]) + .toBeLessThan(redis.getLatestBlockTemplates.mock.invocationCallOrder[0]); + expect(redis.subscribeBlockTemplateUpdates.mock.invocationCallOrder[0]) + .toBeLessThan(redis.getLatestBlockTemplates.mock.invocationCallOrder[0]); + expect(redis.subscribeMiningInfoUpdates.mock.invocationCallOrder[0]) + .toBeLessThan(redis.getLatestBlockTemplates.mock.invocationCallOrder[0]); + expect(getBlockTemplate).toHaveBeenCalledWith(staleTipHeight, 'pplns', undefined); + expect(getBlockTemplate).not.toHaveBeenCalledWith(staleTipHeight, 'solo', undefined); + expect(emitted).toEqual([ + expect.objectContaining({ + payoutMode: 'solo', + height: latestSolo.height, + notificationEventId: 'new-solo-pointer', + }), + ]); + + getBlockTemplate.mockResolvedValue(staleLegacy); + await miningInfoHandler!({ blocks: staleTipHeight }); + + expect(emitted).toHaveLength(1); + expect(emitted[0].height).toBe(latestSolo.height); + } finally { + subscription.unsubscribe(); + warnSpy.mockRestore(); + if (previousMaster == null) delete process.env.MASTER; + else process.env.MASTER = previousMaster; + if (previousApiOnly == null) delete process.env.API_ONLY; + else process.env.API_ONLY = previousApiOnly; + } + }); + + it('prefers a newer timestamped lower-height pointer after a rollback over stale higher mining info', async () => { + const rollbackPointer = { + ...createTemplate(), + payoutMode: 'solo' as const, + height: createTemplate().height - 1, + previousblockhash: '77'.repeat(32), + notificationEventId: 'verified-lower-reorg', + notificationPublishedAtMs: 300, + }; + const staleLegacy = { + ...createTemplate(), + payoutMode: 'solo' as const, + notificationEventId: 'stale-higher-legacy', + notificationPublishedAtMs: 200, + }; + const redis = { + getLatestMiningInfo: jest.fn().mockResolvedValue({ blocks: rollbackPointer.height }), + getLatestBlockTemplates: jest.fn().mockResolvedValue([rollbackPointer]), + getBlockTemplate: jest.fn(async (_height: number, payoutMode: 'solo' | 'pplns') => ( + payoutMode === 'solo' ? staleLegacy : null + )), + }; + const service = new BitcoinRpcService( + createConfig({}), + { getSavedBlockTemplate: jest.fn().mockResolvedValue(null) } as any, + redis as any, + ); + const emitted: IBlockTemplate[] = []; + const subscription = service.newBlockTemplate$.subscribe(template => emitted.push(template)); + + try { + await (service as any).loadLatestTemplateForWorker(); + + expect(redis.getBlockTemplate).toHaveBeenCalledWith( + rollbackPointer.height, + 'solo', + undefined, + ); + expect(emitted).toEqual([ + expect.objectContaining({ + height: rollbackPointer.height, + previousblockhash: rollbackPointer.previousblockhash, + notificationEventId: 'verified-lower-reorg', + }), + ]); + } finally { + subscription.unsubscribe(); + } + }); + + it('re-arms the Core longpoll with the id returned by the prior request', async () => { + const previousMaster = process.env.MASTER; + process.env.MASTER = 'true'; + try { + const service = new BitcoinRpcService( + { get: jest.fn() } as unknown as ConfigService, + {} as any, + {} as any, + ); + (service as any).latestLongpollId = 'lp-1'; + const calls: string[] = []; + jest.spyOn(service, 'getAndBroadcastLatestTemplate').mockImplementation(async (_reason, longpollId) => { + calls.push(longpollId!); + if (calls.length === 1) { + (service as any).latestLongpollId = 'lp-2'; + } else { + process.env.MASTER = 'false'; + } + }); + + await (service as any).listenForLongpollTemplates(); + + expect(calls).toEqual(['lp-1', 'lp-2']); + } finally { + if (previousMaster == null) { + delete process.env.MASTER; + } else { + process.env.MASTER = previousMaster; + } + } + }); + + it('exposes BIP23 proposal validation for reconstructed bridge/full blocks', async () => { + const post = jest.fn().mockResolvedValue({ data: { result: null, error: null } }); + const service = new BitcoinRpcService( + { get: jest.fn() } as unknown as ConfigService, + {} as any, + {} as any, + ); + (service as any).client = { post }; + + await expect(service.TEST_BLOCK_PROPOSAL('00ff')).resolves.toBeNull(); + expect(post).toHaveBeenCalledWith('', expect.objectContaining({ + method: 'getblocktemplate', + params: [{ mode: 'proposal', data: '00ff', rules: ['segwit'] }], + }), undefined); + }); + + it('returns success only when Core confirms an accepted submitted block is active', async () => { + const service = new BitcoinRpcService( + createConfig({}), + {} as any, + {} as any, + ); + const callRpc = jest.spyOn(service as any, 'callRpc') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ confirmations: 1 }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await expect(service.SUBMIT_BLOCK(GENESIS_HEADER_HEX)).resolves.toBe('SUCCESS!'); + expect(callRpc).toHaveBeenNthCalledWith(1, 'submitblock', [GENESIS_HEADER_HEX]); + expect(callRpc).toHaveBeenNthCalledWith(2, 'getblockheader', [GENESIS_BLOCK_HASH, true]); + } finally { + logSpy.mockRestore(); + } + }); + + it('does not treat an accepted side-chain block as a successful submission', async () => { + const service = new BitcoinRpcService( + createConfig({}), + {} as any, + {} as any, + ); + jest.spyOn(service as any, 'callRpc') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ confirmations: -1 }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await expect(service.SUBMIT_BLOCK(GENESIS_HEADER_HEX)) + .resolves.toBe('ACCEPTED_BLOCK_NOT_ACTIVE_CHAIN: confirmations=-1'); + } finally { + logSpy.mockRestore(); + } + }); + + it('fails closed when active-chain verification after submitblock cannot complete', async () => { + const service = new BitcoinRpcService( + createConfig({}), + {} as any, + {} as any, + ); + jest.spyOn(service as any, 'callRpc') + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('header lookup failed')); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await expect(service.SUBMIT_BLOCK(GENESIS_HEADER_HEX)) + .resolves.toBe('ACTIVE_CHAIN_VERIFICATION_FAILED: header lookup failed'); + } finally { + logSpy.mockRestore(); + } + }); + + it('preserves non-null submitblock rejection strings without active-chain lookup', async () => { + const service = new BitcoinRpcService( + createConfig({}), + {} as any, + {} as any, + ); + const callRpc = jest.spyOn(service as any, 'callRpc').mockResolvedValueOnce('stale-prevblk'); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await expect(service.SUBMIT_BLOCK(GENESIS_HEADER_HEX)).resolves.toBe('stale-prevblk'); + expect(callRpc).toHaveBeenCalledTimes(1); + } finally { + logSpy.mockRestore(); + } + }); +}); + +const GENESIS_HEADER_HEX = [ + '01000000', + '00'.repeat(32), + '3ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a', + '29ab5f49', + 'ffff001d', + '1dac2b7c', +].join(''); +const GENESIS_BLOCK_HASH = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f'; + +function createRedisMock(order: string[]) { + return { + setBlockTemplate: jest.fn(async (_height: number, template: IBlockTemplate) => { + order.push(`redis:set:${template.payoutMode}`); + return 'key'; + }), + setLatestMiningInfo: jest.fn().mockResolvedValue(undefined), + publishMiningInfoUpdate: jest.fn(async () => { + order.push('redis:publish:mining-info'); + }), + publishBlockTemplateUpdate: jest.fn(async (update: { payoutMode: string }) => { + order.push(`redis:publish:${update.payoutMode}`); + }), + publishSv1BridgeUpdate: jest.fn(async (update: { template: IBlockTemplate }) => { + order.push(`redis:publish:bridge:${update.template.payoutMode}`); + }), + setLegacyBlockTemplate: jest.fn(async (_height: number, template: IBlockTemplate) => { + order.push(`redis:set:legacy:${template.payoutMode}`); + }), + }; +} + +function createConfig(values: Record): ConfigService { + return { + get: jest.fn((key: string) => values[key]), + } as unknown as ConfigService; +} + +function createTemplateAtHeight(height: number, previousHashByte: string): IBlockTemplate { + const template = createBaseTemplate(); + template.height = height; + template.previousblockhash = previousHashByte.repeat(32); + template.longpollid = `${template.previousblockhash}:${height}`; + template.curtime += height - MockRecording1.BLOCK_TEMPLATE.height; + const fees = template.transactions.reduce((sum, transaction) => sum + transaction.fee, 0); + template.coinbasevalue = calculateBlockSubsidySats(height, 'mainnet') + fees; + return template; +} + +function createBaseTemplate(): IBlockTemplate { + return { + ...MockRecording1.BLOCK_TEMPLATE, + transactions: MockRecording1.BLOCK_TEMPLATE.transactions.map(tx => ({ ...tx })), + }; +} + +function createSeedSnapshot(height: number, subsidySats: number) { + return { + id: `seed-${height}`, + blockHeight: height, + coinbaseValueSats: subsidySats.toString(), + payoutMode: 'pplns', + payoutOutputs: [{ address: 'bc1qseed', amountSats: subsidySats }], + }; +} + +async function flushPromises(): Promise { + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); +} diff --git a/src/services/bitcoin-rpc.service.ts b/src/services/bitcoin-rpc.service.ts index 9f7cb2b..435f931 100644 --- a/src/services/bitcoin-rpc.service.ts +++ b/src/services/bitcoin-rpc.service.ts @@ -1,14 +1,77 @@ import { Injectable, OnModuleInit, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios, { AxiosInstance } from 'axios'; -import { asyncScheduler, BehaviorSubject, delay, filter, from, interval, scheduled, shareReplay, startWith, Subject, switchMap } from 'rxjs'; +import * as crypto from 'crypto'; +import { BehaviorSubject, filter, interval, ReplaySubject, shareReplay, startWith, Subject, switchMap } from 'rxjs'; import { PayoutSnapshotService } from '../ORM/payout-snapshot/payout-snapshot.service'; import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service'; import * as zmq from 'zeromq'; import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate'; import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo'; -import { RedisMessagingService } from './redis-messaging.service'; +import { BlockTemplateUpdate, RedisMessagingService, Sv1BridgeUpdate } from './redis-messaging.service'; +import { + BitcoinNetworkName, + calculateBlockSubsidySats, + createSubsidyOnlyBlockTemplate, +} from './subsidy-only-template.factory'; + +type TemplateRefreshReason = 'startup' | 'new_block' | 'periodic' | 'longpoll'; + +interface BlockNotificationTrace { + eventId: string; + reason: TemplateRefreshReason; + startedWallMs: number; + startedMonotonic: bigint; + stages: Record; +} + +interface StoredBlockTemplateEnvelope { + schemaVersion: 1; + templates: IBlockTemplate[]; +} + +interface PplnsSubsidyBridgeSeed { + candidateHeight: number; + subsidySats: number; + basisBits: string; + payoutSnapshotId: string; + payoutOutputs: NonNullable; + preparedAtMs: number; +} + +interface CanonicalEmissionState { + height: number; + previousBlockHash: string; + publishedAtMs?: number; + signature: string; +} + +interface PendingTemplatePersistence { + tipHeight: number; + templates: IBlockTemplate[]; + trace: BlockNotificationTrace; +} + +interface PendingPplnsTemplatePublication { + blockTemplate: IBlockTemplate; + soloTemplate: IBlockTemplate; + tipHeight: number; + forceCleanJobs: boolean; + tipKey: string; + templateSignature: string; + trace: BlockNotificationTrace; +} + +const DEFAULT_PPLNS_BRIDGE_SEED_MAX_AGE_MS = 5 * 60 * 1000; +const PPLNS_LISTENER_CONFIG_KEYS = [ + 'PPLNS_STRATUM_PORTS', + 'PPLNS_SECURE_STRATUM_PORTS', + 'PPLNS_STRATUM_V2_PORTS', + 'PPLNS_SV2_JDP_PORTS', + 'PPLNS_SV2_TDP_PORTS', + 'PPLNS_DATUM_PORTS', +] as const; @Injectable() export class BitcoinRpcService implements OnModuleInit { @@ -16,11 +79,40 @@ export class BitcoinRpcService implements OnModuleInit { private client: AxiosInstance; private _newBlockTemplate$: BehaviorSubject = new BehaviorSubject(undefined); + private _newSv1BridgeTemplate$ = new ReplaySubject(1); private resetTemplateInterval$ = new Subject(); private rpcRequestId = 0; + private readonly processedTemplateEvents = new Set(); + private workerTemplateTail: Promise = Promise.resolve(); + private pendingTemplatePersistence: PendingTemplatePersistence | null = null; + private persistenceDrainPromise: Promise | null = null; + private periodicTemplateRefresh: Promise | null = null; + private templatePublicationTail: Promise = Promise.resolve(); + private pendingPplnsTemplatePublication: PendingPplnsTemplatePublication | null = null; + private activePplnsTemplatePublication: PendingPplnsTemplatePublication | null = null; + private pplnsTemplatePublicationPromise: Promise | null = null; + private readonly latestBridgeTemplates = new Map<'solo' | 'pplns', IBlockTemplate>(); + private readonly canonicalEmissionStates = new Map<'solo' | 'pplns' | 'all', CanonicalEmissionState>(); + private readonly lastPublishedBridgeTipKeys = new Map<'solo' | 'pplns', string>(); + private readonly pplnsSubsidyBridgeSeeds = new Map(); + private pplnsSeedPrecomputeTail: Promise = Promise.resolve(); + private pplnsSeedPrecomputeGeneration = 0; + private pendingPplnsSeedPrecompute: { template: IBlockTemplate; generation: number } | null = null; + private pplnsSeedPrecomputeRunning = false; + private lastPublishedTemplateSignature: string | null = null; + private lastPublishedTipKey: string | null = null; + private lastPublishedCandidateHeight = -1; + private highestPublishedCandidateHeight = -1; + private reorgVerificationFenceHeight = -1; + private latestLongpollId: string | null = null; + private longpollLoopStarted = false; + private latestLegacyReadyTipKey: string | null = null; public miningInfo: IMiningInfo; public newBlockTemplate$ = this._newBlockTemplate$.pipe(filter(block => block != null), shareReplay({ refCount: true, bufferSize: 1 })); + public newSv1BridgeTemplate$ = this._newSv1BridgeTemplate$.pipe(shareReplay({ refCount: true, bufferSize: 1 })); + /** Core-authoritative early header activation; currently carries the solo subsidy bridge body. */ + public workActivationTemplate$ = this.newSv1BridgeTemplate$; constructor( private readonly configService: ConfigService, @@ -54,14 +146,41 @@ export class BitcoinRpcService implements OnModuleInit { if (process.env.MASTER != 'true') { await this.loadLatestMiningInfoForReplayProcess(); if (process.env.API_ONLY != 'true') { - await this.loadLatestTemplateForWorker(); + await this.redisMessagingService.subscribeSv1BridgeUpdates(async update => { + this.workerTemplateTail = this.workerTemplateTail + .then(() => this.handleSv1BridgeUpdate(update)) + .catch(error => console.error(`Unable to load SV1 bridge update: ${error.message}`)); + await this.workerTemplateTail; + }); + await this.redisMessagingService.subscribeBlockTemplateUpdates(async update => { + this.workerTemplateTail = this.workerTemplateTail + .then(() => this.loadTemplateUpdateForWorker(update)) + .catch(error => console.error(`Unable to load block template update: ${error.message}`)); + await this.workerTemplateTail; + }); } await this.redisMessagingService.subscribeMiningInfoUpdates(async (miningInfo: IMiningInfo) => { this.miningInfo = miningInfo; - if (process.env.API_ONLY != 'true') { - await this.loadTemplateForWorker(miningInfo.blocks); + if (process.env.API_ONLY !== 'true') { + // Compatibility with a rolling deployment whose master still + // publishes only mining-info.updated and the legacy height key. + this.workerTemplateTail = this.workerTemplateTail + .then(() => this.loadTemplateForWorker(miningInfo.blocks, 'solo')) + .catch(error => console.error(`Unable to load legacy template update: ${error.message}`)); + await this.workerTemplateTail; } }); + if (process.env.API_ONLY !== 'true') { + // Subscribe to every live channel before replaying durable state, + // closing the old-master/new-worker startup race. + this.workerTemplateTail = this.workerTemplateTail.then(async () => { + // Bridges are transient acceleration messages, not durable + // state. Replaying one before canonical work can briefly put a + // restarting worker on an orphan; load only authoritative jobs. + await this.loadLatestTemplateForWorker(); + }); + await this.workerTemplateTail; + } if (process.env.API_ONLY == 'true') { console.log('API-only process using Redis mining info replay'); } @@ -90,12 +209,17 @@ export class BitcoinRpcService implements OnModuleInit { // Don't await this, otherwise it will block the rest of the program this.listenForNewBlocks(sock); + await this.getAndBroadcastLatestTemplate('startup'); + void this.listenForLongpollTemplates(); + // Between new blocks we want refresh jobs with the latest transactions this.resetTemplateInterval$.pipe( startWith(null), switchMap(() =>interval(60000)) - ).subscribe(async () =>{ - await this.getAndBroadcastLatestTemplate(); + ).subscribe(() =>{ + void this.getAndBroadcastLatestTemplate('periodic').catch(error => { + console.error(`Periodic block template refresh failed: ${error.message}`); + }); }); } @@ -112,102 +236,1080 @@ export class BitcoinRpcService implements OnModuleInit { private async listenForNewBlocks(sock: zmq.Subscriber) { for await (const [topic, msg] of sock) { console.log("New Block"); - this.miningInfo = await this.getMiningInfo(); - await this.getAndBroadcastLatestTemplate(); + const miningInfoRefresh = this.getMiningInfo(); + try { + await this.getAndBroadcastLatestTemplate('new_block'); + } catch (error) { + console.error(`ZMQ block template refresh failed: ${error.message}`); + continue; + } + const refreshedMiningInfo = await miningInfoRefresh; + if (refreshedMiningInfo != null && refreshedMiningInfo.blocks >= (this.miningInfo?.blocks ?? 0)) { + this.miningInfo = refreshedMiningInfo; + // Never wake pre-channel workers until their compatibility + // template has been written. If it is already ready, refresh + // their mining-info payload now as well. + if (this.latestLegacyReadyTipKey === this.lastPublishedTipKey) { + void this.redisMessagingService.setLatestMiningInfo(refreshedMiningInfo) + .then(() => this.redisMessagingService.publishMiningInfoUpdate(refreshedMiningInfo)) + .catch(error => console.error(`Unable to refresh mining info: ${error.message}`)); + } + } //Reset the block update interval this.resetTemplateInterval$.next(); } } - public async getAndBroadcastLatestTemplate() { + public async getAndBroadcastLatestTemplate( + reason: TemplateRefreshReason = 'periodic', + longpollId?: string, + ) { + if (reason === 'periodic') { + if (this.periodicTemplateRefresh != null) { + await this.periodicTemplateRefresh; + return; + } + const refresh = this.getAndBroadcastLatestTemplateOnce(reason, longpollId); + this.periodicTemplateRefresh = refresh.finally(() => { + this.periodicTemplateRefresh = null; + }); + await this.periodicTemplateRefresh; + return; + } + await this.getAndBroadcastLatestTemplateOnce(reason, longpollId); + } + + private async getAndBroadcastLatestTemplateOnce( + reason: TemplateRefreshReason, + longpollId?: string, + ): Promise { if (this.miningInfo?.blocks == null) { console.warn('Skipping block template broadcast because mining info is not available'); return; } - const blockTemplate = await this.loadBlockTemplate(this.miningInfo.blocks); + const trace = this.startTrace(reason); + const blockTemplate = await this.fetchBlockTemplate(trace, longpollId); if (blockTemplate == null) { console.warn(`Skipping block template broadcast for height ${this.miningInfo.blocks}; block template is not available`); return; } - this._newBlockTemplate$.next(blockTemplate); - await this.redisMessagingService.setLatestMiningInfo(this.miningInfo); - await this.redisMessagingService.setBlockTemplate(this.miningInfo.blocks, blockTemplate); - await this.redisMessagingService.publishMiningInfoUpdate(this.miningInfo); + const publication = this.templatePublicationTail.then(() => + this.publishFetchedBlockTemplate(blockTemplate, reason, trace), + ); + this.templatePublicationTail = publication.catch(error => { + console.error(`Unable to publish fetched block template: ${error.message}`); + }); + await publication; + } + + private async publishFetchedBlockTemplate( + blockTemplate: IBlockTemplate, + reason: TemplateRefreshReason, + trace: BlockNotificationTrace, + ): Promise { + const tipHeight = blockTemplate.height - 1; + const tipKey = `${blockTemplate.height}:${blockTemplate.previousblockhash}`; + const isConflictingTip = this.lastPublishedTipKey != null + && tipKey !== this.lastPublishedTipKey; + const requiresBestHashVerification = isConflictingTip + && (blockTemplate.height <= this.lastPublishedCandidateHeight + || this.reorgVerificationFenceHeight >= 0); + let verifiedTipTransition = false; + if (requiresBestHashVerification) { + let bestBlockHash: string; + try { + bestBlockHash = await this.callRpc('getbestblockhash'); + } catch (error) { + console.warn( + `Refusing unverified non-forward template ${tipKey}: ${error.message ?? error}`, + ); + return; + } + if (bestBlockHash !== blockTemplate.previousblockhash) { + console.warn( + `Ignoring stale non-forward template ${tipKey}; Core best block is ${bestBlockHash}`, + ); + return; + } + verifiedTipTransition = true; + } + const isNewTip = tipKey !== this.lastPublishedTipKey; + this.miningInfo = { ...this.miningInfo, blocks: tipHeight }; + this.markTrace(trace, 'template_ready'); + + if (isNewTip) { + // Start bridge publication first, but never await it on the + // authoritative path. With a healthy Redis connection its command + // is enqueued first; if Redis stalls, canonical storage/publication + // can still make progress independently. + void this.publishSoloSubsidyBridge(blockTemplate, trace); + void this.publishPplnsSubsidyBridge(blockTemplate, trace); + } + + const templateSignature = this.createTemplateSignature(blockTemplate); + if (templateSignature === this.lastPublishedTemplateSignature) { + return; + } + + const soloTemplate: IBlockTemplate = { + ...blockTemplate, + payoutMode: 'solo', + payoutSnapshotId: undefined, + payoutOutputs: undefined, + forceCleanJobs: isNewTip, + jobType: 'full', + notificationEventId: trace.eventId, + notificationPublishedAtMs: Date.now(), + }; + + await this.redisMessagingService.setBlockTemplate(tipHeight, soloTemplate); + this.markTrace(trace, 'redis_template_stored'); + await Promise.all([ + this.publishBlockTemplateUpdate(soloTemplate, trace.eventId), + ]); + this.markTrace(trace, 'workers_notified'); + + // The master has no Stratum listeners. Emitting here used to synchronously + // parse every transaction twice before Redis workers could even begin. + if (process.env.MASTER !== 'true') { + this.emitCanonicalTemplate(soloTemplate); + } + + const priorCandidateHeight = this.lastPublishedCandidateHeight; + if (verifiedTipTransition && blockTemplate.height <= priorCandidateHeight) { + this.reorgVerificationFenceHeight = Math.max( + this.reorgVerificationFenceHeight, + this.highestPublishedCandidateHeight, + priorCandidateHeight, + ); + } + this.lastPublishedTemplateSignature = templateSignature; + this.lastPublishedTipKey = tipKey; + this.lastPublishedCandidateHeight = blockTemplate.height; + this.highestPublishedCandidateHeight = Math.max( + this.highestPublishedCandidateHeight, + blockTemplate.height, + ); + if (verifiedTipTransition + && this.reorgVerificationFenceHeight >= 0 + && blockTemplate.height > this.reorgVerificationFenceHeight) { + this.reorgVerificationFenceHeight = -1; + } + this.queueTemplatePersistence(tipHeight, [soloTemplate], trace); + this.queuePplnsSubsidyBridgeSeedPrecompute(blockTemplate); + this.queuePplnsTemplatePublication({ + blockTemplate, + soloTemplate, + tipHeight, + forceCleanJobs: isNewTip, + tipKey, + templateSignature, + trace, + }); + this.logTrace(trace, soloTemplate); + } + + private createTemplateSignature(blockTemplate: IBlockTemplate): string { + const transactionBodyChecksum = crypto.createHash('sha256'); + const lengthFrame = Buffer.allocUnsafe(4); + const updateFrame = (value: string, field: string, encoding: BufferEncoding = 'utf8') => { + if (typeof value !== 'string') { + throw new Error(`${field} must be a string`); + } + if (encoding === 'hex' && (value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value))) { + throw new Error(`${field} must be valid hexadecimal`); + } + const byteLength = encoding === 'hex' + ? value.length / 2 + : Buffer.byteLength(value, encoding); + lengthFrame.writeUInt32BE(byteLength, 0); + transactionBodyChecksum.update(lengthFrame); + transactionBodyChecksum.update(value, encoding); + }; + + blockTemplate.transactions.forEach((transaction, index) => { + updateFrame(transaction.txid, `transactions[${index}].txid`); + updateFrame(transaction.hash ?? '', `transactions[${index}].hash`); + updateFrame(transaction.data, `transactions[${index}].data`, 'hex'); + }); + + return [ + blockTemplate.height, + blockTemplate.previousblockhash, + blockTemplate.version, + blockTemplate.bits, + blockTemplate.mintime, + blockTemplate.curtime, + blockTemplate.coinbasevalue, + blockTemplate.vbrequired, + blockTemplate.default_witness_commitment, + blockTemplate.transactions.length, + transactionBodyChecksum.digest('hex'), + ].join(':'); } private async loadLatestTemplateForWorker() { - const latestMiningInfo = await this.redisMessagingService.getLatestMiningInfo(); + const [latestMiningInfo, latestBlockTemplates] = await Promise.all([ + this.redisMessagingService.getLatestMiningInfo(), + this.redisMessagingService.getLatestBlockTemplates(), + ]); if (latestMiningInfo != null) { this.miningInfo = latestMiningInfo; - await this.loadTemplateForWorker(latestMiningInfo.blocks); - return; } - const latestBlockTemplate = await this.redisMessagingService.getLatestBlockTemplate(); - if (latestBlockTemplate != null) { - this._newBlockTemplate$.next(latestBlockTemplate); + const expectedLegacyCandidateHeight = latestMiningInfo?.blocks == null + ? null + : latestMiningInfo.blocks + 1; + const pointerByMode = new Map<'solo' | 'pplns', IBlockTemplate>(); + for (const template of latestBlockTemplates) { + const modes: Array<'solo' | 'pplns'> = template.payoutMode === 'solo' + ? ['solo'] + : template.payoutMode === 'pplns' + ? ['pplns'] + : ['solo', 'pplns']; + for (const mode of modes) { + const existing = pointerByMode.get(mode); + if (existing == null + || template.height > existing.height + || (template.height === existing.height + && (template.notificationPublishedAtMs ?? 0) + >= (existing.notificationPublishedAtMs ?? 0))) { + pointerByMode.set(mode, template); + } + } + } + + const selectedByMode = new Map<'solo' | 'pplns', IBlockTemplate>(); + for (const mode of ['solo', 'pplns'] as const) { + const pointer = pointerByMode.get(mode); + if (pointer != null + && (expectedLegacyCandidateHeight == null + || pointer.height >= expectedLegacyCandidateHeight)) { + selectedByMode.set(mode, pointer); + continue; + } + + const legacyCandidates = latestMiningInfo?.blocks == null + ? [] + : await this.readTemplatesForWorker(latestMiningInfo.blocks, mode); + const legacy = legacyCandidates.reduce((newest, candidate) => ( + newest == null + || candidate.height > newest.height + || (candidate.height === newest.height + && (candidate.notificationPublishedAtMs ?? 0) + >= (newest.notificationPublishedAtMs ?? 0)) + ? candidate + : newest + ), null); + if (pointer != null && this.isPointerPreferredForReplay(pointer, legacy)) { + selectedByMode.set(mode, pointer); + } else if (legacy != null) { + selectedByMode.set(mode, legacy); + } else if (pointer != null) { + selectedByMode.set(mode, pointer); + } + } + + const replayTemplates = [...new Map( + [...selectedByMode.values()].map(template => [ + this.getCanonicalEmissionSignature(template), + template, + ]), + ).values()].sort((left, right) => ( + left.height - right.height + || (left.notificationPublishedAtMs ?? 0) - (right.notificationPublishedAtMs ?? 0) + )); + for (const template of replayTemplates) { + this.emitCanonicalTemplate(template); } } - private async loadTemplateForWorker(blockHeight: number) { - const redisBlockTemplate = await this.redisMessagingService.getBlockTemplate(blockHeight); - if (redisBlockTemplate != null) { - this._newBlockTemplate$.next(redisBlockTemplate); + private isPointerPreferredForReplay( + pointer: IBlockTemplate, + legacy: IBlockTemplate | null, + ): boolean { + if (legacy == null || pointer.height > legacy.height) { + return true; + } + if (pointer.height < legacy.height) { + return pointer.notificationPublishedAtMs != null + && (legacy.notificationPublishedAtMs == null + || pointer.notificationPublishedAtMs > legacy.notificationPublishedAtMs); + } + if (pointer.notificationPublishedAtMs == null) { + return legacy.notificationPublishedAtMs == null; + } + return legacy.notificationPublishedAtMs == null + || pointer.notificationPublishedAtMs >= legacy.notificationPublishedAtMs; + } + + private async loadTemplateUpdateForWorker(update: BlockTemplateUpdate): Promise { + if (this.processedTemplateEvents.has(update.eventId)) { return; } + this.rememberProcessedTemplateEvent(update.eventId); + await this.loadTemplateForWorker( + update.height, + update.payoutMode, + update.previousBlockHash, + update.publishedAtMs, + ); + } + + private async loadTemplateForWorker( + blockHeight: number, + payoutMode: 'solo' | 'pplns' = 'solo', + previousBlockHash?: string, + publishedAtMs?: number, + ) { + for (const template of await this.readTemplatesForWorker( + blockHeight, + payoutMode, + previousBlockHash, + publishedAtMs, + )) { + this.emitCanonicalTemplate(template); + } + } + + private async readTemplatesForWorker( + blockHeight: number, + payoutMode: 'solo' | 'pplns' = 'solo', + previousBlockHash?: string, + publishedAtMs?: number, + ): Promise { + const redisBlockTemplate = await this.redisMessagingService.getBlockTemplate( + blockHeight, + payoutMode, + previousBlockHash, + ); + if (redisBlockTemplate != null) { + return [{ + ...redisBlockTemplate, + notificationPublishedAtMs: publishedAtMs ?? redisBlockTemplate.notificationPublishedAtMs, + }]; + } const savedBlockTemplate = await this.rpcBlockService.getSavedBlockTemplate(blockHeight); if (savedBlockTemplate?.data != null) { - this._newBlockTemplate$.next(JSON.parse(savedBlockTemplate.data)); + return this.parseStoredBlockTemplates(savedBlockTemplate.data) + .filter(template => template.payoutMode == null + || template.payoutMode === 'all' + || template.payoutMode === payoutMode); } + return []; } - private async loadBlockTemplate(blockHeight: number) { + private async fetchBlockTemplate(trace: BlockNotificationTrace, longpollId?: string) { - console.log(`Master fetching block template ${blockHeight}`); + console.log(`Master fetching block template after tip ${this.miningInfo?.blocks}`); let blockTemplate: IBlockTemplate; + let retryDelayMs = this.getPositiveIntegerEnv('BLOCK_TEMPLATE_RETRY_INITIAL_MS', 25); + const maxRetryDelayMs = this.getPositiveIntegerEnv('BLOCK_TEMPLATE_RETRY_MAX_MS', 1000); while (blockTemplate == null) { try { blockTemplate = await this.callRpc('getblocktemplate', [ { rules: ['segwit'], mode: 'template', - capabilities: ['serverlist', 'proposal'] + capabilities: ['serverlist', 'proposal'], + ...(longpollId == null ? {} : { longpollid: longpollId }), } - ]); + ], longpollId == null + ? undefined + : this.getPositiveIntegerEnv('BLOCK_TEMPLATE_LONGPOLL_TIMEOUT_MS', 10 * 60 * 1000)); } catch (e) { console.warn(`Block template is not available yet: ${e.message ?? e}`); - await new Promise(resolve => setTimeout(resolve, 10_000)); + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + retryDelayMs = Math.min(maxRetryDelayMs, retryDelayMs * 2); } } + if (trace.reason === 'longpoll') { + const waitMs = Number(process.hrtime.bigint() - trace.startedMonotonic) / 1e6; + trace.startedWallMs = Date.now(); + trace.startedMonotonic = process.hrtime.bigint(); + trace.stages = { start: 0, longpollWait: waitMs }; + } else { + this.markTrace(trace, 'getblocktemplate_complete'); + } + this.latestLongpollId = blockTemplate.longpollid; + return blockTemplate; + } + + private async listenForLongpollTemplates(): Promise { + if (this.longpollLoopStarted || process.env.MASTER !== 'true') { + return; + } + this.longpollLoopStarted = true; + + while (process.env.MASTER === 'true') { + const longpollId = this.latestLongpollId; + if (longpollId == null) { + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + try { + await this.getAndBroadcastLatestTemplate('longpoll', longpollId); + } catch (error) { + console.error(`Block template longpoll failed: ${error.message}`); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + } + + private queuePplnsTemplatePublication( + pending: PendingPplnsTemplatePublication, + ): void { + const preserveCleanSwitch = [ + this.activePplnsTemplatePublication, + this.pendingPplnsTemplatePublication, + ].some(publication => ( + publication?.tipKey === pending.tipKey + && publication.forceCleanJobs + )); + this.pendingPplnsTemplatePublication = { + ...pending, + forceCleanJobs: pending.forceCleanJobs || preserveCleanSwitch, + }; + if (this.pplnsTemplatePublicationPromise != null) { + return; + } + this.pplnsTemplatePublicationPromise = this.drainPplnsTemplatePublications() + .catch(error => { + console.error(`Unable to publish PPLNS template: ${error.message}`); + }) + .finally(() => { + this.pplnsTemplatePublicationPromise = null; + if (this.pendingPplnsTemplatePublication != null) { + this.queuePplnsTemplatePublication(this.pendingPplnsTemplatePublication); + } + }); + } + + private async drainPplnsTemplatePublications(): Promise { + while (this.pendingPplnsTemplatePublication != null) { + const pending = this.pendingPplnsTemplatePublication; + this.pendingPplnsTemplatePublication = null; + this.activePplnsTemplatePublication = pending; + try { + await this.createAndPublishPplnsTemplate( + pending.blockTemplate, + pending.soloTemplate, + pending.tipHeight, + pending.forceCleanJobs, + pending.tipKey, + pending.templateSignature, + pending.trace, + ); + } finally { + this.activePplnsTemplatePublication = null; + } + } + } + + private async createAndPublishPplnsTemplate( + blockTemplate: IBlockTemplate, + soloTemplate: IBlockTemplate, + tipHeight: number, + forceCleanJobs: boolean, + tipKey: string, + templateSignature: string, + trace: BlockNotificationTrace, + ): Promise { + const legacyRequiresPplns = this.hasConfiguredPplnsListeners(); + let legacyTemplate: IBlockTemplate | null = legacyRequiresPplns + ? null + : soloTemplate; try { const payoutSnapshot = await this.payoutSnapshotService?.createSnapshotForTemplate({ - blockHeight, + blockHeight: blockTemplate.height, coinbaseValueSats: blockTemplate.coinbasevalue, networkDifficulty: this.calculateNetworkDifficulty(parseInt(blockTemplate.bits, 16)), }); - if (payoutSnapshot != null) { - blockTemplate.payoutSnapshotId = payoutSnapshot.id; - blockTemplate.payoutOutputs = payoutSnapshot.payoutOutputs; + this.markTrace(trace, 'payout_snapshot_complete'); + if (payoutSnapshot == null) { + return; } + if (typeof payoutSnapshot.id !== 'string' + || payoutSnapshot.id.trim().length === 0 + || !Array.isArray(payoutSnapshot.payoutOutputs) + || payoutSnapshot.payoutOutputs.length === 0) { + console.error( + `PPLNS snapshot for ${tipKey} has no usable payout outputs; skipping PPLNS publication`, + ); + return; + } + if (this.lastPublishedTipKey !== tipKey + || this.lastPublishedTemplateSignature !== templateSignature) { + console.warn(`Discarding superseded PPLNS snapshot for ${tipKey}`); + return; + } + + const pplnsTemplate: IBlockTemplate = { + ...blockTemplate, + payoutMode: 'pplns', + payoutSnapshotId: payoutSnapshot.id, + payoutOutputs: payoutSnapshot.payoutOutputs, + forceCleanJobs, + jobType: 'full', + notificationEventId: `${trace.eventId}:pplns`, + notificationPublishedAtMs: Date.now(), + }; + legacyTemplate = pplnsTemplate; + await this.redisMessagingService.setBlockTemplate(tipHeight, pplnsTemplate); + this.markTrace(trace, 'pplns_template_stored'); + await this.publishBlockTemplateUpdate(pplnsTemplate, `${trace.eventId}:pplns`); + this.markTrace(trace, 'pplns_workers_notified'); + this.queueTemplatePersistence(tipHeight, [soloTemplate, pplnsTemplate], trace); + this.logTrace(trace, pplnsTemplate); } catch (e) { - console.error('Error creating payout snapshot', e); + console.error('Error creating or publishing payout snapshot template', e); + } finally { + // Pre-channel workers understand one template per height. Publish a + // PPLNS-populated template when available (solo clients ignore those + // outputs), and only then wake them through mining-info.updated. + if (this.lastPublishedTipKey === tipKey + && this.lastPublishedTemplateSignature === templateSignature) { + if (legacyTemplate == null) { + this.markTrace(trace, 'legacy_workers_held_for_pplns'); + console.warn( + `Holding legacy workers on prior work for ${tipKey}; a safe PPLNS compatibility template is unavailable`, + ); + return; + } + try { + await this.redisMessagingService.setLegacyBlockTemplate(tipHeight, legacyTemplate); + this.latestLegacyReadyTipKey = tipKey; + await this.redisMessagingService.setLatestMiningInfo(this.miningInfo); + await this.redisMessagingService.publishMiningInfoUpdate(this.miningInfo); + this.markTrace(trace, 'legacy_workers_notified'); + } catch (error) { + console.error(`Unable to publish legacy template compatibility update: ${error.message}`); + } + } + } + } + + private async publishBlockTemplateUpdate(blockTemplate: IBlockTemplate, eventId: string): Promise { + await this.redisMessagingService.publishBlockTemplateUpdate({ + schemaVersion: 1, + eventId, + height: blockTemplate.height - 1, + previousBlockHash: blockTemplate.previousblockhash, + payoutMode: blockTemplate.payoutMode === 'pplns' ? 'pplns' : 'solo', + publishedAtMs: blockTemplate.notificationPublishedAtMs ?? Date.now(), + }); + } + + private async publishSoloSubsidyBridge( + authoritativeTemplate: IBlockTemplate, + trace: BlockNotificationTrace, + ): Promise { + if (!this.isSv1SubsidyBridgeEnabled() + || !this.getSv1SubsidyBridgePayoutModes().has('solo')) { + return false; } + const tipKey = `${authoritativeTemplate.height}:${authoritativeTemplate.previousblockhash}`; + if (this.lastPublishedBridgeTipKeys.get('solo') === tipKey) { + return false; + } + + // Reserve the tip before the Redis await so duplicate ZMQ/longpoll + // callbacks cannot launch a second in-flight bridge publication. + this.lastPublishedBridgeTipKeys.set('solo', tipKey); + try { - console.log(`Saving block ${blockHeight}`); - await this.rpcBlockService.saveBlock(blockHeight, JSON.stringify(blockTemplate)); + const bridgeTemplate = createSubsidyOnlyBlockTemplate({ + authoritativeTemplate, + network: this.getNetworkName(), + payoutMode: 'solo', + halvingInterval: this.getOptionalPositiveIntegerEnv('SUBSIDY_HALVING_INTERVAL'), + }); + this.validateSubsidyAgainstAuthoritativeTemplate( + authoritativeTemplate, + bridgeTemplate.coinbasevalue, + ); + + bridgeTemplate.mintime = Math.max( + authoritativeTemplate.mintime, + authoritativeTemplate.curtime, + Math.floor(Date.now() / 1000), + ); + bridgeTemplate.notificationEventId = `${trace.eventId}:bridge`; + const update: Sv1BridgeUpdate = { + schemaVersion: 1, + type: 'subsidy-bridge', + eventId: `${trace.eventId}:bridge`, + template: bridgeTemplate, + publishedAtMs: Date.now(), + }; + await this.redisMessagingService.publishSv1BridgeUpdate(update); + this.markTrace(trace, 'sv1_bridge_workers_notified'); + return true; + } catch (error) { + if (this.lastPublishedBridgeTipKeys.get('solo') === tipKey) { + this.lastPublishedBridgeTipKeys.delete('solo'); + } + console.error(`Skipping SV1 subsidy bridge: ${error.message}`); + return false; + } + } + + private async publishPplnsSubsidyBridge( + authoritativeTemplate: IBlockTemplate, + trace: BlockNotificationTrace, + ): Promise { + if (!this.isSv1SubsidyBridgeEnabled() + || !this.getSv1SubsidyBridgePayoutModes().has('pplns')) { + return false; + } + + const tipKey = `${authoritativeTemplate.height}:${authoritativeTemplate.previousblockhash}`; + if (this.lastPublishedBridgeTipKeys.get('pplns') === tipKey) { + return false; + } + + const seed = this.getFreshPplnsSubsidyBridgeSeed(authoritativeTemplate.height); + if (seed == null) { + return false; + } + + this.lastPublishedBridgeTipKeys.set('pplns', tipKey); + + try { + const expectedSubsidy = calculateBlockSubsidySats( + authoritativeTemplate.height, + this.getNetworkName(), + this.getOptionalPositiveIntegerEnv('SUBSIDY_HALVING_INTERVAL'), + ); + if (seed.subsidySats !== expectedSubsidy) { + throw new Error( + `PPLNS bridge seed subsidy ${seed.subsidySats} does not match expected subsidy ${expectedSubsidy}`, + ); + } + if (seed.basisBits.toLowerCase() !== authoritativeTemplate.bits.toLowerCase()) { + throw new Error( + `PPLNS bridge seed bits ${seed.basisBits} do not match authoritative bits ${authoritativeTemplate.bits}`, + ); + } + this.validateSubsidyAgainstAuthoritativeTemplate( + authoritativeTemplate, + expectedSubsidy, + ); + + const bridgeTemplate = createSubsidyOnlyBlockTemplate({ + authoritativeTemplate, + network: this.getNetworkName(), + payoutMode: 'pplns', + halvingInterval: this.getOptionalPositiveIntegerEnv('SUBSIDY_HALVING_INTERVAL'), + payoutSnapshot: { + id: seed.payoutSnapshotId, + payoutOutputs: seed.payoutOutputs, + }, + }); + bridgeTemplate.mintime = Math.max( + authoritativeTemplate.mintime, + authoritativeTemplate.curtime, + Math.floor(Date.now() / 1000), + ); + bridgeTemplate.notificationEventId = `${trace.eventId}:bridge:pplns`; + bridgeTemplate.payoutBridgeSeedCreatedAtMs = seed.preparedAtMs; + const update: Sv1BridgeUpdate = { + schemaVersion: 1, + type: 'subsidy-bridge', + eventId: `${trace.eventId}:bridge:pplns`, + template: bridgeTemplate, + publishedAtMs: Date.now(), + }; + await this.redisMessagingService.publishSv1BridgeUpdate(update); + this.markTrace(trace, 'sv1_pplns_bridge_workers_notified'); + return true; + } catch (error) { + if (this.lastPublishedBridgeTipKeys.get('pplns') === tipKey) { + this.lastPublishedBridgeTipKeys.delete('pplns'); + } + console.error(`Skipping PPLNS SV1 subsidy bridge: ${error.message}`); + return false; + } + } + + private queuePplnsSubsidyBridgeSeedPrecompute( + canonicalTemplate: IBlockTemplate, + ): void { + if (!this.isSv1SubsidyBridgeEnabled() + || !this.getSv1SubsidyBridgePayoutModes().has('pplns') + || this.payoutSnapshotService == null) { + return; + } + + const generation = ++this.pplnsSeedPrecomputeGeneration; + this.pendingPplnsSeedPrecompute = { template: canonicalTemplate, generation }; + if (this.pplnsSeedPrecomputeRunning) { + return; + } + this.pplnsSeedPrecomputeRunning = true; + this.pplnsSeedPrecomputeTail = this.drainPplnsSeedPrecompute() + .catch(error => { + console.error(`Unable to precompute PPLNS subsidy bridge seed: ${error.message}`); + }) + .finally(() => { + this.pplnsSeedPrecomputeRunning = false; + if (this.pendingPplnsSeedPrecompute != null) { + this.queuePplnsSubsidyBridgeSeedPrecompute( + this.pendingPplnsSeedPrecompute.template, + ); + } + }); + } + + private async drainPplnsSeedPrecompute(): Promise { + while (this.pendingPplnsSeedPrecompute != null) { + const { template, generation } = this.pendingPplnsSeedPrecompute; + this.pendingPplnsSeedPrecompute = null; + const candidateHeight = template.height + 1; + try { + const subsidySats = calculateBlockSubsidySats( + candidateHeight, + this.getNetworkName(), + this.getOptionalPositiveIntegerEnv('SUBSIDY_HALVING_INTERVAL'), + ); + const snapshot = await this.payoutSnapshotService.createSnapshotForTemplate({ + blockHeight: candidateHeight, + coinbaseValueSats: subsidySats, + networkDifficulty: this.calculateNetworkDifficulty( + parseInt(template.bits, 16), + ), + visibility: 'bridge_seed', + }); + if (generation !== this.pplnsSeedPrecomputeGeneration) { + continue; + } + if (snapshot == null) { + this.pplnsSubsidyBridgeSeeds.delete(candidateHeight); + continue; + } + if (snapshot.blockHeight !== candidateHeight + || Number(snapshot.coinbaseValueSats) !== subsidySats) { + throw new Error( + `PPLNS seed snapshot does not match candidate height ${candidateHeight} and subsidy ${subsidySats}`, + ); + } + const payoutOutputs = this.validatePplnsSeedOutputs( + snapshot.id, + snapshot.payoutOutputs, + subsidySats, + ); + this.storePplnsSubsidyBridgeSeed({ + candidateHeight, + subsidySats, + basisBits: template.bits, + payoutSnapshotId: snapshot.id, + payoutOutputs, + preparedAtMs: Date.now(), + }); + } catch (error) { + if (generation === this.pplnsSeedPrecomputeGeneration) { + this.pplnsSubsidyBridgeSeeds.delete(candidateHeight); + } + console.error(`Unable to precompute PPLNS subsidy bridge seed: ${error.message}`); + } + } + } + + private getFreshPplnsSubsidyBridgeSeed( + candidateHeight: number, + ): PplnsSubsidyBridgeSeed | null { + const seed = this.pplnsSubsidyBridgeSeeds.get(candidateHeight); + if (seed == null) { + return null; + } + const ageMs = Date.now() - seed.preparedAtMs; + const maxAgeMs = this.getPositiveIntegerEnv( + 'SV1_SUBSIDY_BRIDGE_PPLNS_SEED_MAX_AGE_MS', + DEFAULT_PPLNS_BRIDGE_SEED_MAX_AGE_MS, + ); + if (ageMs < 0 || ageMs > maxAgeMs) { + this.pplnsSubsidyBridgeSeeds.delete(candidateHeight); + return null; + } + return seed; + } + + private storePplnsSubsidyBridgeSeed(seed: PplnsSubsidyBridgeSeed): void { + this.pplnsSubsidyBridgeSeeds.set(seed.candidateHeight, seed); + const currentHeight = seed.candidateHeight - 1; + for (const height of this.pplnsSubsidyBridgeSeeds.keys()) { + if (height < currentHeight || height > seed.candidateHeight) { + this.pplnsSubsidyBridgeSeeds.delete(height); + } + } + } + + private validatePplnsSeedOutputs( + payoutSnapshotId: string, + payoutOutputs: IBlockTemplate['payoutOutputs'], + subsidySats: number, + ): NonNullable { + if (typeof payoutSnapshotId !== 'string' || payoutSnapshotId.trim().length === 0) { + throw new Error('PPLNS bridge seed requires a payout snapshot id'); + } + if (!Array.isArray(payoutOutputs) || payoutOutputs.length === 0) { + throw new Error('PPLNS bridge seed requires explicit payout outputs'); + } + + let allocatedSats = 0; + const copiedOutputs = payoutOutputs.map((output) => { + if (typeof output.address !== 'string' || output.address.trim().length === 0 + || !Number.isSafeInteger(output.amountSats) + || output.amountSats < 0) { + throw new Error('PPLNS bridge seed payout outputs require address and amountSats'); + } + allocatedSats += output.amountSats; + return { address: output.address, amountSats: output.amountSats }; + }); + if (allocatedSats !== subsidySats) { + throw new Error('PPLNS bridge seed payout outputs must allocate the entire subsidy'); + } + return copiedOutputs; + } + + private validateSubsidyAgainstAuthoritativeTemplate( + authoritativeTemplate: IBlockTemplate, + subsidySats: number, + ): void { + const totalFees = authoritativeTemplate.transactions.reduce((sum, transaction) => { + if (!Number.isSafeInteger(transaction.fee) || transaction.fee < 0) { + throw new Error('GBT transaction fee is missing or invalid'); + } + return sum + transaction.fee; + }, 0); + if (subsidySats + totalFees !== authoritativeTemplate.coinbasevalue) { + throw new Error( + `GBT coinbase value ${authoritativeTemplate.coinbasevalue} does not equal subsidy ${subsidySats} plus fees ${totalFees}`, + ); + } + } + + private async handleSv1BridgeUpdate(update: Sv1BridgeUpdate): Promise { + if (this.processedTemplateEvents.has(update.eventId)) { + return; + } + this.rememberProcessedTemplateEvent(update.eventId); + const template = { + ...update.template, + notificationPublishedAtMs: update.publishedAtMs, + }; + const payoutMode = template.payoutMode === 'pplns' ? 'pplns' : 'solo'; + if (this.miningInfo?.blocks != null + && template.height < this.miningInfo.blocks + 1) { + console.warn( + `Ignoring stale SV1 bridge ${template.height}:${template.previousblockhash}; current tip is ${this.miningInfo.blocks}`, + ); + return; + } + if (this.isBridgeSupersededByCanonical(template, payoutMode)) { + console.warn( + `Ignoring SV1 bridge ${template.height}:${template.previousblockhash}; canonical ${payoutMode} work is already active`, + ); + return; + } + const existing = this.latestBridgeTemplates.get(payoutMode); + if (existing != null + && (template.height < existing.height + || (template.height === existing.height + && (template.notificationPublishedAtMs ?? 0) + < (existing.notificationPublishedAtMs ?? 0)))) { + return; + } + this.latestBridgeTemplates.set(payoutMode, template); + this._newSv1BridgeTemplate$.next(template); + } + + private isBridgeSupersededByCanonical( + bridge: IBlockTemplate, + payoutMode: 'solo' | 'pplns', + ): boolean { + const bridgePublishedAtMs = bridge.notificationPublishedAtMs; + const canonicalStates = [ + this.canonicalEmissionStates.get(payoutMode), + this.canonicalEmissionStates.get('all'), + ].filter((state): state is CanonicalEmissionState => state != null); + + return canonicalStates.some(canonical => { + if (canonical.height > bridge.height) { + return true; + } + if (canonical.height < bridge.height) { + return false; + } + if (canonical.previousBlockHash === bridge.previousblockhash) { + // Once the full job for this tip is active, replaying an empty + // bridge only discards fees and forces an unnecessary switch. + return true; + } + // A conflicting same-height bridge is a possible reorg only when it + // is provably newer. Timestamp-less legacy canonical work remains + // authoritative rather than guessing and switching to an orphan. + return canonical.publishedAtMs == null + || bridgePublishedAtMs == null + || canonical.publishedAtMs >= bridgePublishedAtMs; + }); + } + + private emitCanonicalTemplate(template: IBlockTemplate): void { + const payoutMode = template.payoutMode === 'pplns' + ? 'pplns' + : template.payoutMode === 'solo' + ? 'solo' + : 'all'; + const signature = this.getCanonicalEmissionSignature(template); + const prior = this.canonicalEmissionStates.get(payoutMode); + if (prior?.signature === signature) { + return; + } + const applicablePriorModes: Array<'solo' | 'pplns' | 'all'> = payoutMode === 'all' + ? ['all', 'solo', 'pplns'] + : [payoutMode, 'all']; + for (const priorMode of applicablePriorModes) { + const applicablePrior = this.canonicalEmissionStates.get(priorMode); + if (applicablePrior == null) { + continue; + } + const hasNewerPublicationTime = applicablePrior.publishedAtMs != null + && template.notificationPublishedAtMs != null + && template.notificationPublishedAtMs >= applicablePrior.publishedAtMs; + if (template.height < applicablePrior.height && !hasNewerPublicationTime) { + console.warn( + `Ignoring out-of-order canonical template ${template.height}:${template.previousblockhash}`, + ); + return; + } + if (template.height === applicablePrior.height + && template.previousblockhash !== applicablePrior.previousBlockHash + && applicablePrior.publishedAtMs != null + && template.notificationPublishedAtMs != null + && template.notificationPublishedAtMs < applicablePrior.publishedAtMs) { + console.warn( + `Ignoring older same-height canonical template ${template.height}:${template.previousblockhash}`, + ); + return; + } + } + this.canonicalEmissionStates.set(payoutMode, { + height: template.height, + previousBlockHash: template.previousblockhash, + publishedAtMs: template.notificationPublishedAtMs, + signature, + }); + const bridgeMode = payoutMode === 'all' ? null : payoutMode; + const bridge = bridgeMode == null ? null : this.latestBridgeTemplates.get(bridgeMode); + if (bridge != null + && template.height >= bridge.height + && (template.height > bridge.height + || template.previousblockhash !== bridge.previousblockhash)) { + this.latestBridgeTemplates.delete(bridgeMode); + } + this._newBlockTemplate$.next(template); + } + + private getCanonicalEmissionSignature(template: IBlockTemplate): string { + return [ + template.notificationEventId ?? '', + template.height, + template.previousblockhash, + template.payoutMode ?? 'all', + template.jobType ?? 'full', + template.payoutSnapshotId ?? '', + template.longpollid ?? '', + template.curtime, + template.coinbasevalue, + template.transactions.length, + template.transactions.at(-1)?.txid ?? '', + ].join(':'); + } + + private rememberProcessedTemplateEvent(eventId: string): void { + this.processedTemplateEvents.add(eventId); + if (this.processedTemplateEvents.size > 1024) { + const oldest = this.processedTemplateEvents.values().next().value; + if (oldest != null) { + this.processedTemplateEvents.delete(oldest); + } + } + } + + private queueTemplatePersistence( + tipHeight: number, + templates: IBlockTemplate[], + trace: BlockNotificationTrace, + ): void { + this.pendingTemplatePersistence = { tipHeight, templates, trace }; + if (this.persistenceDrainPromise != null) { + return; + } + this.persistenceDrainPromise = this.drainTemplatePersistence() + .catch(error => console.error('Error saving block template envelope', error)) + .finally(() => { + this.persistenceDrainPromise = null; + if (this.pendingTemplatePersistence != null) { + this.queueTemplatePersistence( + this.pendingTemplatePersistence.tipHeight, + this.pendingTemplatePersistence.templates, + this.pendingTemplatePersistence.trace, + ); + } + }); + } + + private async drainTemplatePersistence(): Promise { + while (this.pendingTemplatePersistence != null) { + const pending = this.pendingTemplatePersistence; + this.pendingTemplatePersistence = null; + await this.persistBlockTemplates( + pending.tipHeight, + pending.templates, + pending.trace, + ); + } + } + + private async persistBlockTemplates( + tipHeight: number, + templates: IBlockTemplate[], + trace: BlockNotificationTrace, + ): Promise { + const envelope: StoredBlockTemplateEnvelope = { schemaVersion: 1, templates }; + await this.persistBlockTemplate(tipHeight, JSON.stringify(envelope), trace); + } + + private async persistBlockTemplate( + tipHeight: number, + serializedBlockTemplate: string, + trace: BlockNotificationTrace, + ): Promise { + try { + console.log(`Saving block ${tipHeight}`); + await this.rpcBlockService.saveBlock(tipHeight, serializedBlockTemplate); + this.markTrace(trace, 'postgres_template_saved'); console.log('block saved'); } catch (e) { console.error('Error saving block', e); } + } - return blockTemplate; + private parseStoredBlockTemplates(data: string): IBlockTemplate[] { + const parsed = JSON.parse(data) as IBlockTemplate | StoredBlockTemplateEnvelope; + if ((parsed as StoredBlockTemplateEnvelope)?.schemaVersion === 1 + && Array.isArray((parsed as StoredBlockTemplateEnvelope).templates)) { + return (parsed as StoredBlockTemplateEnvelope).templates; + } + return [parsed as IBlockTemplate]; } public async getMiningInfo(): Promise { @@ -223,9 +1325,28 @@ export class BitcoinRpcService implements OnModuleInit { public async SUBMIT_BLOCK(hexdata: string): Promise { let response: string = 'unknown'; try { - response = await this.callRpc('submitblock', [hexdata]); - if (response == null) { - response = 'SUCCESS!'; + const submissionResult = await this.callRpc('submitblock', [hexdata]); + if (submissionResult != null) { + response = submissionResult; + } else { + try { + const blockHash = this.deriveSubmittedBlockHash(hexdata); + const blockHeader = await this.callRpc<{ confirmations?: number }>( + 'getblockheader', + [blockHash, true], + ); + const confirmations = blockHeader?.confirmations; + response = typeof confirmations === 'number' + && Number.isFinite(confirmations) + && confirmations > 0 + ? 'SUCCESS!' + : `ACCEPTED_BLOCK_NOT_ACTIVE_CHAIN: confirmations=${String(confirmations)}`; + } catch (verificationError) { + const message = verificationError instanceof Error + ? verificationError.message + : String(verificationError); + response = `ACTIVE_CHAIN_VERIFICATION_FAILED: ${message}`; + } } console.log(`BLOCK SUBMISSION RESPONSE: ${response}`); console.log(hexdata); @@ -238,6 +1359,26 @@ export class BitcoinRpcService implements OnModuleInit { } + private deriveSubmittedBlockHash(hexdata: string): string { + const normalizedHex = hexdata.trim(); + const headerHex = normalizedHex.slice(0, 160); + if (headerHex.length !== 160 || !/^[0-9a-f]+$/i.test(headerHex)) { + throw new Error('submitted block does not contain a valid 80-byte header'); + } + const header = Buffer.from(headerHex, 'hex'); + const firstHash = crypto.createHash('sha256').update(header).digest(); + return crypto.createHash('sha256').update(firstHash).digest().reverse().toString('hex'); + } + + /** BIP23 proposal validation without submitting or requiring proof of work. */ + public async TEST_BLOCK_PROPOSAL(hexdata: string): Promise { + return this.callRpc('getblocktemplate', [{ + mode: 'proposal', + data: hexdata, + rules: ['segwit'], + }]); + } + public async TEST_MEMPOOL_ACCEPT(rawTransactions: Buffer[]): Promise(method: string, params: unknown[] = []): Promise { + private async callRpc( + method: string, + params: unknown[] = [], + timeoutMs?: number, + ): Promise { const response = await this.client.post('', { jsonrpc: '1.0', id: ++this.rpcRequestId, method, params - }); + }, timeoutMs == null ? undefined : { timeout: timeoutMs }); if (response.data.error != null) { throw response.data.error; @@ -281,4 +1426,86 @@ export class BitcoinRpcService implements OnModuleInit { } return rpcUrl.toString(); } + + private startTrace(reason: TemplateRefreshReason): BlockNotificationTrace { + return { + eventId: `${reason}:${Date.now()}:${this.rpcRequestId + 1}`, + reason, + startedWallMs: Date.now(), + startedMonotonic: process.hrtime.bigint(), + stages: { start: 0 }, + }; + } + + private markTrace(trace: BlockNotificationTrace, stage: string): void { + trace.stages[stage] = Number(process.hrtime.bigint() - trace.startedMonotonic) / 1e6; + } + + private logTrace(trace: BlockNotificationTrace, blockTemplate: IBlockTemplate): void { + console.log(JSON.stringify({ + event: 'block_notification_trace', + eventId: trace.eventId, + reason: trace.reason, + tipHeight: blockTemplate.height - 1, + templateHeight: blockTemplate.height, + previousBlockHash: blockTemplate.previousblockhash, + payoutMode: blockTemplate.payoutMode ?? 'all', + jobType: blockTemplate.jobType ?? 'full', + startedAt: new Date(trace.startedWallMs).toISOString(), + stagesMs: trace.stages, + })); + } + + private hasConfiguredPplnsListeners(): boolean { + return PPLNS_LISTENER_CONFIG_KEYS.some(key => { + const configured = this.configService.get(key) ?? process.env[key]; + if (configured == null) { + return false; + } + return String(configured) + .split(',') + .some(value => value.trim().length > 0); + }); + } + + private getPositiveIntegerEnv(key: string, fallback: number): number { + const configured = Number(this.configService.get(key) ?? process.env[key]); + return Number.isInteger(configured) && configured > 0 ? configured : fallback; + } + + private getOptionalPositiveIntegerEnv(key: string): number | undefined { + const configured = Number(this.configService.get(key) ?? process.env[key]); + return Number.isInteger(configured) && configured > 0 ? configured : undefined; + } + + private isSv1SubsidyBridgeEnabled(): boolean { + const configured = this.configService.get('SV1_SUBSIDY_BRIDGE_ENABLED') + ?? process.env.SV1_SUBSIDY_BRIDGE_ENABLED; + return configured?.toLowerCase() !== 'false'; + } + + private getSv1SubsidyBridgePayoutModes(): ReadonlySet<'solo' | 'pplns'> { + const configured = this.configService.get('SV1_SUBSIDY_BRIDGE_PAYOUT_MODES') + ?? process.env.SV1_SUBSIDY_BRIDGE_PAYOUT_MODES + ?? 'solo'; + const modes = new Set<'solo' | 'pplns'>(); + for (const token of configured.split(',')) { + const mode = token.trim().toLowerCase(); + if (mode === 'solo' || mode === 'pplns') { + modes.add(mode); + } + } + if (modes.size === 0) { + modes.add('solo'); + } + return modes; + } + + private getNetworkName(): BitcoinNetworkName { + const network = this.configService.get('NETWORK') ?? process.env.NETWORK; + if (network === 'mainnet' || network === 'testnet' || network === 'regtest') { + return network; + } + throw new Error(`Invalid NETWORK configuration: ${network ?? 'unset'}`); + } } diff --git a/src/services/prepared-mining-job.factory.spec.ts b/src/services/prepared-mining-job.factory.spec.ts new file mode 100644 index 0000000..7086c49 --- /dev/null +++ b/src/services/prepared-mining-job.factory.spec.ts @@ -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 { + 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 }; +} diff --git a/src/services/prepared-mining-job.factory.ts b/src/services/prepared-mining-job.factory.ts new file mode 100644 index 0000000..7dee58d --- /dev/null +++ b/src/services/prepared-mining-job.factory.ts @@ -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>; +} + +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; + coinbase: Readonly; + body: Readonly; + jobType: 'full' | 'empty'; + payoutMode: PayoutMode | 'all'; + forceCleanJobs: boolean; +} + +export function createPreparedMiningJob( + template: Readonly, +): 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): 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}`, + ); + } +} diff --git a/src/services/redis-messaging.service.spec.ts b/src/services/redis-messaging.service.spec.ts index 6c0e7fb..1d35d8d 100644 --- a/src/services/redis-messaging.service.spec.ts +++ b/src/services/redis-messaging.service.spec.ts @@ -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(); const sets = new Map>(); const clientsByRole: { publisher?: any; subscriber?: any } = {}; +const subscriptions = new Map Promise>(); 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) => { - clientsByRole.subscriber.callback = callback; + subscriptions.set(channel, callback); return Promise.resolve(); }), }; diff --git a/src/services/redis-messaging.service.ts b/src/services/redis-messaging.service.ts index 0a859cb..c40df29 100644 --- a/src/services/redis-messaging.service.ts +++ b/src/services/redis-messaging.service.ts @@ -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 { + if (!await this.ensureConnected()) { + return; + } + await this.publisher.publish(BLOCK_TEMPLATE_CHANNEL, JSON.stringify(update)); + } + + public async subscribeBlockTemplateUpdates(handler: (update: BlockTemplateUpdate) => Promise): Promise { + if (!await this.ensureConnected()) { + return; + } + await this.subscriber.subscribe(BLOCK_TEMPLATE_CHANNEL, async message => { + try { + const update = JSON.parse(message) as Partial; + 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 { + 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): Promise { + 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 { + 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 { + 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 { 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 { + public async getBlockTemplate( + height: number, + payoutMode: PayoutMode = 'solo', + previousBlockHash?: string, + ): Promise { 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 { + public async getLatestBlockTemplate(payoutMode: PayoutMode = 'solo'): Promise { 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 { + 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(key: string): Promise { @@ -148,6 +343,74 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy { ); } + private async readBlockTemplatePointer(value: string | null): Promise { + 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; + const template = update.template as Partial | 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 { if (!this.connected) { try { diff --git a/src/services/stratum-v1-jobs.service.spec.ts b/src/services/stratum-v1-jobs.service.spec.ts index 6aa3654..8713e01 100644 --- a/src/services/stratum-v1-jobs.service.spec.ts +++ b/src/services/stratum-v1-jobs.service.spec.ts @@ -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; - let bitcoinRpcService: { newBlockTemplate$: any, miningInfo: { blocks: number } }; + let bridgeTemplate$: Subject; + 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(); 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' }); + }); }); diff --git a/src/services/stratum-v1-jobs.service.ts b/src/services/stratum-v1-jobs.service.ts index 828c251..3f146c4 100644 --- a/src/services/stratum-v1-jobs.service.ts +++ b/src/services/stratum-v1-jobs.service.ts @@ -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; + /** Ordered SV1-only stream: subsidy bridge first, canonical full job second. */ + public sv1MiningJob$: Observable; 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(); + private readonly lastWorkSignatures = new Map(); + private readonly currentTipKeys = new Map(); + private readonly cachedJobs = new Map(); + private readonly latestJobTemplates = new Map(); + private readonly pinnedCurrentTipTemplateIds = new Map>([ + ['solo', new Set()], + ['pplns', new Set()], + ]); constructor( private readonly bitcoinRpcService: BitcoinRpcService ) { - this.newMiningJob$ = this.bitcoinRpcService.newBlockTemplate$.pipe( + const createMiningJobStream = (blockTemplates: Observable) => 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(); + 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): 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; + } + } diff --git a/src/services/stratum-v1.service.spec.ts b/src/services/stratum-v1.service.spec.ts index 93f87ce..972d382 100644 --- a/src/services/stratum-v1.service.spec.ts +++ b/src/services/stratum-v1.service.spec.ts @@ -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; 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({ diff --git a/src/services/stratum-v1.service.ts b/src/services/stratum-v1.service.ts index 0e6a67f..d623908 100644 --- a/src/services/stratum-v1.service.ts +++ b/src/services/stratum-v1.service.ts @@ -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(); + 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'; } diff --git a/src/services/stratum-v2.service.spec.ts b/src/services/stratum-v2.service.spec.ts new file mode 100644 index 0000000..5c67e76 --- /dev/null +++ b/src/services/stratum-v2.service.spec.ts @@ -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((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((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; + subscribeSpy: jest.Mock; + activations: Subject; + activationSubscribeSpy: jest.Mock; +} { + const templates = new Subject(); + const subscribeSpy = jest.fn((observer) => templates.subscribe(observer)); + const activations = new Subject(); + 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; +} diff --git a/src/services/stratum-v2.service.ts b/src/services/stratum-v2.service.ts index 7ec55e8..8c593e7 100644 --- a/src/services/stratum-v2.service.ts +++ b/src/services/stratum-v2.service.ts @@ -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(); + private readonly latestCanonicalJobs = new Map(); + 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 { + 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 { 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 { diff --git a/src/services/subsidy-only-template.factory.spec.ts b/src/services/subsidy-only-template.factory.spec.ts new file mode 100644 index 0000000..4a6f27c --- /dev/null +++ b/src/services/subsidy-only-template.factory.spec.ts @@ -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', + }; +} diff --git a/src/services/subsidy-only-template.factory.ts b/src/services/subsidy-only-template.factory.ts new file mode 100644 index 0000000..b2c90cf --- /dev/null +++ b/src/services/subsidy-only-template.factory.ts @@ -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; + 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 +> = { + 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 { + 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, + }; +} diff --git a/src/services/template-provider.service.ts b/src/services/template-provider.service.ts index 6920367..5e0b2a2 100644 --- a/src/services/template-provider.service.ts +++ b/src/services/template-provider.service.ts @@ -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 => { diff --git a/src/utils/difficulty.utils.spec.ts b/src/utils/difficulty.utils.spec.ts index 4251c74..0ea4cba 100644 --- a/src/utils/difficulty.utils.spec.ts +++ b/src/utils/difficulty.utils.spec.ts @@ -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); diff --git a/src/utils/difficulty.utils.ts b/src/utils/difficulty.utils.ts index 61da11f..0a51174 100644 --- a/src/utils/difficulty.utils.ts +++ b/src/utils/difficulty.utils.ts @@ -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); diff --git a/test/bitcoin-regtest-proposal.integration-spec.ts b/test/bitcoin-regtest-proposal.integration-spec.ts new file mode 100644 index 0000000..1e2379b --- /dev/null +++ b/test/bitcoin-regtest-proposal.integration-spec.ts @@ -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('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 { + 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 { + return rpc('getblocktemplate', [{ + mode: 'proposal', + data: block.toHex(false), + rules: ['segwit'], + }]); + } + + async function rpc(method: string, params: unknown[]): Promise { + 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; + } +});