Parallel mining and the external-miner protocol¶
pyrxd ships two ways to mine a dMint contract: a slow but correct
in-process reference miner (mine_solution), and a fast subprocess
miner via mine_solution_external. The subprocess path is the
production-ready option, and pyrxd 0.5.1 added a bundled implementation
at pyrxd.contrib.miner so callers don’t have to provide their own.
The two miners¶
mine_solution — slow but correct¶
In-process, single-threaded. Calls verify_sha256d_solution per
attempt — same code path the on-chain covenant ultimately mirrors.
Useful for tests, dev, and “I want to mine overnight without external
tooling.” Not viable for production mining.
How long a sweep takes depends on the machine, so pyrxd measures rather than assumes: run
pyrxd glyph dmint-estimate --difficulty 1 --workers 1
to get your single-core rate (MEASURED) alongside the attempt distribution (EXACT) and the resulting ETA (PROJECTED). See Estimating time-to-mint below.
mine_solution_external — fast via subprocess¶
Reads from a user-supplied miner binary (or the bundled
pyrxd.contrib.miner) via a JSON-over-stdio protocol. Locally
re-verifies every nonce the external miner returns, so a buggy or
malicious miner can’t ship a wrong nonce into your transaction.
The wire protocol (v1)¶
Pinned in pyrxd 0.5.1. Future protocol changes are additive only,
gated by the optional protocol field. The miner reads one JSON
object from stdin and writes one JSON object to stdout.
Request (stdin)¶
Field |
Type |
Required |
Description |
|---|---|---|---|
|
string |
yes |
64-byte preimage as 128 hex chars |
|
string |
yes |
u64 BE target as 16 hex chars (no |
|
int |
yes |
4 (V1 contracts) or 8 (V2) |
|
int |
no |
Always 1 currently; reject unknown |
Response on hit (stdout)¶
Field |
Type |
Required |
Description |
|---|---|---|---|
|
string |
yes |
|
|
int |
optional |
Best-effort metric (≤ 2^40) |
|
number |
optional |
Finite, ≥ 0 (NaN/Inf rejected) |
Response on exhaustion (stdout)¶
{"exhausted": true}
Exit code 2. mine_solution_external recognises this and raises
MaxAttemptsError immediately. A miner that doesn’t know this
convention can still surface exhaustion by sleeping past the parent’s
timeout_s — pyrxd will SIGKILL it and raise MaxAttemptsError via
the timeout path. Both behaviours are valid.
Progress frames (stderr, optional — added after 0.13.0)¶
While it searches, a miner MAY write zero or more progress lines to stderr — never stdout, never mixed with the response above:
{"progress": {"attempts": 4200000, "elapsed_s": 1.7}}
This needed no protocol version bump — it’s still protocol: 1 —
because it is purely additive on both sides:
stdout keeps carrying exactly one line, unchanged. Every consumer that only ever reads stdout (every consumer before this addition) sees no difference.
An old miner that has never heard of progress frames writes nothing extra to stderr. Silence is valid: no progress is shown, exactly as before.
A new miner talking to an old parent that still discards stderr outright is equally safe — the frames land in a stream nobody reads.
Pass mine_solution_external(..., progress=callback) to opt in on the
consumer side. Progress is a display hint, not a trust boundary: a
malformed or adversarial line is silently dropped (it can never be
parsed into anything that looks like a solution — the parser can only
ever produce an (attempts, elapsed_s) pair, and stdout is the only
channel the result parser reads), and stays bounded in memory no
matter how much or how fast a miner writes to stderr — see
pyrxd.glyph.dmint.miner._ExternalMinerProgressReader for the reader
that replaced the old stderr=subprocess.DEVNULL and keeps its same
flat-memory guarantee.
The bundled pyrxd.contrib.miner emits these frames by default
(--quiet suppresses them, same as the exhaustion message) — it is
the reference implementation of this extension as well as of the base
protocol.
Exit codes¶
Code |
Meaning |
|---|---|
0 |
Solution found |
1 |
Usage/protocol error (stderr has details) |
2 |
Nonce space exhausted (stdout: |
≥128 |
Killed by signal (parent timeout-fired SIGKILL) |
The bundled miner: pyrxd.contrib.miner¶
A 32-core-friendly parallel pure-Python miner ships as
pyrxd.contrib.miner in 0.5.1. Two ways to invoke:
As a console script:
echo '{"preimage_hex":"...","target_hex":"7fffffffffffffff","nonce_width":4}' \
| pyrxd-miner
As a module from mine_solution_external:
import sys
from pyrxd.glyph.dmint import build_pow_preimage, mine_solution_external
pow_result = build_pow_preimage(txid_le, contract_ref, input_script, output_script)
result = mine_solution_external(
preimage=pow_result.preimage,
target=state.target,
miner_argv=[sys.executable, "-m", "pyrxd.contrib.miner"],
nonce_width=4,
)
# result.nonce is verified against pyrxd's internal SHA256d check.
Performance¶
Numbers below are from one dated run on one machine. They are a data
point, not a spec — benchmark your own with pyrxd glyph dmint-estimate.
Measured against the canonical pyrxd PXD mint at txid
c9fdcd3488f3e396bec3ce0b766bb8070963e7e75bb513b8820b6663e469e530
(2026-05-11), on a 32-core i9-14900K:
~28 Mh/s aggregate
~900 Kh/s per core
Full V1 nonce-space sweep (2^32 attempts): ~2.5 minutes
First mainnet mint after the M1 scriptSig fix: 15.3 seconds total including network round-trips (lucky early hit, but consistent with the ~40% hit-per-sweep distribution at difficulty=1)
On a 4-core CI VM, a full sweep takes ~20 minutes. Still acceptable for “deploy + first mints” workloads.
Note what those two lines already show: 28 Mh/s across 32 cores is ~875 Kh/s per core, not 32 × the single-core figure. Cross-core scaling is sublinear, and pyrxd does not measure it — which is why every aggregate rate the estimator prints is labelled PROJECTED.
Estimating time-to-mint¶
pyrxd glyph dmint-estimate benchmarks this machine and turns a target
into an expected grind. It keeps three kinds of number apart, and so
should you:
MEASURED — the single-core SHA256d rate, benchmarked on the same hash chain the workers run.
EXACT — the hit probability and attempt counts. Closed form.
PROJECTED — every ETA, plus the
single-core × workersaggregate rate they divide by.
pyrxd glyph dmint-estimate --difficulty 1 # offline
pyrxd glyph dmint-estimate --contract <TXID>:<VOUT> # against a live contract
pyrxd glyph dmint-estimate --difficulty 1 --json # separate top-level keys
The same numbers stream to stderr while pyrxd glyph claim-dmint is
mining (--no-progress to silence), reporting the observed aggregate
rate — measured, not projected — and the remaining-time distribution.
The arithmetic, and one formula to avoid¶
A digest is a hit iff its top 96 bits, read big-endian, are below the
target (the verifier’s “four zero bytes plus an 8-byte value under the
target” is exactly that condition, because the target is always under
2**64). SHA-256 is uniform, so:
p = target / 2**96
E[attempts] = 2**96 / target
MAX_SHA256D_TARGET / target is the difficulty multiplier, not an
attempt count. It is low by a factor of ~2**33: at difficulty 1 it
says one expected attempt, where the true mean is ~8.59 billion. The
cross-check is the V1 reroll behaviour pyrxd already relies on — at
difficulty 1 a full 2^32 nonce sweep hits with probability
1 - (1-p)**(2**32) ≈ 39%, which is why V1 rerolls its OP_RETURN.
Because the process is geometric it is memoryless: attempts already spent do not shorten what remains. That is why pyrxd reports a mean plus p50/p90/p99 quantiles and never a countdown, and why the V1 reroll loop does not perturb the estimate — chopping an i.i.d. hash stream into 2^32-nonce pieces leaves the total-attempts distribution unchanged.
Live progress from your own code¶
mine() takes an optional progress callback. It is called with
(attempts, elapsed_s) from inside the worker-cleanup context, so an
exception raised in it (the supported way to impose a deadline) still
reaps every worker:
from pyrxd.contrib.miner.parallel import MineParams, mine
from pyrxd.glyph.dmint import estimate_attempts, live_stats
est = estimate_attempts(target)
def on_progress(attempts: int, elapsed_s: float) -> None:
s = live_stats(attempts, elapsed_s, est)
print(f"{s.observed_hashes_per_second:,.0f} h/s, mean {s.remaining_mean_s:,.0f}s remaining")
result = mine(MineParams(...), progress=on_progress)
pyrxd.glyph.dmint.mine_solution accepts the same progress hook, and
so does mine_solution_external (added after 0.13.0 — see “Progress
frames” above):
from pyrxd.glyph.dmint import mine_solution_external
result = mine_solution_external(
preimage=preimage,
target=target,
miner_argv=[sys.executable, "-m", "pyrxd.contrib.miner"],
nonce_width=4,
progress=lambda attempts, elapsed: ..., # called if the miner emits frames
)
An external miner that never emits a progress frame (the common case
for a third-party binary that predates this addition) simply means the
callback is never called — the grind still runs to completion or
timeout exactly as it would with progress=None.
Why pure-Python and not C / GPU¶
The load-bearing risk for any miner is silent divergence with the
verifier. A miner that’s byte-equivalent in the easy cases but drifts
on an edge case ships nonces the on-chain covenant rejects — same bug
class as the M1 scriptSig incident (see
docs/solutions/logic-errors/dmint-v1-mint-scriptsig-divergence.md).
hashlib.sha256 is a thin wrapper around OpenSSL’s C implementation —
the same primitive that verify_sha256d_solution uses. The parallel
miner builds on that. By construction, the miner can’t compute
different bytes than the verifier on the same input.
A hand-rolled C miner (or a GPU implementation) has to re-implement the midstate precompute, the byte-order conventions, and the fixed-preimage-length padding. Any one of those drifting silently produces nonces the network rejects. Until someone is willing to ship
maintain that miner with byte-equality tests against pyrxd’s verifier, the pure-Python option is the safer default.
Cross-platform¶
The bundled miner explicitly requests multiprocessing’s spawn
start method via get_context("spawn") — works identically on Linux,
macOS, and Windows. CI runs Linux + macOS; Windows is best-effort.
Writing a custom miner¶
A custom miner is a free-standing executable that satisfies the protocol above. To validate yours against pyrxd’s verifier:
import json
import subprocess
import sys
from pyrxd.glyph.dmint import verify_sha256d_solution
preimage = bytes.fromhex("ab" * 64)
target = 0x7FFFFFFFFFFFFFFF
request = json.dumps({
"preimage_hex": preimage.hex(),
"target_hex": f"{target:016x}",
"nonce_width": 4,
})
result = subprocess.run(
["/path/to/your/miner"],
input=request.encode(),
capture_output=True,
timeout=600,
)
response = json.loads(result.stdout)
nonce = bytes.fromhex(response["nonce_hex"])
# This MUST pass. If it fails, your miner drifted.
assert verify_sha256d_solution(preimage, nonce, target, nonce_width=4), (
"miner returned a nonce that pyrxd's verifier rejects — drift bug"
)
The bundled pyrxd.contrib.miner passes this test by construction
(it uses hashlib.sha256 directly). Custom miners must keep passing
it after every change.
Supply-chain safety¶
mine_solution_external runs whatever binary the caller points at —
including malicious binaries that intercept $PATH. Mitigations:
Pass an absolute path (
["/usr/local/bin/glyph-miner", ...]) rather than a bare name to bypass$PATHresolution.For the bundled miner:
[sys.executable, "-m", "pyrxd.contrib.miner"]resolves through the pyrxd install, not$PATH. Reasonably safe.Local nonce re-verification (in
mine_solution_external) defends against a malicious miner returning a wrong nonce. It does NOT defend against a malicious miner exfiltrating the preimage (which encodes the contract ref + funding-script hash) over the network.