This document records a benchmark of mining-path yield over Tor versus clearnet: what we measured, from where, and how we decided.
Status: complete (2026-06-22). Routing p2pool over Tor costs ≈10 % of mining yield on
mini. The cost is propagation latency (uncle/late shares), not rejects, and Tor stays the default. Full per-arm results and the committed raw data are in Results. The methodology and decision rule below were fixed before the run (pre-registration); the run executed on the test bench.
While the stack is actively mining at steady state, does routing the mining-path networking over Tor instead of clearnet measurably cost yield or reliability? This gates the Tor-by-default decision for two paths:
- p2pool outbound sidechain P2P (#165) —
p2pool.clearnetknob. - XvB donation mining (#166) —
xvb.torknob.
This is not about initial sync over Tor. That is a separate, already-settled question (#183/#234: Tor IBD is impractically slow, hence the opt-in clearnet-then-Tor initial sync). Here both monerod and Tari stay on Tor throughout; only the p2pool / XvB egress changes between arms.
Every source below was verified against the live p2pool container on the test bench (/stats/* files written
by p2pool's --local-api / --data-api, plus the dashboard /api/state and docker stats).
"Does Tor cost us money" is captured directly by our share of the PPLNS reward window. No raw orphan counter is required: an orphaned/uncled share fails to earn its full weight, which shows up here.
| Metric | Meaning | Source · field |
|---|---|---|
| PPLNS reward share | our % of the sidechain reward window — direct revenue | /stats/local/stratum · block_reward_share_percent |
| Yield efficiency | reward-share ÷ hashrate-share; < 1 ⇒ shares going stale |
derived: block_reward_share_percent ÷ ( hashrate_1h ÷ /stats/pool/stats · pool_statistics.hashRate ) |
| Effort | hashes-per-share vs expected — rises when shares are found but not counted | /stats/local/stratum · average_effort, current_effort |
| Uncle / orphan events (our shares) | raw stale-share count — a mechanism cross-check on the yield number | parse the p2pool console log (SHARE FOUND + SideChain uncle markers); not in /api/state |
Why reward-share is primary, not a raw orphan count. p2pool includes uncle shares at reduced weight and drops orphans entirely; both effects land in
block_reward_share_percent, so the net revenue impact is measurable from the stratum stats today. The raw uncle count (log-parse) is kept as a secondary cross-check, not the decision metric. It is noisier and harder to attribute to our shares.
These explain why the yield moved, or confirm it didn't. All are available today.
| Metric | Source · field |
|---|---|
| Share-found cadence / time-to-first-share | /stats/local/stratum · shares_found, last_share_found_time |
| Sidechain peer count & churn | /stats/local/p2p · connections, incoming_connections, peer_list_size |
| Stratum-level rejects | /stats/local/stratum · shares_failed |
| Effective hashrate on pool | /stats/local/stratum · hashrate_1h, hashrate_24h |
| Tor daemon overhead under sustained traffic | docker stats tor (CPU %, mem) |
Not collected in the headline run. XvB is disabled so the p2pool-yield comparison isn't confounded (see Method below). These fields apply only to a separate, dedicated XvB-transport measurement, where they'd be sampled during donation windows (when
algo_servicehas routed the proxy to XvB).
| Metric | Source · field |
|---|---|
| Accepted / rejected / invalid shares to XvB | xmrig-proxy /summary → /api/state · proxy_summary.{accepted,rejected,invalid} |
| Credited raffle weight | XvB stats API (already fetched over Tor, #163) · block_reward_share_percent analogue at XvB |
- Rig: the test bench (running
develop) plus the full RigForge fleet (8 miners, ~269 kH/s combined) at a fixed hashrate, all pointed at the bench's stratum. A constant total hashrate across both arms is load-bearing for the ratio metric below. - Sidechain:
mini. At ~269 kH/s we are ~1.8 % of theminisidechain (observed ~14.75 MH/s), or ~155 of our own shares/day. That gives enough statistical power while staying a small, representative participant. (mainat this hashrate yields only ~10 shares/day, too sparse.nanowould make us ~11 % of the chain, large enough that our own latency would drag it and overstate the Tor penalty. The uncle/orphan rate is set by the ~10 s share interval, the same on every sidechain, so theminiresult generalises tomain/nano.) - Design — INTERLEAVED A/B (not one long block per arm): alternate T → C → T → C in fixed
20 h blocks so each arm samples the same diurnal / weekly / network-weather range. (A "5 days Tor
then 5 days clearnet" layout would confound the arm with the calendar period.) Discard the first
3 h after each switch while p2pool reconnects and the PPLNS window re-stabilises. As run: 6 blocks,
3 per arm, over ~5 days, yielding ~270–290 of our shares per arm. That is below the ideal ≥500, a
deliberately shortened schedule, which is why the result is reported as "roughly 10 %"
rather than a tight point estimate (see the confidence note in Results).
- Arm T (Tor):
p2pool.clearnet=false(--socks5),tor_egress_firewall=true,xvb.tor=true— thedevelopdefaults (fail-closed, egress-gated). - Arm C (clearnet):
p2pool.clearnet=true,tor_egress_firewall=false,xvb.tor=false. The firewall must be off in this arm. The #270 Tor-egress firewall DROPs direct clearnet dials, so leaving it on would give p2pool 0 sidechain peers and the arm would silently collect garbage. The arm switch toggles the two together. monerod + Tari keep their Tor app-config in both arms, so only p2pool's transport differs; clearnet exposure here is the intended baseline on the test bench. - Only the mining-path transport flips. Held constant in both arms: monerod + Tari on Tor,
XvB disabled (
xvb.enabled=false, see below), the rig, hashrate, sidechain, and monerod tip. - Why XvB is disabled for this run. With
XVB_DONATION_LEVEL=autothe optimizer dynamically splits the fleet between p2pool and XvB to climb raffle tiers. On the test bench it routed ~96 kH/s to XvB vs only ~18 kH/s to p2pool while chasing the Whale tier. That (a) starves p2pool, soreward_share, our primary metric, would be measured on a fraction of the fleet, and (b) makes the split itself a function of Tor latency, i.e. the optimizer reacts to the very thing we're trying to isolate. Disabling XvB sends the full ~269 kH/s to p2pool in both arms, soreward_shareis a clean readout of transport overhead (and ~155 shares/day, not a trickle). The XvB transport overhead (#166) is a separate, smaller question, benchmarked on its own, not folded into the p2pool-yield comparison. The harness re-assertsxvb.enabled=falseon everypithead applyso a switch or recovery can't let the optimizer drift back on. - Every switch is egress-gated by
bench-verify-egress.sh. Arm T must be a clean all-Tor PASS before any data is collected, so a leak can never silently invalidate a window.
- Arm T (Tor):
- Controlling natural variance: (1) interleave (the dominant control); (2) compare the ratio yield-efficiency = reward-share ÷ hashrate-share, which cancels out the sidechain's total hashrate drifting as other miners come and go; (3) run to a target share count, not a fixed clock, so Poisson noise (~1/√N) sits below the effect we're resolving; (4) the clearnet arm's block-to-block variance is the noise floor for the decision; (5) hold everything else constant.
- Collector:
bench-collect.sh, a read-only poller that snapshots the/stats/*fields plusdocker stats torinto one JSONL line per interval (default 5 min), per arm, in~/pithead-bench/<arm>.jsonl. No new container.
The run must survive the operator being offline for days, so everything runs on the test bench; a laptop / SSH session is only an optional observer. Three layers:
bench-orchestrate.sh(detached on the bench) drives the whole run: flips the arm each block viapithead apply, egress-gates the switch, (re)starts the collector, health-checks every cycle, writes~/pithead-bench/status.json, and self-protects. Transient blips (a miner reconnecting, Tor circuit churn) are tolerated and recorded, but a genuine unrecoverable break pauses collection and marks that window invalid rather than polluting the dataset. An unwatched multi-day break then costs calendar time, not data.- Cron watchdog (
*/15 * * * *plus an@rebootentry) runsbench-healthcheck.sh: restarts the orchestrator or collector if either died, re-ups the stack if it's down, appendshealth.log. This catches the orchestrator itself dying or a bench reboot, which the orchestrator can't self-report. Runs as the operator user; the only privileged step (iptables) is covered by the scoped/etc/sudoers.d/pithead-firewallgrant. - Observe over Tailscale:
ssh $BENCH_HOST bench-status(bench-status.sh) prints a one-screen summary: current arm, day, last-OK, shares per arm, egress verdict, miners, collector. The dashboard UI (on the bench's Tailscale IP) is the live stack glance.
# 1. calibrate (~6–12 h, Tor arm): confirm live mini total, our share, shares/day
tests/integration/benchmarks/bench-orchestrate.sh --calibrate --pool mini
# 2. start the autonomous interleaved run + install the watchdog cron
# (the exact invocation used for the run recorded in Results)
nohup tests/integration/benchmarks/bench-orchestrate.sh run \
--pool mini --block-hours 20 --blocks 6 --settle-hours 3 --interval 300 >/dev/null 2>&1 &
tests/integration/benchmarks/bench-orchestrate.sh --install-cron # */15 + @reboot watchdog
# 3. check from anywhere over Tailscale
ssh $BENCH_HOST bench-status
ssh $BENCH_HOST tail -n 40 pithead-bench/health.log
# 4. graceful stop (restores the Tor default + egress firewall + re-enables XvB, removes the cron, keeps the JSONL)
tests/integration/benchmarks/bench-orchestrate.sh --stopThe clearnet arm establishes the noise floor (its own variance across sub-windows). Then:
- Tor yield-efficiency / reward-share delta within that noise floor ⇒ keep Tor as the default for that path (#165/#166 ship as-is); document the measured (negligible) trade-off.
- Tor materially below clearnet (a reward-share loss clearly outside the noise, especially if
concentrated on
--mini/--nano) ⇒ document the trade-off and reconsider: a per-sidechain default, or clearnet-default with a Tor opt-in, rather than a blanket Tor default.
Either way the conclusion lands in docs/privacy.md so operators get an honest
trade-off, and it sets the final default for #165/#166 before the v1.1 release.
Run: mini, 2026-06-17 → 2026-06-22 (~5 days), 6 × 20h blocks interleaved T/C/T/C/T/C (3 per arm),
first 3h of each block discarded. Full 8-miner fleet ≈ 269 kH/s held constant, XvB disabled
so the whole fleet hit p2pool in both arms. The sidechain grew from ~14.7 to ~20.9 MH/s over the run
(our share drifted ~1.8 % → ~1.3 %), which is why the yield-efficiency ratio (reward ÷
hashrate share) is the headline metric, not raw reward share. ~1,220 post-settle snapshots total.
Reproduce with python3 tests/integration/benchmarks/bench-analyze.py docs/benchmarks/data/run-2026-06-17-mini;
the raw per-arm JSONL plus events.log are committed there (originals on the test bench at ~/pithead-bench/).
Per-arm (mean of the 3 block means; ± is the block-to-block stdev, the noise floor):
| Arm | Blocks | Reward share % | Yield-efficiency | Avg effort % | Shares | Rejects | Peers | Tor-daemon CPU |
|---|---|---|---|---|---|---|---|---|
| clearnet | 3 | 1.375 ± 0.132 | 1.054 ± 0.085 | 89.9 | 292 | 0 | 10.0 | 19.6 % |
| Tor | 3 | 1.218 ± 0.157 | 0.933 ± 0.098 | 112.9 | 270 | 0 | 10.2 | 18.7 % |
| Δ (Tor vs clearnet) | −11.4 % | −11.5 % | +23 pts | −7.5 % | — | ≈0 | ≈0 |
Per-block (transparency; note the ranges overlap):
| Blk | Arm | Reward % | Yield-eff | Effort % | Shares |
|---|---|---|---|---|---|
| 1 | Tor | 1.396 | 0.860 | 111.8 | 107 |
| 2 | clearnet | 1.543 | 0.969 | 95.6 | 111 |
| 3 | Tor | 1.013 | 0.869 | 125.0 | 76 |
| 4 | clearnet | 1.363 | 1.170 | 86.3 | 94 |
| 5 | Tor | 1.245 | 1.071 | 101.9 | 87 |
| 6 | clearnet | 1.220 | 1.024 | 87.7 | 87 |
Findings:
- Tor costs ≈ 10–12 % of p2pool yield on
mini. Reward share and yield-efficiency agree (−11.4 % / −11.5 %), and the direction is consistent across the run. - The cost is propagation latency, not anything else. Rejects were 0 in both arms, the Tor daemon used the same CPU in both (~19 %, since monerod + Tari route over Tor regardless of arm), and peer counts matched (~10). The only moving part is share-propagation delay over Tor, which shows up as higher effort on Tor (113 % vs 90 %): more hashing per counted share, i.e. more shares landing as uncles or too late for the PPLNS window.
- Confidence: moderate, not precise. With 3 blocks per arm the ~11 % effect sits only modestly above the clearnet block-to-block noise floor (±8–10 %), and per-block yield-eff ranges overlap (Tor 0.86–1.07 vs clearnet 0.97–1.17). Read it as "roughly 10 %", economically real and directionally consistent, but not a tight point estimate. A longer run would narrow the interval.
Recommendation — keep Tor the default (#165/#166 ship as-is in develop). Per the decision rule
this is the "materially below clearnet" case, so the trade-off is documented rather than hidden. Not
exposing the operator's home IP to the P2Pool network is the project's core value, and ~10 % is a
modest, opt-out-able price for it. Operators who prioritise yield over IP privacy can set
p2pool.clearnet: true (now functional; it was a no-op until the #294 firewall-toggle fix
found during this benchmark) and accept the exposure. No per-sidechain split is warranted: the
mechanism is the ~10 s share interval, common to every sidechain, so mini is representative. The
absolute cost is largest on the smaller, faster chains where our share, and thus uncle sensitivity,
is highest. This conclusion is mirrored operator-facing in ../privacy.md.