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 consensus-subsidy bridge before traversing or serializing the full body', 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('publishes compact authoritative activation before canonical template work', async () => { const order: string[] = []; const redis = createRedisMock(order); redis.publishSv1PrestageActivation = jest.fn(async (activation: any) => { order.push(`redis:publish:activation:${activation.payoutMode}`); return true; }); const template = createTemplateAtHeight(840_000, '68'); 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.publishSv1PrestageActivation).toHaveBeenCalledWith(expect.objectContaining({ schemaVersion: 1, type: 'prestage-activation', height: template.height, previousBlockHash: template.previousblockhash, version: template.version, bits: template.bits, minTime: template.mintime, currentTime: template.curtime, subsidySats: calculateBlockSubsidySats(template.height, 'mainnet'), payoutMode: 'solo', requiredVersionBits: template.vbrequired >>> 0, })); expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled(); expect(order.indexOf('redis:publish:activation:solo')) .toBeLessThan(order.indexOf('redis:set:solo')); }); it('falls back to the full empty bridge when compact activation delivery fails', async () => { const order: string[] = []; const redis = createRedisMock(order); redis.publishSv1PrestageActivation = jest.fn().mockResolvedValue(false); const template = createTemplateAtHeight(840_000, '69'); 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); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); await service.getAndBroadcastLatestTemplate('new_block'); expect(redis.publishSv1PrestageActivation).toHaveBeenCalledTimes(1); expect(redis.publishSv1BridgeUpdate).toHaveBeenCalledWith(expect.objectContaining({ type: 'subsidy-bridge', template: expect.objectContaining({ payoutMode: 'solo', jobType: 'empty' }), })); errorSpy.mockRestore(); }); it('retries a timed-out compact activation through the normal Redis lane', async () => { const redis = createRedisMock([]); let resolveUrgent: () => void; const stalled = new Promise(resolve => { resolveUrgent = resolve; }); redis.publishSv1PrestageActivation = jest.fn(async ( _activation: any, lane?: 'fallback', ) => { if (lane == null) { await stalled; } return true; }); const template = createTemplateAtHeight(840_000, '67'); const service = new BitcoinRpcService( createConfig({ NETWORK: 'mainnet', SV1_BRIDGE_PUBLISH_BUDGET_MS: '1' }), { 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); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); await service.getAndBroadcastLatestTemplate('new_block'); await flushPromises(); expect(redis.publishSv1PrestageActivation).toHaveBeenCalledWith( expect.objectContaining({ type: 'prestage-activation' }), 'fallback', ); expect(redis.setBlockTemplate).toHaveBeenCalled(); resolveUrgent!(); warnSpy.mockRestore(); }); it('rejects an urgent bridge when Core fee data cannot validate its subsidy', async () => { const redis = createRedisMock([]); const template = createTemplateAtHeight(840_000, '70'); Object.defineProperty(template.transactions[0], 'fee', { get: () => { throw new Error('full transaction body was traversed'); }, }); const service = new BitcoinRpcService( createConfig({ NETWORK: 'mainnet' }), {} as any, redis as any, ); const trace = (service as any).startTrace('new_block', Date.now()); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); await expect((service as any).publishSoloSubsidyBridge(template, trace)) .resolves.toBe('failed'); expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled(); consoleSpy.mockRestore(); }); it('rejects an urgent bridge when Core coinbasevalue is not subsidy plus fees', async () => { const redis = createRedisMock([]); const template = createTemplateAtHeight(840_000, '79'); template.coinbasevalue += 1; const service = new BitcoinRpcService( createConfig({ NETWORK: 'mainnet' }), {} as any, redis as any, ); const trace = (service as any).startTrace('new_block', Date.now()); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); await expect((service as any).publishSoloSubsidyBridge(template, trace)) .resolves.toBe('failed'); expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled(); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining( 'does not equal subsidy', )); consoleSpy.mockRestore(); }); 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('skipped'); 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('skipped'); (service as any).storePplnsSubsidyBridgeSeed({ ...baseSeed, candidateHeight: template.height, basisBits: '1d00ffff', }); await expect((service as any).publishPplnsSubsidyBridge( template, trace, )).resolves.toBe('failed'); (service as any).storePplnsSubsidyBridgeSeed({ ...baseSeed, candidateHeight: template.height, preparedAtMs: Date.now() - 11, }); await expect((service as any).publishPplnsSubsidyBridge( template, trace, )).resolves.toBe('skipped'); 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'); } return true; }); 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('accepts the first authoritative template from an independent auxiliary longpoll source', async () => { const redis = createRedisMock([]); const template = createTemplateAtHeight(840_000, '73'); template.longpollid = 'aux-next-longpoll'; const auxiliaryClient = { post: jest.fn().mockResolvedValue({ data: { result: template, error: null }, }), }; const source = { name: 'aux-1', client: auxiliaryClient, latestLongpollId: 'aux-prior-longpoll', longpollLoopStarted: true, }; const service = new BitcoinRpcService( createConfig({ NETWORK: 'mainnet', BLOCK_TEMPLATE_LONGPOLL_TIMEOUT_MS: '1000' }), { saveBlock: jest.fn().mockResolvedValue(undefined), getSavedBlockTemplate: jest.fn() } as any, redis as any, ); service.miningInfo = { blocks: template.height - 1 } as any; const primaryPost = jest.fn().mockResolvedValue({ data: { result: template.previousblockhash, error: null }, }); (service as any).client = { post: primaryPost }; const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); await (service as any).getAndBroadcastLatestTemplateOnce( 'longpoll', source.latestLongpollId, undefined, source, ); expect(auxiliaryClient.post).toHaveBeenCalledWith('', expect.objectContaining({ method: 'getblocktemplate', params: [expect.objectContaining({ longpollid: 'aux-prior-longpoll' })], }), { timeout: 1000 }); expect(source.latestLongpollId).toBe('aux-next-longpoll'); expect(primaryPost).toHaveBeenCalledWith('', expect.objectContaining({ method: 'getbestblockhash', }), undefined); expect(redis.publishSv1BridgeUpdate).toHaveBeenCalled(); expect(redis.setBlockTemplate).toHaveBeenCalledWith( template.height - 1, expect.objectContaining({ previousblockhash: template.previousblockhash }), ); const sourceLog = logSpy.mock.calls .map(call => call[0]) .find(value => typeof value === 'string' && value.includes('block_source_notification')); expect(JSON.parse(sourceLog)).toEqual(expect.objectContaining({ source: 'longpoll', templateSource: 'aux-1', })); logSpy.mockRestore(); }); it('rejects auxiliary work before urgent and canonical publication when primary Core has another tip', async () => { const redis = createRedisMock([]); const template = createTemplateAtHeight(840_000, '74'); const source = { name: 'aux-1', client: { post: jest.fn().mockResolvedValue({ data: { result: template, error: null }, }), }, latestLongpollId: 'aux-prior-longpoll', longpollLoopStarted: true, }; const service = new BitcoinRpcService( createConfig({ NETWORK: 'mainnet' }), { saveBlock: jest.fn(), getSavedBlockTemplate: jest.fn() } as any, redis as any, ); service.miningInfo = { blocks: template.height - 1 } as any; (service as any).client = { post: jest.fn().mockResolvedValue({ data: { result: '75'.repeat(32), error: null }, }), }; const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); await (service as any).getAndBroadcastLatestTemplateOnce( 'longpoll', source.latestLongpollId, undefined, source, ); expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled(); expect(redis.setBlockTemplate).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('aux_template_rejected')); warnSpy.mockRestore(); }); 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 }, lane: 'fallback' | undefined, ) => { order.push(`redis:publish:bridge:${update.template.payoutMode}:${lane ?? 'urgent'}`); if (lane == null) { await stalledBridge; } return true; }) as any); 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(2); expect(redis.publishSv1BridgeUpdate).toHaveBeenNthCalledWith( 2, expect.objectContaining({ type: 'subsidy-bridge' }), 'fallback', ); 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:fallback')).toBeLessThan(order.indexOf('redis:set:solo')); resolveBridge!(); await flushPromises(); }); it('retries the bridge from the canonical path after an immediate Redis delivery failure', async () => { const redis = createRedisMock([]); redis.publishSv1BridgeUpdate .mockResolvedValueOnce(false) .mockResolvedValueOnce(true); const template = createTemplateAtHeight(840_000, '78'); 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); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); await service.getAndBroadcastLatestTemplate('new_block'); expect(redis.publishSv1BridgeUpdate).toHaveBeenCalledTimes(2); expect(redis.setBlockTemplate).toHaveBeenCalledWith( template.height - 1, expect.objectContaining({ payoutMode: 'solo', jobType: 'full' }), ); errorSpy.mockRestore(); }); it.each([ ['mainnet', 'main'], ['testnet', 'test'], ['regtest', 'regtest'], ] as const)('accepts NETWORK=%s only for the matching Core chain', (network, chain) => { const service = new BitcoinRpcService( createConfig({ NETWORK: network }), {} as any, {} as any, ); expect(() => (service as any).validateConfiguredNetworkAgainstCore(chain)) .not.toThrow(); expect(() => (service as any).validateConfiguredNetworkAgainstCore( chain === 'main' ? 'test' : 'main', )).toThrow(`NETWORK=${network} does not match Bitcoin Core chain=`); }); it('publishes a durable next-height empty prestage after canonical work', async () => { const redis = createRedisMock([]); const template = createTemplateAtHeight(840_000, '72'); 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'); await flushPromises(); expect(redis.publishSv1PrestageUpdate).toHaveBeenCalledWith(expect.objectContaining({ schemaVersion: 1, type: 'subsidy-prestage', template: expect.objectContaining({ height: template.height + 1, previousblockhash: '0'.repeat(64), payoutMode: 'solo', jobType: 'empty', transactions: [], coinbasevalue: calculateBlockSubsidySats(template.height + 1, 'mainnet'), }), })); }); 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('delivers fresh compact activations once and ignores stale or duplicate events', () => { const service = new BitcoinRpcService( createConfig({}), {} as any, {} as any, ); service.miningInfo = { blocks: 900_000 } as any; const activations: any[] = []; service.newSv1PrestageActivation$.subscribe(activation => activations.push(activation)); const activation = { schemaVersion: 1, type: 'prestage-activation', eventId: 'fresh-activation', height: 900_001, previousBlockHash: '61'.repeat(32), version: 0x20000000, bits: '17034219', minTime: 1_700_000_000, currentTime: 1_700_000_001, subsidySats: 312_500_000, payoutMode: 'solo', requiredVersionBits: 0, publishedAtMs: Date.now(), }; (service as any).handleSv1PrestageActivation({ ...activation, eventId: 'stale-activation', height: 899_999, }); (service as any).handleSv1PrestageActivation(activation); (service as any).handleSv1PrestageActivation(activation); (service as any).emitCanonicalTemplate({ ...createTemplate(), height: activation.height, previousblockhash: activation.previousBlockHash, payoutMode: 'solo', jobType: 'full', notificationEventId: 'canonical-before-late-activation', notificationPublishedAtMs: activation.publishedAtMs + 1, }); (service as any).handleSv1PrestageActivation({ ...activation, eventId: 'late-after-canonical', publishedAtMs: activation.publishedAtMs + 2, }); expect(activations).toEqual([ expect.objectContaining({ eventId: activation.eventId, workerReceivedAtMs: expect.any(Number), }), ]); }); 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}`); return true; }), publishSv1PrestageActivation: undefined as jest.Mock | undefined, publishSv1PrestageUpdate: jest.fn(async (update: { template: IBlockTemplate }) => { order.push(`redis:publish:prestage:${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)); }