A chain-agnostic transaction generation tool for blockchain load testing and benchmarking.
txgen generates signed, RLP-encoded transactions from YAML workload specifications. It outputs transactions as newline-delimited JSON (NDJSON) that can be piped to sending tools or saved for later use.
For end-to-end workflow examples, see the txgen Cookbook.
Key features:
- Chain-agnostic: Plugin architecture supports multiple chains (Ethereum, Tempo)
- Deterministic: Seed-based RNG for reproducible transaction generation
- Flexible: YAML specs with weighted template mixing, value generators, and account pools
- Multi-chain scenarios: Run correlated submit, receipt, and event workflows from one command
- Fast generation: Generate transaction streams without network I/O
cargo install --path crates/txgen-ethereum
cargo install --path crates/txgen-tempo
cargo install --path crates/bench-cliOr build from source:
cargo build --releaseThe workspace provides three binaries: txgen-ethereum and txgen-tempo for chain-specific transaction workflows, and bench for benchmarking. Transaction generation remains offline; scenario run connects directly to every chain named by a scenario, while scenario validate and scenario render perform offline checks only.
Generate transactions from a workload spec.
# Generate 1000 Ethereum transactions
txgen-ethereum generate -s workload.yaml -n 1000
# Generate Tempo transactions with reproducible seed
txgen-tempo generate -s workload.yaml -n 1000 --seed 42
# Generate for a bounded wall-clock duration
txgen-tempo generate -s workload.yaml --duration 5m
# Fetch nonces from chain before generating
txgen-ethereum generate -s workload.yaml -n 1000 --rpc http://localhost:8545
# Output to file
txgen-ethereum generate -s workload.yaml -n 1000 -o transactions.ndjson| Flag | Description |
|---|---|
-s, --spec <PATH> |
Workload specification file (YAML) |
-n, --count <N> |
Maximum number of workload transactions to generate; required unless --duration is set |
--duration <DUR> |
Maximum workload generation duration; setup is emitted first and excluded |
-o, --output <PATH> |
Output file (default: stdout) |
--rpc <URL> |
RPC endpoint for fetching current nonces |
--seed <SEED> |
RNG seed for reproducibility |
Required RPC methods: eth_getTransactionCount (only when --rpc is provided)
Run an asynchronous, multi-chain transaction workflow from one process. A scenario references existing workload templates for transaction construction and adds checkpoints, receipt waits, event waits, cross-step values, concurrency controls, and journey-level reporting. It does not replace the workload specification or change the generate output format.
txgen-tempo scenario run \
--scenario scenario.yaml \
--count 100 \
--starts-per-second 5 \
--max-in-flight 20 \
--tx-rate 50 \
--max-rpc-in-flight 100 \
--seed 42 \
--failure-policy continue \
--report json:scenario-report.json| Flag | Description |
|---|---|
--scenario <PATH> |
Versioned scenario YAML file |
--count <N> |
Maximum scenario instances to start; defaults to 1 when --duration is omitted |
--duration <DUR> |
Stop starting instances after this run duration; in-flight instances are then completed |
--starts-per-second <RATE> |
New scenario journeys started per second, not transaction TPS (0, the default, is unlimited) |
--max-in-flight <N> |
Maximum active scenario instances (default: 1) |
--step-timeout <DUR> |
Override the scenario's default step timeout; an explicit timeout on a step remains more specific |
--seed <SEED> |
Deterministic binding and workload seed |
--failure-policy <POLICY> |
continue (default) or fail-fast |
--tx-rate <TPS> |
Separate per-chain transaction-submission limit across scenario instances (0 = unlimited) |
--max-rpc-in-flight <N> |
Upper bound on simultaneous transaction-submission RPC calls per chain (default: 100) |
--report <DESTINATION> |
Report destination; repeat for more than one. A bare path or json:<path> writes JSON, and clickhouse:<url> publishes to ClickHouse. JSON is written to stdout when omitted |
-m, --metadata <KEY=VALUE> |
Metadata for external reporters; repeat for additional fields. ClickHouse requires git-sha and git-ref |
--sample-instances <N> |
Include up to N sanitized instance lifecycle records in the report (default: 0) |
When both --count and --duration are present, txgen stops starting journeys at the first limit. See Scenario Specification for the schema, step results, expressions, and execution semantics.
Resolve and statically validate a scenario without connecting to its RPC endpoints:
txgen-tempo scenario validate --scenario scenario.yamlValidation expands included fragments first, then loads the referenced workload and ABI files and checks chains, bindings, templates, events, filters, saves, DAG IDs and dependencies, output ancestry, and statically known types. A valid document prints a success message and exits with status zero; invalid input reports its composition or validation context and exits nonzero. Remote chain IDs, nonces, and deployed state are checked only by scenario run.
Validate a scenario and print its deterministic, fully expanded YAML form:
# Print to stdout.
txgen-tempo scenario render --scenario scenario.yaml
# Write to a file instead.
txgen-tempo scenario render \
--scenario scenario.yaml \
--output rendered.yamlThe rendered document contains ordinary inline steps with resolved workload paths: top-level include and fragments declarations are removed, fragment use steps are replaced in place, and fragment-authored { param: name } expressions are substituted. Literal keys with those names in ordinary application data are preserved. Omitting --output writes YAML to stdout. Composition provenance is intentionally not serialized, so loading the rendered file later treats its steps as ordinary inline steps and cannot reproduce the original fragment metadata in reports or errors. Rendering is offline, but environment references are expanded while loading, so rendered RPC URLs may contain credentials and should be handled accordingly.
List signer account addresses from a workload spec (useful for funding). Destination-only address_pools are intentionally omitted.
txgen-ethereum addresses -s workload.yaml
txgen-ethereum addresses -s workload.yaml -f json
txgen-ethereum addresses -s workload.yaml -f shell # space-separated for xargs| Flag | Description |
|---|---|
-s, --spec <PATH> |
Workload specification file (YAML) |
-f, --format <FMT> |
Output format: plain, json, shell (default: plain) |
Required RPC methods: None (offline)
Generate a Zone private-RPC authorization-token map for the exact logical signers in one account pool. The command loads the pool through the normal WorkloadSpec environment expansion and account derivation paths, including the existing [start, end) range semantics.
# Generate a one-shot map
txgen-tempo auth-token-map \
--spec zones-workload.yml \
--pool users \
--zone-id 71 \
--chain-id 421700071 \
--ttl-secs 600 \
--output /run/secrets/zone-auth-tokens.json
# Refresh the complete map before its tokens expire
txgen-tempo auth-token-map \
--spec zones-workload.yml \
--pool users \
--zone-id 71 \
--chain-id 421700071 \
--ttl-secs 600 \
--refresh-before-secs 30 \
--watch \
--output /run/secrets/zone-auth-tokens.json| Flag | Description |
|---|---|
--spec <PATH> |
Workload specification file (YAML); ${ENV_VAR} values are expanded before parsing |
--pool <NAME> |
Non-empty logical/root signer pool to include |
--zone-id <ID> |
Nonzero Zone ID encoded in every token |
--chain-id <ID> |
Chain ID encoded in every token |
--ttl-secs <N> |
Token lifetime in seconds (default: 600; maximum: 2,592,000, or 30 days) |
--refresh-before-secs <N> |
Refresh lead time, which must be less than the TTL (default: 30) |
--watch |
Keep running and atomically replace the complete map before expiry |
--output <PATH> |
Secret output file |
--force |
Replace an existing output in one-shot mode |
The compact JSON output is a flat, address-sorted map from normalized lowercase 0x-prefixed logical sender addresses to 188-character lowercase hex tokens without a 0x prefix. Every token in one refresh shares the same issue and expiry timestamps. Keychain workloads use the root account pool: access keys do not receive separate entries because the root account token authenticates the logical sender.
Treat the output as a secret. Write it to a runtime secret directory rather than the repository; on Unix, txgen creates replacement files with mode 0600 and atomically renames them so readers do not observe partial content. It does not print the map, mnemonic, private keys, signatures, or tokens. One-shot mode rejects an existing output unless --force is supplied, while watch mode retains the last valid file if a refresh fails. A typical 1,000-account map is about 236 KB (230.5 KiB).
The 30-day TTL limit is the protocol maximum; a remote Zone node may enforce a smaller maximum. This command only derives accounts and writes tokens locally—it does not submit RPC requests or distribute the map.
Required RPC methods: None (offline)
Extract raw RLP-encoded blocks from an archive node as NDJSON. Use --bal to attach RLP-encoded block access lists for replaying EIP-7928/Amsterdam payloads.
txgen-ethereum extract --rpc http://localhost:8545 --from 1000 --to 2000 -o blocks.ndjson
# Extract the blocks' signed transactions for replay on transaction basis
txgen-ethereum extract --rpc http://localhost:8545 --from 1000 --to 2000 \
--format transactions | bench send --rpc-url http://localhost:8545| Flag | Description |
|---|---|
--rpc <URL> |
RPC endpoint (archive node) |
--from <N> |
First block number (inclusive) |
--to <N> |
Last block number (inclusive) |
-o, --output <PATH> |
Output file (default: stdout) |
--buffer-size <N> |
Number of blocks to prefetch ahead (default: 20) |
--bal |
Include RLP-encoded block access lists in the bal field |
--format <FORMAT> |
Output blocks (default) or transactions; transaction output is accepted by bench send |
--format transactions preserves source block and transaction order. Transactions from different
senders may be submitted concurrently by bench send; transactions from the same sender use a
shared scheduling key and are submitted in order. The target node must be at the state immediately
before the replay range and configured to build blocks.
Required RPC methods: debug_getRawBlock; with --bal: eth_getBlockAccessListByBlockNumber
Generate reth-bb-compatible big-block payloads as NDJSON. Use --bal to fetch and merge constituent block access lists into merged_block_access_list.
txgen-ethereum extract-big-blocks \
--rpc http://archive:8545 \
--from 910020 \
--count 25 \
--target-gas 2G \
-o big-blocks.ndjson| Flag | Description |
|---|---|
--rpc <URL> |
RPC endpoint (archive node) |
--from <N> |
First source block number |
--count <N> |
Number of synthetic big blocks to emit |
--target-gas <GAS> |
Target gas per big block; accepts K, M, G suffixes |
-o, --output <PATH> |
Output file (default: stdout) |
--buffer-size <N> |
Reserved for future prefetching compatibility (default: 20) |
--bal |
Include a merged RLP-encoded block access list for each synthetic big block |
Required RPC methods: debug_getRawBlock; with --bal: eth_getBlockAccessListByBlockNumber
Send pre-generated transactions from NDJSON file or stdin.
After sending completes, queries the node for per-block statistics (transaction count, gas used) and includes them in the report.
# From file
bench send --input transactions.ndjson --rpc-url http://localhost:8545 --tps 500
# From stdin (pipe from txgen)
txgen-ethereum generate -s workload.yaml -n 1000 | bench send --rpc-url http://localhost:8545
# With JSON report and metadata
bench send -i txs.ndjson --rpc-url http://localhost:8545 \
--report json:report.json \
-m build-sha=abcdef -m build-profile=perf
# Sender-scoped authentication with a separate unrestricted query RPC
bench send -i txs.ndjson \
--rpc-url http://submit.example:8544 \
--query-rpc-url http://query.example:8546 \
--sender-header-name X-Authorization-Token \
--sender-header-map /run/secrets/sender-auth.json| Flag | Description |
|---|---|
-i, --input <PATH> |
Input NDJSON file (default: stdin) |
--rpc-url <URL> |
RPC endpoint URLs, comma-separated or repeated (default: http://localhost:8545) |
--query-rpc-url <URL> |
Optional RPC endpoint for block, txpool, and other aggregate queries |
--sender-header-name <NAME> |
HTTP header populated from the sender map for sender-scoped requests |
--sender-header-map <PATH> |
JSON file mapping logical transaction senders to secret header values |
--sender-header-reload-interval <DUR> |
How often to check the sender-header map for an atomic replacement (default: 1s) |
--tps <N> |
Target transactions per second (0 = unlimited) |
--max-concurrent <N> |
Maximum concurrent requests (default: 100) |
--retries <N> |
Retry failed transaction submissions N times (0 = never retry, omitted = retry forever) |
--timeout <DUR> |
Request timeout (default: 30s) |
--report <FORMAT> |
Report destinations, repeatable (see Reporters) |
-m, --metadata <K=V> |
Metadata key=value pairs for the report, repeatable |
--metrics-url <URL or NODE:URL,...> |
Prometheus endpoint(s) to scrape during the run (see Metrics Scraping) |
--scrape-interval-ms <N> |
Scrape interval in milliseconds (default: 500) |
--metrics-align <TIMESTAMP> |
Align exported metric timestamps to a benchmark-start Unix timestamp, in seconds or milliseconds |
--metrics-forward <URL> |
Forward scraped samples in real time via Prometheus remote write; requires --metrics-url |
--collect-latencies |
Collect and report aggregate latency stats plus individual request samples under time_series.latencies (default: disabled) |
--skip-setup |
Ignore setup-phase transactions in the input stream |
--drain-timeout <N> |
Wait for txpool drain after sending, in seconds (default: 0, set >0 to enable) |
Required RPC methods: eth_sendRawTransaction, eth_getTransactionReceipt (setup and inclusion waits), eth_blockNumber, eth_getBlockByNumber, txpool_status (for --drain-timeout)
bench send can select an HTTP credential from the transaction's logical on-chain sender. The sender-header map is a JSON object whose keys are 20-byte addresses. Values are inserted into the header named by --sender-header-name:
{
"0x1111111111111111111111111111111111111111": "example-only-value-for-sender-1",
"0x2222222222222222222222222222222222222222": "example-only-value-for-sender-2"
}The values above are deliberately fake. --sender-header-name and --sender-header-map must be supplied together. Treat a real map as a secret: keep it out of process arguments and benchmark metadata, restrict its filesystem permissions, and replace it atomically rather than editing it in place. Bench warns when the map is readable by group or other users on supported platforms. It normalizes address keys and validates the complete replacement before activating it. A malformed replacement produces a sanitized warning and leaves the last valid map active.
Each authenticated transaction must have a sender field in its NDJSON record and a matching entry in the map. Selection never uses submission_keys or inclusion_keys. Standard and sponsored transactions use the transaction sender; Tempo keychain transactions use the authorized user, not the access key. Missing sender metadata or a missing mapping fails before the request is submitted. Legacy NDJSON without sender remains accepted when sender authentication is disabled.
Authentication headers are constructed per request while the RPC providers share one HTTP client and connection pool. Submission retries retain the selected header. Receipt polling for setup transactions and inclusion waits stays on the selected submission RPC and uses the same sender context.
When --query-rpc-url is set, initial and final block-number reads, block statistics, and txpool_status drain checks use that endpoint without sender credentials. Aggregate queries never select a sender mapping. Without a query URL, these operations retain the existing behavior of using the first --rpc-url provider.
Txgen consumes already-generated credential values; it does not encode, sign, or renew them. Generation-time nonce requests made by txgen-ethereum generate --rpc or txgen-tempo generate --rpc are not authenticated by these bench send options. Use an unrestricted RPC for nonce prefetching, or generate with suitable offline nonce configuration.
For a private Tempo Zone RPC, every authenticated transaction therefore needs an externally generated token mapped to its logical sender.
Submit RLP-encoded blocks or reth-bb big-block payloads via reth Engine API.
Raw-block inputs may include an optional bal field produced by txgen extract --bal; bench send-blocks forwards it to reth_newPayload alongside the block RLP. Big-block inputs use the current reth-bb BigBlockData format where each NDJSON line contains the constituent execution payloads in env_switches, plus prior_block_hashes, block_number, and optional merged_block_access_list.
bench send-blocks --engine http://localhost:8551 --jwt-secret /path/to/jwt.hex --input blocks.ndjson
bench send-blocks --engine http://localhost:8551 --jwt-secret /path/to/jwt.hex --input big-blocks.ndjson
# Exercise reorg paths by building synthetic side-fork blocks via testing_buildBlockV1.
bench send-blocks \
--engine http://localhost:8551 \
--jwt-secret /path/to/jwt.hex \
--rpc http://localhost:8545 \
--reorg 8 \
--input blocks.ndjson| Flag | Description |
|---|---|
--engine <URL> |
Engine API endpoint |
--jwt-secret <PATH> |
Path to JWT secret file |
-i, --input <PATH> |
Input NDJSON file (default: stdin) |
--wait-for-persistence <POLICY> |
Persistence wait policy: always, never, or every:N (default: never) |
--wait-time <DURATION> |
Minimum interval between block submissions. Accepts 100ms, 2s, or bare milliseconds like 400 |
--reorg [DEPTH] |
Build synthetic side-fork blocks and alternate forkchoice updates to exercise reorg paths. If DEPTH is omitted, defaults to 8. Requires raw RLP block input |
--reorg-gap <BLOCKS> |
Add BLOCKS canonical blocks between resolved side chains (default: 0; requires --reorg) |
--rpc <URL> |
Regular HTTP RPC endpoint for testing_buildBlockV1 when --reorg is enabled (default: http://localhost:8545) |
--report <FORMAT> |
Report destinations, repeatable (see Reporters) |
-m, --metadata <K=V> |
Metadata key=value pairs for the report, repeatable |
--metrics-url <URL or NODE:URL,...> |
Prometheus endpoint(s) to scrape during the run (see Metrics Scraping) |
--scrape-interval-ms <N> |
Scrape interval in milliseconds (default: 500) |
--metrics-align <TIMESTAMP> |
Align exported metric timestamps to a benchmark-start Unix timestamp, in seconds or milliseconds |
--metrics-forward <URL> |
Forward scraped samples in real time via Prometheus remote write; requires --metrics-url |
For send-blocks, aggregate run rates use benchmark wall-clock duration. Per-block timestamps remain the original chain timestamps from the input. With --reorg, canonical block stats remain canonical-only, but the wall-clock duration includes synthetic fork block build/submission work.
Required RPC methods: reth_newPayload, reth_forkchoiceUpdated (reth custom Engine API). Big-block inputs require a reth-bb compatible node. --reorg additionally requires testing_buildBlockV1 on the regular HTTP RPC endpoint, for example from a node started with --http --http.api eth,testing. --rpc-url and --local-rpc-url are accepted as backwards-compatible aliases for --rpc.
Print an existing JSON report to the console.
bench view
bench view report.json| Argument | Description |
|---|---|
<INPUT> |
JSON report file (default: report.json) |
Required RPC methods: None (offline)
During bench send, the console reporter displays a live progress line:
Sent: 5000 | OK: 4800 | Fail: 50 | Inflight: 150/200 | Rate: 980/1000 tps
| Field | Description |
|---|---|
Sent |
Total transactions submitted to the sender |
OK |
Successful RPC responses |
Fail |
Failed RPC responses |
Inflight |
Transactions sent but not yet resolved (current/--max-concurrent) |
Rate |
Actual send rate (current/--tps target) |
Reading backpressure:
- Concurrency-bound:
Inflightis at or near--max-concurrent. The RPC endpoint can't keep up — responses are slow relative to the send rate. Increase--max-concurrentor reduce--tps. - Rate-limited:
Ratematches the--tpstarget andInflightis well below--max-concurrent. The rate limiter is the bottleneck (working as intended). - Source-bound:
Rateis below--tpsandInflightis low. The transaction source (file I/O or stdin pipe) is the bottleneck.
Report destinations are specified with --report and can be repeated:
| Format | Description |
|---|---|
console |
Print summary to stderr (default if no reporters specified) |
json:<path> |
Write JSON report to file |
clickhouse:<url> |
Push benchmark data to ClickHouse |
prometheus:<url> |
Push samples via Prometheus remote write (/api/v1/write) |
victoriametrics:<url> |
Alias for Prometheus remote write, intended for VictoriaMetrics |
All bench commands support built-in Prometheus metrics scraping via --metrics-url. When enabled, one or more background scrapers periodically fetch node /metrics endpoints and include all samples in the JSON report.
# Scrape node metrics alongside the benchmark
bench send -i txs.ndjson --metrics-url http://127.0.0.1:9001/metrics --report json:report.json
# Custom scrape interval (default: 500ms)
bench send -i txs.ndjson --metrics-url http://127.0.0.1:9001/metrics --scrape-interval-ms 200
# Scrape multiple nodes and tag each scraped sample with node=<label>
bench send -i txs.ndjson --metrics-url a:http://node-a:9001/metrics,b:http://node-b:9001/metricsFor a single endpoint, pass the URL directly. For multiple endpoints, every comma-separated entry must use node_label:URL; the label is added to scraped Prometheus samples as node=<node_label>.
Internal txgen metrics are snapshotted on the same interval and included alongside node metrics. In send mode: txgen_transactions_sent_total, txgen_transactions_success_total, etc. In send-blocks mode: txgen_blocks_sent_total, txgen_blocks_success_total, txgen_blocks_failed_total.
Metadata key=value pairs (-m key=value) are applied as labels to all samples, useful for tagging runs with build SHAs, profiles, or experiment IDs.
Use --metrics-align <TIMESTAMP> to shift exported sample timestamps while preserving each sample's offset within the run. The timestamp is treated as benchmark start time and may be Unix seconds or milliseconds; after conversion to milliseconds, exported sample unix_ms values become TIMESTAMP + offset_ms.
bench send and bench send-blocks can also forward the scrape stream to a
Prometheus-compatible remote write endpoint while the run is still active:
bench send -i txs.ndjson \
--metrics-url http://127.0.0.1:9001/metrics \
--report json:report.json \
--metrics-forward http://victoriametrics:8428 \
-m scenario=tip20-10k -m run_id=$(uuidgen)This uses the same Prometheus remote write payload and PROMETHEUS_* environment variables as the Prometheus reporter, but uploads each scrape batch as it is archived. The JSON reporter still writes the compressed .samples.ndjson.gz sidecar at finalization.
The ClickHouse reporter stores common run metadata in txgen_runs. Bench runs additionally use txgen_blocks and txgen_metric_samples; scenario runs use txgen_scenario_runs and txgen_scenario_steps and do not write txgen_blocks. Both send and scenario runs write one exact outer-transaction gas row per confirmed tracked receipt to txgen_receipt_gas; send-blocks writes none. Block data is factual chain data, while metric samples are point-in-time scrape snapshots with no block attribution. Bench reporting requires four metadata keys:
bench send -i txs.ndjson \
--metrics-url http://127.0.0.1:9001/metrics \
--report clickhouse:https://host:8443 \
-m scenario=tip20-10k \
-m platform=tempo \
-m git-sha=abc123 \
-m git-ref=main| Required Metadata | Description |
|---|---|
scenario |
Benchmark scenario name (e.g. tip20-10k) |
platform |
Target platform: ethereum or tempo |
git-sha |
Node commit SHA being benchmarked |
git-ref |
Node git branch/ref |
Authentication and insert batching are configured via environment variables: CLICKHOUSE_USER, CLICKHOUSE_PASSWORD, CLICKHOUSE_DATABASE, and CLICKHOUSE_SAMPLE_BATCH_SIZE (default: 50000). See scripts/clickhouse/README.md for schema setup and example queries.
Scenario report destinations use the same --report flag and can be repeated. A bare path remains an alias for a JSON file:
CLICKHOUSE_USER=default \
CLICKHOUSE_PASSWORD=secret \
CLICKHOUSE_DATABASE=benchmarks \
txgen-tempo scenario run \
--scenario scenario.yaml \
--count 100 \
--report scenario-report.json \
--report clickhouse:https://host:8443 \
-m git-sha=abc123 \
-m git-ref=main \
-m github-run-url=https://github.com/example/actions/runs/123The scenario name, platform, and mode=scenario are derived from the finalized report; those metadata keys are reserved and conflicting user values are rejected. git-sha and git-ref are required metadata for the common txgen_runs row. Every destination receives the same client-generated run_id. JSON destinations are finalized before ClickHouse publication, so an upload error is returned without deleting a report file that was already written.
Deploy migrations through 009_txgen_scenario_causal_metrics.sql before publishing scenario report schema version 2. Detail rows are synchronously acknowledged before txgen_runs is inserted as the visibility marker. Queries for complete benchmark or scenario reports must begin at txgen_runs and join detail tables by run_id; an interrupted publication can leave child rows without a visible common run row.
The bench JSON report includes:
samples— point-in-time metric snapshots (internal + node), stored as a time seriesblocks— factual chain data for each block in the run (tx count, gas used, etc.)receipt_metrics— confirmed-transactiongas_used,effective_gas_price, andfee_paiddistributions grouped by workload input
The Prometheus reporter forwards every sample in the unified time series (internal txgen_* counters plus scraped node Prometheus metrics) to a Prometheus-compatible endpoint via the /api/v1/write endpoint. The same endpoint works with VictoriaMetrics, Cortex, Thanos, etc. Samples are sent in Prometheus Remote Write format with their original unix_ms timestamps, so backfilling at the end of the run does not lose fidelity.
bench send -i txs.ndjson \
--metrics-url http://127.0.0.1:9001/metrics \
--report prometheus:http://prometheus:8428 \
-m scenario=tip20-10k \
-m platform=tempo \
-m git-sha=abc123 \
-m git-ref=mainEach -m key=value pair from --metadata is encoded as a label on every sample (perfect for run_id, scenario, git_sha, platform, …). Label keys are sanitized to match the Prometheus identifier rules (-, ., etc. become _).
Connection knobs are read from environment variables to keep secrets off the command line:
| Env var | Purpose |
|---|---|
PROMETHEUS_BEARER_TOKEN |
Sent as Authorization: Bearer … (e.g. for VM Cloud / vmauth) |
PROMETHEUS_USER / PROMETHEUS_PASSWORD |
HTTP basic auth credentials |
PROMETHEUS_TENANT_ID |
Cluster VM accountID query parameter |
PROMETHEUS_BATCH_SIZE |
Samples per HTTP request (default: 50000) |
PROMETHEUS_ENCODE_WORKERS |
Parallel workers used to build and compress final report requests (default: up to 8) |
PROMETHEUS_TIMEOUT_SECS |
Per-request HTTP timeout in seconds (default: 60) |
PROMETHEUS_QUEUE_SIZE |
Real-time forwarder queue size in scrape batches (default: 16) |
Example with auth:
PROMETHEUS_BEARER_TOKEN=$(cat ~/.prometheus-token) \
bench send -i txs.ndjson \
--metrics-url http://127.0.0.1:9001/metrics \
--report prometheus:https://prometheus.example.com \
-m scenario=tip20-10k -m run_id=$(uuidgen)Scenarios describe complete journeys across asynchronous RPC boundaries. Each named chain points to an ordinary txgen workload spec; submit selects a template from that workload and applies the same deep-merge behavior used by workload sequences. Existing workload specs, templates, generate commands, and NDJSON formats remain valid.
Scenario files use version: 1. Environment references such as ${ORIGIN_RPC_URL} are expanded before parsing. Relative workload and request-auth map paths are resolved from the file that declares the chain; because included fragment libraries cannot declare chains, that is the root scenario file. Paths inside a workload, including ABI artifacts, remain relative to the workload file.
Each binary supplies one network adapter for the whole run: every chain in a txgen-tempo scenario must use network: tempo, and every chain in a txgen-ethereum scenario must use network: ethereum. The endpoints may still represent independent chains with different chain IDs and workload files. Named chains must use distinct normalized submission RPC URLs so they cannot maintain conflicting nonce state for one endpoint.
Before starting measured instances, the runner checks every chain ID and pending nonce source, then materializes every workload setup without submission. Only after those checks succeed does it materialize, submit, and confirm each setup transaction just in time, retaining setup.<id>.* bindings for scenario templates. A setup failure aborts initialization. Initialization RPC operations and each setup transaction have a five-minute safety timeout. Point a scenario at a workload without setup steps when the target chain is already prepared.
This generic example sends a message on one chain, observes its delivery on another chain, submits an acknowledgement, and observes the acknowledgement back on the first chain. The ABI names, event names, addresses, and templates are supplied by the referenced workloads; the engine itself has no application-specific assumptions.
version: 1
chains:
origin:
network: tempo
rpc_url: ${ORIGIN_RPC_URL}
chain_id: auto
workload: ./origin-workload.yaml
destination:
network: tempo
rpc_url: ${DESTINATION_RPC_URL}
chain_id: auto
workload: ./destination-workload.yaml
scenario:
name: message-roundtrip
timeout: 45s
bindings:
user:
account:
pool: users
select: lease
steps:
- checkpoint:
chain: destination
save: destination_before_message
- submit:
chain: origin
template: publish_message
with:
from: { var: user.ref }
save: publish
- wait_receipt:
chain: origin
transaction_hash: { var: publish.tx_hash }
confirmations: 1
save: publish_receipt
- wait_log:
chain: origin
transaction_hash: { var: publish.tx_hash }
abi: Relay
event: MessagePublished
save: message_published
- wait_log:
chain: destination
from_block: { var: destination_before_message.block_number }
address: ${DESTINATION_RELAY_ADDRESS}
abi: Relay
event: MessageDelivered
where:
messageId: { var: message_published.args.messageId }
poll_interval: 250ms
confirmations: 2
max_block_range: 1000
save: message_delivered
timeout: 2m
- checkpoint:
chain: origin
save: origin_before_acknowledgement
- submit:
chain: destination
template: acknowledge_message
with:
from: { var: user.ref }
call:
args:
- { var: message_delivered.args.messageId }
await: receipt
save: acknowledgement
- wait_log:
chain: origin
from_block: { var: origin_before_acknowledgement.block_number }
address: ${ORIGIN_RELAY_ADDRESS}
abi: Relay
event: AcknowledgementRecorded
where:
acknowledgementId:
keccak256_packed:
types: [bytes32, bytes32]
values:
- { var: message_delivered.args.messageId }
- { var: acknowledgement.tx_hash }
save: acknowledgement_recordedOmit scenario.execution to retain the ordered, strictly sequential behavior of existing specifications. Set execution: dag to make dependencies explicit and start every ready step immediately:
scenario:
name: concurrent-observation
execution: dag
steps:
- id: before_delivery
checkpoint:
chain: destination
save: destination_cursor
- id: publish
depends_on: [before_delivery]
submit:
chain: origin
template: publish_message
save: publication
- id: observe_receipt
depends_on: [publish]
wait_receipt:
chain: origin
transaction_hash: { var: publication.tx_hash }
save: publication_receipt
- id: observe_delivery
depends_on: [before_delivery, publish]
wait_log:
chain: destination
from_block: { var: destination_cursor.block_number }
abi: Relay
event: MessageDelivered
save: delivery
- id: after_both
depends_on: [observe_receipt, observe_delivery]
checkpoint:
chain: originEvery DAG step has a stable, unique id; depends_on may be omitted for a root step. A runtime output may be referenced only by a dependency descendant: observe_delivery depends on before_delivery for its cursor and on publish for the causal edge. Validation rejects missing dependencies, cycles, and references to outputs that are not dependency ancestors. A journey completes after every required terminal branch completes. Independent ready steps run concurrently, while after_both cannot start until both observation branches finish.
chain_id: auto queries the endpoint and uses the returned chain ID for signing. An explicit integer may be used instead and is validated against the endpoint. When one account binding is used to submit on multiple chains, its named pool must derive the same ordered addresses in each consuming workload.
rpc_url is the submission endpoint. query_rpc_url optionally selects a separate unauthenticated endpoint for chain ID and nonce initialization, checkpoints, confirmation heights, and log queries; it defaults to rpc_url. A chain can authenticate sender-scoped submission traffic with the same sender map used by bench send:
chains:
zone:
network: tempo
rpc_url: ${ZONE_SUBMISSION_RPC_URL}
query_rpc_url: ${ZONE_QUERY_RPC_URL}
observation:
mode: auto
websocket_url: ${ZONE_WEBSOCKET_URL}
poll_interval: 50ms
request_auth:
sender_header:
name: X-Authorization-Token
map: ./zone-sender-auth.json
reload_interval: 1s
chain_id: auto
workload: ./zone-workload.yamlThe selected header is attached only to eth_sendRawTransaction and sender-scoped transaction/receipt lookups on the submission endpoint. It is recomputed for each request so atomically replaced maps can take effect. submit await: receipt already carries its materialized sender; standalone wait_receipt and transaction-hash-only wait_log steps must supply sender, commonly from the saved submit result:
- wait_receipt:
chain: zone
transaction_hash: { var: publish.tx_hash }
sender: { var: publish.sender }observation.mode is auto, subscription, or poll. auto prefers WebSocket new-head and log subscriptions when websocket_url is available, performs canonical backfill and verification around subscription setup, and falls back to polling. subscription requires the WebSocket transport. poll always uses RPC polling. poll_interval is the chain fallback interval for scenario waits and defaults to 50ms; an individual wait_receipt or wait_log may override it with its own mode and poll_interval. Subscription wakeups are paired with canonical backfill so receipts and logs produced before or during setup are not lost.
A scenario document may add top-level include and fragments sections. Both are optional, so existing version 1 documents with one inline scenario.steps list parse and run as before. The root document may contain version, include, fragments, chains, and scenario. An included document is a fragment library and may contain only version, include, and fragments; an included file cannot replace or contribute chains or the root scenario.
Includes are traversed depth-first in their listed order. Each include path is relative to the file that declares it, not the process working directory or the root scenario. Canonical paths are used to detect a file reached again on the active include stack; that is an include cycle. Reaching the same file again after its earlier traversal completed is not silently deduplicated: it is traversed again, and any repeated fragment contribution is reported as a duplicate declaration. Fragment names must be unique across the complete include graph and the root document, and there is no implicit precedence or override mechanism. Include cycles, missing files, and a non-version-1 version in any document are errors.
Each fragment defines typed parameters, optional typed outputs, and an ordered list containing normal steps or nested fragment uses:
fragments:
submit-and-confirm:
parameters:
chain: string
sender: account_ref
recipient: address
amount: u256
outputs:
submission: submit
receipt: receipt
steps:
- submit:
chain: { param: chain }
template: transfer
with:
from: { param: sender }
call:
args:
- { param: recipient }
- { param: amount }
save: submission
- wait_receipt:
chain: { param: chain }
transaction_hash: { var: submission.tx_hash }
save: receiptSupported parameter types are:
| Type | Accepted value |
|---|---|
string |
A string literal or string-valued runtime expression |
account_ref |
An account reference such as { var: user.ref } |
address |
An address literal or address-valued runtime expression |
u256 |
An unsigned integer literal or compatible runtime expression |
bytes |
A variable-length byte string or compatible runtime expression |
bytes32 |
A 32-byte value or compatible runtime expression |
bool |
A Boolean literal or Boolean-valued runtime expression |
value |
Any supported YAML value or txgen runtime expression |
Parameter substitution uses the exact single-key form { param: name }; it replaces that whole YAML node. Parameters are not interpolated into strings. Arguments may be literals, environment-expanded values, runtime references, or other supported txgen value expressions. Every fragment use must provide exactly the declared keys in with: missing and unknown parameters are rejected, and statically knowable values must match their declared types. A parameter does not make a normally static schema field dynamic; values substituted into fields such as chain, template, abi, or event must resolve during expansion rather than at runtime.
Use a fragment anywhere an inline step is allowed:
- use: submit-and-confirm
as: first_transfer
with:
chain: primary
sender: { var: user.ref }
recipient: { var: user.address }
amount: 1as is required. It must be one valid, non-dotted name segment and must be unique within its containing scenario or fragment. The same fragment may be used repeatedly under different aliases. Its local saves are placed below the alias, so submission and receipt above become first_transfer.submission and first_transfer.receipt. Fragment-authored { var: submission.tx_hash } references resolve to the local save before caller-supplied parameter values are injected; a runtime expression passed through with therefore keeps the caller's scope. A nested use adds another segment: an inner alias confirm under an outer alias batch produces names such as batch.confirm.receipt. Report provenance uses the local save as the local step name when present and a deterministic action/index-derived name for an unsaved step.
In a DAG scenario, a fragment use may also declare depends_on beside use and as. Expansion adds those caller dependencies to every root of the fragment's internal DAG, so the complete fragment waits for the declared prerequisites while independent roots inside it may still start together:
- use: submit-and-confirm
as: transfer
depends_on: [fund_account]
with:
chain: primary
sender: { var: user.ref }
recipient: { var: user.address }
amount: 1An outputs entry names a save declared by the fragment and its required result kind. The supported result kinds are checkpoint, invoke, submit, receipt, and log; expansion rejects missing output saves and result-kind mismatches. Local saves omitted from outputs remain private: callers may reference only declared outputs below the instance alias. A parent fragment must likewise re-export a nested output before its own caller can access it. Fragment uses may nest to any acyclic depth, while direct and indirect fragment recursion is rejected with the use chain.
Expansion is deterministic and occurs before the existing chain, binding, save, forward-reference, template, ABI, event-filter, and type validation. Duplicate aliases, expanded saves, and invalid names are therefore checked together with surrounding inline steps. Errors identify the declaring source file and, when applicable, the fragment, instance alias, local step, and expanded step index.
Composition stops on missing include files or fragment names, include cycles, fragment recursion, missing or unknown parameters, duplicate fragment names or aliases, conflicting expanded saves, invalid output declarations, and unresolved parameter or variable references. Fragment dependency and output contracts are checked even when a fragment is not instantiated by the root scenario. These errors are reported before execution begins.
This layout keeps the entry point and workload together while the fragment library uses a nested relative include:
scenario.yaml
primary-workload.yaml
fragments/
common.yaml
transfers.yaml
fragments/common.yaml declares one fragment and includes another file relative to itself:
version: 1
include:
- transfers.yaml
fragments:
capture-head:
parameters:
chain: string
outputs:
cursor: checkpoint
steps:
- checkpoint:
chain: { param: chain }
save: cursorfragments/transfers.yaml contains the reusable transaction pair:
version: 1
fragments:
submit-and-confirm:
parameters:
chain: string
sender: account_ref
recipient: address
amount: u256
outputs:
submission: submit
receipt: receipt
steps:
- submit:
chain: { param: chain }
template: transfer
with:
from: { param: sender }
call:
args:
- { param: recipient }
- { param: amount }
save: submission
- wait_receipt:
chain: { param: chain }
transaction_hash: { var: submission.tx_hash }
save: receiptscenario.yaml instantiates the same fragment twice, combines those uses with another fragment, and then references an exported result from a later inline step:
version: 1
include:
- fragments/common.yaml
chains:
primary:
network: tempo
rpc_url: "${RPC_URL}"
chain_id: auto
workload: ./primary-workload.yaml
scenario:
name: composed-transfers
timeout: 5m
bindings:
user:
account:
pool: users
select: lease
steps:
- use: capture-head
as: before_transfers
with:
chain: primary
- use: submit-and-confirm
as: first_transfer
with:
chain: primary
sender: { var: user.ref }
recipient: { var: user.address }
amount: 1
- use: submit-and-confirm
as: second_transfer
with:
chain: primary
sender: { var: user.ref }
recipient: { var: user.address }
amount: 2
- wait_receipt:
chain: primary
transaction_hash: { var: first_transfer.receipt.transaction_hash }
save: first_transfer_recheckedThe expanded order is the capture-head step, both steps from first_transfer, both steps from second_transfer, and the final inline receipt wait. The corresponding fragment saves are before_transfers.cursor, first_transfer.submission, first_transfer.receipt, second_transfer.submission, and second_transfer.receipt.
Every step selects one named chain. id, depends_on, save, and timeout are sibling keys of the step action. id and depends_on are used by DAG scenarios; legacy sequential scenarios continue to derive identity and ordering from list position.
Captures the chain's current canonical block cursor. Use it immediately before an action that will cause an event on another chain, then pass its block_number to wait_log. This closes the gap between transaction submission and registration of the event wait.
- checkpoint:
chain: destination
save: before_deliveryRuns a query-only action supplied by the selected network adapter. with values may use runtime expressions, and the adapter's structured result can be saved and passed to later steps. Unsupported action names are rejected before any journey starts.
The Tempo adapter provides prepare_encrypted_deposit, which is compatible with viem's zone.encryptedDeposit.prepareRecipient: it reads the current ZonePortal encryption key and creates a fresh secp256k1/AES-GCM payload for each invocation. Cryptographic material always comes from the operating system and is intentionally not controlled by the scenario seed. portalAddress is optional for Zone IDs known to viem; supply it for other deployments. memo is an optional bytes32 and defaults to zero.
- invoke:
chain: l1
action: prepare_encrypted_deposit
with:
recipient: { var: user.address }
zoneId: 9
portalAddress: ${ZONE_PORTAL_ADDRESS}
save: prepared
- submit:
chain: l1
template: encrypted_deposit
with:
from: { var: user.ref }
call:
args:
- ${TOKEN_ADDRESS}
- 1000000
- { var: prepared.keyIndex }
- { var: prepared.encrypted }The saved result contains viem's chainId, encrypted, keyIndex, portalAddress, and zoneId fields. encrypted contains ciphertext, ephemeralPubkeyX, ephemeralPubkeyYParity, nonce, and tag and can be used directly as the named tuple argument of depositEncrypted.
Loads a named template from the selected chain's workload, deep-merges with, resolves runtime expressions, signs with that chain's adapter, and submits it through the in-process sender. The step completes after RPC acceptance and exposes the transaction hash. Set await: receipt to keep the step open until a successful receipt is observed; a reverted receipt fails the step.
- submit:
chain: origin
template: publish_message
with:
from: { var: user.ref }
await: receipt
save: publishObserves a supplied transaction hash using the chain's configured observation mode. By default a reverted receipt fails the step; set allow_revert: true only when a revert is an expected result. The default fallback poll interval is 50ms, and the default is zero additional confirmations. confirmations: 0 verifies the canonical receipt without waiting for another block.
- wait_receipt:
chain: origin
transaction_hash: { var: publish.tx_hash }
sender: { var: publish.sender }
mode: auto
poll_interval: 50ms
confirmations: 2
save: publish_receiptLoads an ABI artifact from the selected chain's workload and resolves event by name or exact signature. It decodes indexed and unindexed arguments, then returns the first canonical match in block-number/log-index order.
A log wait must provide from_block or transaction_hash. Optional address, transaction_hash, and where fields narrow the match. Each where entry compares one decoded argument to a resolved, typed runtime value. The default fallback poll interval is 50ms, with zero additional confirmations and at most 1,000 blocks per eth_getLogs request. The waiter backfills from the starting cursor, uses the selected subscription or polling mode, honors confirmations, and discards removed or reorged candidates after canonical verification.
- wait_log:
chain: destination
from_block: { var: before_delivery.block_number }
address: ${DESTINATION_RELAY_ADDRESS}
abi: Relay
event: "MessageDelivered(bytes32,address)"
mode: auto
where:
messageId: { var: message_published.args.messageId }
max_block_range: 1000
save: deliveredFor multiple required events emitted by one transaction, use receipt-scoped events instead of separate waits:
- wait_log:
chain: origin
transaction_hash: { var: withdrawal.tx_hash }
sender: { var: withdrawal.sender }
confirmations: 0
events:
withdrawal:
address: ${PORTAL_ADDRESS}
abi: Portal
event: WithdrawalProcessed
where:
withdrawalId: { var: withdrawal_requested.args.withdrawalId }
callback:
address: ${CALLBACK_ADDRESS}
abi: Callback
event: WithdrawalCallback
where:
withdrawalId: { var: withdrawal_requested.args.withdrawalId }
save: processedThe grouped form requires transaction_hash, rejects from_block, and requires every named event. events is mutually exclusive with the legacy top-level abi, event, address, and where fields. The saved result contains common receipt fields plus events.withdrawal and events.callback; each child retains its exact decoded arguments and log index for downstream expressions. Events from the same receipt share one inclusion and observation milestone, rather than appearing as independent near-zero-duration protocol steps.
save: <name> adds an immutable typed result to the current scenario instance. Saved values are not shared between concurrent instances, and a name cannot be reused. Sequential scenarios reject forward references; DAG scenarios require the producing step to be a dependency ancestor. Fragment-local save names remain simple names in their declarations; expansion qualifies them with the complete instance alias path so callers can use expressions such as { var: first_transfer.receipt.block_number }.
| Step | Saved fields |
|---|---|
checkpoint |
chain, block_number, optional block_hash, captured_at |
invoke |
adapter-defined result plus chain and action |
submit |
chain, template/id, sender, tx_hash, submitted_at, acceptance_latency, optional receipt |
submit.receipt |
chain, transaction_hash, block_hash, block_number, transaction_index, status, gas_used, block_timestamp_ms, first_observed_at, confirmed_at, confirmation_depth |
wait_receipt |
chain, transaction_hash, block_hash, block_number, transaction_index, status, gas_used, block_timestamp_ms, first_observed_at, confirmed_at, confirmation_depth |
wait_log |
chain, address, transaction_hash, block_hash, block_number, transaction_index, log_index, event, typed args, first_observed_at, confirmed_at, confirmation_depth, canonical block_timestamp_ms |
grouped wait_log |
Common receipt timing and confirmation fields plus events.<id> children containing each event's address, log index, event name, and typed args |
The compatibility aliases tx_hash, contract_address, event_name, and observed_at may be used where applicable. Saved results and reports never include private keys, mnemonics, authorization headers, or other signer secrets.
captured_at, submitted_at, observed_at, first_observed_at, and confirmed_at are Unix timestamps in milliseconds. Canonical block timestamps retain Tempo's millisecond timestamp part when the RPC supplies it. acceptance_latency is an integer number of milliseconds.
Use { var: path.to.value } anywhere a step accepts a runtime expression. Account bindings expose <name>.ref for a workload from/sponsor field and <name>.address for an address value. Step results expose the fields listed above.
from: { var: user.ref }
transaction_hash: { var: publish.tx_hash }
messageId: { var: delivered.args.messageId }The initial deterministic transformations are:
# Hash raw bytes, a string, bytes32, an address, or a uint8 array.
digest:
keccak256: { var: delivered.args.payload }
# Solidity abi.encode(...).
encoded:
abi_encode:
types: [address, uint256]
values:
- { var: user.address }
- 100
# Solidity abi.encodePacked(...).
packed:
abi_encode_packed:
types: [address, bytes32]
values:
- { var: user.address }
- { var: delivered.args.messageId }
# keccak256(abi.encodePacked(...)).
correlation:
keccak256_packed:
types: [address, bytes32]
values:
- { var: user.address }
- { var: delivered.args.messageId }abi_encode supports Solidity tuple declarations with member names, including nested tuples and arrays; provide those values as YAML objects keyed by the member names. ABI expressions run only while an individual scenario instance materializes runtime expressions, after referenced values are available. abi_encode_packed and keccak256_packed also accept an inferred list, for example keccak256_packed: [{ var: user.address }, { var: publish.tx_hash }]. Prefer the typed form when integer widths or another Solidity type distinction matters. Transformations are deterministic; arbitrary scripting is not supported.
--starts-per-second controls journeys: a value of 5 starts at most five new scenario instances per second, regardless of how many transactions each instance submits. --max-in-flight limits whole instances. These controls are independent from --tx-rate and --max-rpc-in-flight, which limit individual transaction submissions on each chain across all active instances.
An account binding with select: lease holds one pool account for the entire instance and returns it on success, failure, timeout, or cancellation. Two active instances do not receive the same leased account. select: random and select: { index: N } retain their non-exclusive workload-style behavior.
Independent DAG submits may use the same account concurrently when every affected transaction uses an expiring nonce; nonce reservations are atomic and deterministic for a fixed seed. If independent regular-nonce submits from one account attempt to share an ordered nonce lane, the journey is rejected even when the first RPC finishes before its sibling reaches submission. Add an explicit dependency to order those submits.
The effective timeout for a step is its explicit sibling timeout, otherwise the CLI --step-timeout override when supplied, otherwise scenario.timeout, and finally five minutes when none is configured. A timeout fails that instance and is counted separately in the report. If transaction acceptance is still unknown at a submit deadline, further submissions on that chain are disabled to avoid reusing an uncertain nonce. Receipt reverts fail unless explicitly allowed. Under continue, later instances continue to start after a failure. Under fail-fast, the runner stops starting new instances after the first failure while allowing instances that already started to finish. A scenario counts as completed successfully only after every required submit and wait step succeeds, and the command exits nonzero after writing its report when any instance failed or timed out.
The seed controls deterministic account/template/value choices. Each instance has isolated saved values, timing, failure state, and leases, so concurrent completion order cannot leak data between journeys.
The scenario runner accepts repeatable report destinations. A bare --report report.json is the backward-compatible JSON form; --report json:report.json is the explicit form, and --report clickhouse:<url> publishes the same finalized report to ClickHouse. When --report is omitted, JSON is written to stdout. All destinations share the report's client-generated run_id, and JSON files are written before ClickHouse publication so a publication failure does not remove the local report.
Report schema version 2 adds stable step IDs and dependencies, explicitly labeled per-step command duration, client-observed end-to-end latency, per-instance observed critical-path latency, and causal-edge latency aggregates. The original completed-journey latency field remains as a backward-compatible alias for the client-observed E2E distribution. Critical-path P50/P95/P99 values are calculated from completed per-instance traces; txgen never constructs a path percentile by adding aggregate step or edge percentiles.
The protocol timing fields distinguish:
- Chain inclusion latency: elapsed time between relevant canonical protocol milestones, using block timestamps when both ends are available.
- Client observation latency: delay from canonical destination inclusion to the client's first observation.
- Causal-edge latency: the observed source-milestone to destination-milestone interval for one declared dependency edge.
- Total journey latency: monotonic client elapsed time from starting one instance until every required terminal branch completes.
Command duration measures how long the runner spent executing a step, including local orchestration and RPC work. It is reported separately and is not described as protocol latency.
The report also includes the configured scenario name and execution configuration; started, completed, failed, and timed-out instance counts; completed scenarios per second; observed maximum in-flight instances; receipt gas metrics grouped by chain, input template, and scenario step; and failures grouped by stage and sanitized error class. Counts, minima, maxima, and means are exact; percentile estimates use a deterministic reservoir capped at 65,536 observations per distribution so duration-based runs use bounded memory.
--sample-instances optionally retains bounded, secret-free lifecycle traces. A sampled trace includes the critical-path step IDs and raw submission, acceptance, receipt, and log milestones with monotonic run offsets, wall-clock observation times, transaction and canonical block identity, transaction/log indices, canonical block timestamps, and confirmation depth. Grouped events from one receipt share one inclusion milestone. Traces never include calldata, signed transaction bytes, private keys, mnemonics, authorization headers or maps, template overlays, decoded event values, or other runtime secrets.
Receipt metrics come only from confirmed outer-transaction receipts; txgen does not trace or split gas across internal calls. Each distribution reports count, min, mean, p50, p95, and p99. When a receipt omits both effectiveGasPrice and legacy gasPrice, its gas usage is still counted while its effective-price and fee distributions remain empty.
When ClickHouse reporting is configured, txgen writes causal-edge aggregates to txgen_scenario_causal_edges and optional samples to txgen_scenario_instance_traces, txgen_scenario_trace_steps, and txgen_scenario_trace_milestones. It also writes the exact transaction hash, sender, canonical labels, optional scenario instance, receipt status and block identity, gas used, optional effective gas price, and optional fee paid for each collected receipt to txgen_receipt_gas. Apply scripts/clickhouse/009_txgen_scenario_causal_metrics.sql before publishing report schema version 2.
Steps expanded from fragments carry an optional provenance object in aggregate step reports, failure records, and sampled lifecycle steps. It records source_file, fragment, instance_alias, local_step_name, and the zero-based local_step_index. Inline steps omit it. Consumers can group command duration by fragment and local step across instances, or include the alias to compare individual fragment uses. Because scenario render omits this source metadata, a later run of rendered YAML reports those flattened steps as inline steps.
Client-observed and critical-path distributions use monotonic elapsed time. Wall-clock timestamps are included only for correlation with external chain and node data. Chain timestamp deltas remain signed so clock skew between independent chains is visible. Transaction acceptance alone never marks a journey successful.
Transactions are output as NDJSON with scheduling keys split by release policy:
{
"phase": "workload",
"id": "transfer",
"raw": "0x02f86c01...",
"sender": "0x1111111111111111111111111111111111111111",
"submission_keys": [
"0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc"
],
"inclusion_keys": []
}| Field | Description |
|---|---|
phase |
setup or workload; missing phase is treated as workload by bench |
id |
Optional diagnostic identifier |
raw |
RLP-encoded signed transaction (EIP-2718 envelope) |
sender |
Logical on-chain transaction sender used for request-scoped authentication |
submission_keys |
20-byte ordering constraints released after RPC submission succeeds |
inclusion_keys |
20-byte ordering constraints released after the transaction is included in a block |
Scheduling rule: Transactions that share any scheduling key must be sent sequentially until that key's release condition is met. Normal transactions carry their natural nonce-lane key in submission_keys, because the chain enforces nonce order after admission. Sequence steps on the same nonce lane are submitted back-to-back; txgen only adds synthetic inclusion_keys at cross-lane sequence boundaries where nonce order cannot guarantee execution order.
The sender is independent of scheduling metadata. Txgen emits it for newly generated transactions. Older NDJSON may omit it and can still be sent without sender authentication.
Workload specs are YAML files that define accounts, transaction templates, optional transaction sequences, and mix ratios.
Specs can be assembled from reusable YAML pieces with include, merge, and
append. Includes are applied in order and are resolved relative to the file
that declares them. Artifact paths inside included files are also resolved
relative to the file that declares the artifact.
include:
- tip20/base.yml
- tip20/recipient-random.yml
- tip20/fee-token-any-tip20.yml
- tip20/auth-keychain.yml
- tip20/nonce-expiring.ymlPiece files can use merge for normal recursive object overlays. Mapping
values are merged recursively; scalar and sequence values replace the previous
value.
merge:
templates:
tip20_transfer:
gas_limit: 3000000Use append when a piece needs to add to a sequence without replacing earlier
entries. The leaves of an append section must be lists.
append:
setup:
steps:
- id: authorize_keychain_users
keychain_authorize_pool:
accounts:
pool: users
access_keys:
mnemonic: "${ACCESS_KEY_MNEMONIC}"
range: [100000, 101000]# Chain ID for transaction signing
chain_id: 1
# Default gas configuration
gas:
max_fee_per_gas: 1000000000 # 1 gwei
max_priority_fee_per_gas: 1000000000
# Signer account pools derived from mnemonics
accounts:
users:
mnemonic: "${MNEMONIC}" # Supports environment variable expansion
range: [0, 100] # Derive accounts 0-99
deployer:
mnemonic: "${MNEMONIC}"
index: 0 # Single account
# Destination-only address pools. These are usable as recipients/arguments,
# but cannot be used for `from`/`sponsor` signing and are omitted by `addresses`.
# Mnemonic-backed address pools are derived lazily and cached on first use.
address_pools:
existing_users:
mnemonic: "${RECIPIENT_MNEMONIC}"
range: [0, 10000]
bloated_state_users:
fast:
seed: "${STATE_BLOAT_SEED}"
range: [10000, 1000000]
known_contracts:
addresses:
- "0x0000000000000000000000000000000000000001"
- "0x0000000000000000000000000000000000000002"
# ABI/deployment artifacts for contract calls and setup deploys
artifacts:
erc20: "./abis/ERC20.json"
token:
abi: "./out/Token.sol/Token.json"
bytecode: "./out/Token.sol/Token.json"
bytecode_only:
bytecode: "./out/Contract.bin" # ABI defaults to empty when omitted
# Optional deterministic setup transactions emitted before workload txs
setup:
steps:
- id: token
deploy:
type: eip1559
artifact: token
from:
pool: deployer
select: { index: 0 }
gas_limit: 5000000
constructor_args: ["Benchmark Token", "BENCH"]
# Transaction templates
templates:
transfer:
type: eip1559
from:
pool: users
select: random
to:
address_pool:
pool: existing_users
select: random
value: 1000
gas_limit: 21000
# Optional multi-transaction sequences
sequences:
two_transfers:
bindings:
sender:
account:
pool: users
select: random
recipient:
account:
pool: users
select: random
amount:
u256:
uniform: [1, 100]
steps:
- template: transfer
with:
from: { var: sender.ref }
to: { var: recipient.address }
value: { var: amount }
- template: transfer
with:
from: { var: sender.ref }
to: { var: recipient.address }
value: { var: amount }
# Weighted mix for generation
mix:
- template: transfer
weight: 90
- sequence: two_transfers
weight: 10Accounts are selected from signer pools using select. Only accounts pools can be used in signing positions such as from and sponsor; address_pools are destination-only values.
from:
pool: users
select: random # Random signer account from poolUse address_pools when you want to send to known users without making those users available for signing. Mnemonic-backed pools are lazy: txgen derives and caches an address only when it is selected, so large ranges such as [0, 1000000] do not add startup cost. Fast pools derive deterministic non-signable addresses as last20(keccak256(keccak256(seed) || index_be_u64)), matching Tempo state-bloat addresses beyond the signable range.
accounts:
senders:
mnemonic: "${SENDER_MNEMONIC}"
range: [0, 100]
address_pools:
existing_users:
mnemonic: "${RECIPIENT_MNEMONIC}"
range: [0, 1000000]
state_bloat_users:
fast:
seed: "${STATE_BLOAT_SEED}"
range: [10000, 1000000]
fixed_recipients:
addresses:
- "0x0000000000000000000000000000000000000001"
- "0x0000000000000000000000000000000000000002"Reference an address pool from any address-valued field:
to:
address_pool:
pool: existing_users
select: randomor select a specific derivation/list index:
to:
address_pool:
pool: existing_users
select: { index: 12345 }txgen addresses intentionally omits address_pools; it only lists signer account addresses for funding.
Dynamic values can be generated using expressions:
# Uniform random in range
value:
uniform: [1000, 10000]
# Random choice from list
to:
choice:
- "0x0000000000000000000000000000000000000001"
- "0x0000000000000000000000000000000000000002"
# Account address from signer pool
to:
pool: users
select: random
# Destination-only address from address pool
to:
address_pool:
pool: existing_users
select: random
# Random address
to: random
# Random address in an ABI argument
call:
to: "0x..."
abi: erc20
function: transfer
args:
- random
- 1000000
# Destination-only address in an ABI argument
call:
to: "0x..."
abi: erc20
function: transfer
args:
- address_pool:
pool: existing_users
select: random
- 1000000
# Random bytes
input:
random_bytes: 32random generates a value of the target field type. It is supported for fixed-size
numeric and hash/address-like values (u64, u128, u256, address,
bytes32). Use random_bytes when you need dynamically sized byte payloads.
Use the call field for ABI-encoded contract calls:
templates:
erc20_transfer:
type: eip1559
from:
pool: users
select: random
gas_limit: 65000
call:
to: "0x..." # Contract address
abi: erc20 # Artifact name
function: transfer # Function name
args:
- address_pool:
pool: existing_users
select: random # recipient from destination-only pool
- 1000000 # amount
value: 0For correlated call arguments, args can define local variables and then use
them in values. Variables are resolved once per call, so dependent values can
reuse the same random choices:
call:
to: "0x..."
abi: dex
function: placeFlip
args:
vars:
amount:
uniform: { min: 100000000, max: 500000000 }
is_bid:
choice: [true, false]
tick:
if:
cond: { var: is_bid }
then: { uniform: { min: -30, max: 0, step: 10 } }
else: { uniform: { min: 0, max: 30, step: 10 } }
flip_tick:
if:
cond: { var: is_bid }
then: { uniform: { min: { var: tick }, max: 30, step: 10 } }
else: { uniform: { min: -30, max: { var: tick }, step: 10 } }
values:
- "0x20c0000000000000000000000000000000000001"
- { var: amount }
- { var: is_bid }
- { var: tick }
- { var: flip_tick }Call argument variables reuse normal txgen generators such as choice and
uniform. The var and if expressions are local to the call argument block and let
arguments depend on earlier resolved variables.
Solidity tuple arguments may be written as a list in component order or as a mapping keyed by the component names in the ABI. Tuple arrays use a list of tuple values. For example:
args:
- token: "0x..."
amount: 1000000Use setup.steps for deterministic transactions that prepare the chain before the measured workload, such as contract deployments and mint/configuration calls. txgen emits all setup transactions first with phase: "setup"; workload transactions are emitted afterwards with phase: "workload".
bench send treats the first workload transaction as a setup barrier: it waits for all setup transactions to be included, resets benchmark timing/metrics, and only then sends workload transactions. Use bench send --skip-setup to ignore setup transactions when the target chain is already prepared.
artifacts:
token:
abi: ./out/Token.sol/Token.json
bytecode: ./out/Token.sol/Token.json
setup:
steps:
- id: token
bindings:
deployer:
account: { pool: deployer, select: { index: 0 } }
deploy:
type: eip1559
artifact: token
from: { var: deployer.ref }
gas_limit: 5000000
constructor_args: ["Benchmark Token", "BENCH"]
- id: mint_user0
bindings:
deployer:
account: { pool: deployer, select: { index: 0 } }
user:
account: { pool: users, select: { index: 0 } }
tx:
type: eip1559
from: { var: deployer.ref }
gas_limit: 100000
call:
to: { var: setup.token.address }
abi: token
function: mint
args:
- { var: user.address }
- 1000000000000000000000
templates:
transfer_token:
type: eip1559
from: { pool: users, select: random }
gas_limit: 65000
call:
to: { var: setup.token.address }
abi: token
function: transfer
args:
- { pool: users, select: random }
- 1000000000000000000Setup outputs are deterministic only. Supported references are:
| Reference | Description |
|---|---|
setup.<id>.address |
Contract address for deployment steps |
setup.<id>.tx_hash |
Signed transaction hash |
setup.<id>.sender |
Sender address |
setup.<id>.nonce |
Sender nonce used by the setup transaction |
Setup does not support using contract call return values, logs, or receipt fields as later inputs.
Use sequences when one generated workload item should emit multiple ordered transactions. Bindings are resolved once per sequence instance and can be referenced from any step with { var: ... }.
sequences:
approve_then_transfer_from:
bindings:
owner:
account: { pool: users, select: random }
spender:
account: { pool: users, select: random }
recipient:
account: { pool: users, select: random }
amount:
u256: { uniform: [1, 1000] }
steps:
- template: erc20_approve
with:
from: { var: owner.ref }
call:
args:
- { var: spender.address }
- { var: amount }
- template: erc20_transfer_from
with:
from: { var: spender.ref }
call:
args:
- { var: owner.address }
- { var: recipient.address }
- { var: amount }Supported binding references:
| Binding | References |
|---|---|
account |
<name>.ref, <name>.address |
address |
<name> |
bytes32 |
<name> |
abi_hash |
<name> |
u256 |
<name> |
u64 |
<name> |
string |
<name> |
Sequences also expose { var: chain_id } as the top-level workload chain_id unless a binding named chain_id is defined.
Hash bindings can reference other sequence bindings and are resolved once per sequence instance. For deterministic IDs that contracts compute with keccak256(abi.encode(...)) (such as Tempo MPP channel IDs), use abi_hash:
bindings:
payer:
account: { pool: users, select: random }
ephemeral_recipient:
address: random
amount:
u256: random
salt:
bytes32: { random_bytes: 32 }
channel_id:
abi_hash:
types: [address, address, address, bytes32, address, address, uint256]
values:
- { var: payer.address } # payer
- { var: payer.address } # payee
- "0x20c0000000000000000000000000000000000000" # token
- { var: salt }
- "0x0000000000000000000000000000000000000000" # authorizedSigner
- "0x0000000000000000000000000000000000000000" # channel contract
- { var: chain_id }Each emitted sequence step gets its natural nonce-lane key as a submission_key. Txgen then groups adjacent sequence steps by lane. Steps in the same lane rely on nonce order and can be submitted back-to-back. When a sequence crosses lanes, txgen adds a synthetic boundary key: the previous run releases it after inclusion, and the next run requires it as a submission_key. This preserves cross-lane ordering without receipt-gating same-lane nonce chains.
When set, txgen generate -n counts emitted transactions, not sequence instances. txgen never emits a partial sequence; if no remaining mix entry fits the remaining transaction budget or --duration elapses before the next workload item starts, generation stops early.
See examples/sequence.yaml for a small syntax example, examples/tip20-sequence.yaml for a Tempo TIP20 approve -> transferFrom sequence whose second transaction depends on the first, and examples/tip20-mpp.yaml for TIP20 transfers mixed with deterministic MPP channel open -> close sequences.
Standard Ethereum transaction types:
| Type | Description |
|---|---|
legacy |
Pre-EIP-1559 transactions |
eip2930 |
Access list transactions |
eip1559 |
Dynamic fee transactions |
All Ethereum types plus Tempo-native transactions:
| Type | Description |
|---|---|
tempo |
Native 0x76 transactions with protocol, 2D, or expiring nonces |
Tempo-specific fields:
templates:
tempo_transfer:
type: tempo
from:
pool: users
select: random
to: "0x..."
value: 1000
gas_limit: 21000
max_fee_per_gas: 1000000000
max_priority_fee_per_gas: 1000000000
# Tempo-specific replay protection
nonce_key: "42" # 2D nonce lane (0 = protocol nonce)
expiring_nonce: true # TIP-1009 expiring nonce mode
valid_for_secs: 25 # Relative expiry window, resolved at generation time
valid_before: 1700100000 # Absolute expiry timestamp (alternative to valid_for_secs)
fee_token: "0x..." # Pay gas in stablecoin
valid_after: 1700000000 # Scheduled: valid after timestamp
# Batched calls
calls:
- to: "0x..."
abi: erc20
function: transfer
args: ["0x...", 1000]
- to: "0x..."
abi: erc20
function: approve
args: ["0x...", 5000]2D nonces: Using different nonce_key values allows transactions from the same sender to be sent in parallel without nonce conflicts.
Expiring nonces: Set expiring_nonce: true to generate TIP-1009 transactions. txgen will set nonce_key = U256::MAX and nonce = 0 automatically. You must provide either:
valid_before: an absolute Unix timestamp in secondsvalid_for_secs: a relative TTL in seconds, resolved when the transaction is generated
valid_for_secs must be <= 30, matching Tempo's expiring nonce validity window.
For streamed benchmark pipelines such as txgen-tempo generate | bench send, txgen also applies a deterministic per-transaction bump to max_fee_per_gas before sponsor signing and sender signing. This guarantees that otherwise identical expiring transactions still produce unique signed payloads, avoiding hash-based replay collisions. max_priority_fee_per_gas remains exactly as configured, including zero.
Recommended benchmark setting: valid_for_secs: 25. This matches tempo-bench's default behavior and stays inside Tempo's 30-second protocol limit while leaving some propagation slack.
Benchmarking caveat: Expiring nonce transactions are still time-bounded by valid_before <= now + 30s. Streamed generation/send pipelines are practical because txgen builds and signs each transaction immediately before emitting it, but pre-generating a large expiring-tx file and replaying it later is still unsafe because many transactions will expire before submission.
Keychain access keys: Tempo templates can sign workload transactions with an AccountKeychain access key while keeping the logical sender as from. Add a setup step to pre-authorize one deterministic access key per account, then reference that setup step from workload templates:
setup:
steps:
- id: authorize_keychain_users
keychain_authorize_pool:
accounts: { pool: users }
access_keys:
mnemonic: "test test test test test test test test test test test junk"
range: [100000, 100003]
key_type: secp256k1
limits:
- token: "0x20c0000000000000000000000000000000000000"
amount: "1000000000000000000000000"
period: 0
allowed_calls: unrestricted
templates:
keychain_tip20_transfer:
type: tempo
auth:
mode: keychain
access_key:
from_setup: authorize_keychain_users
pair: same_index
from: { pool: users, select: random }
gas_limit: 400000
call:
to: "0x20c0000000000000000000000000000000000000"
abi: ERC20
function: transfer
args:
- { pool: users, select: random }
- 1Access keys derived by keychain_authorize_pool.access_keys are hidden signing keys. They are not added to accounts and are not printed by txgen addresses.
For inline provisioning workloads, use auth.mode: key_authorization. txgen derives a deterministic per-transaction secp256k1 access key, signs the key_authorization with the root from account, attaches it to the Tempo transaction, and signs the transaction with a keychain signature:
auth:
mode: key_authorization
access_key:
derive: per_tx
mnemonic: "${TXGEN_ACCESS_KEY_MNEMONIC}"
range: [1000000, 1001000]
key_type: secp256k1
limits:
- token: "0x20c0000000000000000000000000000000000000"
amount: "1000000000000000000000000"
period: 0
witness:
random_bytes: 32Inline access_key.mnemonic is a hidden signing seed for generated access keys; it does not need funded accounts and is not listed by txgen addresses. Omit it only for disposable benchmark specs, where txgen falls back to the public benchmark mnemonic at index 1000000.
allowed_calls may be unrestricted, deny_all, or a list of scopes with target and selectors entries. Selector values are 4-byte 0x hex strings.
Prefetch caveat: txgen-tempo generate --rpc only prefetches constant 2D nonce keys that are fixed in the spec. Dynamic/generated nonce_key values (uniform, choice, etc.) are resolved per transaction and are not prefetched automatically.
Summary of which RPC methods are required by each feature:
| RPC Method | Required By |
|---|---|
eth_chainId |
scenario run (chain_id: auto and explicit-ID validation) |
eth_getTransactionCount |
txgen-ethereum generate --rpc, txgen-tempo generate --rpc, scenario run (query RPC; pending nonce initialization) |
eth_getStorageAt |
txgen-tempo scenario run (Tempo parallel nonce lanes) |
eth_sendRawTransaction |
bench send, scenario run (workload setup and submit), optionally sender-authenticated |
eth_getTransactionByHash |
scenario run (sender-scoped submission RPC; reconcile a rejected or uncertain submission) |
eth_getTransactionReceipt |
bench send, scenario run (workload setup, submit await: receipt, wait_receipt, and transaction-hash wait_log), optionally sender-authenticated |
eth_blockNumber |
bench send (query RPC when configured; benchmark block range), scenario run (checkpoint, confirmations, and block-range log polling) |
eth_getBlockByNumber |
bench send (query RPC when configured; per-block stats collection), scenario run (checkpoint) |
eth_getLogs |
scenario run (block-range wait_log) |
eth_call |
txgen-tempo scenario run (prepare_encrypted_deposit and other adapter invoke actions) |
debug_getRawBlock |
txgen extract, txgen-ethereum extract-big-blocks |
eth_getBlockAccessListByBlockNumber |
txgen extract --bal, txgen-ethereum extract-big-blocks --bal |
reth_newPayload |
bench send-blocks |
reth_forkchoiceUpdated |
bench send-blocks |
testing_buildBlockV1 |
bench send-blocks --reorg |
txpool_status |
bench send (query RPC when configured; pool drain wait) |
Note:
debug_*methods require a node with the debug namespace enabled (typically archive nodes).reth_*methods are custom reth Engine API extensions.
See the examples/ directory:
simple.yaml— Basic Ethereum transferstempo.yaml— Tempo transactions with 2D and expiring noncestempo-mainnet-spam.yaml— Tempo mainnet workloadbench-spec.yaml— Bench workload specificationerc20.abi.json— ERC-20 ABI artifact
# Run the simple example
txgen-ethereum generate -s examples/simple.yaml -n 10 --seed 42
# Run the Tempo example
txgen-tempo generate -s examples/tempo.yaml -n 10 --seed 42txgen/
├── crates/
│ ├── txgen-core/ # Core library: spec parsing, account management, output
│ ├── txgen-cli/ # Shared CLI framework and NetworkAdapter trait
│ ├── txgen-ethereum/ # Ethereum binary: legacy, eip2930, eip1559
│ ├── txgen-tempo/ # Tempo binary: 0x76 + delegates to ethereum
│ ├── bench-core/ # Benchmarking: metrics, sender, reporters
│ └── bench-cli/ # Bench CLI binary (bench)
└── examples/ # Example workload specs
Each chain binary implements the NetworkAdapter trait from txgen-cli:
pub trait NetworkAdapter: Send + Sync {
type Template: DeserializeOwned + Send;
type Network: Network;
type SignContext: RequestSignContext<Self::Network>;
fn build_request(
&self,
template: Self::Template,
ctx: &mut BuildContext<'_>,
) -> Result<TxRequest<<Self::Network as Network>::TransactionRequest, Self::SignContext>>;
fn expand_setup_extension(
&mut self,
step_id: &str,
extension_name: &str,
value: serde_yaml::Value,
ctx: &mut BuildContext<'_>,
) -> Result<Option<Vec<serde_yaml::Value>>> {
Ok(None)
}
fn prefetch_nonces(/* ... */) -> impl Future<Output = Result<()>> { ... }
}MIT OR Apache-2.0