Reusable Solana transaction observation benchmark.
The tool generates small signed mainnet transactions, submits each transaction to one or more configured providers, and writes reproducible landing and block- placement artifacts from configured Yellowstone receivers.
It is intentionally provider-neutral. Add a provider by implementing a sender adapter, not by hard-coding a benchmark path.
RPCEdge links:
- Website: https://rpcedge.com
- Docs: https://docs.rpcedge.com
Primary measurement:
signed transaction submitted
-> observed on one or more configured receivers
-> matched by signature
-> earliest receiver observation selected for that transaction
-> landing, missing-rate, and block-position comparison
Provider ACK latency is retained only as a diagnostic side channel. It is not the benchmark result. Receiver-specific timing is also diagnostic: transaction- sender reports use one landing clock, the earliest matched observation for each signature across all configured receivers.
Leader geography, validator client, datacenter, route hints, and bad-leader
context should come from the saved getLeaderSlots snapshot when building the
public report. Private ClickHouse joins are optional internal diagnostics, not
required for the benchmarker.
solana_rpc: standard JSON-RPCsendTransaction.rpcedge_raw_http: raw transaction bytes over HTTP.rpcedge_route_aware_http: RPCEdge route-aware JSON submit endpoint.rpcedge_quic_raw_tx: raw transaction bytes over persistent RPCEdge QUIC.
Planned adapter examples:
- Astralane
- Helius Sender
- Harmonic
- Other provider-specific low-latency senders
git clone <repo-url>
cd solana-tx-bench
cargo run -- init-config --output bench.yamlEdit bench.yaml:
- set
keypair_pathto a funded low-value keypair; - set
max_spend_lamports; - configure providers and API key environment variable names.
Run one transaction:
export RPCEDGE_API_KEY=...
cargo run -- run --config bench.yamlSubmission diagnostics are written under artifact_dir/test_id/:
manifest.jsonsamples.ndjsonsummary.jsonsummary.md
Run a leader-paced smoke test:
cargo run --release -- run-leader-paced \
--config bench.yaml \
--duration-seconds 300 \
--txs-per-leader-run 1 \
--leader-run-concurrency 1 \
--slot-trigger grpc_slot \
--capture-leader-slots \
--collect-rpcedge \
--collect-entriesThis sends at most one transaction for each observed contiguous leader run, signing each transaction with a fresh blockhash. It is the preferred first benchmark shape because it avoids spammy fixed-rate traffic and naturally samples different leaders over the run window.
Set --txs-per-leader-run N --leader-run-concurrency N to send multiple
distinct transactions at the same time for each observed leader run. Keep the
default concurrency of 1 for baseline reports unless you are intentionally
testing burst behavior.
By default, run-leader-paced is driven by the RPCEdge Yellowstone gRPC slot
stream (--slot-trigger grpc_slot). This avoids getSlot polling for send
timing. --slot-trigger rpc_poll exists only as a debug fallback.
--collect-entries adds a normal Yellowstone entry subscription beside
SubscribeDeshred and processed transaction subscriptions. The runner stores
slot-wide entries.ndjson and derives entry-placements.ndjson by joining the
processed transaction slot_index into each entry's transaction range. This is
block-placement context for "did we land early in the produced block"; it is
not packet-arrival time at the leader and it is not an exact PoH tick label.
--capture-leader-slots calls JSON-RPC getLeaderSlots before the run and
writes leader-slots-snapshot.json beside the samples. When pointed at
RPCEdge's RPC gateway, that snapshot can include leader geography, validator
client, route hints, and historical landing-latency profiles. This makes cohort
reports reproducible from local artifacts instead of requiring a private
database join. If --leader-slots-rpc-url is omitted, the runner uses the
rpc_url from bench.yaml.
In grpc_slot mode the runner refreshes the leader-slot snapshot when the
gRPC-observed slot approaches or leaves the cached horizon. Refresh files are
written as leader-slots-snapshot-<start_slot>.json.
For a QUIC-only RPCEdge sender benchmark, configure a single provider:
providers:
- name: rpcedge-quic-frankfurt
kind: rpcedge_quic_raw_tx
endpoint: "185.191.118.181:4433"
api_key_env: "RPCEDGE_API_KEY"
route_mode: only
routes:
- tpu_quic
server_name: "relay.rpcedge.com"Use run for a tiny fixed-count canary, then run-leader-paced for the
leader/cohort benchmark.
Route-policy comparisons must use paired samples. Do not run TPU-only for one
time window and always_race for a later time window and call that an equal
comparison; the leader set, slot phase, network state, and fee market changed.
Use paired_route_policies for the fair comparison. For each leader run it
builds one transaction per policy arm and sends all arms from the same gRPC slot
signal:
cargo run --release -- run-leader-paced \
--config examples/rpcedge-quic-frankfurt.yaml \
--duration-seconds 1800 \
--txs-per-leader-run 1 \
--leader-run-concurrency 3 \
--slot-trigger grpc_slot \
--route-strategy paired_route_policies \
--capture-leader-slots \
--collect-rpcedge \
--collect-entriesThe paired arms are:
tpu_quic_only:only: [tpu_quic]always_race:only: [tpu_quic, jito_bundle, harmonic_bundle]software_client_aware: JitoLabs/BAM/FireBAM get TPU+Jito, Harmonic* gets TPU+Harmonic, everything else gets TPU-only.
All paired baseline arms use the same compute-unit limit and the same compute-unit price from the config. Route policy must not silently mutate priority fee; fee ladders are separate experiments.
Arms that include jito_bundle may pay relay-side Jito tips. Those tips are
tracked separately from the signed transaction fee. For RPCEdge QUIC
benchmarks, set route_results: true on the provider to request bounded
route-result ACKs. When returned, reports show route-tip generated spend from
route_tip_lamports, and route-tip landed spend only when the corresponding
route_tip_signature is observed by the benchmark streams. Jito tip accounts
are included automatically in RPCEdge observation filters. Use
--observation-account <pubkey> only for custom/private provider accounts.
Individual strategies are route availability smoke tests only:
# Baseline: static TPU QUIC only from bench.yaml.
cargo run --release -- run-leader-paced \
--config examples/rpcedge-quic-frankfurt.yaml \
--duration-seconds 1800 \
--slot-trigger grpc_slot \
--capture-leader-slots \
--collect-rpcedge
# Control: always race TPU, Jito bundle, and Harmonic bundle.
cargo run --release -- run-leader-paced \
--config examples/rpcedge-quic-frankfurt.yaml \
--duration-seconds 1800 \
--slot-trigger grpc_slot \
--route-strategy always_race \
--capture-leader-slots \
--collect-rpcedge
# Strategy: software-client-aware routing. JitoLabs/BAM/FireBAM get
# TPU+Jito bundle, Harmonic* gets TPU+Harmonic bundle, and everything
# else stays TPU-only until a dedicated provider route exists.
cargo run --release -- run-leader-paced \
--config examples/rpcedge-quic-frankfurt.yaml \
--duration-seconds 1800 \
--slot-trigger grpc_slot \
--route-strategy software_client_aware \
--capture-leader-slots \
--collect-rpcedgeUse the same transaction shape and observation endpoints for smoke tests, but
only paired/matched-policy artifacts are valid for comparing first-observed
landing time, landed slot delta, processed block slot_index, success ratio,
and extra priority/tip cost. Provider ACK and receiver-specific latency are
diagnostic only.
Retries, delayed resends, and duplicate spam are intentionally excluded from
baseline route-policy comparisons; benchmark those as separate tail-mitigation
strategies. To test same-signature resend behavior, keep
--txs-per-leader-run 1 and add resend attempts:
cargo run --release -- run-leader-paced \
--config examples/rpcedge-quic-frankfurt-route-results-5m.yaml \
--duration-seconds 300 \
--txs-per-leader-run 1 \
--leader-run-concurrency 3 \
--route-strategy paired_route_policies \
--resend-attempts 3 \
--resend-interval-ms 50 \
--slot-trigger grpc_slot \
--capture-leader-slots \
--collect-rpcedgeThis signs one transaction per policy arm, then submits that same signature
three times at 0ms, +50ms, and +100ms. Landing remains measured per
unique signature; provider ACK diagnostics are recorded per attempt.
Use paired_providers when two equivalent relay processes select different
network paths. It creates one arm per configured provider and sends each signed
transaction only to its assigned provider. The mode requires exactly two
route-aware providers and two paired_payer_keypair_paths.
Payer assignment swaps across arms by the four-slot leader-group index. This prevents concurrent transactions from sharing a writable fee payer and prevents either route from remaining coupled to one payer identity.
The Frankfurt DoubleZero/public config is
examples/rpcedge-doublezero-public-frankfurt.yaml. Run it inside the
Frankfurt transient public-route wrapper:
solana-tx-bench run-leader-paced \
--config examples/rpcedge-doublezero-public-frankfurt.yaml \
--duration-seconds 900 \
--txs-per-leader-run 1 \
--leader-run-concurrency 2 \
--route-strategy paired_providers \
--leader-network-path-class doublezero \
--slot-trigger grpc_slot \
--capture-leader-slots \
--collect-rpcedge \
--collect-entriesnetworkPath.class=doublezero is eligible only when verified=true; Edge
publication fields are not TPU route evidence. Provider config order defines
the comparison: arm 0 is baseline, arm 1 is candidate, and every reported delta
is candidate - baseline. Put public Internet first and DoubleZero second so a
negative latency delta means DoubleZero was observed sooner.
The output selects the earliest configured receiver observation for each signature. Every timing and block-placement delta requires a complete pair observed in the same slot. Different-slot and one-sided outcomes appear only in coverage. The report does not measure packet-arrival time inside the leader TPU.
Generate the complete DoubleZero-versus-public output after the run:
scripts/report-doublezero-public.sh artifacts/<test-id>The script validates the artifact and invokes the standard paired report writer.
It writes one public report, index.html, plus summary.json and
paired-rows.json as machine-readable evidence. The same typed analysis
produces every output; the script contains no statistical calculations. PoH
placement is derived offline from the saved entry history. Reporting does not
change collection or transaction submission.
Leader-paced outputs add:
leader-sends.ndjsonleader-runs.ndjsonleader-slots-snapshot.json, when--capture-leader-slotsis enabledleader-slots-snapshot-<start_slot>.json, for rolling gRPC-slot refreshesmatched-observations.ndjsonmatched-observation-summary.jsonmatched-observation-summary.md
Build the self-contained report:
cargo run --release -- report \
--artifact-dir artifacts/<test_id>The report command writes report.json, report.md, and report.html using
only local artifacts from the run directory. Leader/client/location cohorts are
computed from saved getLeaderSlots snapshots.
Compare two or more runs:
cargo run --release -- compare \
--artifact-dir artifacts/<tpu_only_test_id> \
--label "TPU QUIC only" \
--artifact-dir artifacts/<client_aware_test_id> \
--label "Client aware" \
--output-dir docs/reports/<comparison_slug> \
--title "RPCEdge TPU QUIC vs Client-Aware Routing"The compare command writes comparison.json, comparison.md, comparison.html,
and index.html for GitHub Pages. It uses an RPCEdge landing score across
first-observed landing milliseconds, landed slots, processed block position,
same-slot rate, and success ratio. Provider ACK is retained in raw artifacts for
diagnostics, but it is not landing proof and is not part of the score.
Collect matched observations from RPCEdge Yellowstone processed + SubscribeDeshred:
cargo run -- collect-rpcedge \
--test-id my-run \
--duration-seconds 120 \
--min-sources 2For end-to-end landing attribution, run either --collect-rpcedge during the
benchmark or run a separate RPCEdge collector for the same test_id. Sender ACK
artifacts alone do not prove shred or processed observation.
Observation summaries:
observation-summary.jsonobservation-summary.md
The generated transaction is a memo-free self-transfer. The configured
lamports move from the keypair back to the same keypair, and the tool adds the
iteration number to the transfer amount so every transaction has a unique
signature without Memo program compute overhead. It still spends Solana fees and
any priority fee you configure.
Always set max_spend_lamports. This is only a local transaction-fee cap; it
is not a replacement for using a throwaway keypair with tiny funds.
See examples/bench.example.yaml.
See docs/methodology.md.
See docs/related-work.md for how this repo compares to existing Solana sender and stream benchmark tools.
See docs/artifacts.md.
See docs/polaris-enrichment.md for the intended
boundary between portable getLeaderSlots enrichment and Polaris-private
gateway/customer diagnostics. Private data should not be required by this public
benchmark repo.
The artifacts are plain NDJSON and JSON. The built-in report command writes
portable report.json, report.md, and report.html files directly from the
artifact directory:
cargo run --release -- report --artifact-dir artifacts/<test_id>Jupyter or Python notebooks can still be useful for custom charts, but they are not required for the standard public report. Keep provider ACK and receiver- specific diagnostics out of the transaction-sender result tables.
Published report archive: