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
5 changes: 5 additions & 0 deletions .changeset/tempo-zone-instance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"prool": patch
---

Added `tempoZone` instance for running a Tempo Zone node (`tempo-zone dev`) as a host process or testcontainer.
1 change: 1 addition & 0 deletions src/Instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
60 changes: 60 additions & 0 deletions src/instances/tempoZone.test.ts
Original file line number Diff line number Diff line change
@@ -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(), '<tmpdir>')

test('command: default', () => {
expect(redact(command({ port: 9545 }))).toMatchInlineSnapshot(
`"dev --datadir <tmpdir>/.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 <tmpdir>/.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 <tmpdir>/.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 <tmpdir>/.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 <tmpdir>/.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"`,
)
})
207 changes: 207 additions & 0 deletions src/instances/tempoZone.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
}
48 changes: 47 additions & 1 deletion src/testcontainers/Instance.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
})
})
Loading
Loading