From 0efa526045b06f8e28b92e017bdb5a56d4b8d21f Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:30:11 +1000 Subject: [PATCH 1/4] feat(tempo): add hardfork selection --- .changeset/tempo-hardfork.md | 12 ++++ README.md | 16 +++++ src/instances/tempo.test.ts | 32 ++++++++++ src/instances/tempo.ts | 97 ++++++++++++++++++++--------- src/internal/tempoGenesis.test.ts | 54 ++++++++++++++++ src/internal/tempoGenesis.ts | 21 +++++++ src/testcontainers/Instance.test.ts | 18 ++++++ src/testcontainers/Instance.ts | 32 +++++++++- 8 files changed, 250 insertions(+), 32 deletions(-) create mode 100644 .changeset/tempo-hardfork.md create mode 100644 src/internal/tempoGenesis.test.ts create mode 100644 src/internal/tempoGenesis.ts diff --git a/.changeset/tempo-hardfork.md b/.changeset/tempo-hardfork.md new file mode 100644 index 0000000..3022047 --- /dev/null +++ b/.changeset/tempo-hardfork.md @@ -0,0 +1,12 @@ +--- +"prool": minor +--- + +Added a Tempo `hardfork` option that activates the selected fork and earlier forks at genesis while disabling later forks for binary and container instances. + +```ts +import { Instance } from 'prool' + +const instance = Instance.tempo({ hardfork: 'T10' }) +await instance.start() +``` diff --git a/README.md b/README.md index b7343ca..ca7fc43 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,22 @@ await server.start() // "http://localhost:8545/n" ``` +Set `hardfork` to activate a specific Tempo fork and all earlier forks from genesis: + +```ts +import { Instance } from 'prool' + +const instance = Instance.tempo({ hardfork: 'T10' }) +await instance.start() +``` + +The same option is available on `Instance.tempo` from `prool/testcontainers`. +Prool exports genesis from the selected binary or image, disables later Tempo forks, +and starts the node with the modified genesis. The fork must be present in that +genesis or startup fails. When `chain` is omitted, `hardfork` uses the dev genesis. +Genesis allocations and Ethereum fork settings are preserved. Without `hardfork`, +the node's existing defaults apply. + ### Alto (Bundler Node) #### Requirements diff --git a/src/instances/tempo.test.ts b/src/instances/tempo.test.ts index 21f55f5..255743c 100644 --- a/src/instances/tempo.test.ts +++ b/src/instances/tempo.test.ts @@ -205,3 +205,35 @@ test('command: behavior: http port override', () => { `"node --authrpc.port 8575 --datadir /.prool/tempo.8545 --dev --dev.block-time 50ms --engine.disable-precompile-cache --engine.legacy-state-root --faucet.address 0x20c0000000000000000000000000000000000000 0x20c0000000000000000000000000000000000001 0x20c0000000000000000000000000000000000002 0x20c0000000000000000000000000000000000003 --faucet.amount 1000000000000 --faucet.enabled --faucet.private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --faucet.node-address http://localhost:1337 --http.addr 0.0.0.0 --http.api all --http.corsdomain * --http.port 1337 --port 8555 --ws --ws.addr 0.0.0.0 --ws.api all --ws.port 1337"`, ) }) + +test( + 'behavior: selects hardfork across restarts', + { timeout: slowTestTimeout }, + async () => { + const instance = defineInstance({ hardfork: 'T9', port: await getPort() }) + for (let i = 0; i < 2; i++) { + await instance.start() + const response = await fetch(instance.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tempo_forkSchedule', + params: [], + }), + }) + const { result, error } = await response.json() + expect(error).toBeUndefined() + expect(result.active).toBe('T9') + await instance.stop() + } + }, +) + +test('behavior: rejects unsupported hardfork', async () => { + const instance = defineInstance({ hardfork: 'T999' }) + await expect(instance.start()).rejects.toThrow( + 'Hardfork "T999" is not present in the Tempo genesis.', + ) +}) diff --git a/src/instances/tempo.ts b/src/instances/tempo.ts index 9ef7364..e096601 100644 --- a/src/instances/tempo.ts +++ b/src/instances/tempo.ts @@ -1,12 +1,21 @@ +import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' +import { execa as exec } from 'execa' import * as Instance from '../Instance.js' +import { configureGenesis } from '../internal/tempoGenesis.js' import { deepAssign, toArgs } from '../internal/utils.js' import { execa } from '../processes/execa.js' export function command(parameters: tempo.Parameters): string[] { - const { blockMaxTransactions, blockTime, mnemonic, port, ...rest } = - parameters + const { + blockMaxTransactions, + blockTime, + hardfork: _, + mnemonic, + port, + ...rest + } = parameters const datadir = path.join(os.tmpdir(), '.prool', `tempo.${port}`) const defaultParameters = { @@ -89,7 +98,7 @@ export function command(parameters: tempo.Parameters): string[] { * ``` */ export const tempo = Instance.define((parameters?: tempo.Parameters) => { - const { binary = 'tempo', log: log_, ...args } = parameters || {} + const { binary = 'tempo', hardfork, log: log_, ...args } = parameters || {} const log = (() => { try { @@ -114,36 +123,56 @@ export const tempo = Instance.define((parameters?: tempo.Parameters) => { name, port: args.port ?? 8545, async start({ port = args.port }, options) { - return await process.start( - ($) => - $({ - env: { - RUST_LOG, + let directory: string | undefined + let chain = args.chain + try { + if (hardfork !== undefined) { + const { stdout } = await exec(binary, [ + '-q', + 'dump-genesis', + '--chain', + chain ?? 'dev', + ]) + const genesis = configureGenesis(stdout, hardfork) + directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'prool-tempo-genesis-'), + ) + chain = path.join(directory, 'genesis.json') + await fs.writeFile(chain, genesis) + } + return await process.start( + ($) => + $({ + env: { + RUST_LOG, + }, + })`${[binary, ...command({ ...args, chain, port })]}`, + { + ...options, + // Resolve when the process is listening via consensus engine message. + resolver({ process, reject, resolve }) { + process.stdout.on('data', (data) => { + const message = data.toString() + if (log) console.log(message) + if ( + message.includes( + 'Received new payload from consensus engine', + ) || + message.includes('Received block from consensus engine') + ) + resolve() + if (message.includes('shutting down')) reject('shutting down') + }) + process.stderr.on('data', (data) => { + const message = data.toString() + if (log) console.error(message) + }) }, - })`${[binary, ...command({ ...args, port })]}`, - { - ...options, - // Resolve when the process is listening via consensus engine message. - resolver({ process, reject, resolve }) { - process.stdout.on('data', (data) => { - const message = data.toString() - if (log) console.log(message) - if ( - message.includes( - 'Received new payload from consensus engine', - ) || - message.includes('Received block from consensus engine') - ) - resolve() - if (message.includes('shutting down')) reject('shutting down') - }) - process.stderr.on('data', (data) => { - const message = data.toString() - if (log) console.error(message) - }) }, - }, - ) + ) + } finally { + if (directory) await fs.rm(directory, { recursive: true, force: true }) + } }, async stop() { await process.stop() @@ -183,6 +212,12 @@ export declare namespace tempo { * The path to the data dir for all reth files and subdirectories. */ datadir?: string | undefined + /** + * Activates this Tempo hardfork and earlier forks at genesis, disabling later forks. + * Must be present in the selected chain's genesis. Defaults to the dev chain + * when set without `chain`. Preserves genesis allocations. + */ + hardfork?: `T${number}` | 'T1A' | 'T1B' | 'T1C' | undefined /** * Faucet options. */ diff --git a/src/internal/tempoGenesis.test.ts b/src/internal/tempoGenesis.test.ts new file mode 100644 index 0000000..f7c5fa0 --- /dev/null +++ b/src/internal/tempoGenesis.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from 'vitest' +import { configureGenesis } from './tempoGenesis.js' + +const genesis = JSON.stringify({ + config: { + chainId: 1337, + pragueTime: 0, + t0Time: 0, + t10Time: 100, + t11Time: 200, + t1Time: 50, + t1aTime: 60, + t1bTime: 70, + t1cTime: 80, + t2Time: 90, + t9Time: 99, + }, + alloc: { '0x1234': { balance: '0x1', code: '0x00' } }, +}) + +test('enables preceding forks and removes later forks', () => { + expect(JSON.parse(configureGenesis(genesis, 'T10'))).toEqual({ + config: { + chainId: 1337, + pragueTime: 0, + t0Time: 0, + t1Time: 0, + t1aTime: 0, + t1bTime: 0, + t1cTime: 0, + t2Time: 0, + t9Time: 0, + t10Time: 0, + }, + alloc: { '0x1234': { balance: '0x1', code: '0x00' } }, + }) +}) + +test.each([ + ['T1', ['t0Time', 't1Time']], + ['T1A', ['t0Time', 't1Time', 't1aTime']], + ['T1B', ['t0Time', 't1Time', 't1aTime', 't1bTime']], +])('orders subforks for %s', (hardfork, expected) => { + const { config } = JSON.parse(configureGenesis(genesis, hardfork)) + expect(Object.keys(config).filter((key) => key.startsWith('t'))).toEqual( + expected, + ) +}) + +test('rejects a fork absent from genesis', () => { + expect(() => configureGenesis(genesis, 'T99')).toThrow( + 'Hardfork "T99" is not present in the Tempo genesis.', + ) +}) diff --git a/src/internal/tempoGenesis.ts b/src/internal/tempoGenesis.ts new file mode 100644 index 0000000..30ed5b0 --- /dev/null +++ b/src/internal/tempoGenesis.ts @@ -0,0 +1,21 @@ +/** Sets the Tempo activation schedule without changing genesis allocations. */ +export function configureGenesis(genesis: string, hardfork: string): string { + const value = JSON.parse(genesis) as { config: Record } + const forks = Object.keys(value.config) + .filter((key) => /^t\d+[a-z]*Time$/.test(key)) + .sort((a, b) => + a.slice(0, -4).localeCompare(b.slice(0, -4), 'en', { numeric: true }), + ) + const target = `${hardfork.toLowerCase()}Time` + const index = forks.indexOf(target) + if (index === -1) + throw new Error( + `Hardfork "${hardfork}" is not present in the Tempo genesis.`, + ) + + for (const [i, fork] of forks.entries()) { + if (i <= index) value.config[fork] = 0 + else delete value.config[fork] + } + return JSON.stringify(value) +} diff --git a/src/testcontainers/Instance.test.ts b/src/testcontainers/Instance.test.ts index 08aac71..8e7b9d5 100644 --- a/src/testcontainers/Instance.test.ts +++ b/src/testcontainers/Instance.test.ts @@ -202,3 +202,21 @@ describe('tempoZone', () => { expect(instance.status).toEqual('stopped') }) }) + +test('behavior: selects hardfork', { timeout: slowTestTimeout }, async () => { + const instance = defineInstance({ hardfork: 'T10' }) + await instance.start() + const response = await fetch(instance.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tempo_forkSchedule', + params: [], + }), + }) + const { result, error } = await response.json() + expect(error).toBeUndefined() + expect(result.active).toBe('T10') +}) diff --git a/src/testcontainers/Instance.ts b/src/testcontainers/Instance.ts index fa9b5cf..57faf2b 100644 --- a/src/testcontainers/Instance.ts +++ b/src/testcontainers/Instance.ts @@ -13,6 +13,7 @@ import { type tempoZone as core_tempoZone, command as zoneCommand, } from '../instances/tempoZone.js' +import { configureGenesis } from '../internal/tempoGenesis.js' import * as ContainerOptions from './containerOptions.js' export type { Endpoint, Instance, InstanceOptions } from '../Instance.js' @@ -323,6 +324,7 @@ export const tempo = Instance.define((parameters?: tempo.Parameters) => { const { containerName = `tempo.${crypto.randomUUID()}`, image = 'ghcr.io/tempoxyz/tempo:latest', + hardfork, log: log_, startupTimeout, ...args @@ -352,6 +354,30 @@ export const tempo = Instance.define((parameters?: tempo.Parameters) => { const containerPort = port ?? 8545 + let genesis: string | undefined + const chain = + hardfork === undefined ? args.chain : '/tmp/prool-genesis.json' + if (hardfork !== undefined) { + const exporter = await new GenericContainer(image) + .withPullPolicy(PullPolicy.alwaysPull()) + .withPlatform('linux/x86_64') + .withAutoRemove(false) + .withCommand(['-q', 'dump-genesis', '--chain', args.chain ?? 'dev']) + .withWaitStrategy(Wait.forOneShotStartup()) + .withStartupTimeout( + ContainerOptions.resolveStartupTimeout(startupTimeout), + ) + .start() + try { + let output = '' + for await (const chunk of await exporter.logs()) + output += chunk.toString() + genesis = configureGenesis(output, hardfork) + } finally { + await exporter.stop() + } + } + const c = new GenericContainer(image) .withPullPolicy(PullPolicy.alwaysPull()) .withPlatform('linux/x86_64') @@ -361,7 +387,7 @@ export const tempo = Instance.define((parameters?: tempo.Parameters) => { ]) .withName(containerName) .withEnvironment({ RUST_LOG }) - .withCommand(command({ ...args, port: containerPort })) + .withCommand(command({ ...args, chain, port: containerPort })) .withWaitStrategy( Wait.forLogMessage( /Received (block|new payload) from consensus engine/, @@ -387,6 +413,9 @@ export const tempo = Instance.define((parameters?: tempo.Parameters) => { ContainerOptions.resolveStartupTimeout(startupTimeout), ) + if (genesis !== undefined) + c.withCopyContentToContainer([{ content: genesis, target: chain! }]) + c.start() .then((started) => { container = started @@ -410,6 +439,7 @@ export const tempo = Instance.define((parameters?: tempo.Parameters) => { export declare namespace tempo { export type Parameters = Omit & + Pick & ContainerOptions.Parameters & { /** * Name of the container. From a4696a63f15347eafe87447950715241f4544b29 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:32:15 +1000 Subject: [PATCH 2/4] docs: remove hardfork readme changes --- README.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/README.md b/README.md index ca7fc43..b7343ca 100644 --- a/README.md +++ b/README.md @@ -123,22 +123,6 @@ await server.start() // "http://localhost:8545/n" ``` -Set `hardfork` to activate a specific Tempo fork and all earlier forks from genesis: - -```ts -import { Instance } from 'prool' - -const instance = Instance.tempo({ hardfork: 'T10' }) -await instance.start() -``` - -The same option is available on `Instance.tempo` from `prool/testcontainers`. -Prool exports genesis from the selected binary or image, disables later Tempo forks, -and starts the node with the modified genesis. The fork must be present in that -genesis or startup fails. When `chain` is omitted, `hardfork` uses the dev genesis. -Genesis allocations and Ethereum fork settings are preserved. Without `hardfork`, -the node's existing defaults apply. - ### Alto (Bundler Node) #### Requirements From ae06483b21c5eb5850331a1d7c1e1b983ccdb65a Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:32:38 +1000 Subject: [PATCH 3/4] chore: use patch changeset --- .changeset/tempo-hardfork.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tempo-hardfork.md b/.changeset/tempo-hardfork.md index 3022047..8bbe524 100644 --- a/.changeset/tempo-hardfork.md +++ b/.changeset/tempo-hardfork.md @@ -1,5 +1,5 @@ --- -"prool": minor +"prool": patch --- Added a Tempo `hardfork` option that activates the selected fork and earlier forks at genesis while disabling later forks for binary and container instances. From 27a83868d05a461212fde4a7909fde6bc9a28462 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:47:43 +1000 Subject: [PATCH 4/4] fix(tempo): isolate hardfork data directories --- src/instances/tempo.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/instances/tempo.ts | 18 ++++++++---------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/instances/tempo.test.ts b/src/instances/tempo.test.ts index 255743c..2aff521 100644 --- a/src/instances/tempo.test.ts +++ b/src/instances/tempo.test.ts @@ -1,4 +1,5 @@ import * as os from 'node:os' +import * as path from 'node:path' import getPort from 'get-port' import { Instance } from 'prool' import { afterEach, expect, test } from 'vitest' @@ -237,3 +238,38 @@ test('behavior: rejects unsupported hardfork', async () => { 'Hardfork "T999" is not present in the Tempo genesis.', ) }) + +test('command: explicit datadir overrides hardfork directory', () => { + const args = command({ datadir: '/custom/tempo', hardfork: 'T9', port: 8545 }) + expect(args[args.indexOf('--datadir') + 1]).toBe('/custom/tempo') +}) + +test( + 'behavior: isolates hardforks on the same port', + { timeout: slowTestTimeout }, + async () => { + const port = await getPort() + for (const hardfork of ['T9', 'T10', 'T9'] as const) { + const instance = defineInstance({ hardfork, port }) + await instance.start() + const args = instance._internal.process.spawnargs + expect(args[args.indexOf('--datadir') + 1]).toBe( + path.join(os.tmpdir(), '.prool', `tempo.${port}.${hardfork}`), + ) + const response = await fetch(instance.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tempo_forkSchedule', + params: [], + }), + }) + const { result, error } = await response.json() + expect(error).toBeUndefined() + expect(result.active).toBe(hardfork) + await instance.stop() + } + }, +) diff --git a/src/instances/tempo.ts b/src/instances/tempo.ts index e096601..39fad7f 100644 --- a/src/instances/tempo.ts +++ b/src/instances/tempo.ts @@ -8,16 +8,14 @@ import { deepAssign, toArgs } from '../internal/utils.js' import { execa } from '../processes/execa.js' export function command(parameters: tempo.Parameters): string[] { - const { - blockMaxTransactions, - blockTime, - hardfork: _, - mnemonic, - port, - ...rest - } = parameters + const { blockMaxTransactions, blockTime, hardfork, mnemonic, port, ...rest } = + parameters - const datadir = path.join(os.tmpdir(), '.prool', `tempo.${port}`) + const datadir = path.join( + os.tmpdir(), + '.prool', + hardfork === undefined ? `tempo.${port}` : `tempo.${port}.${hardfork}`, + ) const defaultParameters = { authrpc: { port: port! + 30, @@ -146,7 +144,7 @@ export const tempo = Instance.define((parameters?: tempo.Parameters) => { env: { RUST_LOG, }, - })`${[binary, ...command({ ...args, chain, port })]}`, + })`${[binary, ...command({ ...args, chain, hardfork, port })]}`, { ...options, // Resolve when the process is listening via consensus engine message.