From 80fb368bd67959e5b0cf7dd92a78a1f2c5a1e968 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:02:02 +1000 Subject: [PATCH 1/3] feat: add `tempoZone` instance --- src/Instance.ts | 1 + src/instances/tempoZone.test.ts | 60 ++++++++ src/instances/tempoZone.ts | 207 +++++++++++++++++++++++++ src/testcontainers/Instance.test.ts | 48 +++++- src/testcontainers/Instance.ts | 226 ++++++++++++++++++++++++++++ 5 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 src/instances/tempoZone.test.ts create mode 100644 src/instances/tempoZone.ts diff --git a/src/Instance.ts b/src/Instance.ts index b110976..211028a 100644 --- a/src/Instance.ts +++ b/src/Instance.ts @@ -3,6 +3,7 @@ import { EventEmitter } from 'eventemitter3' export { alto } from './instances/alto.js' export { anvil } from './instances/anvil.js' export { tempo } from './instances/tempo.js' +export { tempoZone } from './instances/tempoZone.js' type EventTypes = { exit: [code: number | null, signal: NodeJS.Signals | null] diff --git a/src/instances/tempoZone.test.ts b/src/instances/tempoZone.test.ts new file mode 100644 index 0000000..49ba6d1 --- /dev/null +++ b/src/instances/tempoZone.test.ts @@ -0,0 +1,60 @@ +import * as os from 'node:os' +import { expect, test } from 'vitest' +import { command } from './tempoZone.js' + +const redact = (args: string[]) => + args.join(' ').replaceAll(os.tmpdir(), '') + +test('command: default', () => { + expect(redact(command({ port: 9545 }))).toMatchInlineSnapshot( + `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 -- --authrpc.port 9549 --ipcdisable"`, + ) +}) + +test('command: behavior: l1 options', () => { + expect( + redact( + command({ + l1: { + factoryAddress: '0x00000000000000000000000000000000000fac70', + rpcUrl: 'ws://l1:8546', + }, + port: 9545, + }), + ), + ).toMatchInlineSnapshot( + `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://l1:8546 --l1.factory-address 0x00000000000000000000000000000000000fac70 --private-rpc.port 9548 -- --authrpc.port 9549 --ipcdisable"`, + ) +}) + +test('command: behavior: dev options', () => { + expect( + redact( + command({ + dev: { + key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + token: '0x20c0000000000000000000000000000000000001', + }, + port: 9545, + }), + ), + ).toMatchInlineSnapshot( + `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 --dev.key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --dev.token 0x20c0000000000000000000000000000000000001 -- --authrpc.port 9549 --ipcdisable"`, + ) +}) + +test('command: behavior: node args', () => { + expect( + redact(command({ nodeArgs: ['--full'], port: 9545 })), + ).toMatchInlineSnapshot( + `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 9548 -- --authrpc.port 9549 --ipcdisable --full"`, + ) +}) + +test('command: behavior: private rpc port override', () => { + expect( + redact(command({ port: 9545, privateRpc: { port: 1337 } })), + ).toMatchInlineSnapshot( + `"dev --datadir /.prool/tempo-zone.9545 --http.addr 0.0.0.0 --http.port 9545 --l1.rpc-url ws://localhost:8546 --private-rpc.port 1337 -- --authrpc.port 9549 --ipcdisable"`, + ) +}) diff --git a/src/instances/tempoZone.ts b/src/instances/tempoZone.ts new file mode 100644 index 0000000..55f218e --- /dev/null +++ b/src/instances/tempoZone.ts @@ -0,0 +1,207 @@ +import * as os from 'node:os' +import * as path from 'node:path' +import * as Instance from '../Instance.js' +import { deepAssign, toArgs } from '../internal/utils.js' +import { execa } from '../processes/execa.js' + +// `tempo-zone dev` derives the WS RPC (port + 1) and P2P (port + 2) ports from +// `http.port`. Each instance occupies the compact block [port, port + 4]. +export function command(parameters: tempoZone.Parameters): string[] { + const { nodeArgs, port, ...rest } = parameters + + const datadir = path.join(os.tmpdir(), '.prool', `tempo-zone.${port}`) + const defaultParameters = { + datadir, + http: { + addr: '0.0.0.0', + port: port!, + }, + l1: { + rpcUrl: 'ws://localhost:8546', + }, + privateRpc: { + port: port! + 3, + }, + } + + const args = deepAssign(defaultParameters, rest) + + return [ + 'dev', + ...toArgs(args, { + arraySeparator: null, + }), + // Forwarded to `tempo-zone node`; keeps concurrent instances from colliding. + '--', + '--authrpc.port', + String(port! + 4), + '--ipcdisable', + ...((nodeArgs as string[] | undefined) ?? []), + ] +} + +/** + * Defines a Tempo Zone instance. + * + * Provisions a fresh zone against a Tempo dev L1 (`tempo-zone dev`) and runs + * the zone node. Requires a reachable Tempo dev L1 websocket RPC (`l1.rpcUrl`). + * + * @example + * ```ts + * const instance = Instance.tempoZone({ + * l1: { rpcUrl: 'ws://localhost:8546' }, + * port: 9545, + * }) + * await instance.start() + * // ... + * await instance.stop() + * ``` + */ +export const tempoZone = Instance.define( + (parameters?: tempoZone.Parameters) => { + const { binary = 'tempo-zone', log: log_, ...args } = parameters || {} + + const log = (() => { + try { + return JSON.parse(log_ as string) + } catch { + return log_ + } + })() + const RUST_LOG = log && typeof log !== 'boolean' ? log : '' + + const name = 'tempo-zone' + const process = execa({ name }) + + return { + _internal: { + args, + get process() { + return process._internal.process + }, + }, + host: args.host ?? 'localhost', + name, + port: args.port ?? 9545, + async start({ port = args.port }, options) { + return await process.start( + ($) => + $({ + env: { + RUST_LOG, + }, + })`${[binary, ...command({ ...args, port })]}`, + { + ...options, + // Resolve when the zone RPC server is listening (fires after provisioning). + resolver({ process, reject, resolve }) { + let stderr = '' + process.stdout.on('data', (data) => { + const message = data.toString() + if (log) console.log(message) + if (message.includes('RPC HTTP server started')) resolve() + if (message.includes('shutting down')) reject('shutting down') + }) + process.stderr.on('data', (data) => { + const message = data.toString() + if (log) console.error(message) + stderr += message + }) + // Provisioning failures (e.g. unreachable L1) exit non-zero before the RPC starts. + process.once('exit', (code) => { + if (code) reject(stderr || `exited with code ${code}`) + }) + }, + }, + ) + }, + async stop() { + await process.stop() + }, + } + }, +) + +export declare namespace tempoZone { + export type Parameters = { + /** + * Path or alias to the Tempo Zone binary. + */ + binary?: string | undefined + /** + * Directory for genesis.json, zone.json, node data, and logs. Wiped on start. + */ + datadir?: string | undefined + /** + * Dev provisioning options. + */ + dev?: + | { + /** + * Dev private key (hex): L1 fee payer, factory deployer, portal admin, + * and zone sequencer. + */ + key?: string | undefined + /** + * Initial TIP-20 token enabled on the portal. + * @default pathUSD (0x20c0000000000000000000000000000000000000) + */ + token?: string | undefined + } + | undefined + /** + * Host the server will listen on. + */ + host?: string | undefined + /** + * Tempo L1 options. + */ + l1?: + | { + /** + * Existing ZoneFactory address on the L1. Deploys the bundled factory + * when omitted. + */ + factoryAddress?: string | undefined + /** + * Tempo L1 WebSocket RPC URL. + * @default "ws://localhost:8546" + */ + rpcUrl?: string | undefined + } + | undefined + /** + * Rust log level configuration (sets RUST_LOG environment variable). + * Can be a log level or a custom filter string. + */ + log?: + | 'trace' + | 'debug' + | 'info' + | 'warn' + | 'error' + | (string & {}) + | boolean + | undefined + /** + * Extra arguments forwarded to `tempo-zone node`. + */ + nodeArgs?: string[] | undefined + /** + * Port the server will listen on. + */ + port?: number | undefined + /** + * Private RPC options. + */ + privateRpc?: + | { + /** + * Zone private RPC port. + * @default `port + 3` + */ + port?: number | undefined + } + | undefined + } & Record +} diff --git a/src/testcontainers/Instance.test.ts b/src/testcontainers/Instance.test.ts index cf6f136..31f1451 100644 --- a/src/testcontainers/Instance.test.ts +++ b/src/testcontainers/Instance.test.ts @@ -1,6 +1,6 @@ import getPort from 'get-port' import { Instance } from 'prool/testcontainers' -import { afterEach, expect, test } from 'vitest' +import { afterEach, describe, expect, test } from 'vitest' const instances: Instance.Instance[] = [] const slowTestTimeout = 30_000 @@ -125,3 +125,49 @@ test('behavior: faucet funds address', { timeout: 120_000 }, async () => { } expect(balance).toBeGreaterThan(0n) }) + +// Requires a `tempo-zone` image with the `dev` command and GHCR access +// (the package is private), e.g. `VITE_TEMPO_ZONE_IMAGE=ghcr.io/tempoxyz/tempo-zone:latest`. +const tempoZoneImage = process.env['VITE_TEMPO_ZONE_IMAGE'] +const zonePort = await getPort() + +describe.runIf(tempoZoneImage)('tempoZone', () => { + const defineZoneInstance = ( + parameters: Instance.tempoZone.Parameters = {}, + ) => { + const instance = Instance.tempoZone({ + image: tempoZoneImage, + port: zonePort, + startupTimeout: 120_000, + ...parameters, + }) + instances.push(instance) + return instance + } + + test('default', { timeout: 300_000 }, async () => { + const instance = defineZoneInstance() + + await instance.start() + expect(instance.status).toEqual('started') + + const rpc = async (method: string, params: unknown[]) => { + const response = await fetch(`http://${instance.host}:${instance.port}`, { + body: JSON.stringify({ id: 0, jsonrpc: '2.0', method, params }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + return await response.json() + } + + // Zone chain IDs are derived: ZONE_CHAIN_ID_BASE + (zone_id % range). + const { result: chainId } = await rpc('eth_chainId', []) + expect(BigInt(chainId)).toBeGreaterThanOrEqual(421_700_000n) + + const { result: blockNumber } = await rpc('eth_blockNumber', []) + expect(blockNumber).toBeDefined() + + await instance.stop() + expect(instance.status).toEqual('stopped') + }) +}) diff --git a/src/testcontainers/Instance.ts b/src/testcontainers/Instance.ts index 90b5706..0f6f270 100644 --- a/src/testcontainers/Instance.ts +++ b/src/testcontainers/Instance.ts @@ -1,11 +1,17 @@ import { GenericContainer, + Network, PullPolicy, + type StartedNetwork, type StartedTestContainer, Wait, } from 'testcontainers' import * as Instance from '../Instance.js' import { command, type tempo as core_tempo } from '../instances/tempo.js' +import { + type tempoZone as core_tempoZone, + command as zoneCommand, +} from '../instances/tempoZone.js' import * as ContainerOptions from './containerOptions.js' export type { Instance, InstanceOptions } from '../Instance.js' @@ -131,3 +137,223 @@ export declare namespace tempo { port?: number | undefined } } + +/** + * Defines a Tempo Zone instance. + * + * Starts a Tempo dev L1 container and a zone container (`tempo-zone dev`) on a + * shared network, provisioning a fresh zone against the L1. Pass `l1.rpcUrl` + * to attach to an existing L1 instead (the URL must be reachable from inside + * the container, e.g. `ws://host.docker.internal:8546`). + * + * @example + * ```ts + * const instance = Instance.tempoZone({ port: 9545 }) + * await instance.start() + * // ... + * await instance.stop() + * ``` + */ +export const tempoZone = Instance.define( + (parameters?: tempoZone.Parameters) => { + const { + containerName = `tempo-zone.${crypto.randomUUID()}`, + image = 'ghcr.io/tempoxyz/tempo-zone:latest', + l1: l1Parameters, + log: log_, + startupTimeout, + ...args + } = parameters || {} + + const log = (() => { + try { + return JSON.parse(log_ as string) + } catch { + return log_ + } + })() + const RUST_LOG = log && typeof log !== 'boolean' ? log : '' + + // L1 ports inside the container (only reachable over the shared network). + const l1HttpPort = 8545 + const l1WsPort = 8546 + + const name = 'tempo-zone' + let container: StartedTestContainer | undefined + let l1Container: StartedTestContainer | undefined + let network: StartedNetwork | undefined + + async function teardown() { + if (container) await container.stop().catch(() => {}) + if (l1Container) await l1Container.stop().catch(() => {}) + if (network) await network.stop().catch(() => {}) + container = undefined + l1Container = undefined + network = undefined + } + + return { + _internal: { + args, + get l1() { + if (!l1Container) return undefined + return { + host: l1Container.getHost(), + port: l1Container.getMappedPort(l1HttpPort), + wsPort: l1Container.getMappedPort(l1WsPort), + } + }, + }, + host: args.host ?? 'localhost', + name, + port: args.port ?? 9545, + async start({ port = args.port }, { emitter, setEndpoint }) { + const containerPort = port ?? 9545 + const timeout = ContainerOptions.resolveStartupTimeout(startupTimeout) + + const logConsumer = + (reject: (error: Error) => void) => + (stream: NodeJS.ReadableStream) => { + stream.on('data', (data) => { + const message = data.toString() + emitter.emit('message', message) + emitter.emit('stdout', message) + if (log) console.log(message) + if (message.includes('shutting down')) + reject(new Error(`Failed to start: ${message}`)) + }) + stream.on('error', (error) => { + if (log) console.error(error.message) + emitter.emit('message', error.message) + emitter.emit('stderr', error.message) + reject(new Error(`Failed to start: ${error.message}`)) + }) + } + + try { + // Start a dev L1 on a shared network unless attaching to an existing one. + let l1RpcUrl = l1Parameters?.rpcUrl + if (!l1RpcUrl) { + network = await new Network().start() + l1Container = await new GenericContainer( + l1Parameters?.image ?? 'ghcr.io/tempoxyz/tempo:latest', + ) + .withPullPolicy(PullPolicy.alwaysPull()) + .withPlatform('linux/x86_64') + .withNetwork(network) + .withNetworkAliases('l1') + .withExposedPorts(l1HttpPort, l1WsPort) + .withName(`${containerName}.l1`) + .withEnvironment({ RUST_LOG }) + .withCommand( + command({ + port: l1HttpPort, + // The zone anchors to the L1 over WebSocket. + ws: [true, { addr: '0.0.0.0', api: 'all', port: l1WsPort }], + }), + ) + .withWaitStrategy( + Wait.forLogMessage( + /Received (block|new payload) from consensus engine/, + ), + ) + .withLogConsumer( + logConsumer(() => { + // L1 shutdown surfaces via the zone container failing to start. + }), + ) + .withStartupTimeout(timeout) + .start() + l1RpcUrl = `ws://l1:${l1WsPort}` + } + + const promise = Promise.withResolvers() + + let c = new GenericContainer(image) + .withPullPolicy(PullPolicy.alwaysPull()) + .withPlatform('linux/x86_64') + .withExposedPorts(containerPort) + .withExtraHosts([ + { host: 'host.docker.internal', ipAddress: 'host-gateway' }, + ]) + .withName(containerName) + .withEnvironment({ RUST_LOG }) + .withCommand( + zoneCommand({ + ...args, + l1: { + ...(l1Parameters?.factoryAddress + ? { factoryAddress: l1Parameters.factoryAddress } + : {}), + rpcUrl: l1RpcUrl, + }, + port: containerPort, + }), + ) + .withWaitStrategy(Wait.forLogMessage('RPC HTTP server started')) + .withLogConsumer(logConsumer(promise.reject)) + .withStartupTimeout(timeout) + if (network) c = c.withNetwork(network) + + c.start() + .then((started) => { + container = started + setEndpoint?.({ + host: started.getHost(), + port: started.getMappedPort(containerPort), + }) + promise.resolve() + }) + .catch(promise.reject) + + await promise.promise + } catch (error) { + await teardown() + throw error + } + }, + async stop() { + await teardown() + }, + } + }, +) + +export declare namespace tempoZone { + export type Parameters = Omit & + ContainerOptions.Parameters & { + /** + * Name of the container. + */ + containerName?: string | undefined + /** + * Docker image to use for the zone node. + */ + image?: string | undefined + /** + * Host the server will listen on. + */ + host?: string | undefined + /** + * Tempo L1 options. + */ + l1?: + | (NonNullable & { + /** + * Docker image to use for the L1 node (when no `rpcUrl` is provided). + */ + image?: string | undefined + /** + * Existing Tempo L1 WebSocket RPC URL, reachable from inside the + * container (e.g. `ws://host.docker.internal:8546`). Starts a + * dev L1 container when omitted. + */ + rpcUrl?: string | undefined + }) + | undefined + /** + * Port the server will listen on. + */ + port?: number | undefined + } +} From 352ebc29eb520a62245652228c74fb0fb7b5d06e Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:02:44 +1000 Subject: [PATCH 2/3] chore: changeset --- .changeset/tempo-zone-instance.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tempo-zone-instance.md diff --git a/.changeset/tempo-zone-instance.md b/.changeset/tempo-zone-instance.md new file mode 100644 index 0000000..2d15a49 --- /dev/null +++ b/.changeset/tempo-zone-instance.md @@ -0,0 +1,5 @@ +--- +"prool": minor +--- + +Added `tempoZone` instance for running a Tempo Zone node (`tempo-zone dev`) as a host process or testcontainer. From db9bbff765541378602070d8ca7c8682ff273c88 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:16:45 +1000 Subject: [PATCH 3/3] chore: patch changeset --- .changeset/tempo-zone-instance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tempo-zone-instance.md b/.changeset/tempo-zone-instance.md index 2d15a49..ac840dc 100644 --- a/.changeset/tempo-zone-instance.md +++ b/.changeset/tempo-zone-instance.md @@ -1,5 +1,5 @@ --- -"prool": minor +"prool": patch --- Added `tempoZone` instance for running a Tempo Zone node (`tempo-zone dev`) as a host process or testcontainer.