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
32 changes: 14 additions & 18 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,18 @@ export async function measureBlockTime(rpcUrl, options = {}) {

/** The input checks that do not need a current block; shared with checkSchedule. */
function assertScheduleShape({ startBlock, periodBlocks, periods, designSecondsPerBlock }) {
if (!Number.isInteger(startBlock) || startBlock < 0) {
if (!Number.isSafeInteger(startBlock) || startBlock < 0) {
throw new RangeError(`startBlock must be a non-negative integer (got ${startBlock})`);
}
for (const [name, value] of Object.entries({ periodBlocks, periods })) {
if (!Number.isInteger(value) || value <= 0) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new RangeError(`${name} must be a positive integer (got ${value})`);
}
}
const totalBlocks = periodBlocks * periods;
if (!Number.isSafeInteger(totalBlocks) || !Number.isSafeInteger(startBlock + totalBlocks)) {
throw new RangeError('schedule block counts and end block must stay within the safe integer range');
}
if (!Number.isFinite(designSecondsPerBlock) || designSecondsPerBlock <= 0) {
throw new RangeError(
`designSecondsPerBlock must be a positive finite number (got ${designSecondsPerBlock})`,
Expand Down Expand Up @@ -138,29 +142,21 @@ export function projectSchedule(schedule) {
endBlockTimestamp,
} = schedule;

assertScheduleShape({ startBlock, periodBlocks, periods, designSecondsPerBlock });

// Block numbers and timestamps may be zero (genesis). Rates and counts may not.
for (const [name, value] of Object.entries({ startBlock, currentBlock })) {
if (!Number.isInteger(value) || value < 0) {
throw new RangeError(`${name} must be a non-negative integer (got ${value})`);
}
if (!Number.isSafeInteger(currentBlock) || currentBlock < 0) {
throw new RangeError(`currentBlock must be a non-negative integer (got ${currentBlock})`);
}
for (const [name, value] of Object.entries({ startTimestamp, currentTimestamp })) {
if (!Number.isFinite(value) || value < 0) {
throw new RangeError(`${name} must be a non-negative number (got ${value})`);
}
}
for (const [name, value] of Object.entries({ periodBlocks, periods })) {
if (!Number.isInteger(value) || value <= 0) {
throw new RangeError(`${name} must be a positive integer (got ${value})`);
}
}
for (const [name, value] of Object.entries({
designSecondsPerBlock,
observedSecondsPerBlock,
})) {
if (!Number.isFinite(value) || value <= 0) {
throw new RangeError(`${name} must be a positive finite number (got ${value})`);
}
if (!Number.isFinite(observedSecondsPerBlock) || observedSecondsPerBlock <= 0) {
throw new RangeError(
`observedSecondsPerBlock must be a positive finite number (got ${observedSecondsPerBlock})`,
);
}
if (currentBlock < startBlock) {
throw new RangeError('currentBlock is before startBlock: the schedule has not started');
Expand Down
77 changes: 77 additions & 0 deletions test/safe-counts.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: 2026 1932819331hy-debug
// SPDX-License-Identifier: MIT

import test from 'node:test';
import assert from 'node:assert/strict';
import { projectSchedule, checkSchedule } from '../src/index.js';

const base = { startBlock: 0, currentBlock: 100, startTimestamp: 0,
currentTimestamp: 100, periodBlocks: 10, periods: 20,
designSecondsPerBlock: 1, observedSecondsPerBlock: 1 };

for (const field of ['startBlock', 'currentBlock', 'periodBlocks', 'periods']) {
test(`rejects unsafe integer ${field} instead of a rounded schedule`, () => {
const input = { ...base, [field]: 9007199254740992 };
if (field === 'startBlock') input.currentBlock = input.startBlock;
assert.throws(() => projectSchedule(input), RangeError);
});
}
test('rejects unsafe product of individually safe block counts', () => {
assert.throws(() => projectSchedule({ ...base, periodBlocks: 9007199254740991, periods: 3 }), RangeError);
});
test('rejects unsafe end block even when total blocks is safe', () => {
assert.throws(() => projectSchedule({ ...base, startBlock: 9007199254740990,
currentBlock: 9007199254740990, periodBlocks: 1, periods: 3 }), RangeError);
});
test('preserves the largest exactly supported end block', () => {
const r = projectSchedule({ ...base, startBlock: 9007199254740990,
currentBlock: 9007199254740990, periodBlocks: 1, periods: 1 });
assert.equal(r.endBlock, 9007199254740991);
assert.equal(r.blocksRemaining, 1);
assert.equal(r.complete, false);
});
test('preserves individually safe period counts at the supported limit', () => {
for (const change of [
{ periodBlocks: Number.MAX_SAFE_INTEGER, periods: 1 },
{ periodBlocks: 1, periods: Number.MAX_SAFE_INTEGER },
]) {
const r = projectSchedule({ ...base, ...change, currentBlock: Number.MAX_SAFE_INTEGER });
assert.equal(r.endBlock, Number.MAX_SAFE_INTEGER);
assert.equal(r.blocksRemaining, 0);
assert.equal(r.complete, true);
assert.equal(r.periodsElapsed, change.periods);
}
});
test('checkSchedule accepts the safe end-block boundary and requests exact block tags', async () => {
const head = Number.MAX_SAFE_INTEGER;
const requested = [];
const rpc = { retries: 0, fetchImpl: async (_url, init) => {
const { params: [tag] } = JSON.parse(init.body);
requested.push(tag);
const block = tag === 'latest' ? head : Number(BigInt(tag));
return { ok: true, json: async () => ({ result: {
number: '0x' + block.toString(16),
timestamp: '0x' + (100 + (block - head)).toString(16),
} }) };
} };
const r = await checkSchedule({
rpcUrl: 'https://example.invalid', rpc, startBlock: head - 1,
periodBlocks: 1, periods: 1, designSecondsPerBlock: 1, sampleBlocks: 1,
});
assert.equal(r.endBlock, head);
assert.equal(r.complete, true);
assert.equal(r.projectedEnd, 100);
assert.deepEqual(requested, ['latest', '0x1ffffffffffffe', '0x1ffffffffffffe', '0x1fffffffffffff']);
});
test('checkSchedule rejects unrepresentable schedules before sending an RPC', async () => {
for (const change of [{ startBlock: 9007199254740992 },
{ periodBlocks: 9007199254740992 }, { periods: 9007199254740992 },
{ periodBlocks: 9007199254740991, periods: 3 },
{ startBlock: 9007199254740990, periodBlocks: 1, periods: 3 }]) {
let requests = 0;
const rpc = { retries: 0, fetchImpl: async () => { requests++; throw new Error('network forbidden'); } };
await assert.rejects(() => checkSchedule({ ...base, ...change,
rpcUrl: 'https://example.invalid', rpc }), RangeError);
assert.equal(requests, 0);
}
});