mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 17:15:03 -07:00
Optimize SV1 block notify fanout
This commit is contained in:
@@ -199,7 +199,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
.toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('publishes a validated compact subsidy bridge before serializing the full template', async () => {
|
||||
it('publishes a consensus-subsidy bridge before traversing or serializing the full body', async () => {
|
||||
const order: string[] = [];
|
||||
const redis = createRedisMock(order);
|
||||
const template = createTemplate();
|
||||
@@ -236,6 +236,49 @@ describe('BitcoinRpcService template publication', () => {
|
||||
expect(order.indexOf('redis:publish:bridge:solo')).toBeLessThan(order.indexOf('redis:publish:solo'));
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -500,7 +543,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
await expect((service as any).publishPplnsSubsidyBridge(
|
||||
nextTemplate,
|
||||
trace,
|
||||
)).resolves.toBe(false);
|
||||
)).resolves.toBe('skipped');
|
||||
expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -536,7 +579,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
await expect((service as any).publishPplnsSubsidyBridge(
|
||||
template,
|
||||
trace,
|
||||
)).resolves.toBe(false);
|
||||
)).resolves.toBe('skipped');
|
||||
|
||||
(service as any).storePplnsSubsidyBridgeSeed({
|
||||
...baseSeed,
|
||||
@@ -546,7 +589,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
await expect((service as any).publishPplnsSubsidyBridge(
|
||||
template,
|
||||
trace,
|
||||
)).resolves.toBe(false);
|
||||
)).resolves.toBe('failed');
|
||||
|
||||
(service as any).storePplnsSubsidyBridgeSeed({
|
||||
...baseSeed,
|
||||
@@ -556,7 +599,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
await expect((service as any).publishPplnsSubsidyBridge(
|
||||
template,
|
||||
trace,
|
||||
)).resolves.toBe(false);
|
||||
)).resolves.toBe('skipped');
|
||||
expect(redis.publishSv1BridgeUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -569,6 +612,7 @@ describe('BitcoinRpcService template publication', () => {
|
||||
if (update.template.payoutMode === 'pplns') {
|
||||
throw new Error('PPLNS Redis unavailable');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const service = new BitcoinRpcService(
|
||||
createConfig({
|
||||
@@ -633,6 +677,102 @@ describe('BitcoinRpcService template publication', () => {
|
||||
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();
|
||||
@@ -694,10 +834,16 @@ describe('BitcoinRpcService template publication', () => {
|
||||
const redis = createRedisMock(order);
|
||||
let resolveBridge: () => void;
|
||||
const stalledBridge = new Promise<void>(resolve => { resolveBridge = resolve; });
|
||||
redis.publishSv1BridgeUpdate.mockImplementation(async (update: { template: IBlockTemplate }) => {
|
||||
order.push(`redis:publish:bridge:${update.template.payoutMode}`);
|
||||
await stalledBridge;
|
||||
});
|
||||
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' }),
|
||||
@@ -710,7 +856,12 @@ describe('BitcoinRpcService template publication', () => {
|
||||
|
||||
await service.getAndBroadcastLatestTemplate('new_block');
|
||||
|
||||
expect(redis.publishSv1BridgeUpdate).toHaveBeenCalledTimes(1);
|
||||
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' }),
|
||||
@@ -718,12 +869,85 @@ describe('BitcoinRpcService template publication', () => {
|
||||
expect(redis.publishBlockTemplateUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payoutMode: 'solo',
|
||||
}));
|
||||
expect(order.indexOf('redis:publish:bridge:solo')).toBeLessThan(order.indexOf('redis:set: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');
|
||||
@@ -1263,6 +1487,10 @@ function createRedisMock(order: string[]) {
|
||||
}),
|
||||
publishSv1BridgeUpdate: jest.fn(async (update: { template: IBlockTemplate }) => {
|
||||
order.push(`redis:publish:bridge:${update.template.payoutMode}`);
|
||||
return true;
|
||||
}),
|
||||
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}`);
|
||||
|
||||
Reference in New Issue
Block a user