Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/tempo-hardfork.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"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.

```ts
import { Instance } from 'prool'

const instance = Instance.tempo({ hardfork: 'T10' })
await instance.start()
```
68 changes: 68 additions & 0 deletions src/instances/tempo.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -205,3 +206,70 @@ test('command: behavior: http port override', () => {
`"node --authrpc.port 8575 --datadir <tmpdir>/.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.',
)
})

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()
}
},
)
95 changes: 64 additions & 31 deletions src/instances/tempo.ts
Original file line number Diff line number Diff line change
@@ -1,14 +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 } =
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,
Expand Down Expand Up @@ -89,7 +96,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 {
Expand All @@ -114,36 +121,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, hardfork, 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()
Expand Down Expand Up @@ -183,6 +210,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.
*/
Expand Down
54 changes: 54 additions & 0 deletions src/internal/tempoGenesis.test.ts
Original file line number Diff line number Diff line change
@@ -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.',
)
})
21 changes: 21 additions & 0 deletions src/internal/tempoGenesis.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }
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(

Check failure on line 12 in src/internal/tempoGenesis.ts

View workflow job for this annotation

GitHub Actions / Verify / Test

src/instances/tempo.test.ts > behavior: selects hardfork across restarts

Error: Hardfork "T9" is not present in the Tempo genesis. ❯ configureGenesis src/internal/tempoGenesis.ts:12:11 ❯ start src/instances/tempo.ts:134:27
`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)
}
18 changes: 18 additions & 0 deletions src/testcontainers/Instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
32 changes: 31 additions & 1 deletion src/testcontainers/Instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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/,
Expand All @@ -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
Expand All @@ -410,6 +439,7 @@ export const tempo = Instance.define((parameters?: tempo.Parameters) => {

export declare namespace tempo {
export type Parameters = Omit<core_tempo.Parameters, 'binary'> &
Pick<core_tempo.Parameters, 'chain' | 'hardfork'> &
ContainerOptions.Parameters & {
/**
* Name of the container.
Expand Down
Loading