diff --git a/fixtures/block-identity-rpc.cjs b/fixtures/block-identity-rpc.cjs new file mode 100644 index 0000000..0b9a02e --- /dev/null +++ b/fixtures/block-identity-rpc.cjs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 1932819331hy-debug +// SPDX-License-Identifier: MIT + +const assert = require('node:assert/strict'); + +// Preloaded only by CLI tests. Replace fetch completely, without opening a +// socket or allowing any request to fall through to a real RPC provider. +globalThis.fetch = async (url, init) => { + assert.equal(url, 'https://block-identity.invalid'); + assert.equal(init.method, 'POST'); + const request = JSON.parse(init.body); + assert.equal(request.method, 'eth_getBlockByNumber'); + assert.equal(request.params[1], false); + const tag = request.params[0]; + let number = tag === 'latest' ? 1000 : Number(BigInt(tag)); + if (process.env.BLOCKCLOCK_WRONG_END === '1' && number === 300) number = 301; + return new Response(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { + number: '0x' + number.toString(16), timestamp: '0x' + (10000 + number).toString(16), + } })); +}; diff --git a/src/rpc.js b/src/rpc.js index 5bb7b49..790dbf1 100644 --- a/src/rpc.js +++ b/src/rpc.js @@ -121,8 +121,15 @@ export async function getBlock(url, blockNumber, opts) { if (block === null || block === undefined) { throw new BlockNotFoundError(`block ${blockNumber} not found`, { url, method }); } + const number = quantity(block.number, { url, method, what: `block ${blockNumber} number` }); + // A concrete block request must not silently use another header's timestamp. + // Named tags such as 'latest' have no predetermined height to compare. + if (typeof tag === 'string' && /^0x[0-9a-fA-F]+$/.test(tag) + && BigInt(tag) !== BigInt(number)) { + throw new RpcShapeError(`block ${blockNumber}: node returned block ${number}`, { url, method }); + } return { - number: quantity(block.number, { url, method, what: `block ${blockNumber} number` }), + number, timestamp: quantity(block.timestamp, { url, method, what: `block ${blockNumber} timestamp` }), }; } diff --git a/test/block-identity.test.js b/test/block-identity.test.js new file mode 100644 index 0000000..b44e0b5 --- /dev/null +++ b/test/block-identity.test.js @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 1932819331hy-debug +// SPDX-License-Identifier: MIT + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { getBlock, measureBlockTime, checkSchedule, RpcShapeError, BlockNotFoundError } from '../src/index.js'; + +const url = 'https://block-identity.invalid'; +const header = (number, timestamp) => ({ + number: '0x' + number.toString(16), timestamp: '0x' + timestamp.toString(16), +}); + +// Only the HTTP transport is synthetic; the real RPC client decodes the JSON +// and the public functions parse and use the returned header. +function nodeFor(resultFor) { + let calls = 0; + return { + get calls() { return calls; }, + rpc: { retries: 1, fetchImpl: async (requestUrl, init) => { + assert.equal(requestUrl, url); + assert.equal(init.method, 'POST'); + const request = JSON.parse(init.body); + assert.equal(request.method, 'eth_getBlockByNumber'); + assert.equal(request.params[1], false); + calls++; + return new Response(JSON.stringify({ + jsonrpc: '2.0', id: request.id, result: resultFor(request.params[0]), + })); + } }, + }; +} + +function isShapeError(error) { + assert.ok(error instanceof RpcShapeError, `${error.name}: ${error.message}`); + assert.equal(error.url, url); + assert.equal(error.method, 'eth_getBlockByNumber'); + return true; +} + +for (const [requested, returned] of [[100, 101], ['0x64', 101], [0, 1]]) { + test(`getBlock rejects block ${returned} when ${requested} was requested`, async () => { + const node = nodeFor(() => header(returned, 10000)); + await assert.rejects(() => getBlock(url, requested, node.rpc), isShapeError); + assert.equal(node.calls, 1, 'a mismatched header must not be retried'); + }); +} + +for (const [requested, returned] of [ + [0, 0], ['0x0', 0], [100, 100], ['0xAB', 171], + ['earliest', 0], ['latest', 171], ['safe', 171], ['finalized', 171], + [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], +]) { + test(`getBlock preserves a valid ${requested} response`, async () => { + const node = nodeFor(() => header(returned, 10000)); + assert.deepEqual(await getBlock(url, requested, node.rpc), { number: returned, timestamp: 10000 }); + assert.equal(node.calls, 1); + }); +} + +test('a missing numbered block remains BlockNotFoundError', async () => { + const node = nodeFor(() => null); + await assert.rejects(() => getBlock(url, 100, node.rpc), BlockNotFoundError); + assert.equal(node.calls, 1); +}); + +// A correct fixture has one-second blocks and head 1000. Wrong replies remain +// monotonic, so monotonicity alone cannot catch selecting the wrong header. +function chain(wrongTag) { + return nodeFor((tag) => { + const n = tag === 'latest' ? 1000 : Number(BigInt(tag)); + const returned = tag === wrongTag ? n + 1 : n; + return header(returned, 10000 + returned); + }); +} + +test('measureBlockTime rejects a different block at the sample boundary', async () => { + const node = chain('0x384'); // asked for 900, received 901 + await assert.rejects(() => measureBlockTime(url, { + sampleBlocks: 100, rpc: node.rpc, + }), isShapeError); +}); + +const schedule = { + rpcUrl: url, startBlock: 100, periodBlocks: 100, periods: 2, + designSecondsPerBlock: 1, sampleBlocks: 100, +}; + +for (const [role, tag] of [['start', '0x64'], ['end', '0x12c']]) { + test(`checkSchedule rejects a different ${role} block instead of reporting its timestamp`, async () => { + const node = chain(tag); + await assert.rejects(() => checkSchedule({ ...schedule, rpc: node.rpc }), isShapeError); + }); +} + +test('checkSchedule still reports the actual finish for matching headers', async () => { + const node = chain(); + const report = await checkSchedule({ ...schedule, rpc: node.rpc }); + assert.equal(report.startTimestamp, 10100); + assert.equal(report.currentBlock, 1000); + assert.equal(report.observedSecondsPerBlock, 1); + assert.equal(report.sample.fromBlock, 900); + assert.equal(report.sample.toBlock, 1000); + assert.equal(report.projectedEnd, 10300); + assert.equal(report.complete, true); + assert.equal(report.projectedEndIsEstimate, false); + assert.equal(report.driftPercent, 0); +}); diff --git a/test/cli-block-identity.test.js b/test/cli-block-identity.test.js new file mode 100644 index 0000000..2f51f11 --- /dev/null +++ b/test/cli-block-identity.test.js @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 1932819331hy-debug +// SPDX-License-Identifier: MIT + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const cli = fileURLToPath(new URL('../bin/blockclock.js', import.meta.url)); +const fixture = fileURLToPath(new URL('../fixtures/block-identity-rpc.cjs', import.meta.url)); + +function run({ wrongEnd, json }) { + const result = spawnSync(process.execPath, ['--require', fixture, cli, + '--rpc', 'https://block-identity.invalid', '--start-block', '100', + '--period-blocks', '100', '--periods', '2', '--design-block-time', '1', + '--sample-blocks', '100', ...(json ? ['--json'] : [])], { + encoding: 'utf8', timeout: 10000, + // Keep the subprocess's only preload deterministic. The fixture itself + // supplies all responses and never delegates to the network. + env: { ...process.env, NODE_OPTIONS: '', BLOCKCLOCK_WRONG_END: wrongEnd ? '1' : '0' }, + }); + assert.ifError(result.error); + assert.equal(result.signal, null); + return result; +} + +for (const json of [true, false]) { + const mode = json ? 'JSON' : 'text'; + test(`${mode} CLI rejects the wrong end header without printing a success report`, () => { + const result = run({ wrongEnd: true, json }); + assert.equal(result.status, 2, result.stderr); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /block 300: node returned block 301/); + }); + + test(`${mode} CLI still reports success for matching block headers`, () => { + const result = run({ wrongEnd: false, json }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + if (json) { + const report = JSON.parse(result.stdout); + assert.equal(report.endBlock, 300); + assert.equal(report.projectedEnd, 10300); + assert.equal(report.projectedEndIsEstimate, false); + assert.equal(report.withinTolerance, true); + } else { + assert.match(result.stdout, /Ends\s+block 300\s+\(already passed\)/); + assert.match(result.stdout, /actual\s+1970-01-01/); + assert.match(result.stdout, /OK\s+drift of 0\.0%/); + assert.doesNotMatch(result.stdout, /estimated/); + } + }); +}