Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions fixtures/block-identity-rpc.cjs
Original file line number Diff line number Diff line change
@@ -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),
} }));
};
9 changes: 8 additions & 1 deletion src/rpc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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` }),
};
}
107 changes: 107 additions & 0 deletions test/block-identity.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
53 changes: 53 additions & 0 deletions test/cli-block-identity.test.js
Original file line number Diff line number Diff line change
@@ -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/);
}
});
}