Skip to content

Commit 83233b0

Browse files
authored
feat: add tempoZone instance (#85)
* feat: add `tempoZone` instance * chore: changeset * chore: patch changeset
1 parent a1e7dbe commit 83233b0

6 files changed

Lines changed: 546 additions & 1 deletion

File tree

‎.changeset/tempo-zone-instance.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"prool": patch
3+
---
4+
5+
Added `tempoZone` instance for running a Tempo Zone node (`tempo-zone dev`) as a host process or testcontainer.

‎src/Instance.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { EventEmitter } from 'eventemitter3'
33
export { alto } from './instances/alto.js'
44
export { anvil } from './instances/anvil.js'
55
export { tempo } from './instances/tempo.js'
6+
export { tempoZone } from './instances/tempoZone.js'
67

78
type EventTypes = {
89
exit: [code: number | null, signal: NodeJS.Signals | null]

‎src/instances/tempoZone.test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as os from 'node:os'
2+
import { expect, test } from 'vitest'
3+
import { command } from './tempoZone.js'
4+
5+
const redact = (args: string[]) =>
6+
args.join(' ').replaceAll(os.tmpdir(), '<tmpdir>')
7+
8+
test('command: default', () => {
9+
expect(redact(command({ port: 9545 }))).toMatchInlineSnapshot(
10+
`"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"`,
11+
)
12+
})
13+
14+
test('command: behavior: l1 options', () => {
15+
expect(
16+
redact(
17+
command({
18+
l1: {
19+
factoryAddress: '0x00000000000000000000000000000000000fac70',
20+
rpcUrl: 'ws://l1:8546',
21+
},
22+
port: 9545,
23+
}),
24+
),
25+
).toMatchInlineSnapshot(
26+
`"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"`,
27+
)
28+
})
29+
30+
test('command: behavior: dev options', () => {
31+
expect(
32+
redact(
33+
command({
34+
dev: {
35+
key: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
36+
token: '0x20c0000000000000000000000000000000000001',
37+
},
38+
port: 9545,
39+
}),
40+
),
41+
).toMatchInlineSnapshot(
42+
`"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"`,
43+
)
44+
})
45+
46+
test('command: behavior: node args', () => {
47+
expect(
48+
redact(command({ nodeArgs: ['--full'], port: 9545 })),
49+
).toMatchInlineSnapshot(
50+
`"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"`,
51+
)
52+
})
53+
54+
test('command: behavior: private rpc port override', () => {
55+
expect(
56+
redact(command({ port: 9545, privateRpc: { port: 1337 } })),
57+
).toMatchInlineSnapshot(
58+
`"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"`,
59+
)
60+
})

‎src/instances/tempoZone.ts‎

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import * as os from 'node:os'
2+
import * as path from 'node:path'
3+
import * as Instance from '../Instance.js'
4+
import { deepAssign, toArgs } from '../internal/utils.js'
5+
import { execa } from '../processes/execa.js'
6+
7+
// `tempo-zone dev` derives the WS RPC (port + 1) and P2P (port + 2) ports from
8+
// `http.port`. Each instance occupies the compact block [port, port + 4].
9+
export function command(parameters: tempoZone.Parameters): string[] {
10+
const { nodeArgs, port, ...rest } = parameters
11+
12+
const datadir = path.join(os.tmpdir(), '.prool', `tempo-zone.${port}`)
13+
const defaultParameters = {
14+
datadir,
15+
http: {
16+
addr: '0.0.0.0',
17+
port: port!,
18+
},
19+
l1: {
20+
rpcUrl: 'ws://localhost:8546',
21+
},
22+
privateRpc: {
23+
port: port! + 3,
24+
},
25+
}
26+
27+
const args = deepAssign(defaultParameters, rest)
28+
29+
return [
30+
'dev',
31+
...toArgs(args, {
32+
arraySeparator: null,
33+
}),
34+
// Forwarded to `tempo-zone node`; keeps concurrent instances from colliding.
35+
'--',
36+
'--authrpc.port',
37+
String(port! + 4),
38+
'--ipcdisable',
39+
...((nodeArgs as string[] | undefined) ?? []),
40+
]
41+
}
42+
43+
/**
44+
* Defines a Tempo Zone instance.
45+
*
46+
* Provisions a fresh zone against a Tempo dev L1 (`tempo-zone dev`) and runs
47+
* the zone node. Requires a reachable Tempo dev L1 websocket RPC (`l1.rpcUrl`).
48+
*
49+
* @example
50+
* ```ts
51+
* const instance = Instance.tempoZone({
52+
* l1: { rpcUrl: 'ws://localhost:8546' },
53+
* port: 9545,
54+
* })
55+
* await instance.start()
56+
* // ...
57+
* await instance.stop()
58+
* ```
59+
*/
60+
export const tempoZone = Instance.define(
61+
(parameters?: tempoZone.Parameters) => {
62+
const { binary = 'tempo-zone', log: log_, ...args } = parameters || {}
63+
64+
const log = (() => {
65+
try {
66+
return JSON.parse(log_ as string)
67+
} catch {
68+
return log_
69+
}
70+
})()
71+
const RUST_LOG = log && typeof log !== 'boolean' ? log : ''
72+
73+
const name = 'tempo-zone'
74+
const process = execa({ name })
75+
76+
return {
77+
_internal: {
78+
args,
79+
get process() {
80+
return process._internal.process
81+
},
82+
},
83+
host: args.host ?? 'localhost',
84+
name,
85+
port: args.port ?? 9545,
86+
async start({ port = args.port }, options) {
87+
return await process.start(
88+
($) =>
89+
$({
90+
env: {
91+
RUST_LOG,
92+
},
93+
})`${[binary, ...command({ ...args, port })]}`,
94+
{
95+
...options,
96+
// Resolve when the zone RPC server is listening (fires after provisioning).
97+
resolver({ process, reject, resolve }) {
98+
let stderr = ''
99+
process.stdout.on('data', (data) => {
100+
const message = data.toString()
101+
if (log) console.log(message)
102+
if (message.includes('RPC HTTP server started')) resolve()
103+
if (message.includes('shutting down')) reject('shutting down')
104+
})
105+
process.stderr.on('data', (data) => {
106+
const message = data.toString()
107+
if (log) console.error(message)
108+
stderr += message
109+
})
110+
// Provisioning failures (e.g. unreachable L1) exit non-zero before the RPC starts.
111+
process.once('exit', (code) => {
112+
if (code) reject(stderr || `exited with code ${code}`)
113+
})
114+
},
115+
},
116+
)
117+
},
118+
async stop() {
119+
await process.stop()
120+
},
121+
}
122+
},
123+
)
124+
125+
export declare namespace tempoZone {
126+
export type Parameters = {
127+
/**
128+
* Path or alias to the Tempo Zone binary.
129+
*/
130+
binary?: string | undefined
131+
/**
132+
* Directory for genesis.json, zone.json, node data, and logs. Wiped on start.
133+
*/
134+
datadir?: string | undefined
135+
/**
136+
* Dev provisioning options.
137+
*/
138+
dev?:
139+
| {
140+
/**
141+
* Dev private key (hex): L1 fee payer, factory deployer, portal admin,
142+
* and zone sequencer.
143+
*/
144+
key?: string | undefined
145+
/**
146+
* Initial TIP-20 token enabled on the portal.
147+
* @default pathUSD (0x20c0000000000000000000000000000000000000)
148+
*/
149+
token?: string | undefined
150+
}
151+
| undefined
152+
/**
153+
* Host the server will listen on.
154+
*/
155+
host?: string | undefined
156+
/**
157+
* Tempo L1 options.
158+
*/
159+
l1?:
160+
| {
161+
/**
162+
* Existing ZoneFactory address on the L1. Deploys the bundled factory
163+
* when omitted.
164+
*/
165+
factoryAddress?: string | undefined
166+
/**
167+
* Tempo L1 WebSocket RPC URL.
168+
* @default "ws://localhost:8546"
169+
*/
170+
rpcUrl?: string | undefined
171+
}
172+
| undefined
173+
/**
174+
* Rust log level configuration (sets RUST_LOG environment variable).
175+
* Can be a log level or a custom filter string.
176+
*/
177+
log?:
178+
| 'trace'
179+
| 'debug'
180+
| 'info'
181+
| 'warn'
182+
| 'error'
183+
| (string & {})
184+
| boolean
185+
| undefined
186+
/**
187+
* Extra arguments forwarded to `tempo-zone node`.
188+
*/
189+
nodeArgs?: string[] | undefined
190+
/**
191+
* Port the server will listen on.
192+
*/
193+
port?: number | undefined
194+
/**
195+
* Private RPC options.
196+
*/
197+
privateRpc?:
198+
| {
199+
/**
200+
* Zone private RPC port.
201+
* @default `port + 3`
202+
*/
203+
port?: number | undefined
204+
}
205+
| undefined
206+
} & Record<string, unknown>
207+
}

‎src/testcontainers/Instance.test.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import getPort from 'get-port'
22
import { Instance } from 'prool/testcontainers'
3-
import { afterEach, expect, test } from 'vitest'
3+
import { afterEach, describe, expect, test } from 'vitest'
44

55
const instances: Instance.Instance[] = []
66
const slowTestTimeout = 30_000
@@ -125,3 +125,49 @@ test('behavior: faucet funds address', { timeout: 120_000 }, async () => {
125125
}
126126
expect(balance).toBeGreaterThan(0n)
127127
})
128+
129+
// Requires a `tempo-zone` image with the `dev` command and GHCR access
130+
// (the package is private), e.g. `VITE_TEMPO_ZONE_IMAGE=ghcr.io/tempoxyz/tempo-zone:latest`.
131+
const tempoZoneImage = process.env['VITE_TEMPO_ZONE_IMAGE']
132+
const zonePort = await getPort()
133+
134+
describe.runIf(tempoZoneImage)('tempoZone', () => {
135+
const defineZoneInstance = (
136+
parameters: Instance.tempoZone.Parameters = {},
137+
) => {
138+
const instance = Instance.tempoZone({
139+
image: tempoZoneImage,
140+
port: zonePort,
141+
startupTimeout: 120_000,
142+
...parameters,
143+
})
144+
instances.push(instance)
145+
return instance
146+
}
147+
148+
test('default', { timeout: 300_000 }, async () => {
149+
const instance = defineZoneInstance()
150+
151+
await instance.start()
152+
expect(instance.status).toEqual('started')
153+
154+
const rpc = async (method: string, params: unknown[]) => {
155+
const response = await fetch(`http://${instance.host}:${instance.port}`, {
156+
body: JSON.stringify({ id: 0, jsonrpc: '2.0', method, params }),
157+
headers: { 'Content-Type': 'application/json' },
158+
method: 'POST',
159+
})
160+
return await response.json()
161+
}
162+
163+
// Zone chain IDs are derived: ZONE_CHAIN_ID_BASE + (zone_id % range).
164+
const { result: chainId } = await rpc('eth_chainId', [])
165+
expect(BigInt(chainId)).toBeGreaterThanOrEqual(421_700_000n)
166+
167+
const { result: blockNumber } = await rpc('eth_blockNumber', [])
168+
expect(blockNumber).toBeDefined()
169+
170+
await instance.stop()
171+
expect(instance.status).toEqual('stopped')
172+
})
173+
})

0 commit comments

Comments
 (0)