From 2809043854dc99272965fff2f63c610ca9044046 Mon Sep 17 00:00:00 2001 From: Hysann <1932819331hy@gmail.com> Date: Sat, 31 Oct 2026 10:13:13 +0900 Subject: [PATCH 1/2] fix: reject unsafe schedule block counts before projection --- src/index.js | 14 +++++++++---- test/safe-counts.test.js | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 test/safe-counts.test.js diff --git a/src/index.js b/src/index.js index 94ed3b4..f794728 100644 --- a/src/index.js +++ b/src/index.js @@ -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})`, @@ -138,9 +142,11 @@ 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) { + if (!Number.isSafeInteger(value) || value < 0) { throw new RangeError(`${name} must be a non-negative integer (got ${value})`); } } @@ -150,7 +156,7 @@ export function projectSchedule(schedule) { } } 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})`); } } diff --git a/test/safe-counts.test.js b/test/safe-counts.test.js new file mode 100644 index 0000000..16f4a73 --- /dev/null +++ b/test/safe-counts.test.js @@ -0,0 +1,44 @@ +// 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('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); + } +}); From 28751f577c9a1ed3037ed435223910c1f924d07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BA=A6=20=E5=AF=BB?= Date: Sat, 31 Oct 2026 04:29:29 +0000 Subject: [PATCH 2/2] test: preserve safe schedule boundaries through RPC validation --- src/index.js | 22 ++++++---------------- test/safe-counts.test.js | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/index.js b/src/index.js index f794728..cfe25d6 100644 --- a/src/index.js +++ b/src/index.js @@ -145,28 +145,18 @@ export function projectSchedule(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.isSafeInteger(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.isSafeInteger(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'); diff --git a/test/safe-counts.test.js b/test/safe-counts.test.js index 16f4a73..65e48d4 100644 --- a/test/safe-counts.test.js +++ b/test/safe-counts.test.js @@ -30,6 +30,39 @@ test('preserves the largest exactly supported end block', () => { 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 },